mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-22 08:07:42 +00:00
Merged generation history
- Immutable generation epochs now coexist with dimension stacks - Historical terrain, biomes, structures, and locate behavior remain stable across updates - Versioned generation kernels preserve the combined runtime contract
This commit is contained in:
commit
56b5f4dd0b
263 files changed
+39624
-1849
No files matched your search
@@ -1,6 +1,11 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding;
|
||||
import art.arcane.iris.engine.history.GenerationEpoch;
|
||||
import art.arcane.iris.engine.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
@@ -13,6 +18,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
@@ -201,12 +207,102 @@ public class IrisDatapackCompilerInputFingerprintTest {
|
||||
nestedKeyPack.toAbsolutePath().normalize().toFile()), compilerRoots);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldInputRootDiscoveryIncludesEveryRetainedGenerationEpoch() throws Exception {
|
||||
Path dataDirectory = tmp.newFolder("history-root-data").toPath();
|
||||
Path serverRoot = tmp.newFolder("history-root-server").toPath();
|
||||
Path world = serverRoot.resolve("dimensions/iris/history");
|
||||
Files.createDirectories(world);
|
||||
Path firstPack = activePack("history-pack-first");
|
||||
Path secondPack = activePack("history-pack-second");
|
||||
write(secondPack.resolve("biomes/second.json"), "second-biome");
|
||||
GenerationHistory history = GenerationHistory.create(
|
||||
world,
|
||||
firstPack,
|
||||
GenerationPackFingerprint.compute(firstPack, GenerationPackFingerprint.CURRENT_VERSION),
|
||||
41L,
|
||||
generationContract(),
|
||||
GenerationRegistryContract.empty()
|
||||
);
|
||||
history.stageUpdate(
|
||||
secondPack,
|
||||
GenerationPackFingerprint.compute(secondPack, GenerationPackFingerprint.CURRENT_VERSION),
|
||||
generationContract(),
|
||||
GenerationRegistryContract.empty(),
|
||||
256
|
||||
);
|
||||
Path legacyDecoy = world.resolve("iris/pack");
|
||||
write(legacyDecoy.resolve("dimensions/legacy.json"), "legacy");
|
||||
|
||||
List<File> compilerRoots = IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot);
|
||||
|
||||
Set<File> expected = Set.of(
|
||||
history.paths().packRoot(history.manifest().activeEpoch().epochId()).toFile(),
|
||||
history.paths().packRoot(history.manifest().pendingEpoch().orElseThrow().epochId()).toFile()
|
||||
);
|
||||
assertEquals(expected, Set.copyOf(compilerRoots));
|
||||
assertEquals(2, compilerRoots.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void corruptGenerationHistoryFailsPackRootDiscoveryClosed() throws Exception {
|
||||
Path dataDirectory = tmp.newFolder("corrupt-history-data").toPath();
|
||||
Path serverRoot = tmp.newFolder("corrupt-history-server").toPath();
|
||||
Files.createDirectories(serverRoot.resolve("dimensions/iris/corrupt/iris/generation"));
|
||||
|
||||
try {
|
||||
IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot);
|
||||
fail("Generation history without a manifest must fail pack discovery");
|
||||
} catch (IOException expected) {
|
||||
assertTrue(expected.getMessage().contains("manifest"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootWorldGenerationHistoryIsACompilerInput() throws Exception {
|
||||
Path dataDirectory = tmp.newFolder("root-history-data").toPath();
|
||||
Path serverRoot = tmp.newFolder("root-history-server").toPath();
|
||||
Path pack = activePack("root-history-pack");
|
||||
GenerationHistory history = GenerationHistory.create(
|
||||
serverRoot,
|
||||
pack,
|
||||
GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION),
|
||||
91L,
|
||||
generationContract(),
|
||||
GenerationRegistryContract.empty()
|
||||
);
|
||||
|
||||
List<File> compilerRoots = IrisDatapackCompiler.collectCompilerInputRoots(dataDirectory, serverRoot);
|
||||
|
||||
assertEquals(List.of(history.activePackRoot().toFile()), compilerRoots);
|
||||
}
|
||||
|
||||
private Path activePack(String name) throws IOException {
|
||||
Path pack = tmp.newFolder(name).toPath();
|
||||
write(pack.resolve("dimensions/overworld.json"), "dimension-a");
|
||||
return pack;
|
||||
}
|
||||
|
||||
private static GenerationEpoch.DimensionContract generationContract() {
|
||||
return new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:overworld_type",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
"c".repeat(64)
|
||||
);
|
||||
}
|
||||
|
||||
private Path write(Path path, String value) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, value, StandardCharsets.UTF_8);
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding;
|
||||
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.v1217.DataFixerV1217;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
|
||||
import art.arcane.iris.engine.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Rule;
|
||||
@@ -19,12 +31,15 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisDatapackCompilerTest {
|
||||
@Rule
|
||||
@@ -60,8 +75,9 @@ public class IrisDatapackCompilerTest {
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/alpha.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/beta.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension_type/world_local.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/alpha/worldgen/biome/alpha_custom.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/beta/worldgen/biome/beta_custom.json")));
|
||||
assertEquals(3, regularFileCount(datapackRoot.resolve("data/iris/worldgen/biome/biomes")));
|
||||
assertFalse(Files.exists(datapackRoot.resolve("data/alpha/worldgen/biome/alpha_custom.json")));
|
||||
assertFalse(Files.exists(datapackRoot.resolve("data/beta/worldgen/biome/beta_custom.json")));
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/world_local/worldgen/biome/world_custom.json")));
|
||||
assertFalse(Files.exists(datapackRoot.resolve("data/iris/dimension_type/hidden.json")));
|
||||
assertFalse(Files.exists(datapackRoot.resolve("data/iris/dimension_type/deleted.json")));
|
||||
@@ -91,7 +107,7 @@ public class IrisDatapackCompilerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void compilesRecursiveDimensionBiomeToCanonicalRegistryPath() throws Exception {
|
||||
public void compilesRecursiveDimensionBiomeToContentAddressedRegistryPath() throws Exception {
|
||||
Path packRoot = temporaryFolder.newFolder("recursive-dimension-pack").toPath();
|
||||
Path datapackRoot = temporaryFolder.newFolder("recursive-dimension-datapack").toPath();
|
||||
createPack(packRoot, "layers/sky", "Aurora");
|
||||
@@ -104,7 +120,9 @@ public class IrisDatapackCompilerTest {
|
||||
false
|
||||
);
|
||||
|
||||
assertTrue(Files.isRegularFile(
|
||||
assertEquals(1, regularFileCount(
|
||||
datapackRoot.resolve("data/iris/worldgen/biome/biomes")));
|
||||
assertFalse(Files.exists(
|
||||
datapackRoot.resolve("data/layers/worldgen/biome/sky/aurora.json")));
|
||||
assertTrue(Files.isRegularFile(
|
||||
datapackRoot.resolve("data/iris/dimension_type/layers_sky.json")));
|
||||
@@ -316,10 +334,22 @@ public class IrisDatapackCompilerTest {
|
||||
new DataFixerV1217()
|
||||
);
|
||||
|
||||
IrisBiomeCustom customBiome = data.getBiomeLoader().load("test")
|
||||
.getCustomDerivitives().getFirst();
|
||||
String hostBiomeKey = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
"host",
|
||||
customBiome,
|
||||
data.getContentGate()
|
||||
);
|
||||
String sourceBiomeKey = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
"layers/source",
|
||||
customBiome,
|
||||
data.getContentGate()
|
||||
);
|
||||
assertTrue(requirements.containsKey("dimension_type/iris:host"));
|
||||
assertTrue(requirements.containsKey("dimension_type/iris:layers_source"));
|
||||
assertTrue(requirements.containsKey("worldgen/biome/host:stack_custom"));
|
||||
assertTrue(requirements.containsKey("worldgen/biome/layers:source/stack_custom"));
|
||||
assertTrue(requirements.containsKey("worldgen/biome/" + hostBiomeKey));
|
||||
assertTrue(requirements.containsKey("worldgen/biome/" + sourceBiomeKey));
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
@@ -386,10 +416,200 @@ public class IrisDatapackCompilerTest {
|
||||
assertTrue(Files.isRegularFile(datapackRoot.resolve("data/iris/dimension/moon.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changedCustomBiomeDefinitionsCoexistUnderContentKeys() throws Exception {
|
||||
Path firstPack = temporaryFolder.newFolder("content-biome-first").toPath();
|
||||
Path secondPack = temporaryFolder.newFolder("content-biome-second").toPath();
|
||||
Path datapackRoot = temporaryFolder.newFolder("content-biome-datapack").toPath();
|
||||
createPack(firstPack, "shared", "shared_custom");
|
||||
createPack(secondPack, "shared", "shared_custom");
|
||||
setCustomBiomeTemperature(secondPack, 1.25);
|
||||
|
||||
IrisDatapackCompiler.compile(
|
||||
List.of(firstPack.toFile(), secondPack.toFile()),
|
||||
new KList<File>().qadd(datapackRoot.toFile()),
|
||||
List.of(),
|
||||
new DataFixerV1217(),
|
||||
false
|
||||
);
|
||||
|
||||
assertEquals(2, regularFileCount(datapackRoot.resolve("data/iris/worldgen/biome/biomes")));
|
||||
assertFalse(Files.exists(datapackRoot.resolve("data/shared/worldgen/biome/shared_custom.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainedPacksCompileBeforeAPlatformIsBound() throws Exception {
|
||||
assertRetainedPackCompilesBeforePlatformBinding(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainedMultiDimensionPacksCompileAndBootstrapBeforePlatformBinding() throws Exception {
|
||||
assertRetainedPackCompilesBeforePlatformBinding(true);
|
||||
}
|
||||
|
||||
private void assertRetainedPackCompilesBeforePlatformBinding(boolean multipleDimensions) throws Exception {
|
||||
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
Path pack = temporaryFolder.newFolder("unbound-retained-pack").toPath();
|
||||
Path server = temporaryFolder.newFolder("unbound-retained-server").toPath();
|
||||
Path world = server.resolve("world/dimensions/iris/history");
|
||||
Files.createDirectories(world);
|
||||
Path output = temporaryFolder.newFolder("unbound-retained-output").toPath();
|
||||
createPack(pack, "overworld", "retained_custom");
|
||||
if (multipleDimensions) {
|
||||
createPack(pack, "upper", "retained_custom", "NETHER");
|
||||
}
|
||||
IrisData data = IrisData.openDatapackCompiler(pack.toFile());
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load("overworld");
|
||||
String fingerprint = GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.blockTypeKeys()).thenReturn(List.of());
|
||||
when(registries.entityKeys()).thenReturn(List.of());
|
||||
PlatformGenerationRegistry registry = new BootstrapGenerationRegistry();
|
||||
GenerationRegistryContract contract = GenerationRegistryContractFactory.create(
|
||||
data, dimension, fingerprint, new DataFixerV1217(), registries, registry);
|
||||
GenerationHistory history = GenerationHistory.create(world, pack, fingerprint, 77L,
|
||||
GenerationEpochContractFactory.create(dimension, "overworld", "iris:overworld"), contract);
|
||||
Path retainedPack = history.paths().packRoot(history.manifest().activeEpoch().epochId());
|
||||
IrisDatapackCompiler.CompilationResult compiled = IrisDatapackCompiler.compile(List.of(retainedPack.toFile()),
|
||||
new KList<File>().qadd(output.toFile()), List.of(), new DataFixerV1217(), false);
|
||||
assertFalse(IrisPlatforms.isBound());
|
||||
int expectedDimensions = multipleDimensions ? 2 : 1;
|
||||
assertEquals(expectedDimensions, compiled.dimensionCount());
|
||||
assertEquals(expectedDimensions, regularFileCount(output.resolve("data/iris/worldgen/biome/biomes")));
|
||||
Map<String, String> requirements = IrisDatapackCompiler.computeRegistryRequirements(
|
||||
List.of(retainedPack.toFile()), new DataFixerV1217());
|
||||
assertTrue(requirements.containsKey("dimension_type/iris:overworld"));
|
||||
if (multipleDimensions) {
|
||||
assertTrue(requirements.containsKey("dimension_type/iris:upper"));
|
||||
assertTrue(Files.isRegularFile(output.resolve("data/iris/dimension_type/upper.json")));
|
||||
assertNotEquals(Files.readString(output.resolve("data/iris/dimension_type/overworld.json")),
|
||||
Files.readString(output.resolve("data/iris/dimension_type/upper.json")));
|
||||
Path dataDirectory = server.resolve("plugins/Iris");
|
||||
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolve(server, new String[0]);
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult installed =
|
||||
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
|
||||
}, startupPaths);
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.INSTALLED, installed.status());
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/iris/dimension_type/upper.json")));
|
||||
assertEquals(2, regularFileCount(installed.datapackRoot().resolve("data/iris/worldgen/biome/biomes")));
|
||||
DefaultPackBootstrapProvisioner.ProvisionResult unchanged =
|
||||
DefaultPackBootstrapProvisioner.provision(dataDirectory, ignored -> {
|
||||
}, startupPaths);
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status());
|
||||
assertFalse(IrisPlatforms.isBound());
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
if (previous != null) {
|
||||
IrisPlatforms.bind(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateCustomIdsPreservePhysicalBiomesAndDeterministicLegacyAlias() throws Exception {
|
||||
Path pack = temporaryFolder.newFolder("duplicate-custom-biomes").toPath().resolve("iris/pack");
|
||||
Path output = temporaryFolder.newFolder("duplicate-custom-output").toPath();
|
||||
createPack(pack, "overworld", "savanna_plateau");
|
||||
Path secondBiome = pack.resolve("biomes/second.json");
|
||||
Files.writeString(secondBiome, """
|
||||
{"name":"Second Plateau","derivative":"minecraft:plains",
|
||||
"customDerivitives":[{"id":"savanna_plateau","grassColor":"#BFB755"}]}
|
||||
""", StandardCharsets.UTF_8);
|
||||
IrisDatapackCompiler.CompilationResult compiled = IrisDatapackCompiler.compile(
|
||||
List.of(pack.toFile()), new KList<File>().qadd(output.toFile()),
|
||||
List.of(), new DataFixerV1217(), false);
|
||||
assertEquals(2, compiled.biomeCount());
|
||||
assertEquals(2, regularFileCount(output.resolve("data/iris/worldgen/biome/biomes")));
|
||||
Map<String, String> requirements = IrisDatapackCompiler.computeRegistryRequirements(
|
||||
List.of(pack.toFile()), new DataFixerV1217());
|
||||
assertTrue(requirements.containsKey("worldgen/biome/overworld:savanna_plateau"));
|
||||
IrisData data = IrisData.openDatapackCompiler(pack.toFile());
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load("overworld");
|
||||
IrisBiomeCustom first = data.getBiomeLoader().load("test").getCustomDerivitives().getFirst();
|
||||
IrisBiomeCustom second = data.getBiomeLoader().load("second").getCustomDerivitives().getFirst();
|
||||
String firstKey = data.customBiomeResourceKey(dimension, first);
|
||||
String secondKey = data.customBiomeResourceKey(dimension, second);
|
||||
assertNotEquals(firstKey, secondKey);
|
||||
String selected = firstKey.compareTo(secondKey) < 0 ? firstKey : secondKey;
|
||||
assertEquals(selected, data.customBiomeResourceKey(dimension, "savanna_plateau"));
|
||||
Path physicalFile = output.resolve("data/iris/worldgen/biome/" + selected.substring("iris:".length()) + ".json");
|
||||
assertEquals(Files.readString(physicalFile),
|
||||
Files.readString(output.resolve("data/overworld/worldgen/biome/savanna_plateau.json")));
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedPhysicalBiomesMergeTagRequirementsAcrossPacks() throws Exception {
|
||||
Path first = temporaryFolder.newFolder("biome-tags-first").toPath();
|
||||
Path second = temporaryFolder.newFolder("biome-tags-second").toPath();
|
||||
Path output = temporaryFolder.newFolder("biome-tags-output").toPath();
|
||||
createPack(first, "shared", "shared_custom");
|
||||
createPack(second, "shared", "shared_custom");
|
||||
Path changed = second.resolve("biomes/test.json");
|
||||
Files.writeString(changed, Files.readString(changed).replace(
|
||||
"\"id\": \"shared_custom\"",
|
||||
"\"id\": \"shared_custom\", \"tags\": [\"example:second\"]"));
|
||||
Map<String, String> requirements = IrisDatapackCompiler.computeRegistryRequirements(
|
||||
List.of(first.toFile(), second.toFile()), new DataFixerV1217());
|
||||
Map<String, String> reversed = IrisDatapackCompiler.computeRegistryRequirements(
|
||||
List.of(second.toFile(), first.toFile()), new DataFixerV1217());
|
||||
assertEquals(requirements, reversed);
|
||||
IrisDatapackCompiler.compile(List.of(first.toFile(), second.toFile()),
|
||||
new KList<File>().qadd(output.toFile()), List.of(), new DataFixerV1217(), false);
|
||||
assertEquals(1, regularFileCount(output.resolve("data/iris/worldgen/biome/biomes")));
|
||||
assertTrue(Files.isRegularFile(output.resolve("data/example/tags/worldgen/biome/second.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conflictingLegacyBiomeAliasesFailClosed() throws Exception {
|
||||
Path firstPack = temporaryFolder.newFolder("legacy-biome-first").toPath().resolve("iris/pack");
|
||||
Path secondPack = temporaryFolder.newFolder("legacy-biome-second").toPath().resolve("iris/pack");
|
||||
Path datapackRoot = temporaryFolder.newFolder("legacy-biome-datapack").toPath();
|
||||
createPack(firstPack, "shared", "shared_custom");
|
||||
createPack(secondPack, "shared", "shared_custom");
|
||||
setCustomBiomeTemperature(secondPack, 1.25);
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> IrisDatapackCompiler.compile(
|
||||
List.of(firstPack.toFile(), secondPack.toFile()),
|
||||
new KList<File>().qadd(datapackRoot.toFile()),
|
||||
List.of(),
|
||||
new DataFixerV1217(),
|
||||
false
|
||||
)
|
||||
);
|
||||
|
||||
assertTrue(failure.getMessage().contains("Conflicting custom biome output"));
|
||||
}
|
||||
|
||||
private static void createPack(Path root, String dimensionKey, String biomeId) throws Exception {
|
||||
createPack(root, dimensionKey, biomeId, "NORMAL");
|
||||
}
|
||||
|
||||
private static long regularFileCount(Path root) throws IOException {
|
||||
try (Stream<Path> files = Files.list(root)) {
|
||||
return files.filter(Files::isRegularFile).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static void setCustomBiomeTemperature(Path packRoot, double temperature) throws IOException {
|
||||
Path biome = packRoot.resolve("biomes/test.json");
|
||||
String source = Files.readString(biome, StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
biome,
|
||||
source.replace("\"id\": \"shared_custom\"",
|
||||
"\"id\": \"shared_custom\", \"temperature\": " + temperature),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
}
|
||||
|
||||
private static void createPack(
|
||||
Path root,
|
||||
String dimensionKey,
|
||||
@@ -439,4 +659,26 @@ public class IrisDatapackCompilerTest {
|
||||
dimension
|
||||
);
|
||||
}
|
||||
|
||||
private static final class BootstrapGenerationRegistry implements PlatformGenerationRegistry {
|
||||
@Override
|
||||
public String runtimeIdentity() {
|
||||
return "bootstrap-test";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatedDefinitionRendererIdentity() {
|
||||
return "bootstrap-renderer-v1";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) {
|
||||
return "iris:" + dimensionTypeKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Definition definition(String registryKey, String resourceKey) {
|
||||
return Definition.resourceIdentity(registryKey, resourceKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,17 @@ public class IrisSettingsDefaultsTest {
|
||||
assertFalse(settings.isAutoImportDatapackStructures());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generationTransitionWidthHasSafeBounds() {
|
||||
IrisSettings.IrisSettingsGenerator settings = new IrisSettings.IrisSettingsGenerator();
|
||||
|
||||
assertEquals(256, settings.getGenerationTransitionWidthBlocks());
|
||||
settings.setGenerationTransitionWidthBlocks(1);
|
||||
assertEquals(16, settings.getGenerationTransitionWidthBlocks());
|
||||
settings.setGenerationTransitionWidthBlocks(20_000);
|
||||
assertEquals(8_192, settings.getGenerationTransitionWidthBlocks());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hotloadSnapshotIsValidatedBeforeReplacingLiveSettings() {
|
||||
IrisSettings previous = IrisSettings.settings;
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.engine.history.GenerationEpoch;
|
||||
import art.arcane.iris.engine.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -406,26 +412,34 @@ public class IrisWorldStorageTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frozenPackRootRequiresRealWorldLocalSnapshot() throws Exception {
|
||||
File dimensionRoot = temporaryFolder.newFolder("frozen-pack-world");
|
||||
public void activeGenerationPackRequiresValidImmutableHistory() throws Exception {
|
||||
File dimensionRoot = temporaryFolder.newFolder("generation-history-world");
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)
|
||||
() -> IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot)
|
||||
);
|
||||
|
||||
Path irisRoot = Files.createDirectory(dimensionRoot.toPath().resolve("iris"));
|
||||
Path externalPack = temporaryFolder.newFolder("external-pack").toPath();
|
||||
Files.createSymbolicLink(irisRoot.resolve("pack"), externalPack);
|
||||
Path externalGeneration = temporaryFolder.newFolder("external-generation").toPath();
|
||||
Files.createSymbolicLink(irisRoot.resolve("generation"), externalGeneration);
|
||||
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisWorldStorage.requireFrozenPackRoot(dimensionRoot)
|
||||
() -> IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot)
|
||||
);
|
||||
|
||||
Files.delete(irisRoot.resolve("pack"));
|
||||
Path packRoot = Files.createDirectory(irisRoot.resolve("pack"));
|
||||
assertEquals(packRoot.toFile(), IrisWorldStorage.requireFrozenPackRoot(dimensionRoot));
|
||||
Files.delete(irisRoot.resolve("generation"));
|
||||
Path sourcePack = createGenerationPack("storage-active-pack");
|
||||
GenerationHistory history = createGenerationHistory(dimensionRoot.toPath(), sourcePack, 42L);
|
||||
assertEquals(
|
||||
history.activePackRoot().toFile(),
|
||||
IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot, 42L)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> IrisWorldStorage.requireActiveGenerationPackRoot(dimensionRoot, 43L)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -444,4 +458,44 @@ public class IrisWorldStorageTest {
|
||||
Files.createDirectory(namespace.resolve("moon"));
|
||||
assertTrue(IrisWorldStorage.hasManagedWorldStorage(levelRoot));
|
||||
}
|
||||
|
||||
private Path createGenerationPack(String name) throws IOException {
|
||||
Path pack = temporaryFolder.newFolder(name).toPath();
|
||||
Path dimensions = Files.createDirectories(pack.resolve("dimensions"));
|
||||
Files.writeString(dimensions.resolve("overworld.json"), "{}");
|
||||
return pack;
|
||||
}
|
||||
|
||||
private static GenerationHistory createGenerationHistory(Path world, Path pack, long seed)
|
||||
throws IOException {
|
||||
String fingerprint = GenerationPackFingerprint.compute(
|
||||
pack,
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
GenerationEpoch.DimensionContract dimensionContract = new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:overworld_type",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
"c".repeat(64)
|
||||
);
|
||||
return GenerationHistory.create(
|
||||
world,
|
||||
pack,
|
||||
fingerprint,
|
||||
seed,
|
||||
dimensionContract,
|
||||
GenerationRegistryContract.empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -85,6 +85,7 @@ public class ServerConfiguratorRegistryVerificationTest {
|
||||
new File("packs/overworld/dimensions/overworld.json"));
|
||||
when(dimension.getDimensionTypeKey()).thenReturn("iris:overworld");
|
||||
when(dimension.getLoader()).thenReturn(data);
|
||||
when(data.customBiomeResourceKey(dimension, customBiome)).thenReturn("overworld:missing");
|
||||
|
||||
ResourceLoader<IrisDimension> loader = mock(ResourceLoader.class);
|
||||
when(loader.getPossibleKeys()).thenReturn(new String[]{"overworld"});
|
||||
|
||||
@@ -175,7 +175,7 @@ public class BukkitWorldConfigurationTest {
|
||||
assertEquals(
|
||||
levelRoot.resolve("dimensions/iris/broken").toAbsolutePath().normalize(),
|
||||
entryOf(audited, "world_iris_broken").storagePath());
|
||||
assertTrue(entryOf(audited, "world_iris_broken").detail().contains("pack"));
|
||||
assertTrue(entryOf(audited, "world_iris_broken").detail().contains("generation history"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+71
-27
@@ -6,6 +6,11 @@ import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSna
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Phase;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementJournal.Transaction;
|
||||
import art.arcane.iris.engine.history.GenerationEpoch;
|
||||
import art.arcane.iris.engine.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
@@ -59,18 +64,17 @@ public class WorldReplacementBootstrapTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publishesArmedReplacementAfterFinderAddsNestedMetadata() throws Exception {
|
||||
public void rejectsNestedMetadataAddedAfterImmutableEpochPublication() throws Exception {
|
||||
Transaction transaction = stagedTransaction(Phase.ARMED, true, "original");
|
||||
configureReplacement(transaction);
|
||||
Path dimensions = paths(transaction).stage().resolve("iris/pack/dimensions");
|
||||
Path dimensions = activePackRoot(paths(transaction).stage()).resolve("dimensions");
|
||||
Files.writeString(dimensions.resolve(".DS_Store"), "Finder metadata");
|
||||
|
||||
WorldReplacementBootstrap.ReconcileResult result = reconcile();
|
||||
assertThrows(IOException.class, this::reconcile);
|
||||
|
||||
assertEquals(1, result.published());
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals("original", Files.readString(backup(transaction).resolve("original.txt")));
|
||||
assertEquals(Phase.PUBLISHED, loadSingle().phase());
|
||||
assertTrue(Files.isDirectory(paths(transaction).stage()));
|
||||
assertEquals("original", Files.readString(target.worldDirectory().resolve("original.txt")));
|
||||
assertEquals(Phase.ARMED, loadSingle().phase());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -267,12 +271,8 @@ public class WorldReplacementBootstrapTest {
|
||||
UUID endId = UUID.randomUUID();
|
||||
ReplacementPaths endPaths = WorldReplacementFilesystem.paths(endTarget, endId);
|
||||
writeOriginalTarget(endPaths, "end-original");
|
||||
Path endDimension = endPaths.stage().resolve("iris/pack/dimensions/theend.json");
|
||||
Files.createDirectories(endDimension.getParent());
|
||||
Files.writeString(endDimension, "end-replacement");
|
||||
String endFingerprint = WorldReplacementFilesystem.fingerprintPack(
|
||||
endPaths.stage().resolve("iris/pack")
|
||||
);
|
||||
Path endPack = createStagedHistory(endPaths.stage(), "theend", "end-replacement", SEED);
|
||||
String endFingerprint = WorldReplacementFilesystem.fingerprintPack(endPack);
|
||||
Transaction end = new Transaction(
|
||||
endId,
|
||||
endKey,
|
||||
@@ -295,8 +295,8 @@ public class WorldReplacementBootstrapTest {
|
||||
assertEquals(0, result.rolledBack());
|
||||
assertEquals(0, result.retained());
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals("end-replacement", Files.readString(endTarget.worldDirectory()
|
||||
.resolve("iris/pack/dimensions/theend.json")));
|
||||
assertEquals("end-replacement", Files.readString(activePackRoot(endTarget.worldDirectory())
|
||||
.resolve("dimensions/theend.json")));
|
||||
assertEquals("nether-original", Files.readString(backup(nether).resolve("original.txt")));
|
||||
assertEquals("end-original", Files.readString(endPaths.backup().resolve("original.txt")));
|
||||
List<Transaction> published = WorldReplacementJournal.load(dataDirectory, levelRoot);
|
||||
@@ -317,13 +317,14 @@ public class WorldReplacementBootstrapTest {
|
||||
UUID overworldId = UUID.randomUUID();
|
||||
ReplacementPaths overworldPaths = WorldReplacementFilesystem.paths(overworldTarget, overworldId);
|
||||
writeOriginalTarget(overworldPaths, "overworld-original");
|
||||
Path overworldDimension = overworldPaths.stage().resolve("iris/pack/dimensions/overworld.json");
|
||||
Files.createDirectories(overworldDimension.getParent());
|
||||
Files.writeString(overworldDimension, "overworld-replacement");
|
||||
WorldReplacementEntryGuard.stage(levelRoot, overworldPaths.stage(), overworldId);
|
||||
String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack(
|
||||
overworldPaths.stage().resolve("iris/pack")
|
||||
Path overworldPack = createStagedHistory(
|
||||
overworldPaths.stage(),
|
||||
"overworld",
|
||||
"overworld-replacement",
|
||||
overworldSeed
|
||||
);
|
||||
WorldReplacementEntryGuard.stage(levelRoot, overworldPaths.stage(), overworldId);
|
||||
String overworldFingerprint = WorldReplacementFilesystem.fingerprintPack(overworldPack);
|
||||
Transaction overworld = new Transaction(
|
||||
overworldId,
|
||||
overworldKey,
|
||||
@@ -358,7 +359,7 @@ public class WorldReplacementBootstrapTest {
|
||||
target.worldDirectory()
|
||||
);
|
||||
assertEquals("overworld-replacement", Files.readString(
|
||||
overworldTarget.worldDirectory().resolve("iris/pack/dimensions/overworld.json")
|
||||
activePackRoot(overworldTarget.worldDirectory()).resolve("dimensions/overworld.json")
|
||||
));
|
||||
assertEquals("replacement", replacementContent(target.worldDirectory()));
|
||||
assertEquals("overworld-original", Files.readString(overworldPaths.backup().resolve("original.txt")));
|
||||
@@ -474,10 +475,8 @@ public class WorldReplacementBootstrapTest {
|
||||
if (originalPresent) {
|
||||
writeOriginalTarget(paths, originalContent);
|
||||
}
|
||||
Path dimension = paths.stage().resolve("iris/pack/dimensions/underworld.json");
|
||||
Files.createDirectories(dimension.getParent());
|
||||
Files.writeString(dimension, "replacement");
|
||||
String fingerprint = WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack"));
|
||||
Path pack = createStagedHistory(paths.stage(), "underworld", "replacement", SEED);
|
||||
String fingerprint = WorldReplacementFilesystem.fingerprintPack(pack);
|
||||
Transaction transaction = new Transaction(
|
||||
id,
|
||||
WORLD_KEY,
|
||||
@@ -545,6 +544,51 @@ public class WorldReplacementBootstrapTest {
|
||||
}
|
||||
|
||||
private String replacementContent(Path worldDirectory) throws Exception {
|
||||
return Files.readString(worldDirectory.resolve("iris/pack/dimensions/underworld.json"));
|
||||
return Files.readString(activePackRoot(worldDirectory).resolve("dimensions/underworld.json"));
|
||||
}
|
||||
|
||||
private Path createStagedHistory(Path world, String dimensionKey, String content, long seed)
|
||||
throws Exception {
|
||||
Path source = world.resolveSibling(world.getFileName() + "." + dimensionKey + ".pack-source");
|
||||
Path dimension = source.resolve("dimensions").resolve(dimensionKey + ".json");
|
||||
Files.createDirectories(dimension.getParent());
|
||||
Files.writeString(dimension, content);
|
||||
String fingerprint = GenerationPackFingerprint.compute(
|
||||
source,
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
Files.createDirectory(world);
|
||||
return GenerationHistory.create(
|
||||
world,
|
||||
source,
|
||||
fingerprint,
|
||||
seed,
|
||||
generationContract(dimensionKey),
|
||||
GenerationRegistryContract.empty()
|
||||
).activePackRoot();
|
||||
}
|
||||
|
||||
private static Path activePackRoot(Path world) throws IOException {
|
||||
return GenerationHistory.open(world).activePackRoot();
|
||||
}
|
||||
|
||||
private static GenerationEpoch.DimensionContract generationContract(String dimensionKey) {
|
||||
return new GenerationEpoch.DimensionContract(
|
||||
dimensionKey,
|
||||
"iris:" + dimensionKey + "_type",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
"c".repeat(64)
|
||||
);
|
||||
}
|
||||
}
|
||||
+46
-5
@@ -1,5 +1,10 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.engine.history.GenerationEpoch;
|
||||
import art.arcane.iris.engine.history.GenerationEpochContractFactory;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationPackFingerprint;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
@@ -216,7 +221,7 @@ public class WorldReplacementFilesystemTest {
|
||||
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.target().resolve("iris/generation")));
|
||||
assertFalse(Files.exists(paths.stage()));
|
||||
assertFalse(Files.exists(paths.backup()));
|
||||
}
|
||||
@@ -492,10 +497,24 @@ public class WorldReplacementFilesystemTest {
|
||||
}
|
||||
|
||||
private String writeStage(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
|
||||
Path contentFile = packContent(paths.stage());
|
||||
Path source = paths.stage().resolveSibling(paths.stage().getFileName() + ".pack-source");
|
||||
Path contentFile = source.resolve("dimensions/underworld.json");
|
||||
Files.createDirectories(contentFile.getParent());
|
||||
Files.writeString(contentFile, content);
|
||||
return WorldReplacementFilesystem.fingerprintPack(paths.stage().resolve("iris/pack"));
|
||||
String generationFingerprint = GenerationPackFingerprint.compute(
|
||||
source,
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
Files.createDirectory(paths.stage());
|
||||
GenerationHistory history = GenerationHistory.create(
|
||||
paths.stage(),
|
||||
source,
|
||||
generationFingerprint,
|
||||
42L,
|
||||
generationContract("underworld"),
|
||||
GenerationRegistryContract.empty()
|
||||
);
|
||||
return WorldReplacementFilesystem.fingerprintPack(history.activePackRoot());
|
||||
}
|
||||
|
||||
private void writeOriginalTarget(WorldReplacementFilesystem.ReplacementPaths paths, String content) throws Exception {
|
||||
@@ -535,8 +554,30 @@ public class WorldReplacementFilesystemTest {
|
||||
return Files.readString(packContent(worldDirectory));
|
||||
}
|
||||
|
||||
private Path packContent(Path worldDirectory) {
|
||||
return worldDirectory.resolve("iris/pack/dimensions/underworld.json");
|
||||
private Path packContent(Path worldDirectory) throws IOException {
|
||||
return GenerationHistory.open(worldDirectory)
|
||||
.activePackRoot()
|
||||
.resolve("dimensions/underworld.json");
|
||||
}
|
||||
|
||||
private static GenerationEpoch.DimensionContract generationContract(String dimensionKey) {
|
||||
return new GenerationEpoch.DimensionContract(
|
||||
dimensionKey,
|
||||
"iris:" + dimensionKey + "_type",
|
||||
"NETHER",
|
||||
"OVERWORLD",
|
||||
0,
|
||||
0,
|
||||
256,
|
||||
256,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
"c".repeat(64)
|
||||
);
|
||||
}
|
||||
|
||||
private String artifactStem(String name, UUID transactionId) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package art.arcane.iris.core.loader;
|
||||
|
||||
import art.arcane.iris.engine.framework.PreservationRegistry;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContract;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.spy;
|
||||
|
||||
public class IrisDataRetainedPackTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
private IrisData retained;
|
||||
|
||||
@Before
|
||||
public void openRetainedData() throws Exception {
|
||||
IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class));
|
||||
retained = spy(IrisData.openRuntime(temporaryFolder.newFolder("retained-pack")));
|
||||
retained.bindGenerationRegistryContract(GenerationRegistryContract.empty());
|
||||
}
|
||||
|
||||
@After
|
||||
public void closeData() {
|
||||
if (retained != null) {
|
||||
retained.close();
|
||||
}
|
||||
IrisServices.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingRetainedResourceNeverSearchesMutableGlobalPacks() {
|
||||
doReturn(null).when(retained).load(IrisDimension.class, "missing", false);
|
||||
|
||||
try (MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class)) {
|
||||
assertNull(IrisData.loadAnyDimension("missing", retained));
|
||||
platforms.verifyNoInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void existingRetainedResourceComesFromItsBoundPack() {
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
doReturn(dimension).when(retained).load(IrisDimension.class, "overworld", false);
|
||||
|
||||
try (MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class)) {
|
||||
assertSame(dimension, IrisData.loadAnyDimension("overworld", retained));
|
||||
platforms.verifyNoInteractions();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,15 +379,13 @@ public class IrisLanguageTest {
|
||||
{
|
||||
"locale": "de_DE",
|
||||
"messages": {
|
||||
"iris.bukkit.runtime.commanddeveloper.update_world_warning": [
|
||||
"iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [
|
||||
"Sicherung erstellen.",
|
||||
"Mögliche Probleme:",
|
||||
" - Beschädigte Chunks",
|
||||
" - Neu generierte Chunks",
|
||||
" - Fehlende Strukturen",
|
||||
" - Nicht verbundene Höhlen",
|
||||
" - Nicht verbundene Geländeschichten",
|
||||
"Risiken bestätigt.",
|
||||
"Vorhandene Chunks bleiben erhalten.",
|
||||
"Neue Chunks verwenden das neue Paket.",
|
||||
"Die Grenze bleibt gespeichert.",
|
||||
"Den vollständigen Verlauf sichern.",
|
||||
"Neustart bestätigen.",
|
||||
"Welt {world}, Paket {pack}"
|
||||
],
|
||||
"iris.bukkit.runtime.commandpack.more_warning_s": {
|
||||
@@ -401,13 +399,11 @@ public class IrisLanguageTest {
|
||||
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
|
||||
assertEquals(
|
||||
"Sicherung erstellen.\n"
|
||||
+ "Mögliche Probleme:\n"
|
||||
+ " - Beschädigte Chunks\n"
|
||||
+ " - Neu generierte Chunks\n"
|
||||
+ " - Fehlende Strukturen\n"
|
||||
+ " - Nicht verbundene Höhlen\n"
|
||||
+ " - Nicht verbundene Geländeschichten\n"
|
||||
+ "Risiken bestätigt.\n"
|
||||
+ "Vorhandene Chunks bleiben erhalten.\n"
|
||||
+ "Neue Chunks verwenden das neue Paket.\n"
|
||||
+ "Die Grenze bleibt gespeichert.\n"
|
||||
+ "Den vollständigen Verlauf sichern.\n"
|
||||
+ "Neustart bestätigen.\n"
|
||||
+ "Welt world, Paket pack",
|
||||
IrisLanguage.plain(
|
||||
BukkitRuntimeMessages.COMMAND_DEVELOPER_UPDATE_WORLD_WARNING,
|
||||
|
||||
@@ -2,20 +2,26 @@ package art.arcane.iris.core.nms;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ServerShutdownBoundaryTest {
|
||||
@Test
|
||||
public void await_returnsImmediatelyWhenBoundaryIsAlreadyReached() {
|
||||
assertTrue(ServerShutdownBoundary.await(
|
||||
assertTrue(new ServerShutdownBoundary(
|
||||
() -> true,
|
||||
Thread.currentThread(),
|
||||
Thread.currentThread()).await(
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS
|
||||
));
|
||||
@@ -23,9 +29,9 @@ public class ServerShutdownBoundaryTest {
|
||||
|
||||
@Test
|
||||
public void await_doesNotJoinTheCallingServerThread() {
|
||||
assertFalse(ServerShutdownBoundary.await(
|
||||
assertFalse(new ServerShutdownBoundary(
|
||||
() -> false,
|
||||
Thread.currentThread(),
|
||||
Thread.currentThread()).await(
|
||||
5L,
|
||||
TimeUnit.SECONDS
|
||||
));
|
||||
@@ -44,11 +50,10 @@ public class ServerShutdownBoundaryTest {
|
||||
await(releaseServer);
|
||||
boundaryReached.set(true);
|
||||
}, "server-boundary-test");
|
||||
ServerShutdownBoundary boundary = new ServerShutdownBoundary(boundaryReached::get, serverThread);
|
||||
Thread waiterThread = new Thread(() -> {
|
||||
waiterStarted.countDown();
|
||||
result.set(ServerShutdownBoundary.await(
|
||||
boundaryReached::get,
|
||||
serverThread,
|
||||
result.set(boundary.await(
|
||||
5L,
|
||||
TimeUnit.SECONDS
|
||||
));
|
||||
@@ -76,9 +81,9 @@ public class ServerShutdownBoundaryTest {
|
||||
serverThread.start();
|
||||
|
||||
try {
|
||||
assertFalse(ServerShutdownBoundary.await(
|
||||
assertFalse(new ServerShutdownBoundary(
|
||||
() -> false,
|
||||
serverThread,
|
||||
serverThread).await(
|
||||
0L,
|
||||
TimeUnit.MILLISECONDS
|
||||
));
|
||||
@@ -88,6 +93,34 @@ public class ServerShutdownBoundaryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparationLinksTheBoundarySupplierBeforeShutdown() {
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
ServerShutdownBoundary boundary = new ServerShutdownBoundary(
|
||||
() -> reads.incrementAndGet() > 1, Thread.currentThread());
|
||||
|
||||
assertEquals(1, reads.get());
|
||||
assertTrue(boundary.await(0L, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preparedBoundaryRemainsUsableAfterItsClassLoaderCloses() throws Exception {
|
||||
URL classes = ServerShutdownBoundary.class.getProtectionDomain().getCodeSource().getLocation();
|
||||
AtomicBoolean reached = new AtomicBoolean(false);
|
||||
BooleanSupplier supplier = reached::get;
|
||||
Object boundary;
|
||||
Method awaitMethod;
|
||||
try (URLClassLoader loader = new URLClassLoader(new URL[]{classes}, ClassLoader.getPlatformClassLoader())) {
|
||||
Class<?> type = Class.forName(ServerShutdownBoundary.class.getName(), true, loader);
|
||||
boundary = type.getConstructor(BooleanSupplier.class, Thread.class)
|
||||
.newInstance(supplier, Thread.currentThread());
|
||||
awaitMethod = type.getMethod("await", long.class, TimeUnit.class);
|
||||
}
|
||||
|
||||
reached.set(true);
|
||||
assertEquals(Boolean.TRUE, awaitMethod.invoke(boundary, 0L, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await();
|
||||
|
||||
+24
-15
@@ -1,6 +1,8 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.BukkitStartupPaths;
|
||||
import art.arcane.iris.engine.history.GenerationRegistryContractFactory;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.Assume;
|
||||
@@ -118,8 +120,8 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
assertTrue(Files.isRegularFile(installed.packRoots().get("underworld").resolve("dimensions/underworld_roof.json")));
|
||||
assertEquals(root.resolve("datapacks/iris"), installed.datapackRoot());
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta")));
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
|
||||
assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(installed.datapackRoot(), "overworld", "bootstrap_biome")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(installed.datapackRoot(), "underworld", "underworld_biome")));
|
||||
assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack")));
|
||||
assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(
|
||||
dataDirectory,
|
||||
@@ -176,8 +178,8 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, rebuilt.status());
|
||||
assertEquals(2, requests.get());
|
||||
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("pack.mcmeta")));
|
||||
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json")));
|
||||
assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/underworld/worldgen/biome/underworld_biome.json")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(rebuilt.datapackRoot(), "overworld", "bootstrap_biome")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(rebuilt.datapackRoot(), "underworld", "underworld_biome")));
|
||||
} finally {
|
||||
if (!serverStopped) {
|
||||
server.stop(0);
|
||||
@@ -323,7 +325,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
assertEquals(1, requests.get());
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
assertEquals(target.toRealPath(), link.toRealPath());
|
||||
assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(result.datapackRoot(), "overworld", "local_biome")));
|
||||
|
||||
Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8);
|
||||
assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(
|
||||
@@ -375,7 +377,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
|
||||
assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, updated.status());
|
||||
assertEquals(2, requests.get());
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/second/worldgen/biome/second_biome.json")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "second", "second_biome")));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/world_local/worldgen/biome/world_local_biome.json")));
|
||||
} finally {
|
||||
server.stop(0);
|
||||
@@ -406,7 +408,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
|
||||
assertEquals(3, requests.get());
|
||||
assertTrue(Files.readString(editedBiome).contains("locally_edited_biome"));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot().resolve("data/overworld/worldgen/biome/locally_edited_biome.json")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "overworld", "locally_edited_biome")));
|
||||
Properties marker = new Properties();
|
||||
try (InputStream input = Files.newInputStream(dataDirectory.resolve("bootstrap/provisioned.properties"))) {
|
||||
marker.load(input);
|
||||
@@ -446,8 +448,7 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
|
||||
assertEquals(3, requests.get());
|
||||
assertTrue(Files.readString(editedBiome).contains("underworld_local_edit"));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot()
|
||||
.resolve("data/underworld/worldgen/biome/underworld_local_edit.json")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "underworld", "underworld_local_edit")));
|
||||
Properties marker = loadProperties(dataDirectory.resolve("bootstrap/provisioned.properties"));
|
||||
assertEquals("true", marker.getProperty("pack.overworld.managed"));
|
||||
assertEquals("false", marker.getProperty("pack.underworld.managed"));
|
||||
@@ -491,12 +492,9 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
.contains("overworld_first"));
|
||||
assertTrue(Files.readString(dataDirectory.resolve("packs/underworld/biomes/local.json"))
|
||||
.contains("underworld_second"));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot()
|
||||
.resolve("data/overworld/worldgen/biome/overworld_first.json")));
|
||||
assertTrue(Files.isRegularFile(updated.datapackRoot()
|
||||
.resolve("data/underworld/worldgen/biome/underworld_second.json")));
|
||||
assertFalse(Files.exists(updated.datapackRoot()
|
||||
.resolve("data/underworld/worldgen/biome/underworld_first.json")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "overworld", "overworld_first")));
|
||||
assertTrue(Files.isRegularFile(installedBiomePath(updated.datapackRoot(), "underworld", "underworld_second")));
|
||||
assertFalse(Files.exists(installedBiomePath(updated.datapackRoot(), "underworld", "underworld_first")));
|
||||
} finally {
|
||||
if (initialServer != null) {
|
||||
initialServer.stop(0);
|
||||
@@ -668,6 +666,17 @@ public class DefaultPackBootstrapProvisionerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static Path installedBiomePath(Path datapackRoot, String dimension, String id) {
|
||||
String resourceKey = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
dimension, new IrisBiomeCustom().setId(id)
|
||||
);
|
||||
int separator = resourceKey.indexOf(':');
|
||||
return datapackRoot.resolve("data")
|
||||
.resolve(resourceKey.substring(0, separator))
|
||||
.resolve("worldgen/biome")
|
||||
.resolve(resourceKey.substring(separator + 1) + ".json");
|
||||
}
|
||||
|
||||
private static DefaultPackBootstrapProvisioner.ProvisionOptions options(
|
||||
HttpServer server,
|
||||
Path serverRoot,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.core.Logger;
|
||||
import org.apache.logging.log4j.core.config.LoggerConfig;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class LogFilterSVCTest {
|
||||
@Test
|
||||
public void removesFilterFromItsOriginalLoggerAfterLoggingShutdown() {
|
||||
Logger logger = mock(Logger.class);
|
||||
LoggerConfig configuration = mock(LoggerConfig.class);
|
||||
when(logger.get()).thenReturn(configuration);
|
||||
LogFilterSVC service = new LogFilterSVC();
|
||||
|
||||
try (MockedStatic<LogManager> logging = mockStatic(LogManager.class)) {
|
||||
logging.when(LogManager::getRootLogger).thenReturn(logger);
|
||||
service.onEnable();
|
||||
service.onEnable();
|
||||
logging.when(LogManager::getRootLogger).thenThrow(new IllegalStateException("Logging stopped"));
|
||||
|
||||
service.onDisable();
|
||||
service.onDisable();
|
||||
|
||||
logging.verify(LogManager::getRootLogger, times(1));
|
||||
verify(configuration, times(1)).addFilter(service);
|
||||
verify(configuration, times(1)).removeFilter(service);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bindsToTheNewConfigurationAfterReenable() {
|
||||
Logger firstLogger = mock(Logger.class);
|
||||
Logger secondLogger = mock(Logger.class);
|
||||
LoggerConfig firstConfiguration = mock(LoggerConfig.class);
|
||||
LoggerConfig secondConfiguration = mock(LoggerConfig.class);
|
||||
when(firstLogger.get()).thenReturn(firstConfiguration);
|
||||
when(secondLogger.get()).thenReturn(secondConfiguration);
|
||||
LogFilterSVC service = new LogFilterSVC();
|
||||
|
||||
try (MockedStatic<LogManager> logging = mockStatic(LogManager.class)) {
|
||||
logging.when(LogManager::getRootLogger).thenReturn(firstLogger, secondLogger);
|
||||
service.onEnable();
|
||||
service.onDisable();
|
||||
service.onEnable();
|
||||
service.onDisable();
|
||||
|
||||
verify(firstConfiguration).addFilter(service);
|
||||
verify(firstConfiguration).removeFilter(service);
|
||||
verify(secondConfiguration).addFilter(service);
|
||||
verify(secondConfiguration).removeFilter(service);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class PreservationSVCTest {
|
||||
@Test
|
||||
public void retainsShutdownExecutorsUntilTheyActuallyTerminate() {
|
||||
PreservationSVC preservation = new PreservationSVC();
|
||||
ExecutorService executor = mock(ExecutorService.class);
|
||||
when(executor.isShutdown()).thenReturn(true);
|
||||
when(executor.isTerminated()).thenReturn(false);
|
||||
preservation.register(executor);
|
||||
|
||||
try (MockedStatic<IrisData> data = mockStatic(IrisData.class)) {
|
||||
preservation.dereference();
|
||||
assertTrue(preservation.hasActiveResources());
|
||||
|
||||
when(executor.isTerminated()).thenReturn(true);
|
||||
preservation.dereference();
|
||||
assertFalse(preservation.hasActiveResources());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tracksOwnedThreadsUntilTheyExit() throws Exception {
|
||||
PreservationSVC preservation = new PreservationSVC();
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch stop = new CountDownLatch(1);
|
||||
Thread worker = new Thread(() -> awaitStop(started, stop), "preservation-shutdown-test");
|
||||
preservation.register(worker);
|
||||
worker.start();
|
||||
try {
|
||||
assertTrue(started.await(5L, TimeUnit.SECONDS));
|
||||
assertTrue(preservation.hasActiveResources());
|
||||
} finally {
|
||||
stop.countDown();
|
||||
worker.join(5000L);
|
||||
}
|
||||
assertFalse(worker.isAlive());
|
||||
assertFalse(preservation.hasActiveResources());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyPreservationDoesNotPreventLoaderRelease() {
|
||||
assertFalse(new PreservationSVC().hasActiveResources());
|
||||
}
|
||||
|
||||
private static void awaitStop(CountDownLatch started, CountDownLatch stop) {
|
||||
started.countDown();
|
||||
try {
|
||||
stop.await();
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -15,8 +15,8 @@ public class StudioSVCWorldPackFeedbackContractTest {
|
||||
"src/main/java/art/arcane/iris/core/service/StudioSVC.java"
|
||||
)).replace("\r\n", "\n");
|
||||
|
||||
assertEquals(1, occurrences(source, "STUDIO_S_V_C_PACK_COPY_REQUIRES_ASYNC_THREAD"));
|
||||
assertEquals(2, occurrences(source, "STUDIO_S_V_C_PACK_INSTALL_FAILED"));
|
||||
assertEquals(2, occurrences(source, "STUDIO_S_V_C_PACK_COPY_REQUIRES_ASYNC_THREAD"));
|
||||
assertEquals(3, occurrences(source, "STUDIO_S_V_C_PACK_INSTALL_FAILED"));
|
||||
assertFalse(source.contains("STUDIO_S_V_C_INSTALLING_PACKAGE"));
|
||||
assertFalse(source.contains("Publishing snapshot"));
|
||||
assertFalse(source.contains("sender.sendMessage(\"Iris refused to copy a pack"));
|
||||
|
||||
@@ -444,6 +444,26 @@ public class StudioSVCWorldPackPublishTest {
|
||||
assertEquals("second", second.join());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void productionWorldPublicationUsesImmutableGenerationHistory() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/service/StudioSVC.java"
|
||||
)).replace("\r\n", "\n");
|
||||
int productionStart = source.indexOf("private IrisDimension publishGenerationHistory(");
|
||||
int transientStart = source.indexOf("private IrisDimension installIntoDirectory(", productionStart);
|
||||
String production = source.substring(productionStart, transientStart);
|
||||
|
||||
assertTrue(production.contains("GenerationHistory.create("));
|
||||
assertTrue(production.contains("GenerationHistory.openIfPresent(dimensionRoot, worldSeed)"));
|
||||
assertTrue(production.contains("GenerationHistory.adoptLegacyPack("));
|
||||
assertTrue(production.contains("history.stageUpdate("));
|
||||
assertTrue(production.contains("getGenerationTransitionWidthBlocks()"));
|
||||
assertTrue(production.contains("history.activePackRoot()"));
|
||||
assertFalse(production.contains("AtomicDirectoryPublisher.publish("));
|
||||
assertTrue(source.contains("installIntoTransientWorld("));
|
||||
assertTrue(source.contains("new File(dimensionRoot, \"iris/pack\")"));
|
||||
}
|
||||
|
||||
private static void writeValidPack(Path packRoot) throws Exception {
|
||||
Files.createDirectories(packRoot.resolve("dimensions"));
|
||||
Files.createDirectories(packRoot.resolve("regions"));
|
||||
|
||||
+6
-3
@@ -19,7 +19,7 @@ public class IrisPersistentWorldCreationContractTest {
|
||||
int dimensionRoot = create.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)");
|
||||
int levelRoot = create.indexOf("WorldCreatorCompat.persistentLevelRoot(worldKey)");
|
||||
int existingStorage = create.indexOf("Files.exists(storageRoot.toPath())");
|
||||
int freezePack = create.indexOf(".installIntoWorld(sender, resolvedDimension, dimensionRoot)");
|
||||
int freezePack = create.indexOf("studioService.installIntoWorld(sender, resolvedDimension, dimensionRoot, seed)");
|
||||
int persistentCreator = create.indexOf(".persistent(!studio && !benchmark)");
|
||||
int bukkitCreate = create.indexOf("INMS.get().createWorldAsync(wc, request)");
|
||||
int rollbackStorage = create.indexOf(
|
||||
@@ -48,13 +48,16 @@ public class IrisPersistentWorldCreationContractTest {
|
||||
int persistentStorage = create.indexOf("WorldCreatorCompat.persistentDimensionRoot(worldKey)");
|
||||
int canonicalIdentity = create.indexOf(".platformIdentity(worldKey.toString())");
|
||||
int configuredName = create.indexOf(".name(creator.name())");
|
||||
int exactPack = create.indexOf("new File(w.worldFolder(), \"iris/pack\")");
|
||||
int history = create.indexOf("requireGenerationHistory(w.worldFolder(), seed)");
|
||||
int exactPack = create.indexOf("requireActivePack(generationHistory)");
|
||||
|
||||
assertTrue(persistentCreator >= 0);
|
||||
assertTrue(persistentStorage > persistentCreator);
|
||||
assertTrue(canonicalIdentity > persistentStorage);
|
||||
assertTrue(configuredName > canonicalIdentity);
|
||||
assertTrue(exactPack > configuredName);
|
||||
assertTrue(history > configuredName);
|
||||
assertTrue(exactPack > history);
|
||||
assertTrue(create.contains("new File(w.worldFolder(), \"iris/pack\")"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package art.arcane.iris.core.tools;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisToolbeltShutdownContractTest {
|
||||
@Test
|
||||
public void disabledPluginDoesNotImplyServerShutdown() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.irisToolbeltSource")));
|
||||
int start = source.indexOf("public static boolean isServerStopping()");
|
||||
int end = source.indexOf("public static void beginWorldMaintenance", start);
|
||||
assertTrue(start >= 0);
|
||||
assertTrue(end > start);
|
||||
String stopping = source.substring(start, end);
|
||||
|
||||
assertTrue(stopping.contains("return INMS.isBound() && INMS.get().isServerStopping();"));
|
||||
assertFalse(stopping.contains("isEnabled()"));
|
||||
assertFalse(stopping.contains("hasPlugin()"));
|
||||
assertFalse(stopping.contains("getMethod("));
|
||||
}
|
||||
}
|
||||
+76
-7
@@ -6,6 +6,7 @@ import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class EngineRuntimePublicationContractTest {
|
||||
@@ -38,17 +39,85 @@ public class EngineRuntimePublicationContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dimensionStackRunsAfterHostStaticObjects() throws IOException {
|
||||
public void dimensionStackRegistrationRunsAfterHostStaticObjects() throws IOException {
|
||||
String builderSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java")).replace("\r\n", "\n");
|
||||
int buildStart = builderSource.indexOf("private EngineRuntime buildRuntime(");
|
||||
int buildEnd = builderSource.indexOf("GenerationRuntime buildDetachedGenerationRuntime(", buildStart);
|
||||
String build = builderSource.substring(buildStart, buildEnd);
|
||||
|
||||
assertBefore(build, "assembly.runtimeKernel.registerStaticObjects(engine, assembly.mode)",
|
||||
"assembly.runtimeKernel.registerDimensionStack(");
|
||||
|
||||
String kernelSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/history/GenerationKernelV1.java")).replace("\r\n", "\n");
|
||||
assertBefore(kernelSource, "staticObjects.apply(engine, x, z, blocks)",
|
||||
"dimensionStack.actuate(x, z, blocks, multicore, chunkContext)");
|
||||
assertBefore(kernelSource, "dimensionStack.actuate(x, z, blocks, multicore, chunkContext)",
|
||||
"stackCustom.modify(x, z, blocks, multicore, chunkContext)");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedRuntimeBuildDoesNotCreateWorldOwnedServices() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java")).replace("\r\n", "\n");
|
||||
int buildStart = source.indexOf("EngineRuntime buildRuntime(EngineTarget runtimeTarget)");
|
||||
int buildEnd = source.indexOf("DimensionStackContext buildDimensionStackContext", buildStart);
|
||||
int buildStart = source.indexOf("GenerationRuntime buildDetachedGenerationRuntime(");
|
||||
int buildEnd = source.indexOf("void publishRuntime(", buildStart);
|
||||
String build = source.substring(buildStart, buildEnd);
|
||||
|
||||
assertBefore(build, "staticObjects.apply(engine, x, z, blocks)",
|
||||
"dimensionStack.actuate(x, z, blocks, multicore, context)");
|
||||
assertBefore(build, "dimensionStack.actuate(x, z, blocks, multicore, context)",
|
||||
"stackCustom.modify(x, z, blocks, multicore, context)");
|
||||
assertTrue(build.contains("assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan)"));
|
||||
assertTrue(build.contains("assembly.runtimeKernel.createMantle(engine, assembly.mantleStorageDirectory)"));
|
||||
assertTrue(build.contains("assembly.mantle.hotload()"));
|
||||
assertTrue(build.contains("assembly.runtimeKernel.createMode(engine)"));
|
||||
assertTrue(build.contains("assembly.runtimeKernel.createUpperContext(engine)"));
|
||||
assertTrue(build.contains("assembly.runtimeKernel.registerStaticObjects(engine, assembly.mode)"));
|
||||
assertFalse(build.contains("EngineEffectsProvider"));
|
||||
assertFalse(build.contains("EngineWorldManagerProvider"));
|
||||
assertFalse(build.contains("worldManager().start()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void runtimePublicationTransfersAnIdenticalMantleWithoutClosingIt() throws IOException {
|
||||
String builderSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineRuntimeBuilder.java")).replace("\r\n", "\n");
|
||||
String shutdownSource = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")).replace("\r\n", "\n");
|
||||
|
||||
assertTrue(builderSource.contains("next.generation().mantle()"));
|
||||
assertTrue(shutdownSource.contains("generationRuntime.mantle() != retainedMantle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void engineAndDetachedRuntimeAcceptExplicitMantleDirectories() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/IrisEngine.java")).replace("\r\n", "\n");
|
||||
|
||||
assertTrue(source.contains("InitializationMode initializationMode,\n Path mantleStorageDirectory"));
|
||||
assertTrue(source.contains("EngineTarget runtimeTarget,\n Path mantleStorageDirectory"));
|
||||
assertTrue(source.contains("exclusive mantle storage directory"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mantleIoPinsTheRuntimeDataAcrossWorkerThreads() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/IrisEngineMantle.java")).replace("\r\n", "\n");
|
||||
|
||||
assertTrue(source.contains("runtimeData::get"));
|
||||
assertTrue(source.contains("runtimeData.set(Objects.requireNonNull(engine.getData()"));
|
||||
assertFalse(source.contains("createDataAdapter(engine::getData)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedDetachedBuildReleasesTransferredTargetOwnership() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/IrisEngine.java")).replace("\r\n", "\n");
|
||||
int buildStart = source.indexOf("public GenerationRuntimeBinding buildDetachedGenerationRuntime(");
|
||||
int buildEnd = source.indexOf("public void closeDetachedGenerationRuntime(", buildStart);
|
||||
String build = source.substring(buildStart, buildEnd);
|
||||
|
||||
assertTrue(build.contains("detachedData.registerEngine(this)"));
|
||||
assertTrue(build.contains("shutdownSequence.closeDetachedTarget(requiredTarget, null)"));
|
||||
assertTrue(build.contains("shutdownSequence.closeDetachedGenerationRuntime(detached, null)"));
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
|
||||
@@ -10,16 +10,28 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class EngineShutdownOwnershipContractTest {
|
||||
@Test
|
||||
public void ownershipCloseMustSucceedBeforeMantleRelease() throws IOException {
|
||||
public void attachedHistoryRouterClosesBeforeRuntimeOwnershipReleases() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")).replace("\r\n", "\n");
|
||||
int closeStart = source.indexOf("void close()");
|
||||
int routerClose = source.indexOf("engine::closeAttachedGenerationHistoryRuntimeRouter", closeStart);
|
||||
int runtimeRelease = source.indexOf("releaseRuntime(failure)", routerClose);
|
||||
|
||||
assertTrue(routerClose > closeStart);
|
||||
assertTrue(runtimeRelease > routerClose);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownershipCloseMustSucceedBeforeRuntimeRelease() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")).replace("\r\n", "\n");
|
||||
int ownershipClose = source.indexOf("NativeStructureOwnershipStore.close(engine)");
|
||||
int ownershipGate = source.indexOf("if (ownershipFailure == null)", ownershipClose);
|
||||
int mantleRelease = source.indexOf("releaseMantle(failure)", ownershipGate);
|
||||
int runtimeRelease = source.indexOf("releaseRuntime(failure)", ownershipGate);
|
||||
|
||||
assertTrue(ownershipClose >= 0);
|
||||
assertTrue(ownershipGate > ownershipClose);
|
||||
assertTrue(mantleRelease > ownershipGate);
|
||||
assertTrue(runtimeRelease > ownershipGate);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -31,12 +43,32 @@ public class EngineShutdownOwnershipContractTest {
|
||||
String cleanup = source.substring(cleanupStart, cleanupEnd);
|
||||
int ownershipClose = cleanup.indexOf("NativeStructureOwnershipStore.close(engine)");
|
||||
int ownershipGate = cleanup.indexOf("if (ownershipFailure == null)", ownershipClose);
|
||||
int mantleRelease = cleanup.indexOf("engine.getMantle()::close", ownershipGate);
|
||||
int detachedRelease = cleanup.indexOf("closeDetachedGenerationRuntimes(cleanupFailure)", ownershipGate);
|
||||
int runtimeRelease = cleanup.indexOf("closeRuntime(engine.runtime, cleanupFailure)", ownershipGate);
|
||||
int closedPublication = cleanup.indexOf("engine.closed = true", ownershipGate);
|
||||
|
||||
assertTrue(ownershipClose >= 0);
|
||||
assertTrue(ownershipGate > ownershipClose);
|
||||
assertTrue(mantleRelease > ownershipGate);
|
||||
assertTrue(detachedRelease > ownershipGate);
|
||||
assertTrue(runtimeRelease > detachedRelease);
|
||||
assertTrue(closedPublication > ownershipGate);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedRuntimesCloseBeforeTheBaseRuntimeAndTarget() throws IOException {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/engine/EngineShutdownSequence.java")).replace("\r\n", "\n");
|
||||
int releaseStart = source.indexOf("private Throwable releaseRuntime(");
|
||||
int releaseEnd = source.indexOf("private Throwable closeDetachedGenerationRuntimes(", releaseStart);
|
||||
String release = source.substring(releaseStart, releaseEnd);
|
||||
int detachedRelease = release.indexOf("closeDetachedGenerationRuntimes(null)");
|
||||
int detachedGate = release.indexOf("if (detachedFailure != null)", detachedRelease);
|
||||
int baseRelease = release.indexOf("closeRuntime(engine.runtime, null)", detachedGate);
|
||||
int targetRelease = source.indexOf("engine.publishedTarget::close", releaseEnd);
|
||||
|
||||
assertTrue(detachedRelease >= 0);
|
||||
assertTrue(detachedGate > detachedRelease);
|
||||
assertTrue(baseRelease > detachedGate);
|
||||
assertTrue(targetRelease > releaseEnd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
|
||||
public class IrisComplexTransitionTest {
|
||||
@Test
|
||||
public void blendsDryBoundaryTerrainFromTheFrozenSurface() {
|
||||
TransitionGenerationPlan.TerrainSample sample = new TransitionGenerationPlan.TerrainSample(
|
||||
8D,
|
||||
0.5D,
|
||||
0D,
|
||||
signature(OptionalInt.empty()),
|
||||
64D,
|
||||
55D,
|
||||
0D
|
||||
);
|
||||
|
||||
assertEquals(82D, IrisComplex.blendNaturalTerrainHeight(100D, sample), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void blendsSubmergedBoundaryTerrainFromTheFrozenOceanFloor() {
|
||||
TransitionGenerationPlan.TerrainSample sample = new TransitionGenerationPlan.TerrainSample(
|
||||
8D,
|
||||
0.5D,
|
||||
0D,
|
||||
signature(OptionalInt.of(63)),
|
||||
64D,
|
||||
55D,
|
||||
0D
|
||||
);
|
||||
|
||||
assertEquals(75D, IrisComplex.blendNaturalTerrainHeight(95D, sample), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesNewTerrainAndHydrologyObjectsAtFullWeight() {
|
||||
TransitionGenerationPlan.TerrainSample terrain = new TransitionGenerationPlan.TerrainSample(
|
||||
32D,
|
||||
1D,
|
||||
1D,
|
||||
signature(OptionalInt.empty()),
|
||||
64D,
|
||||
55D,
|
||||
0D
|
||||
);
|
||||
HydrologyColumnSample hydrology = hydrologySample();
|
||||
|
||||
assertEquals(101.25D, IrisComplex.blendNaturalTerrainHeight(101.25D, terrain), 0D);
|
||||
assertSame(hydrology, IrisComplex.taperHydrologySample(hydrology, 1D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tapersHydrologyGeometryTowardNaturalTerrain() {
|
||||
HydrologyColumnSample tapered = IrisComplex.taperHydrologySample(hydrologySample(), 0.5D);
|
||||
HydrologyColumnLayer layer = tapered.layers().getFirst();
|
||||
|
||||
assertEquals(73, layer.bedY());
|
||||
assertEquals(75, layer.fluidHeadY());
|
||||
assertEquals(75, layer.ceilingY());
|
||||
assertEquals(73, tapered.terrainHeight());
|
||||
assertEquals("river", layer.profileKey());
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature signature(OptionalInt fluidHeight) {
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(15, 8, 64, 55, fluidHeight, OptionalInt.empty()),
|
||||
new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(0, 1, 1),
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("iris:frozen"), new short[]{0})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static HydrologyColumnSample hydrologySample() {
|
||||
HydrologyFeatureRef feature = new HydrologyFeatureRef(
|
||||
1L,
|
||||
HydrologyFeatureType.SURFACE_POOL,
|
||||
2L,
|
||||
3L,
|
||||
4,
|
||||
70,
|
||||
6,
|
||||
1,
|
||||
0,
|
||||
true
|
||||
);
|
||||
HydrologyColumnLayer layer = new HydrologyColumnLayer(
|
||||
feature,
|
||||
66,
|
||||
70,
|
||||
70,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"river",
|
||||
"surface",
|
||||
"mouth",
|
||||
"shore",
|
||||
"bank",
|
||||
"cave"
|
||||
);
|
||||
return new HydrologyColumnSample(4, 6, 80, 63, false, "parent", List.of(layer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,625 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.GenerationRuntime.BiomeMaxes;
|
||||
import art.arcane.iris.engine.framework.EngineEffects;
|
||||
import art.arcane.iris.engine.framework.EngineMode;
|
||||
import art.arcane.iris.engine.framework.EngineStage;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.EngineWorldManager;
|
||||
import art.arcane.iris.engine.history.GenerationKernelRegistry;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.framework.IrisEngineMode;
|
||||
import art.arcane.iris.engine.framework.NativeStructureVolumeMemo;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.common.parallel.MultiBurst;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import org.junit.Test;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.IntConsumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisEngineGenerationRuntimeScopeTest {
|
||||
@BeforeClass
|
||||
public static void bindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.block(anyString())).thenReturn(block);
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void unbindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedGettersResolveDetachedGenerationStateAndWorldServicesStayActive() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 2D, 3D);
|
||||
RuntimeFixture detached = runtime(2, 4D, 5D, 6D);
|
||||
EngineEffects effects = mock(EngineEffects.class);
|
||||
EngineWorldManager worldManager = mock(EngineWorldManager.class);
|
||||
IrisEngine engine = engine(active.runtime, effects, worldManager);
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime);
|
||||
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) {
|
||||
assertSame(detached.target, engine.getTarget());
|
||||
assertSame(detached.data, engine.getData());
|
||||
assertSame(detached.dimension, engine.getDimension());
|
||||
assertSame(detached.runtime.seedManager(), engine.getSeedManager());
|
||||
assertSame(detached.complex, engine.getComplex());
|
||||
assertSame(detached.upperContext, engine.getUpperContext());
|
||||
assertSame(detached.dimensionStackContext, engine.getDimensionStackContext());
|
||||
assertSame(detached.mode, engine.getMode());
|
||||
assertSame(detached.mantle, engine.getMantle());
|
||||
assertSame(detached.hash32, engine.getHash32());
|
||||
assertEquals(2, engine.getCacheID());
|
||||
assertEquals(4D, engine.getMaxBiomeObjectDensity(), 0D);
|
||||
assertEquals(5D, engine.getMaxBiomeLayerDensity(), 0D);
|
||||
assertEquals(6D, engine.getMaxBiomeDecoratorDensity(), 0D);
|
||||
assertSame(effects, engine.getEffects());
|
||||
assertSame(worldManager, engine.getWorldManager());
|
||||
}
|
||||
|
||||
assertSame(active.target, engine.getTarget());
|
||||
assertSame(active.data, engine.getData());
|
||||
assertSame(active.runtime.seedManager(), engine.getSeedManager());
|
||||
assertSame(active.mantle, engine.getMantle());
|
||||
assertEquals(1, engine.getCacheID());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedKernelVersionProvidesTheAlgorithmDispatchKey() throws Exception {
|
||||
GenerationKernelRegistry.Version activeVersion = new GenerationKernelRegistry.Version(1, 1, 1);
|
||||
GenerationKernelRegistry.Version historicalVersion = new GenerationKernelRegistry.Version(2, 3, 4);
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D, activeVersion);
|
||||
RuntimeFixture historical = runtime(2, 1D, 1D, 1D, historicalVersion);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, historical.runtime);
|
||||
|
||||
assertEquals(activeVersion, engine.getGenerationKernelVersion());
|
||||
assertSame(active.runtime.runtimeKernel(), engine.getGenerationRuntimeKernel());
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) {
|
||||
assertEquals(historicalVersion, engine.getGenerationKernelVersion());
|
||||
assertSame(historical.runtime.runtimeKernel(), engine.getGenerationRuntimeKernel());
|
||||
assertNotSame(active.runtime.runtimeKernel(), engine.getGenerationRuntimeKernel());
|
||||
}
|
||||
assertEquals(activeVersion, engine.getGenerationKernelVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void executableKernelFactoriesProduceDistinctRuntimeBindings() throws Exception {
|
||||
GenerationKernelRegistry.Version versionOne = new GenerationKernelRegistry.Version(1, 1, 1);
|
||||
GenerationKernelRegistry.Version versionTwo = new GenerationKernelRegistry.Version(2, 1, 1);
|
||||
IrisComplex complexOne = mock(IrisComplex.class);
|
||||
IrisComplex complexTwo = mock(IrisComplex.class);
|
||||
GenerationKernelRegistry kernels = new GenerationKernelRegistry(
|
||||
versionTwo,
|
||||
Set.of(
|
||||
new GenerationKernelRegistry.Kernel(
|
||||
1,
|
||||
"1".repeat(64),
|
||||
Map.of(
|
||||
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
|
||||
(engine, transitionPlan) -> complexOne
|
||||
)
|
||||
),
|
||||
new GenerationKernelRegistry.Kernel(
|
||||
2,
|
||||
"2".repeat(64),
|
||||
Map.of(
|
||||
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
|
||||
(engine, transitionPlan) -> complexTwo
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
GenerationKernelRegistry.RuntimeKernel kernelOne = kernels.select(versionOne);
|
||||
GenerationKernelRegistry.RuntimeKernel kernelTwo = kernels.select(versionTwo);
|
||||
IrisEngine factoryContext = mock(IrisEngine.class);
|
||||
RuntimeFixture first = runtime(
|
||||
1, 1D, 1D, 1D, kernelOne, kernelOne.createComplex(factoryContext, null));
|
||||
RuntimeFixture second = runtime(
|
||||
2, 1D, 1D, 1D, kernelTwo, kernelTwo.createComplex(factoryContext, null));
|
||||
IrisEngine engine = engine(first.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding firstBinding = new IrisEngine.GenerationRuntimeBinding(
|
||||
engine, first.runtime);
|
||||
IrisEngine.GenerationRuntimeBinding secondBinding = detachedBinding(engine, second.runtime);
|
||||
|
||||
assertSame(kernelOne, firstBinding.runtimeKernel());
|
||||
assertSame(kernelTwo, secondBinding.runtimeKernel());
|
||||
assertSame(complexOne, engine.getComplex());
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(secondBinding)) {
|
||||
assertSame(kernelTwo, engine.getGenerationRuntimeKernel());
|
||||
assertSame(complexTwo, engine.getComplex());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void burstWorkerReopensCapturedGenerationRuntimeScope() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture detached = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime);
|
||||
EngineMode mode = new IrisEngineMode(engine) {
|
||||
};
|
||||
MultiBurst workerPool = new MultiBurst("Generation Runtime Scope Test", Thread.NORM_PRIORITY, () -> 2);
|
||||
when(detached.target.getBurster()).thenReturn(workerPool);
|
||||
AtomicReference<EngineTarget> observedTarget = new AtomicReference<>();
|
||||
AtomicReference<Thread> observedThread = new AtomicReference<>();
|
||||
EngineStage stage = (x, z, blocks, biomes, multicore, context) -> {
|
||||
observedTarget.set(engine.getTarget());
|
||||
observedThread.set(Thread.currentThread());
|
||||
};
|
||||
ChunkContext context = mock(ChunkContext.class);
|
||||
when(context.getGenerationSessionId()).thenReturn(17L);
|
||||
Thread caller = Thread.currentThread();
|
||||
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) {
|
||||
mode.burst(stage).generate(0, 0, null, null, true, context);
|
||||
} finally {
|
||||
mode.close();
|
||||
workerPool.close();
|
||||
}
|
||||
|
||||
assertSame(detached.target, observedTarget.get());
|
||||
assertNotSame(caller, observedThread.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopeRejectsForeignClosedAndOutOfOrderBindings() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture first = runtime(2, 2D, 2D, 2D);
|
||||
RuntimeFixture second = runtime(3, 3D, 3D, 3D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine other = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding foreign = new IrisEngine.GenerationRuntimeBinding(other, first.runtime);
|
||||
assertThrows(IllegalArgumentException.class, () -> engine.openGenerationRuntimeScope(foreign));
|
||||
|
||||
IrisEngine.GenerationRuntimeBinding closed = new IrisEngine.GenerationRuntimeBinding(engine, first.runtime);
|
||||
assertThrows(IllegalStateException.class, () -> engine.openGenerationRuntimeScope(closed));
|
||||
|
||||
IrisEngine.GenerationRuntimeBinding firstBinding = detachedBinding(engine, first.runtime);
|
||||
IrisEngine.GenerationRuntimeBinding secondBinding = detachedBinding(engine, second.runtime);
|
||||
IrisEngine.GenerationRuntimeScope outer = engine.openGenerationRuntimeScope(firstBinding);
|
||||
IrisEngine.GenerationRuntimeScope inner = engine.openGenerationRuntimeScope(secondBinding);
|
||||
assertThrows(IllegalStateException.class, outer::close);
|
||||
inner.close();
|
||||
outer.close();
|
||||
assertSame(active.target, engine.getTarget());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closingDetachedRuntimeReleasesGenerationTargetAndData() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture detached = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime);
|
||||
doAnswer(invocation -> {
|
||||
assertSame(detached.mantle, engine.getMantle());
|
||||
return null;
|
||||
}).when(detached.mode).close();
|
||||
|
||||
engine.closeDetachedGenerationRuntime(binding);
|
||||
|
||||
verify(detached.mode).close();
|
||||
verify(detached.complex).close();
|
||||
verify(detached.mantle).saveAllNow();
|
||||
verify(detached.mantle).close();
|
||||
verify(detached.data).unregisterEngine(engine);
|
||||
verify(detached.target).close();
|
||||
verify(detached.data).close();
|
||||
assertTrue(detached.hash32.isCancelled());
|
||||
engine.closeDetachedGenerationRuntime(binding);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedRuntimeRetirementEvictsCachesOutsideLifecycleLock() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture detached = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime);
|
||||
AtomicInteger retiredRuntime = new AtomicInteger(-1);
|
||||
AtomicBoolean lifecycleLockHeld = new AtomicBoolean(true);
|
||||
engine.addGenerationRuntimeRetirementListener(runtimeId -> {
|
||||
retiredRuntime.set(runtimeId);
|
||||
lifecycleLockHeld.set(Thread.holdsLock(engine.lifecycleLock));
|
||||
});
|
||||
doAnswer(invocation -> {
|
||||
assertEquals(2, retiredRuntime.get());
|
||||
return null;
|
||||
}).when(detached.mode).close();
|
||||
|
||||
engine.closeDetachedGenerationRuntime(binding);
|
||||
|
||||
assertEquals(2, binding.runtimeId());
|
||||
assertEquals(2, retiredRuntime.get());
|
||||
assertTrue(!lifecycleLockHeld.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedRuntimeRejectsNewScopesAfterRetirementBegins() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture detached = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime);
|
||||
AtomicReference<Throwable> rejected = new AtomicReference<>();
|
||||
engine.addGenerationRuntimeRetirementListener(runtimeId -> {
|
||||
try {
|
||||
engine.openGenerationRuntimeScope(binding);
|
||||
} catch (Throwable failure) {
|
||||
rejected.set(failure);
|
||||
}
|
||||
});
|
||||
|
||||
engine.closeDetachedGenerationRuntime(binding);
|
||||
|
||||
assertTrue(rejected.get() instanceof IllegalStateException);
|
||||
assertTrue(rejected.get().getMessage().contains("retiring"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closedDetachedScopeFallsBackToTheBaseRuntime() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture detached = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding binding = detachedBinding(engine, detached.runtime);
|
||||
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(binding)) {
|
||||
assertSame(detached.target, engine.getTarget());
|
||||
engine.closeDetachedGenerationRuntime(binding);
|
||||
assertSame(active.target, engine.getTarget());
|
||||
assertSame(active.mantle, engine.getMantle());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultRuntimeSelectionKeepsThePreviousRuntimeRoutable() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture detached = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisEngine.GenerationRuntimeBinding activeBinding = new IrisEngine.GenerationRuntimeBinding(
|
||||
engine,
|
||||
active.runtime);
|
||||
IrisEngine.GenerationRuntimeBinding detachedRuntimeBinding = detachedBinding(engine, detached.runtime);
|
||||
|
||||
engine.setDefaultGenerationRuntime(detachedRuntimeBinding);
|
||||
|
||||
assertSame(detached.target, engine.getTarget());
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> engine.closeDetachedGenerationRuntime(detachedRuntimeBinding));
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(activeBinding)) {
|
||||
assertSame(active.target, engine.getTarget());
|
||||
}
|
||||
|
||||
engine.setDefaultGenerationRuntime(activeBinding);
|
||||
|
||||
assertSame(active.target, engine.getTarget());
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(detachedRuntimeBinding)) {
|
||||
assertSame(detached.target, engine.getTarget());
|
||||
}
|
||||
engine.closeDetachedGenerationRuntime(detachedRuntimeBinding);
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> engine.openGenerationRuntimeScope(detachedRuntimeBinding));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void attachedHistoryRouterIsClearedWhenEngineShutdownBegins() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(router.engine()).thenReturn(engine);
|
||||
engine.attachGenerationHistoryRuntimeRouter(router);
|
||||
|
||||
assertTrue(engine.beginShutdown());
|
||||
engine.closeAttachedGenerationHistoryRuntimeRouter();
|
||||
engine.closeAttachedGenerationHistoryRuntimeRouter();
|
||||
|
||||
verify(router).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void attachedHistoryRejectsGenerationRuntimeHotload() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(router.engine()).thenReturn(engine);
|
||||
engine.attachGenerationHistoryRuntimeRouter(router);
|
||||
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, engine::hotloadComplex);
|
||||
|
||||
assertTrue(failure.getMessage().contains("immutable Iris generation history"));
|
||||
assertSame(active.runtime, engine.runtime.generation());
|
||||
verify(active.complex, never()).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedHistoryCannotFallBackToTheDefaultGenerationRuntime() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
assertNull(engine.openGenerationHistoryCoordinateScope(0, 0));
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(router.engine()).thenReturn(engine);
|
||||
engine.attachGenerationHistoryRuntimeRouter(router);
|
||||
engine.detachGenerationHistoryRuntimeRouter(router);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> engine.openGenerationHistoryCoordinateScope(0, 0));
|
||||
assertThrows(IllegalStateException.class, () -> engine.getHeight(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void transferredMantleIsNotClosedWithTheRetiredRuntime() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
EngineShutdownSequence shutdownSequence = new EngineShutdownSequence(engine);
|
||||
|
||||
Throwable failure = shutdownSequence.closeRuntime(engine.runtime, active.mantle, null);
|
||||
|
||||
assertNull(failure);
|
||||
verify(active.mantle, never()).saveAllNow();
|
||||
verify(active.mantle, never()).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownedAssemblyMantleIsSavedAndClosedOnlyOnce() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
EngineRuntimeBuilder.RuntimeAssembly assembly = new EngineRuntimeBuilder.RuntimeAssembly(
|
||||
2,
|
||||
active.target,
|
||||
Path.of("build", "test-mantles", "assembly"));
|
||||
assembly.mantle = mock(EngineMantle.class);
|
||||
assembly.ownsMantle = true;
|
||||
EngineShutdownSequence shutdownSequence = new EngineShutdownSequence(engine);
|
||||
|
||||
assertNull(shutdownSequence.closeAssembly(assembly, null));
|
||||
assertNull(shutdownSequence.closeAssembly(assembly, null));
|
||||
|
||||
verify(assembly.mantle).saveAllNow();
|
||||
verify(assembly.mantle).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedRuntimeRejectsTheActiveMantleDirectory() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture detached = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisWorld activeWorld = active.target.getWorld();
|
||||
when(detached.target.getWorld()).thenReturn(activeWorld);
|
||||
setField(engine, "closing", new AtomicBoolean(false));
|
||||
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> engine.buildDetachedGenerationRuntime(detached.target, active.mantleStorageDirectory));
|
||||
|
||||
verify(detached.data, never()).registerEngine(engine);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedDetachedBuildReleasesTransferredTargetOwnership() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture detached = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisWorld activeWorld = active.target.getWorld();
|
||||
when(detached.target.getWorld()).thenReturn(activeWorld);
|
||||
setField(engine, "closing", new AtomicBoolean(false));
|
||||
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
|
||||
EngineRuntimeBuilder runtimeBuilder = mock(EngineRuntimeBuilder.class);
|
||||
setField(engine, "runtimeBuilder", runtimeBuilder);
|
||||
IllegalStateException failure = new IllegalStateException("build failed");
|
||||
Path storageDirectory = Path.of("build", "test-mantles", "failed-detached").toAbsolutePath();
|
||||
when(runtimeBuilder.buildDetachedGenerationRuntime(detached.target, storageDirectory)).thenThrow(failure);
|
||||
|
||||
IllegalStateException thrown = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> engine.buildDetachedGenerationRuntime(detached.target, storageDirectory));
|
||||
|
||||
assertSame(failure, thrown);
|
||||
verify(detached.data).registerEngine(engine);
|
||||
verify(detached.data).unregisterEngine(engine);
|
||||
verify(detached.target).close();
|
||||
verify(detached.data).close();
|
||||
}
|
||||
|
||||
private static IrisEngine engine(
|
||||
GenerationRuntime generationRuntime,
|
||||
EngineEffects effects,
|
||||
EngineWorldManager worldManager
|
||||
) throws Exception {
|
||||
IrisEngine engine = mock(IrisEngine.class, CALLS_REAL_METHODS);
|
||||
setField(engine, "lifecycleLock", new Object());
|
||||
setField(engine, "generationHistoryRuntimeRouterLock", new Object());
|
||||
setField(engine, "runtimeAssembly", new ThreadLocal<EngineRuntimeBuilder.RuntimeAssembly>());
|
||||
setField(engine, "generationRuntimeScopes", new GenerationRuntimeScopeState());
|
||||
Set<GenerationRuntime> detached = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(new IdentityHashMap<GenerationRuntime, Boolean>()));
|
||||
setField(engine, "detachedGenerationRuntimes", detached);
|
||||
Set<GenerationRuntime> retiring = Collections.synchronizedSet(
|
||||
Collections.newSetFromMap(new IdentityHashMap<GenerationRuntime, Boolean>()));
|
||||
setField(engine, "retiringGenerationRuntimes", retiring);
|
||||
setField(engine, "generationRuntimeRetirementListeners", new CopyOnWriteArraySet<IntConsumer>());
|
||||
setField(engine, "shutdownSequence", new EngineShutdownSequence(engine));
|
||||
setField(engine, "hotloader", new EngineHotloader(engine));
|
||||
setField(engine, "nativeStructureVolumeMemo", new NativeStructureVolumeMemo());
|
||||
setField(engine, "closing", new AtomicBoolean(false));
|
||||
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
|
||||
engine.runtime = new EngineRuntime(generationRuntime, effects, worldManager);
|
||||
engine.publishedTarget = generationRuntime.target();
|
||||
return engine;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static IrisEngine.GenerationRuntimeBinding detachedBinding(
|
||||
IrisEngine engine,
|
||||
GenerationRuntime runtime
|
||||
) throws Exception {
|
||||
Field field = IrisEngine.class.getDeclaredField("detachedGenerationRuntimes");
|
||||
field.setAccessible(true);
|
||||
Set<GenerationRuntime> detached = (Set<GenerationRuntime>) field.get(engine);
|
||||
detached.add(runtime);
|
||||
return new IrisEngine.GenerationRuntimeBinding(engine, runtime);
|
||||
}
|
||||
|
||||
private static RuntimeFixture runtime(
|
||||
int cacheId,
|
||||
double objectDensity,
|
||||
double layerDensity,
|
||||
double decoratorDensity
|
||||
) throws IOException {
|
||||
return runtime(
|
||||
cacheId,
|
||||
objectDensity,
|
||||
layerDensity,
|
||||
decoratorDensity,
|
||||
GenerationKernelRegistry.standard().current()
|
||||
);
|
||||
}
|
||||
|
||||
private static RuntimeFixture runtime(
|
||||
int cacheId,
|
||||
double objectDensity,
|
||||
double layerDensity,
|
||||
double decoratorDensity,
|
||||
GenerationKernelRegistry.Version kernelVersion
|
||||
) throws IOException {
|
||||
GenerationKernelRegistry.RuntimeKernel runtimeKernel = runtimeKernel(kernelVersion);
|
||||
return runtime(
|
||||
cacheId,
|
||||
objectDensity,
|
||||
layerDensity,
|
||||
decoratorDensity,
|
||||
runtimeKernel,
|
||||
mock(IrisComplex.class)
|
||||
);
|
||||
}
|
||||
|
||||
private static RuntimeFixture runtime(
|
||||
int cacheId,
|
||||
double objectDensity,
|
||||
double layerDensity,
|
||||
double decoratorDensity,
|
||||
GenerationKernelRegistry.RuntimeKernel runtimeKernel,
|
||||
IrisComplex complex
|
||||
) {
|
||||
IrisWorld world = mock(IrisWorld.class);
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
EngineTarget target = mock(EngineTarget.class);
|
||||
when(target.getWorld()).thenReturn(world);
|
||||
when(target.getDimension()).thenReturn(dimension);
|
||||
when(target.getData()).thenReturn(data);
|
||||
when(target.getBurster()).thenReturn(MultiBurst.burst);
|
||||
UpperDimensionContext upperContext = mock(UpperDimensionContext.class);
|
||||
DimensionStackContext dimensionStackContext = mock(DimensionStackContext.class);
|
||||
EngineMode mode = mock(EngineMode.class);
|
||||
EngineMantle mantle = mock(EngineMantle.class);
|
||||
Path mantleStorageDirectory = Path.of("build", "test-mantles", Integer.toString(cacheId)).toAbsolutePath();
|
||||
CompletableFuture<Long> hash32 = new CompletableFuture<>();
|
||||
GenerationRuntime runtime = new GenerationRuntime(
|
||||
cacheId,
|
||||
target,
|
||||
data,
|
||||
dimension,
|
||||
runtimeKernel.version(),
|
||||
runtimeKernel,
|
||||
mock(SeedManager.class),
|
||||
null,
|
||||
complex,
|
||||
upperContext,
|
||||
dimensionStackContext,
|
||||
mode,
|
||||
mantle,
|
||||
mantleStorageDirectory,
|
||||
hash32,
|
||||
new BiomeMaxes(objectDensity, layerDensity, decoratorDensity));
|
||||
return new RuntimeFixture(
|
||||
runtime,
|
||||
target,
|
||||
data,
|
||||
dimension,
|
||||
complex,
|
||||
upperContext,
|
||||
dimensionStackContext,
|
||||
mode,
|
||||
mantle,
|
||||
mantleStorageDirectory,
|
||||
hash32);
|
||||
}
|
||||
|
||||
private static GenerationKernelRegistry.RuntimeKernel runtimeKernel(
|
||||
GenerationKernelRegistry.Version version
|
||||
) throws IOException {
|
||||
if (GenerationKernelRegistry.standard().supports(
|
||||
version.generatorAbi(),
|
||||
version.rngVersion(),
|
||||
version.seedDerivationVersion())) {
|
||||
return GenerationKernelRegistry.standard().select(version);
|
||||
}
|
||||
return new GenerationKernelRegistry.RuntimeKernel(
|
||||
version,
|
||||
"f".repeat(64),
|
||||
(engine, transitionPlan) -> mock(IrisComplex.class)
|
||||
);
|
||||
}
|
||||
|
||||
private static void setField(Object target, String name, Object value) throws Exception {
|
||||
Field field = IrisEngine.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
private record RuntimeFixture(
|
||||
GenerationRuntime runtime,
|
||||
EngineTarget target,
|
||||
IrisData data,
|
||||
IrisDimension dimension,
|
||||
IrisComplex complex,
|
||||
UpperDimensionContext upperContext,
|
||||
DimensionStackContext dimensionStackContext,
|
||||
EngineMode mode,
|
||||
EngineMantle mantle,
|
||||
Path mantleStorageDirectory,
|
||||
CompletableFuture<Long> hash32
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class UpperDimensionTransitionTest {
|
||||
@Test
|
||||
public void gapLimitedTargetDoesNotCreateAnIntermediateCeilingDip() {
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
when(dimension.getLoadKey()).thenReturn("main");
|
||||
when(dimension.getUpperDimensionGap()).thenReturn(32);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 139D));
|
||||
when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 75D + 4D * x));
|
||||
TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class);
|
||||
when(plan.terrainSampleAt(0, 8)).thenReturn(sample(75D, 0D));
|
||||
when(plan.terrainSampleAt(8, 8)).thenReturn(sample(75D, 0.5D));
|
||||
when(plan.terrainSampleAt(16, 8)).thenReturn(sample(75D, 1D));
|
||||
when(complex.getTransitionGenerationPlan()).thenReturn(plan);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getHeight()).thenReturn(256);
|
||||
|
||||
UpperDimensionContext context = UpperDimensionContext.create(engine, dimension);
|
||||
|
||||
assertEquals(180, context.getEffectiveSurfaceY(0, 8));
|
||||
assertEquals(175, context.getEffectiveSurfaceY(8, 8));
|
||||
assertEquals(171, context.getEffectiveSurfaceY(16, 8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void blendsFrozenEffectiveCeilingTowardTheUnblendedSelfReference() {
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
when(dimension.getLoadKey()).thenReturn("main");
|
||||
when(dimension.getUpperDimensionGap()).thenReturn(32);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(complex.getUnblendedNaturalHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 80D));
|
||||
when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D));
|
||||
TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class);
|
||||
when(plan.terrainSampleAt(-17, 8)).thenReturn(sample(40D, 0.5D));
|
||||
when(complex.getTransitionGenerationPlan()).thenReturn(plan);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getHeight()).thenReturn(256);
|
||||
|
||||
UpperDimensionContext context = UpperDimensionContext.create(engine, dimension);
|
||||
|
||||
assertEquals(195, context.getEffectiveSurfaceY(-17, 8));
|
||||
verify(complex, never()).getNaturalHeightStream();
|
||||
verify(complex, never()).getNaturalTrueBiomeStream();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesBoundaryAndCurrentEndpointsAndFixedGap() {
|
||||
UpperDimensionContext.CeilingLayout layout = new UpperDimensionContext.CeilingLayout(384, 32);
|
||||
double frozen = UpperDimensionContext.blendCeilingDepth(180D, sample(60D, 0D));
|
||||
double current = UpperDimensionContext.blendCeilingDepth(180D, sample(60D, 1D));
|
||||
|
||||
assertEquals(323, UpperDimensionContext.effectiveSurfaceY(frozen, layout, 100));
|
||||
assertEquals(203, UpperDimensionContext.effectiveSurfaceY(current, layout, 100));
|
||||
assertEquals(252, UpperDimensionContext.effectiveSurfaceY(current, layout, 220));
|
||||
assertEquals(188, -64 + UpperDimensionContext.effectiveSurfaceY(current, layout, 220));
|
||||
assertEquals(384, UpperDimensionContext.effectiveSurfaceY(current, layout, Integer.MAX_VALUE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void absentCeilingsRemainOutsideTheHunkAndAppearGradually() {
|
||||
UpperDimensionContext.CeilingLayout layout = new UpperDimensionContext.CeilingLayout(256, 32);
|
||||
assertEquals(256, UpperDimensionContext.effectiveSurfaceY(
|
||||
UpperDimensionContext.blendCeilingDepth(80D, sample(0D, 0D)), layout, 64));
|
||||
assertEquals(215, UpperDimensionContext.effectiveSurfaceY(
|
||||
UpperDimensionContext.blendCeilingDepth(80D, sample(0D, 0.5D)), layout, 64));
|
||||
assertEquals(215, UpperDimensionContext.effectiveSurfaceY(
|
||||
UpperDimensionContext.blendCeilingDepth(0D, sample(80D, 0.5D)), layout, 64));
|
||||
assertEquals(256, UpperDimensionContext.effectiveSurfaceY(
|
||||
UpperDimensionContext.blendCeilingDepth(0D, sample(80D, 1D)), layout, 64));
|
||||
assertEquals(256, UpperDimensionContext.effectiveSurfaceY(0.49D, layout, 64));
|
||||
assertEquals(254, UpperDimensionContext.effectiveSurfaceY(0.51D, layout, 64));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidCeilingLayouts() {
|
||||
assertThrows(IllegalArgumentException.class, () -> new UpperDimensionContext.CeilingLayout(0, 32));
|
||||
assertThrows(IllegalArgumentException.class, () -> new UpperDimensionContext.CeilingLayout(256, -1));
|
||||
}
|
||||
|
||||
private static TransitionGenerationPlan.TerrainSample sample(double depth, double weight) {
|
||||
TerrainBoundarySignature signature = new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(15, 8, 64, 64, OptionalInt.empty(),
|
||||
depth == 0D ? OptionalInt.empty() : OptionalInt.of((int) depth)),
|
||||
new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(-64, 4, 1),
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})));
|
||||
return new TransitionGenerationPlan.TerrainSample(8D, weight, 0D, signature, 64D, 64D, depth);
|
||||
}
|
||||
}
|
||||
+87
-2
@@ -1,8 +1,10 @@
|
||||
package art.arcane.iris.engine.actuator;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineMetrics;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
@@ -23,14 +25,18 @@ import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -45,7 +51,9 @@ public class IrisBiomeActuatorCoordinateTest {
|
||||
@SuppressWarnings("unchecked")
|
||||
public void derivativeScatterUsesEachWorldColumnCoordinate() {
|
||||
bindPlatform();
|
||||
Engine engine = engine();
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(complex.allowsNewDiscreteContentAt(anyInt(), anyInt())).thenReturn(true);
|
||||
Engine engine = engine(complex);
|
||||
IrisBiomeActuator actuator = new IrisBiomeActuator(engine);
|
||||
Hunk<PlatformBiome> output = mock(Hunk.class);
|
||||
when(output.getWidth()).thenReturn(2);
|
||||
@@ -65,6 +73,7 @@ public class IrisBiomeActuatorCoordinateTest {
|
||||
ChunkedDataCache<IrisBiome> biomeCache = mock(ChunkedDataCache.class);
|
||||
when(biomeCache.get(anyInt(), anyInt())).thenReturn(biome);
|
||||
ChunkContext context = mock(ChunkContext.class);
|
||||
when(context.getComplex()).thenReturn(complex);
|
||||
when(context.getBiome()).thenReturn(biomeCache);
|
||||
|
||||
actuator.onActuate(100, -200, output, false, context);
|
||||
@@ -85,8 +94,83 @@ public class IrisBiomeActuatorCoordinateTest {
|
||||
verifyNoMoreInteractions(mantle);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
private Engine engine() {
|
||||
public void transitionColumnEmitsFrozenPhysicalKeyWithoutResolvingANewBiome() {
|
||||
bindPlatform();
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(complex.allowsNewDiscreteContentAt(100, -200)).thenReturn(false);
|
||||
TransitionGenerationPlan transitionPlan = mock(TransitionGenerationPlan.class);
|
||||
TransitionGenerationPlan.TerrainSample terrainSample = mock(TransitionGenerationPlan.TerrainSample.class);
|
||||
when(complex.getTransitionGenerationPlan()).thenReturn(transitionPlan);
|
||||
when(transitionPlan.terrainSampleAt(100, -200)).thenReturn(terrainSample);
|
||||
when(terrainSample.historicalPhysicalBiomeKeyAt(0))
|
||||
.thenReturn(Optional.of("iris:old-physical"));
|
||||
Engine engine = engine(complex);
|
||||
IrisBiomeActuator actuator = new IrisBiomeActuator(engine);
|
||||
Hunk<PlatformBiome> output = mock(Hunk.class);
|
||||
when(output.getWidth()).thenReturn(1);
|
||||
when(output.getDepth()).thenReturn(1);
|
||||
when(output.getHeight()).thenReturn(1);
|
||||
ChunkedDataCache<IrisBiome> biomeCache = mock(ChunkedDataCache.class);
|
||||
ChunkContext context = mock(ChunkContext.class);
|
||||
when(context.getComplex()).thenReturn(complex);
|
||||
when(context.getBiome()).thenReturn(biomeCache);
|
||||
|
||||
actuator.onActuate(100, -200, output, false, context);
|
||||
|
||||
verify(biomeCache, never()).get(anyInt(), anyInt());
|
||||
verify(IrisPlatforms.get().registries()).biome("iris:old-physical");
|
||||
verify(IrisPlatforms.get().biomeWriter(), never()).biomeIdFor(anyString());
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
verify(mantle).set(eq(100), eq(0), eq(-200), argThat(value -> {
|
||||
MatterBiomeInject injection = (MatterBiomeInject) value;
|
||||
return !injection.isCustom() && "iris:old-physical".equals(injection.getBiomeKey());
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void transitionColumnRestoresEveryFrozenVerticalBiomeRange() {
|
||||
bindPlatform();
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(complex.allowsNewDiscreteContentAt(100, -200)).thenReturn(false);
|
||||
TransitionGenerationPlan transitionPlan = mock(TransitionGenerationPlan.class);
|
||||
TransitionGenerationPlan.TerrainSample terrainSample = mock(TransitionGenerationPlan.TerrainSample.class);
|
||||
when(complex.getTransitionGenerationPlan()).thenReturn(transitionPlan);
|
||||
when(transitionPlan.terrainSampleAt(100, -200)).thenReturn(terrainSample);
|
||||
when(terrainSample.historicalPhysicalBiomeKeyAt(anyInt())).thenAnswer(invocation ->
|
||||
Optional.of(invocation.<Integer>getArgument(0) < -60
|
||||
? "iris:old-bottom"
|
||||
: "iris:old-top"));
|
||||
Engine engine = engine(complex);
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
IrisBiomeActuator actuator = new IrisBiomeActuator(engine);
|
||||
Hunk<PlatformBiome> output = mock(Hunk.class);
|
||||
when(output.getWidth()).thenReturn(1);
|
||||
when(output.getDepth()).thenReturn(1);
|
||||
when(output.getHeight()).thenReturn(8);
|
||||
ChunkContext context = mock(ChunkContext.class);
|
||||
when(context.getComplex()).thenReturn(complex);
|
||||
when(context.getBiome()).thenReturn(mock(ChunkedDataCache.class));
|
||||
|
||||
actuator.onActuate(100, -200, output, false, context);
|
||||
|
||||
verify(output).set(eq(0), eq(0), eq(0), eq(0), eq(3), eq(0), any(PlatformBiome.class));
|
||||
verify(output).set(eq(0), eq(4), eq(0), eq(0), eq(7), eq(0), any(PlatformBiome.class));
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
verify(mantle).set(eq(100), eq(0), eq(-200), argThat(value -> {
|
||||
MatterBiomeInject injection = (MatterBiomeInject) value;
|
||||
return "iris:old-bottom".equals(injection.getBiomeKey());
|
||||
}));
|
||||
verify(mantle).set(eq(100), eq(4), eq(-200), argThat(value -> {
|
||||
MatterBiomeInject injection = (MatterBiomeInject) value;
|
||||
return "iris:old-top".equals(injection.getBiomeKey());
|
||||
}));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Engine engine(IrisComplex complex) {
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
when(engineMantle.getMantle()).thenReturn(mantle);
|
||||
@@ -96,6 +180,7 @@ public class IrisBiomeActuatorCoordinateTest {
|
||||
when(engine.getCacheID()).thenReturn(1);
|
||||
when(engine.getSeedManager()).thenReturn(seedManager);
|
||||
when(engine.getMantle()).thenReturn(engineMantle);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getMetrics()).thenReturn(new EngineMetrics(16));
|
||||
return engine;
|
||||
}
|
||||
|
||||
+28
@@ -9,9 +9,14 @@ import art.arcane.iris.engine.object.IrisDecorationStep;
|
||||
import art.arcane.iris.engine.object.IrisMaterialPalette;
|
||||
import art.arcane.iris.engine.object.IrisRiverMaterialConfig;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.junit.Test;
|
||||
import org.junit.Before;
|
||||
import org.junit.After;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -23,6 +28,7 @@ import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -31,6 +37,28 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisTerrainNormalActuatorHydrologyTest {
|
||||
private IrisPlatform previousPlatform;
|
||||
|
||||
@Before
|
||||
public void bindPlatform() {
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.block(anyString())).thenReturn(block);
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
|
||||
@After
|
||||
public void restorePlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
if (previousPlatform != null) {
|
||||
IrisPlatforms.bind(previousPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hydrologyOwnedFluidBypassesBiomeSeaLayers() {
|
||||
PlatformBlockState water = mock(PlatformBlockState.class);
|
||||
|
||||
@@ -5,11 +5,16 @@ import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.DimensionStackContext;
|
||||
import art.arcane.iris.engine.DimensionTerrainContext;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.history.ChunkGenerationSemantics;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.engine.history.GenerationSemanticIndex;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
@@ -25,6 +30,7 @@ import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.Future;
|
||||
@@ -39,6 +45,7 @@ import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -94,6 +101,7 @@ public class HintedLocatorTest {
|
||||
when(engine.getFocusRegion()).thenReturn(null);
|
||||
when(engine.isClosed()).thenReturn(false);
|
||||
when(engine.acquireGenerationLease(any(String.class))).thenReturn(GenerationSessionLease.noop());
|
||||
when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true);
|
||||
when(data.getBiomeLoader()).thenReturn(biomeLoader);
|
||||
when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>());
|
||||
}
|
||||
@@ -463,6 +471,267 @@ public class HintedLocatorTest {
|
||||
assertTrue(elapsed < 10_000);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void locatorMergesRecordedAndActiveCandidatesByDistance() throws Exception {
|
||||
Position2 origin = new Position2(0, 0);
|
||||
Position2 activeChunk = new Position2(1, 0);
|
||||
Locator<String> locator = new Locator<>() {
|
||||
@Override
|
||||
public boolean matches(Engine ignoredEngine, Position2 chunk) {
|
||||
return activeChunk.equals(chunk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchCandidate nearestRecordedCandidate(
|
||||
Engine ignoredEngine,
|
||||
Position2 ignoredOrigin,
|
||||
int ignoredRadius
|
||||
) {
|
||||
return SearchCandidate.atChunkCenter(new Position2(20, 0));
|
||||
}
|
||||
};
|
||||
|
||||
Position2 result = locator.find(engine, origin, 60_000, ignored -> { }).get();
|
||||
|
||||
assertEquals(activeChunk, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void impossibleTargetWithNoHistoricalFallbackReturnsWithoutScanning() throws Exception {
|
||||
IrisEngine routedEngine = mock(IrisEngine.class);
|
||||
GenerationHistory history = mock(GenerationHistory.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(routedEngine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(routedEngine.acquireGenerationLease(any(String.class))).thenReturn(GenerationSessionLease.noop());
|
||||
when(router.history()).thenReturn(history);
|
||||
when(history.hasHistoricalFallbackInSquare(anyInt(), anyInt(), anyInt())).thenReturn(false);
|
||||
Locator<String> locator = new HintedLocator<>((ignoredEngine, chunk) -> {
|
||||
throw new AssertionError("Impossible target must not enter procedural search.");
|
||||
}, ignoredEngine -> HintedLocator.SearchPlan.impossible());
|
||||
|
||||
assertNull(locator.find(routedEngine, new Position2(0, 0), 60_000, ignored -> { })
|
||||
.get(3, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hintedLocatorMergesRecordedAndActiveCandidatesByDistance() throws Exception {
|
||||
Position2 origin = new Position2(0, 0);
|
||||
Position2 activeChunk = new Position2(1, 0);
|
||||
Locator<String> exact = new Locator<>() {
|
||||
@Override
|
||||
public boolean matches(Engine ignoredEngine, Position2 chunk) {
|
||||
return activeChunk.equals(chunk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchCandidate nearestRecordedCandidate(
|
||||
Engine ignoredEngine,
|
||||
Position2 ignoredOrigin,
|
||||
int ignoredRadius
|
||||
) {
|
||||
return SearchCandidate.atChunkCenter(new Position2(20, 0));
|
||||
}
|
||||
};
|
||||
Locator<String> locator = new HintedLocator<>(
|
||||
exact,
|
||||
ignoredEngine -> HintedLocator.SearchPlan.unpruned()
|
||||
);
|
||||
|
||||
Position2 result = locator.find(engine, origin, 60_000, ignored -> { }).get();
|
||||
|
||||
assertEquals(activeChunk, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactBlockPositionsDecideStructureCandidateDistance() {
|
||||
Position2 origin = new Position2(0, 0);
|
||||
Locator.SearchCandidate fartherRecorded = new Locator.SearchCandidate(
|
||||
new Position2(1, 0),
|
||||
31,
|
||||
8
|
||||
);
|
||||
Locator.SearchCandidate nearerActive = new Locator.SearchCandidate(
|
||||
new Position2(1, 0),
|
||||
16,
|
||||
8
|
||||
);
|
||||
|
||||
assertEquals(nearerActive, Locator.nearer(origin, fartherRecorded, nearerActive));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plainLocatorChecksLaterRingsThatCanContainACloserMatch() throws Exception {
|
||||
Position2 corner = new Position2(-40, -40);
|
||||
Position2 nearer = new Position2(50, 0);
|
||||
Locator<String> locator = (ignoredEngine, chunk) -> chunk.equals(corner) || chunk.equals(nearer);
|
||||
|
||||
assertEquals(nearer, locator.find(engine, new Position2(0, 0), 10_000, ignored -> { })
|
||||
.get(15, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fineLocatorChoosesTheSameNearestTieRegardlessOfWorkerOrder() throws Exception {
|
||||
Set<Position2> matches = Set.of(new Position2(-12, 0), new Position2(0, -12),
|
||||
new Position2(0, 12), new Position2(12, 0));
|
||||
Locator<String> locator = new HintedLocator<>((ignoredEngine, chunk) -> matches.contains(chunk),
|
||||
ignoredEngine -> HintedLocator.SearchPlan.unpruned());
|
||||
|
||||
assertEquals(new Position2(-12, 0), locator.find(engine, new Position2(0, 0), 10_000, ignored -> { })
|
||||
.get(15, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coarseRefinementRanksMatchesFromTheSearchOrigin() throws Exception {
|
||||
Locator<String> locator = new HintedLocator<>(
|
||||
(ignoredEngine, chunk) -> chunk.equals(new Position2(8, 0)) || chunk.equals(new Position2(5, 0)),
|
||||
ignoredEngine -> HintedLocator.SearchPlan.of((x, z) -> x == 136 && z == 8, 4, null));
|
||||
|
||||
assertEquals(new Position2(5, 0), locator.find(engine, new Position2(0, 0), 10_000, ignored -> { })
|
||||
.get(15, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ringCoordinatesNeverWrapAcrossTheBlockCoordinateLimit() {
|
||||
KList<Position2> cells = new KList<>();
|
||||
int maximumChunk = Integer.MAX_VALUE >> 4;
|
||||
HintedLocator.appendRing(cells, new Position2(maximumChunk, maximumChunk), 1, 1);
|
||||
|
||||
assertEquals(Set.of(new Position2(maximumChunk - 1, maximumChunk - 1),
|
||||
new Position2(maximumChunk - 1, maximumChunk),
|
||||
new Position2(maximumChunk, maximumChunk - 1)), new HashSet<>(cells));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void transitionGatePreventsProceduralLocatorEvaluation() {
|
||||
AtomicInteger evaluations = new AtomicInteger();
|
||||
HistoryAwareLocator<String> locator = new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:temperate",
|
||||
(ignoredEngine, ignoredChunk) -> {
|
||||
evaluations.incrementAndGet();
|
||||
return true;
|
||||
}
|
||||
);
|
||||
when(complex.allowsNewGenerationChunk(3, 4)).thenReturn(false);
|
||||
|
||||
assertFalse(locator.matchesForSearch(engine, new Position2(3, 4)));
|
||||
assertEquals(0, evaluations.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void semanticRecordsAndOwnershipSelectTheOnlyTruthfulLocatorSource() throws Exception {
|
||||
IrisEngine routedEngine = mock(IrisEngine.class);
|
||||
IrisComplex routedComplex = mock(IrisComplex.class);
|
||||
GenerationHistory history = mock(GenerationHistory.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
AtomicInteger scopeDepth = new AtomicInteger();
|
||||
AtomicInteger evaluations = new AtomicInteger();
|
||||
ChunkGenerationSemantics negative = semantics(1, "iris:other");
|
||||
ChunkGenerationSemantics positive = semantics(2, "iris:target");
|
||||
HistoryAwareLocator<String> locator = new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:target",
|
||||
(ignoredEngine, chunk) -> {
|
||||
evaluations.incrementAndGet();
|
||||
return scopeDepth.get() > 0 && (chunk.getX() == 0 || chunk.getX() == 3);
|
||||
}
|
||||
);
|
||||
|
||||
when(routedEngine.getComplex()).thenReturn(routedComplex);
|
||||
when(routedEngine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.history()).thenReturn(history);
|
||||
when(history.semantics(anyInt(), anyInt())).thenAnswer(invocation -> switch (
|
||||
invocation.<Integer>getArgument(0)
|
||||
) {
|
||||
case 1 -> Optional.of(negative);
|
||||
case 2 -> Optional.of(positive);
|
||||
default -> Optional.empty();
|
||||
});
|
||||
when(history.isHistoricallyOwned(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int chunkX = invocation.getArgument(0);
|
||||
return chunkX >= 0 && chunkX <= 2;
|
||||
});
|
||||
when(history.isActiveUnowned(anyInt(), anyInt())).thenAnswer(invocation ->
|
||||
invocation.<Integer>getArgument(0) == 3);
|
||||
when(routedComplex.allowsNewGenerationChunk(anyInt(), anyInt())).thenAnswer(invocation ->
|
||||
invocation.<Integer>getArgument(0) == 3);
|
||||
when(routedEngine.openGenerationHistoryCoordinateScope(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
GenerationHistoryRuntimeRouter.CoordinateScope scope =
|
||||
mock(GenerationHistoryRuntimeRouter.CoordinateScope.class);
|
||||
scopeDepth.incrementAndGet();
|
||||
doAnswer(close -> {
|
||||
scopeDepth.decrementAndGet();
|
||||
return null;
|
||||
}).when(scope).close();
|
||||
return scope;
|
||||
});
|
||||
|
||||
assertTrue(locator.matches(routedEngine, new Position2(0, 0)));
|
||||
assertTrue(locator.matchesForSearch(routedEngine, new Position2(0, 0)));
|
||||
assertFalse(locator.matches(routedEngine, new Position2(1, 0)));
|
||||
assertFalse(locator.matchesForSearch(routedEngine, new Position2(1, 0)));
|
||||
assertTrue(locator.matches(routedEngine, new Position2(2, 0)));
|
||||
assertFalse(locator.matchesForSearch(routedEngine, new Position2(2, 0)));
|
||||
assertTrue(locator.matches(routedEngine, new Position2(3, 0)));
|
||||
assertTrue(locator.matchesForSearch(routedEngine, new Position2(3, 0)));
|
||||
assertEquals(4, evaluations.get());
|
||||
assertEquals(0, scopeDepth.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void historicalFallbackBypassesCurrentPackCoarseHints() throws Exception {
|
||||
IrisEngine routedEngine = mock(IrisEngine.class);
|
||||
IrisComplex routedComplex = mock(IrisComplex.class);
|
||||
GenerationHistory history = mock(GenerationHistory.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
AtomicInteger scopeDepth = new AtomicInteger();
|
||||
HistoryAwareLocator<String> exact = new HistoryAwareLocator<>(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:old_only",
|
||||
(ignoredEngine, chunk) -> chunk.equals(new Position2(1, 0)) && scopeDepth.get() > 0
|
||||
);
|
||||
Locator<String> locator = new HintedLocator<>(
|
||||
exact,
|
||||
ignoredEngine -> HintedLocator.SearchPlan.of((blockX, blockZ) -> false, 4, null)
|
||||
);
|
||||
|
||||
when(routedEngine.getComplex()).thenReturn(routedComplex);
|
||||
when(routedEngine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(routedEngine.isClosed()).thenReturn(false);
|
||||
when(routedEngine.acquireGenerationLease(any(String.class))).thenReturn(GenerationSessionLease.noop());
|
||||
when(router.history()).thenReturn(history);
|
||||
when(history.findRecorded(any())).thenReturn(Optional.empty());
|
||||
when(history.semantics(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
when(history.isHistoricallyOwned(anyInt(), anyInt())).thenAnswer(invocation ->
|
||||
invocation.<Integer>getArgument(0) == 1
|
||||
&& invocation.<Integer>getArgument(1) == 0);
|
||||
when(history.hasHistoricalFallbackInSquare(anyInt(), anyInt(), anyInt())).thenAnswer(invocation ->
|
||||
Math.abs((long) invocation.<Integer>getArgument(0) - 1L) <= invocation.<Integer>getArgument(2)
|
||||
&& Math.abs((long) invocation.<Integer>getArgument(1)) <= invocation.<Integer>getArgument(2));
|
||||
when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(false);
|
||||
when(routedComplex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(false);
|
||||
when(routedEngine.openGenerationHistoryCoordinateScope(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
GenerationHistoryRuntimeRouter.CoordinateScope scope =
|
||||
mock(GenerationHistoryRuntimeRouter.CoordinateScope.class);
|
||||
scopeDepth.incrementAndGet();
|
||||
doAnswer(close -> {
|
||||
scopeDepth.decrementAndGet();
|
||||
return null;
|
||||
}).when(scope).close();
|
||||
return scope;
|
||||
});
|
||||
|
||||
Position2 result = locator.find(
|
||||
routedEngine,
|
||||
new Position2(0, 0),
|
||||
60_000,
|
||||
ignored -> { }
|
||||
).get(10, TimeUnit.SECONDS);
|
||||
|
||||
assertEquals(new Position2(1, 0), result);
|
||||
assertEquals(0, scopeDepth.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startingAnotherSearchDoesNotCancelTheActiveRequest() throws Exception {
|
||||
CountDownLatch planning = new CountDownLatch(1);
|
||||
@@ -492,6 +761,13 @@ public class HintedLocatorTest {
|
||||
assertEquals(origin, first.get(10, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
private static ChunkGenerationSemantics semantics(int chunkX, String regionKey) {
|
||||
return ChunkGenerationSemantics.builder(chunkX, 0, 1L)
|
||||
.addRegion(regionKey)
|
||||
.seal()
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findLocatesObjectThroughCoarseCascade() throws Exception {
|
||||
IrisRegion regionA = region("reg_a");
|
||||
|
||||
+11
@@ -19,6 +19,7 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.structure.StructureGraphCatalog;
|
||||
import art.arcane.iris.engine.object.IrisDirection;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
@@ -41,6 +42,7 @@ import art.arcane.iris.engine.object.ObjectPlaceMode;
|
||||
import art.arcane.iris.engine.object.StructureDistribution;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Set;
|
||||
@@ -53,6 +55,7 @@ import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -447,6 +450,14 @@ public class IrisStructureLocatorContractTest {
|
||||
@Test
|
||||
public void locateRanksEditableStartsByResolvedBlockOrigin() {
|
||||
Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(complex.allowsNewGenerationFootprint(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
ProceduralStream<Double> riverHeight = mock(ProceduralStream.class);
|
||||
when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHeight);
|
||||
when(riverHeight.get(anyDouble(), anyDouble())).thenReturn(63D);
|
||||
when(engine.getAllBiomes()).thenReturn(new KList<>());
|
||||
when(engine.getHeight(anyInt(), anyInt(), eq(true))).thenReturn(128);
|
||||
when(engine.getDimension().getFluidHeight()).thenReturn(63);
|
||||
IrisStructurePlacement placement = engine.getDimension().getStructures().get(0);
|
||||
|
||||
+125
@@ -1,11 +1,24 @@
|
||||
package art.arcane.iris.engine.framework;
|
||||
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterSlice;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
@@ -14,17 +27,34 @@ import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeStructureOwnershipStoreTest {
|
||||
private static final String FINGERPRINT = "34".repeat(32);
|
||||
|
||||
@BeforeClass
|
||||
public static void initializeMantleBlockState() throws Exception {
|
||||
PlatformBlockState air = mock(PlatformBlockState.class);
|
||||
try (MockedStatic<B> blocks = mockStatic(B.class)) {
|
||||
blocks.when(() -> B.getState("AIR")).thenReturn(air);
|
||||
Class.forName(EngineMantle.class.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fullReferenceEnvelopeWritesOnlyItsOriginAuthority() {
|
||||
Engine engine = engine();
|
||||
@@ -400,6 +430,74 @@ public class NativeStructureOwnershipStoreTest {
|
||||
assertEquals(2, storage.flushes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedOwnershipFlushUsesMantleCapturedBeforeRuntimePromotion() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
IrisEngine.GenerationRuntimeBinding bindingA = runtimeBinding(11);
|
||||
IrisEngine.GenerationRuntimeBinding bindingB = runtimeBinding(12);
|
||||
MantleFixture mantleA = mantleFixture();
|
||||
MantleFixture mantleB = mantleFixture();
|
||||
AtomicReference<GenerationHistoryRuntimeRouter.RuntimeOwnership> ownership =
|
||||
new AtomicReference<>(new GenerationHistoryRuntimeRouter.RuntimeOwnership(1L, bindingA));
|
||||
AtomicReference<EngineMantle> currentMantle = new AtomicReference<>(mantleA.engineMantle());
|
||||
AtomicInteger currentRuntime = new AtomicInteger(11);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.currentRuntimeOwnership()).thenAnswer(ignored -> Optional.of(ownership.get()));
|
||||
when(engine.getMantle()).thenAnswer(ignored -> currentMantle.get());
|
||||
when(engine.getCacheID()).thenAnswer(ignored -> currentRuntime.get());
|
||||
NativeStructureOwnershipStore.MantleStorage storage =
|
||||
new NativeStructureOwnershipStore.MantleStorage(engine);
|
||||
NativeStructureOwnershipRecord record = record("test:captured_mantle", 4, -5, 109L);
|
||||
|
||||
storage.write(NativeStructureOwnershipStore.pack(4, -5), record);
|
||||
ownership.set(new GenerationHistoryRuntimeRouter.RuntimeOwnership(2L, bindingB));
|
||||
currentMantle.set(mantleB.engineMantle());
|
||||
currentRuntime.set(12);
|
||||
|
||||
assertTrue(storage.flush());
|
||||
verify(mantleA.mantle(), times(2)).getChunk(4, -5);
|
||||
verify(mantleA.mantle()).saveIdleTectonicPlates(any());
|
||||
verify(mantleB.mantle(), never()).getChunk(anyInt(), anyInt());
|
||||
verify(mantleB.mantle(), never()).saveIdleTectonicPlates(any());
|
||||
storage.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flushPartitionsPendingOwnershipByCapturedRuntime() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
IrisEngine.GenerationRuntimeBinding bindingA = runtimeBinding(21);
|
||||
IrisEngine.GenerationRuntimeBinding bindingB = runtimeBinding(22);
|
||||
MantleFixture mantleA = mantleFixture();
|
||||
MantleFixture mantleB = mantleFixture();
|
||||
AtomicReference<GenerationHistoryRuntimeRouter.RuntimeOwnership> ownership =
|
||||
new AtomicReference<>(new GenerationHistoryRuntimeRouter.RuntimeOwnership(3L, bindingA));
|
||||
AtomicReference<EngineMantle> currentMantle = new AtomicReference<>(mantleA.engineMantle());
|
||||
AtomicInteger currentRuntime = new AtomicInteger(21);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.currentRuntimeOwnership()).thenAnswer(ignored -> Optional.of(ownership.get()));
|
||||
when(engine.getMantle()).thenAnswer(ignored -> currentMantle.get());
|
||||
when(engine.getCacheID()).thenAnswer(ignored -> currentRuntime.get());
|
||||
NativeStructureOwnershipStore.MantleStorage storage =
|
||||
new NativeStructureOwnershipStore.MantleStorage(engine);
|
||||
NativeStructureOwnershipRecord recordA = record("test:partition_a", -7, 8, 110L);
|
||||
NativeStructureOwnershipRecord recordB = record("test:partition_b", 9, -10, 111L);
|
||||
|
||||
storage.write(NativeStructureOwnershipStore.pack(-7, 8), recordA);
|
||||
ownership.set(new GenerationHistoryRuntimeRouter.RuntimeOwnership(4L, bindingB));
|
||||
currentMantle.set(mantleB.engineMantle());
|
||||
currentRuntime.set(22);
|
||||
storage.write(NativeStructureOwnershipStore.pack(9, -10), recordB);
|
||||
|
||||
assertTrue(storage.flush());
|
||||
verify(mantleA.mantle(), times(2)).getChunk(-7, 8);
|
||||
verify(mantleA.mantle()).saveIdleTectonicPlates(any());
|
||||
verify(mantleB.mantle(), times(2)).getChunk(9, -10);
|
||||
verify(mantleB.mantle()).saveIdleTectonicPlates(any());
|
||||
storage.close();
|
||||
}
|
||||
|
||||
private static Engine engine() {
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.isClosing()).thenReturn(false);
|
||||
@@ -407,6 +505,30 @@ public class NativeStructureOwnershipStoreTest {
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static IrisEngine.GenerationRuntimeBinding runtimeBinding(int runtimeId) {
|
||||
IrisEngine.GenerationRuntimeBinding binding = mock(IrisEngine.GenerationRuntimeBinding.class);
|
||||
when(binding.runtimeId()).thenReturn(runtimeId);
|
||||
return binding;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MantleFixture mantleFixture() {
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
Matter section = mock(Matter.class);
|
||||
MatterSlice<NativeStructureOwnershipBundle> slice = mock(MatterSlice.class);
|
||||
when(engineMantle.getMantle()).thenReturn(mantle);
|
||||
when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
when(chunk.getOrCreate(0)).thenReturn(section);
|
||||
when(section.<NativeStructureOwnershipBundle>slice(
|
||||
NativeStructureOwnershipBundle.class)).thenReturn(slice);
|
||||
when(slice.get(0, 0, 0)).thenReturn(null);
|
||||
when(mantle.saveIdleTectonicPlates(any())).thenReturn(Set.of());
|
||||
return new MantleFixture(engineMantle, mantle);
|
||||
}
|
||||
|
||||
private static NativeStructureOwnershipRecord record(String key, int originX, int originZ,
|
||||
long placementIdentity) {
|
||||
return record(key, originX, originZ, placementIdentity, new IrisStructureTerrain(), 8);
|
||||
@@ -583,4 +705,7 @@ public class NativeStructureOwnershipStoreTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record MantleFixture(EngineMantle engineMantle, Mantle<Matter> mantle) {
|
||||
}
|
||||
}
|
||||
+12
@@ -145,6 +145,18 @@ public class NativeStructurePlacementPlannerTest {
|
||||
assertSame(expected, plans.getFirst().placement());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void transitionBandRejectsNativeStructureStarts() {
|
||||
Engine engine = engine(77L, -64, 384, 150);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(complex.allowsNewGenerationChunk(0, 0)).thenReturn(false);
|
||||
IrisStructurePlacement placement = nativePlacement();
|
||||
|
||||
assertNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0));
|
||||
assertTrue(NativeStructurePlacementPlanner.plansAt(engine, 0, 0).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void weightedNativeSelectionPreservesAuthoredSourceOrder() {
|
||||
KList<IrisNativeStructure> first = new KList<>();
|
||||
|
||||
+34
-8
@@ -8,15 +8,18 @@ import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeStructureVolumeMemoTest {
|
||||
@Test
|
||||
public void repeatedQueriesInsideOneChunkAskThePlatformOnce() {
|
||||
RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 15, 78, 15));
|
||||
NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo();
|
||||
Engine engine = engine(1);
|
||||
|
||||
KList<NativeStructureVolume> first = memo.volumes(null, hooks, 2, 2, 6, 6);
|
||||
KList<NativeStructureVolume> second = memo.volumes(null, hooks, 8, 8, 12, 12);
|
||||
KList<NativeStructureVolume> first = memo.volumes(engine, hooks, 2, 2, 6, 6);
|
||||
KList<NativeStructureVolume> second = memo.volumes(engine, hooks, 8, 8, 12, 12);
|
||||
|
||||
assertEquals(1, hooks.queries().size());
|
||||
assertEquals(first, second);
|
||||
@@ -27,7 +30,7 @@ public class NativeStructureVolumeMemoTest {
|
||||
RecordingHooks hooks = new RecordingHooks();
|
||||
NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo();
|
||||
|
||||
memo.volumes(null, hooks, 20, 36, 21, 37);
|
||||
memo.volumes(engine(1), hooks, 20, 36, 21, 37);
|
||||
|
||||
assertEquals(1, hooks.queries().size());
|
||||
assertEquals(List.of(16, 32, 31, 47), hooks.queries().getFirst());
|
||||
@@ -39,7 +42,7 @@ public class NativeStructureVolumeMemoTest {
|
||||
RecordingHooks hooks = new RecordingHooks(shared);
|
||||
NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo();
|
||||
|
||||
KList<NativeStructureVolume> volumes = memo.volumes(null, hooks, 12, 12, 20, 20);
|
||||
KList<NativeStructureVolume> volumes = memo.volumes(engine(1), hooks, 12, 12, 20, 20);
|
||||
|
||||
assertEquals(4, hooks.queries().size());
|
||||
assertEquals(1, volumes.size());
|
||||
@@ -51,7 +54,7 @@ public class NativeStructureVolumeMemoTest {
|
||||
RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 3, 78, 3));
|
||||
NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo();
|
||||
|
||||
assertTrue(memo.volumes(null, hooks, 5, 5, 9, 9).isEmpty());
|
||||
assertTrue(memo.volumes(engine(1), hooks, 5, 5, 9, 9).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,9 +62,10 @@ public class NativeStructureVolumeMemoTest {
|
||||
RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 15, 78, 15));
|
||||
NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo();
|
||||
|
||||
memo.volumes(null, hooks, 2, 2, 6, 6);
|
||||
Engine engine = engine(1);
|
||||
memo.volumes(engine, hooks, 2, 2, 6, 6);
|
||||
memo.clear();
|
||||
memo.volumes(null, hooks, 2, 2, 6, 6);
|
||||
memo.volumes(engine, hooks, 2, 2, 6, 6);
|
||||
|
||||
assertEquals(2, hooks.queries().size());
|
||||
}
|
||||
@@ -70,7 +74,29 @@ public class NativeStructureVolumeMemoTest {
|
||||
public void missingHooksResolveNoVolumes() {
|
||||
NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo();
|
||||
|
||||
assertTrue(memo.volumes(null, null, 0, 0, 15, 15).isEmpty());
|
||||
assertTrue(memo.volumes(engine(1), null, 0, 0, 15, 15).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void evictingOneRuntimePreservesOtherRuntimeBuckets() {
|
||||
RecordingHooks hooks = new RecordingHooks(volume(0, 64, 0, 15, 78, 15));
|
||||
NativeStructureVolumeMemo memo = new NativeStructureVolumeMemo();
|
||||
Engine firstRuntime = engine(1);
|
||||
Engine secondRuntime = engine(2);
|
||||
|
||||
memo.volumes(firstRuntime, hooks, 2, 2, 6, 6);
|
||||
memo.volumes(secondRuntime, hooks, 2, 2, 6, 6);
|
||||
memo.evictRuntime(1);
|
||||
memo.volumes(secondRuntime, hooks, 2, 2, 6, 6);
|
||||
memo.volumes(firstRuntime, hooks, 2, 2, 6, 6);
|
||||
|
||||
assertEquals(3, hooks.queries().size());
|
||||
}
|
||||
|
||||
private static Engine engine(int runtimeId) {
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getCacheID()).thenReturn(runtimeId);
|
||||
return engine;
|
||||
}
|
||||
|
||||
private static NativeStructureVolume volume(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
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 final class ChunkGenerationOwnershipTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void unassignedChunksUseTheSuppliedCurrentActivation() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("fallback").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
|
||||
assertEquals(4L, ownership.resolve(0, 0, 4L));
|
||||
assertEquals(9L, ownership.resolve(0, 0, 9L));
|
||||
assertFalse(ownership.isExplicitlyAssigned(0, 0));
|
||||
assertThrows(IllegalStateException.class, () -> ownership.explicitActivation(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedQueryOnlyVisitsAssignedChunksInsideTheSquare() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("bounded-query").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
ownership.assign(-33, 0, 1L);
|
||||
ownership.assign(-32, 1, 2L);
|
||||
ownership.assign(-31, 2, 3L);
|
||||
ownership.assign(8192, 8192, 4L);
|
||||
ownership.persist();
|
||||
ChunkGenerationOwnership loaded = ChunkGenerationOwnership.load(directory);
|
||||
Set<Long> visited = new HashSet<>();
|
||||
|
||||
assertFalse(loaded.anyMatchingInSquare(-33, 0, 1, (x, z, activation) -> {
|
||||
visited.add(ChunkGenerationOwnership.packChunk(x, z));
|
||||
return false;
|
||||
}));
|
||||
assertEquals(Set.of(ChunkGenerationOwnership.packChunk(-33, 0),
|
||||
ChunkGenerationOwnership.packChunk(-32, 1)), visited);
|
||||
assertTrue(loaded.anyMatchingInSquare(-32, 1, 0, (x, z, activation) -> activation == 2L));
|
||||
assertFalse(loaded.anyMatchingInSquare(0, 0, 16, (x, z, activation) -> {
|
||||
throw new AssertionError("Unassigned chunks must not be evaluated.");
|
||||
}));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> loaded.anyMatchingInSquare(0, 0, -1, (x, z, activation) -> true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sparseWholeCoordinateQueryUsesTheCatalogWithoutOverflow() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("whole-coordinate-query").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
ownership.assign(Integer.MAX_VALUE, Integer.MAX_VALUE, 1L);
|
||||
ownership.assign(Integer.MIN_VALUE, Integer.MIN_VALUE, 2L);
|
||||
|
||||
assertTrue(ownership.anyMatchingInSquare(0, 0, Integer.MAX_VALUE,
|
||||
(x, z, activation) -> activation == 1L));
|
||||
assertFalse(ownership.anyMatchingInSquare(0, 0, Integer.MAX_VALUE,
|
||||
(x, z, activation) -> activation == 2L));
|
||||
assertTrue(ownership.anyMatchingInSquare(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE,
|
||||
(x, z, activation) -> activation == 2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void assignmentsPersistAcrossEverySignedRegionBoundary() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("boundaries").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
int[][] coordinates = {
|
||||
{-33, -33},
|
||||
{-32, -32},
|
||||
{-1, -1},
|
||||
{0, 0},
|
||||
{31, 31},
|
||||
{32, 32}
|
||||
};
|
||||
for (int index = 0; index < coordinates.length; index++) {
|
||||
ownership.assign(coordinates[index][0], coordinates[index][1], index + 1L);
|
||||
}
|
||||
|
||||
assertEquals(4, ownership.persist());
|
||||
assertEquals(0, ownership.persist());
|
||||
ChunkGenerationOwnership loaded = ChunkGenerationOwnership.load(directory);
|
||||
|
||||
assertEquals(coordinates.length, loaded.explicitChunkCount());
|
||||
for (int index = 0; index < coordinates.length; index++) {
|
||||
int chunkX = coordinates[index][0];
|
||||
int chunkZ = coordinates[index][1];
|
||||
assertEquals(index + 1L, loaded.resolve(chunkX, chunkZ, 99L));
|
||||
assertTrue(loaded.isExplicitlyAssigned(chunkX, chunkZ));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void assignmentCannotReplaceHistoricalOwnership() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("immutable").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
|
||||
assertTrue(ownership.assign(8, -2, 41L));
|
||||
assertFalse(ownership.assign(8, -2, 41L));
|
||||
assertThrows(IllegalStateException.class, () -> ownership.assign(8, -2, 42L));
|
||||
assertEquals(41L, ownership.explicitActivation(8, -2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bulkConflictDoesNotPartiallyAssignInventory() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("bulk").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
ownership.assign(0, 0, 3L);
|
||||
WorldChunkInventory inventory = WorldChunkInventory.ofPackedChunks(
|
||||
ChunkGenerationOwnership.packChunk(0, 0),
|
||||
ChunkGenerationOwnership.packChunk(1, 0)
|
||||
);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> ownership.assignAll(inventory, 4L));
|
||||
|
||||
assertFalse(ownership.isExplicitlyAssigned(1, 0));
|
||||
assertEquals(1, ownership.explicitChunkCount());
|
||||
assertEquals(1, ownership.assignAll(inventory, 3L));
|
||||
assertEquals(3L, ownership.explicitActivation(1, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cutoverAssignmentOnlyMaterializesPreviouslyUnassignedChunks() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("cutover").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
ownership.assign(-1, 0, 2L);
|
||||
WorldChunkInventory inventory = WorldChunkInventory.ofPackedChunks(
|
||||
ChunkGenerationOwnership.packChunk(-1, 0),
|
||||
ChunkGenerationOwnership.packChunk(0, 0),
|
||||
ChunkGenerationOwnership.packChunk(1, 0)
|
||||
);
|
||||
|
||||
assertEquals(2, ownership.assignUnassigned(inventory, 3L));
|
||||
|
||||
assertEquals(2L, ownership.explicitActivation(-1, 0));
|
||||
assertEquals(3L, ownership.explicitActivation(0, 0));
|
||||
assertEquals(3L, ownership.explicitActivation(1, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void snapshotUsesTheStablePackedCoordinateContract() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("snapshot").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
ownership.assign(Integer.MIN_VALUE, Integer.MAX_VALUE, 1L);
|
||||
ownership.assign(-1, 0, 2L);
|
||||
ownership.assign(32, -33, 3L);
|
||||
|
||||
long[] expected = {
|
||||
ChunkGenerationOwnership.packChunk(Integer.MIN_VALUE, Integer.MAX_VALUE),
|
||||
ChunkGenerationOwnership.packChunk(-1, 0),
|
||||
ChunkGenerationOwnership.packChunk(32, -33)
|
||||
};
|
||||
Arrays.sort(expected);
|
||||
long[] snapshot = ownership.snapshotExplicitChunkKeys();
|
||||
|
||||
assertArrayEquals(expected, snapshot);
|
||||
for (long packed : snapshot) {
|
||||
assertEquals(packed, ChunkGenerationOwnership.packChunk(
|
||||
ChunkGenerationOwnership.chunkX(packed),
|
||||
ChunkGenerationOwnership.chunkZ(packed)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadedLookupsDecodeOnlyTheRequestedRegion() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("lazy-load").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
ownership.assign(-65, 63, 17L);
|
||||
ownership.persist();
|
||||
ChunkGenerationOwnership loaded = ChunkGenerationOwnership.load(directory);
|
||||
|
||||
assertEquals(0, loaded.cachedRegionCount());
|
||||
assertEquals(17L, loaded.resolve(-65, 63, 30L));
|
||||
assertEquals(1, loaded.cachedRegionCount());
|
||||
assertEquals(30L, loaded.resolve(-64, 63, 30L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void regionCacheRemainsBoundedAndReloadsEvictedOwnership() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("bounded-cache").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
for (int regionX = 0; regionX < 80; regionX++) {
|
||||
ownership.assign(regionX << 5, 0, regionX + 1L);
|
||||
assertTrue(ownership.cachedRegionCount() <= 64);
|
||||
}
|
||||
ownership.persist();
|
||||
|
||||
ChunkGenerationOwnership loaded = ChunkGenerationOwnership.load(directory);
|
||||
assertEquals(0, loaded.cachedRegionCount());
|
||||
assertEquals(80, loaded.regionCount());
|
||||
for (int regionX = 79; regionX >= 0; regionX--) {
|
||||
assertEquals(regionX + 1L, loaded.explicitActivation(regionX << 5, 0));
|
||||
assertTrue(loaded.cachedRegionCount() <= 64);
|
||||
}
|
||||
assertEquals(1L, loaded.explicitActivation(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void activationIdsMustBePositive() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("activation-range").toPath();
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(directory);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> ownership.resolve(0, 0, 0L));
|
||||
assertThrows(IllegalArgumentException.class, () -> ownership.assign(0, 0, -2L));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ownership.assignAll(WorldChunkInventory.empty(), 0L)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void noncanonicalShardNameFailsClosed() throws Exception {
|
||||
Path sourceDirectory = temporaryFolder.newFolder("canonical-source").toPath();
|
||||
ChunkGenerationOwnership source = ChunkGenerationOwnership.load(sourceDirectory);
|
||||
source.assign(0, 0, 1L);
|
||||
source.persist();
|
||||
Path invalidDirectory = temporaryFolder.newFolder("canonical-invalid").toPath();
|
||||
Files.copy(
|
||||
sourceDirectory.resolve(RegionGenerationOwnership.fileName(0, 0)),
|
||||
invalidDirectory.resolve("unexpected.irow")
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, () -> ChunkGenerationOwnership.load(invalidDirectory));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void symbolicLinkStorageRootAndParentFailClosed() throws Exception {
|
||||
Path probe = temporaryFolder.newFolder("ownership-symlink-probe").toPath();
|
||||
Path probeTarget = temporaryFolder.newFolder("ownership-symlink-probe-target").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(probe.resolve("link"), probeTarget);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
Path rootContainer = temporaryFolder.newFolder("ownership-linked-root-container").toPath();
|
||||
Path linkedRoot = rootContainer.resolve("ownership");
|
||||
Files.createSymbolicLink(linkedRoot, temporaryFolder.newFolder("ownership-linked-root-target").toPath());
|
||||
assertThrows(IOException.class, () -> ChunkGenerationOwnership.load(linkedRoot));
|
||||
|
||||
Path parentContainer = temporaryFolder.newFolder("ownership-linked-parent-container").toPath();
|
||||
Path linkedParent = parentContainer.resolve("generation");
|
||||
Files.createSymbolicLink(linkedParent, temporaryFolder.newFolder("ownership-linked-parent-target").toPath());
|
||||
assertThrows(IOException.class, () -> ChunkGenerationOwnership.load(linkedParent.resolve("ownership")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class ChunkGenerationSemanticsTest {
|
||||
@Test
|
||||
public void builderCreatesADeeplyImmutableCanonicalRecord() {
|
||||
ChunkGenerationSemantics semantics = ChunkGenerationSemantics.builder(-4, 9, 12L)
|
||||
.addSurfaceBiomes(List.of("iris:wetlands", "iris:forest"))
|
||||
.addCaveBiome("iris:limestone_caves")
|
||||
.addRegion("temperate")
|
||||
.addRiverProfile("rivers/default")
|
||||
.addObject("procedural/tree/towering-oak#3@2")
|
||||
.addStructure("minecraft:village_plains", -55, 71, 151)
|
||||
.build();
|
||||
|
||||
assertEquals(List.of("iris:forest", "iris:wetlands"), List.copyOf(semantics.surfaceBiomeKeys()));
|
||||
assertEquals(Set.of("iris:limestone_caves"), semantics.caveBiomeKeys());
|
||||
assertEquals(Set.of("temperate"), semantics.regionKeys());
|
||||
assertEquals(Set.of("rivers/default"), semantics.riverProfileKeys());
|
||||
assertEquals(Set.of("procedural/tree/towering-oak#3@2"), semantics.objectKeys());
|
||||
assertEquals(
|
||||
Set.of(new ChunkGenerationSemantics.StructureOccurrence(
|
||||
"minecraft:village_plains",
|
||||
new ChunkGenerationSemantics.BlockPosition(-55, 71, 151)
|
||||
)),
|
||||
semantics.structures()
|
||||
);
|
||||
assertThrows(UnsupportedOperationException.class, () -> semantics.surfaceBiomeKeys().add("iris:desert"));
|
||||
assertThrows(UnsupportedOperationException.class, () -> semantics.structures().clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void activationAndResourceKeysAreStrict() {
|
||||
assertThrows(IllegalArgumentException.class, () -> ChunkGenerationSemantics.builder(0, 0, 0L));
|
||||
assertThrows(IllegalArgumentException.class, () -> ChunkGenerationSemantics.builder(0, 0, -1L));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ChunkGenerationSemantics.builder(0, 0, 1L).addSurfaceBiome("")
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ChunkGenerationSemantics.builder(0, 0, 1L).addSurfaceBiome(" Iris:Forest")
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ChunkGenerationSemantics.builder(0, 0, 1L).addSurfaceBiome("iris:Forest")
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> ChunkGenerationSemantics.builder(0, 0, 1L).addSurfaceBiome("iris\\forest")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equivalentBuilderOrderProducesEqualRecords() {
|
||||
ChunkGenerationSemantics first = ChunkGenerationSemantics.builder(2, 3, 4L)
|
||||
.addSurfaceBiome("iris:b")
|
||||
.addSurfaceBiome("iris:a")
|
||||
.addStructure("iris:tower", 34, 90, 50)
|
||||
.addStructure("iris:ruin", 35, 64, 51)
|
||||
.build();
|
||||
ChunkGenerationSemantics second = ChunkGenerationSemantics.builder(2, 3, 4L)
|
||||
.addStructure("iris:ruin", 35, 64, 51)
|
||||
.addSurfaceBiome("iris:a")
|
||||
.addStructure("iris:tower", 34, 90, 50)
|
||||
.addSurfaceBiome("iris:b")
|
||||
.build();
|
||||
|
||||
assertEquals(first, second);
|
||||
assertEquals(first.hashCode(), second.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mergeUnionsStageFactsAndPropagatesSeal() {
|
||||
ChunkGenerationSemantics first = ChunkGenerationSemantics.builder(2, 3, 4L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.addRegion("temperate")
|
||||
.build();
|
||||
ChunkGenerationSemantics second = ChunkGenerationSemantics.builder(2, 3, 4L)
|
||||
.addCaveBiome("iris:limestone_caves")
|
||||
.addRegion("temperate")
|
||||
.addStructure("iris:tower", 40, 90, 55)
|
||||
.seal()
|
||||
.build();
|
||||
|
||||
ChunkGenerationSemantics merged = first.merge(second);
|
||||
|
||||
assertEquals(Set.of("iris:forest"), merged.surfaceBiomeKeys());
|
||||
assertEquals(Set.of("iris:limestone_caves"), merged.caveBiomeKeys());
|
||||
assertEquals(Set.of("temperate"), merged.regionKeys());
|
||||
assertEquals(second.structures(), merged.structures());
|
||||
assertTrue(merged.sealed());
|
||||
assertFalse(first.sealed());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sealedRecordAcceptsRetriesButRejectsNewFacts() {
|
||||
ChunkGenerationSemantics sealed = ChunkGenerationSemantics.builder(2, 3, 4L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.addRegion("temperate")
|
||||
.seal()
|
||||
.build();
|
||||
ChunkGenerationSemantics retry = ChunkGenerationSemantics.builder(2, 3, 4L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.build();
|
||||
ChunkGenerationSemantics newFact = ChunkGenerationSemantics.builder(2, 3, 4L)
|
||||
.addSurfaceBiome("iris:desert")
|
||||
.build();
|
||||
|
||||
assertSame(sealed, sealed.merge(retry));
|
||||
assertThrows(IllegalStateException.class, () -> sealed.merge(newFact));
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> sealed.merge(ChunkGenerationSemantics.builder(2, 3, 5L).build())
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> sealed.merge(ChunkGenerationSemantics.builder(3, 3, 4L).build())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class GenerationAdmissionTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void cutoverWaitsForEveryOpenGenerationStageAcrossInstances() throws Exception {
|
||||
GenerationAdmission first = new GenerationAdmission(temporaryFolder.getRoot().toPath());
|
||||
GenerationAdmission second = new GenerationAdmission(temporaryFolder.getRoot().toPath().resolve(".").normalize());
|
||||
GenerationAdmission.StageLease stage = first.enterStage();
|
||||
CountDownLatch requested = new CountDownLatch(1);
|
||||
CompletableFuture<Void> cutover = CompletableFuture.runAsync(() -> {
|
||||
requested.countDown();
|
||||
try (GenerationAdmission.CutoverLease ignored = second.beginCutover()) {
|
||||
}
|
||||
});
|
||||
|
||||
assertTrue(requested.await(5L, TimeUnit.SECONDS));
|
||||
Thread.sleep(100L);
|
||||
assertFalse(cutover.isDone());
|
||||
stage.close();
|
||||
cutover.get(5L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aWaitingCutoverPreventsLaterStagesFromOvertakingIt() throws Exception {
|
||||
GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath());
|
||||
GenerationAdmission.StageLease firstStage = admission.enterStage();
|
||||
CountDownLatch cutoverEntered = new CountDownLatch(1);
|
||||
CountDownLatch releaseCutover = new CountDownLatch(1);
|
||||
CompletableFuture<Void> cutover = CompletableFuture.runAsync(() -> {
|
||||
try (GenerationAdmission.CutoverLease ignored = admission.beginCutover()) {
|
||||
cutoverEntered.countDown();
|
||||
await(releaseCutover);
|
||||
}
|
||||
});
|
||||
CompletableFuture<Void> laterStage = CompletableFuture.runAsync(() -> {
|
||||
try (GenerationAdmission.StageLease ignored = admission.enterStage()) {
|
||||
}
|
||||
});
|
||||
|
||||
firstStage.close();
|
||||
assertTrue(cutoverEntered.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(laterStage.isDone());
|
||||
releaseCutover.countDown();
|
||||
cutover.get(5L, TimeUnit.SECONDS);
|
||||
laterStage.get(5L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aStageMayCompleteOnAThreadOtherThanItsCaller() throws Exception {
|
||||
GenerationAdmission admission = new GenerationAdmission(temporaryFolder.getRoot().toPath());
|
||||
GenerationAdmission.StageLease stage = admission.enterStage();
|
||||
CompletableFuture<Void> close = CompletableFuture.runAsync(stage::close);
|
||||
close.get(5L, TimeUnit.SECONDS);
|
||||
|
||||
CompletableFuture<Void> cutover = CompletableFuture.runAsync(() -> {
|
||||
try (GenerationAdmission.CutoverLease ignored = admission.beginCutover()) {
|
||||
}
|
||||
});
|
||||
cutover.get(5L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startupCutoverIsPermanentlyClosedByFirstStageAdmission() {
|
||||
GenerationAdmission admission = new GenerationAdmission(
|
||||
temporaryFolder.getRoot().toPath().resolve("startup-only")
|
||||
);
|
||||
try (GenerationAdmission.CutoverLease ignored = admission.beginStartupCutover()) {
|
||||
}
|
||||
try (GenerationAdmission.StageLease ignored = admission.enterStage()) {
|
||||
}
|
||||
|
||||
assertThrows(IllegalStateException.class, admission::beginStartupCutover);
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
if (!latch.await(5L, TimeUnit.SECONDS)) {
|
||||
throw new IllegalStateException("Timed out waiting for test latch.");
|
||||
}
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while waiting for test latch.", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class GenerationBlendTest {
|
||||
@Test
|
||||
public void smootherstepUsesExactEndpointsAndMidpoint() {
|
||||
assertEquals(0D, GenerationBlend.newEpochWeight(0D, 128), 0D);
|
||||
assertEquals(0.5D, GenerationBlend.newEpochWeight(64D, 128), 0D);
|
||||
assertEquals(1D, GenerationBlend.newEpochWeight(128D, 128), 0D);
|
||||
assertEquals(1D, GenerationBlend.newEpochWeight(256D, 128), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void smootherstepIsMonotonicAcrossTransition() {
|
||||
double previous = 0D;
|
||||
for (int distance = 1; distance <= 128; distance++) {
|
||||
double weight = GenerationBlend.newEpochWeight(distance, 128);
|
||||
assertTrue(weight > previous);
|
||||
previous = weight;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interpolationPreservesExactBoundaryValues() {
|
||||
assertEquals(72D, GenerationBlend.interpolate(72D, 160D, 0D), 0D);
|
||||
assertEquals(160D, GenerationBlend.interpolate(72D, 160D, 1D), 0D);
|
||||
assertEquals(116D, GenerationBlend.interpolate(72D, 160D, 0.5D), 0D);
|
||||
assertEquals(72, GenerationBlend.interpolateHeight(72, 160, 0D));
|
||||
assertEquals(160, GenerationBlend.interpolateHeight(72, 160, 1D));
|
||||
assertEquals(116, GenerationBlend.interpolateHeight(72, 160, 0.5D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidWeightsDistancesAndWidths() {
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationBlend.newEpochWeight(-1D, 32));
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationBlend.newEpochWeight(Double.NaN, 32));
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationBlend.newEpochWeight(1D, 0));
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationBlend.interpolate(0D, 1D, -0.1D));
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationBlend.interpolateHeight(0, 1, 1.1D));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class GenerationBoundaryStoreTest {
|
||||
private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void publishesDeterministicNegativeCoordinateSnapshot() throws Exception {
|
||||
Path firstRoot = temporaryFolder.newFolder("first-dimension").toPath();
|
||||
Path secondRoot = temporaryFolder.newFolder("second-dimension").toPath();
|
||||
GenerationBoundaryStore firstStore = new GenerationBoundaryStore(firstRoot);
|
||||
GenerationBoundaryStore secondStore = new GenerationBoundaryStore(secondRoot);
|
||||
List<GenerationBoundary.ChunkCoordinate> chunks = List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(2, -3),
|
||||
new GenerationBoundary.ChunkCoordinate(Integer.MIN_VALUE, Integer.MAX_VALUE),
|
||||
new GenerationBoundary.ChunkCoordinate(-1, -1),
|
||||
new GenerationBoundary.ChunkCoordinate(2, -3)
|
||||
);
|
||||
|
||||
GenerationBoundary published = firstStore.publish(42L, chunks);
|
||||
GenerationBoundary reordered = secondStore.publish(42L, List.of(
|
||||
chunks.get(3),
|
||||
chunks.get(2),
|
||||
chunks.get(1),
|
||||
chunks.get(0)
|
||||
));
|
||||
GenerationBoundary loaded = firstStore.load(42L);
|
||||
|
||||
assertEquals(firstRoot.toAbsolutePath().resolve("iris/generation/boundaries"), firstStore.directory());
|
||||
assertTrue(SHA_256.matcher(published.identity()).matches());
|
||||
assertEquals(published.identity(), reordered.identity());
|
||||
assertEquals(published.identity(), loaded.identity());
|
||||
assertEquals(3, loaded.historicalChunkCount());
|
||||
assertTrue(loaded.isHistoricalChunk(2, -3));
|
||||
assertTrue(loaded.isHistoricalChunk(-1, -1));
|
||||
assertTrue(loaded.isHistoricalChunk(Integer.MIN_VALUE, Integer.MAX_VALUE));
|
||||
assertArrayEquals(
|
||||
Files.readAllBytes(firstStore.snapshotPath(42L)),
|
||||
Files.readAllBytes(secondStore.snapshotPath(42L))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keepsPublishedActivationImmutableAndAllowsIdempotentPublication() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("immutable-dimension").toPath();
|
||||
GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot);
|
||||
long[] original = {
|
||||
GenerationBoundary.packChunk(-2, 7),
|
||||
GenerationBoundary.packChunk(0, 0)
|
||||
};
|
||||
|
||||
GenerationBoundary first = store.publishPacked(7L, original);
|
||||
GenerationBoundary repeated = store.publishPacked(7L, original);
|
||||
|
||||
assertEquals(first.identity(), repeated.identity());
|
||||
assertThrows(
|
||||
FileAlreadyExistsException.class,
|
||||
() -> store.publishPacked(7L, new long[]{GenerationBoundary.packChunk(9, 9)})
|
||||
);
|
||||
assertArrayEquals(first.packedHistoricalChunks(), store.load(7L).packedHistoricalChunks());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failsClosedForCorruptionAndTruncation() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("corrupt-dimension").toPath();
|
||||
GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot);
|
||||
store.publishPacked(3L, new long[]{GenerationBoundary.packChunk(-4, 8)});
|
||||
Path corrupted = store.snapshotPath(3L);
|
||||
byte[] corruptedBytes = Files.readAllBytes(corrupted);
|
||||
corruptedBytes[8] ^= 0x01;
|
||||
Files.write(corrupted, corruptedBytes);
|
||||
|
||||
IOException checksumFailure = assertThrows(IOException.class, () -> store.load(3L));
|
||||
assertTrue(checksumFailure.getMessage().contains("checksum"));
|
||||
|
||||
store.publishPacked(4L, new long[]{GenerationBoundary.packChunk(5, -6)});
|
||||
Path truncated = store.snapshotPath(4L);
|
||||
byte[] complete = Files.readAllBytes(truncated);
|
||||
Files.write(truncated, Arrays.copyOf(complete, complete.length - 3));
|
||||
assertThrows(IOException.class, () -> store.load(4L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifiesContentIdentityAfterChecksumValidation() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("identity-dimension").toPath();
|
||||
GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot);
|
||||
store.publishPacked(11L, new long[]{GenerationBoundary.packChunk(-8, -13)});
|
||||
Path snapshot = store.snapshotPath(11L);
|
||||
byte[] encoded = Files.readAllBytes(snapshot);
|
||||
encoded[20] ^= 0x01;
|
||||
updateChecksum(encoded);
|
||||
Files.write(snapshot, encoded);
|
||||
|
||||
IOException failure = assertThrows(IOException.class, () -> store.load(11L));
|
||||
assertTrue(failure.getMessage().contains("identity mismatch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidActivationAndDeclaredBounds() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("bounds-dimension").toPath();
|
||||
GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot);
|
||||
assertThrows(IllegalArgumentException.class, () -> store.publishPacked(0L, new long[0]));
|
||||
store.publishPacked(5L, new long[0]);
|
||||
Path snapshot = store.snapshotPath(5L);
|
||||
byte[] encoded = Files.readAllBytes(snapshot);
|
||||
ByteBuffer.wrap(encoded).putInt(16, Integer.MAX_VALUE);
|
||||
updateChecksum(encoded);
|
||||
Files.write(snapshot, encoded);
|
||||
|
||||
assertThrows(IOException.class, () -> store.load(5L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinksAtEveryStorageComponent() throws Exception {
|
||||
Path probe = temporaryFolder.newFolder("boundary-symlink-probe").toPath();
|
||||
Path probeTarget = temporaryFolder.newFolder("boundary-symlink-probe-target").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(probe.resolve("link"), probeTarget);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
for (String component : new String[]{"dimension", "iris", "generation", "boundaries"}) {
|
||||
assertStorageLinkRejected(component);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentStoreInstancesCannotClobberABoundary() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("boundary-race").toPath();
|
||||
GenerationBoundaryStore firstStore = new GenerationBoundaryStore(dimensionRoot);
|
||||
GenerationBoundaryStore secondStore = new GenerationBoundaryStore(dimensionRoot);
|
||||
long firstChunk = GenerationBoundary.packChunk(1, 2);
|
||||
long secondChunk = GenerationBoundary.packChunk(3, 4);
|
||||
CyclicBarrier barrier = new CyclicBarrier(2);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Boolean> first = executor.submit(
|
||||
() -> publishAfterBarrier(firstStore, 21L, firstChunk, barrier)
|
||||
);
|
||||
Future<Boolean> second = executor.submit(
|
||||
() -> publishAfterBarrier(secondStore, 21L, secondChunk, barrier)
|
||||
);
|
||||
|
||||
assertTrue(first.get() ^ second.get());
|
||||
GenerationBoundary loaded = firstStore.load(21L);
|
||||
assertTrue(loaded.isHistoricalChunk(1, 2) ^ loaded.isHistoricalChunk(3, 4));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentAddressedRegionMasksAreReusedAcrossActivationHistory() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("boundary-history-reuse").toPath();
|
||||
GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot);
|
||||
store.publishPacked(1L, new long[]{
|
||||
GenerationBoundary.packChunk(0, 0),
|
||||
GenerationBoundary.packChunk(32, 0)
|
||||
});
|
||||
store.publishPacked(2L, new long[]{
|
||||
GenerationBoundary.packChunk(0, 0),
|
||||
GenerationBoundary.packChunk(1, 0),
|
||||
GenerationBoundary.packChunk(32, 0)
|
||||
});
|
||||
store.publishPacked(3L, new long[]{
|
||||
GenerationBoundary.packChunk(0, 0),
|
||||
GenerationBoundary.packChunk(1, 0),
|
||||
GenerationBoundary.packChunk(32, 0),
|
||||
GenerationBoundary.packChunk(33, 0)
|
||||
});
|
||||
|
||||
long maskCount;
|
||||
try (Stream<Path> files = Files.list(store.directory())) {
|
||||
maskCount = files.filter(path -> path.getFileName().toString().endsWith(".irbm")).count();
|
||||
}
|
||||
assertEquals(2L, maskCount);
|
||||
GenerationBoundary reopened = new GenerationBoundaryStore(dimensionRoot).load(3L);
|
||||
assertEquals(4, reopened.historicalChunkCount());
|
||||
assertTrue(reopened.isHistoricalChunk(33, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadedBoundaryKeepsOnlyAFixedNumberOfRegionMasksResident() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("boundary-cache").toPath();
|
||||
GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot);
|
||||
long[] chunks = new long[80];
|
||||
for (int regionX = 0; regionX < chunks.length; regionX++) {
|
||||
chunks[regionX] = GenerationBoundary.packChunk(regionX << 5, 0);
|
||||
}
|
||||
store.publishPacked(1L, chunks);
|
||||
GenerationBoundary boundary = new GenerationBoundaryStore(dimensionRoot).load(1L);
|
||||
|
||||
for (int regionX = 0; regionX < chunks.length; regionX++) {
|
||||
assertTrue(boundary.isHistoricalChunk(regionX << 5, 0));
|
||||
assertTrue(boundary.cachedRegionCount() <= 64);
|
||||
}
|
||||
assertTrue(boundary.isHistoricalChunk(0, 0));
|
||||
assertTrue(boundary.cachedRegionCount() <= 64);
|
||||
}
|
||||
|
||||
private void assertStorageLinkRejected(String component) throws Exception {
|
||||
Path container = temporaryFolder.newFolder("boundary-linked-" + component).toPath();
|
||||
Path dimensionRoot = container.resolve("dimension");
|
||||
Path external = temporaryFolder.newFolder("boundary-linked-target-" + component).toPath();
|
||||
if (component.equals("dimension")) {
|
||||
Files.createSymbolicLink(dimensionRoot, external);
|
||||
} else {
|
||||
Files.createDirectory(dimensionRoot);
|
||||
Path iris = dimensionRoot.resolve("iris");
|
||||
if (component.equals("iris")) {
|
||||
Files.createSymbolicLink(iris, external);
|
||||
} else {
|
||||
Files.createDirectory(iris);
|
||||
Path generation = iris.resolve("generation");
|
||||
if (component.equals("generation")) {
|
||||
Files.createSymbolicLink(generation, external);
|
||||
} else {
|
||||
Files.createDirectory(generation);
|
||||
Files.createSymbolicLink(generation.resolve("boundaries"), external);
|
||||
}
|
||||
}
|
||||
}
|
||||
GenerationBoundaryStore store = new GenerationBoundaryStore(dimensionRoot);
|
||||
|
||||
assertThrows(IOException.class, () -> store.publishPacked(1L, new long[0]));
|
||||
}
|
||||
|
||||
private static boolean publishAfterBarrier(
|
||||
GenerationBoundaryStore store,
|
||||
long activationId,
|
||||
long chunk,
|
||||
CyclicBarrier barrier
|
||||
) throws Exception {
|
||||
barrier.await();
|
||||
try {
|
||||
store.publishPacked(activationId, new long[]{chunk});
|
||||
return true;
|
||||
} catch (IOException expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateChecksum(byte[] encoded) {
|
||||
int bodyLength = encoded.length - Integer.BYTES;
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(encoded, 0, bodyLength);
|
||||
ByteBuffer.wrap(encoded, bodyLength, Integer.BYTES).putInt((int) checksum.getValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
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 GenerationBoundaryTest {
|
||||
@Test
|
||||
public void freezesHistoricalOwnershipAndDeduplicatesCoordinates() {
|
||||
ArrayList<GenerationBoundary.ChunkCoordinate> source = new ArrayList<>(List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(2, -3),
|
||||
new GenerationBoundary.ChunkCoordinate(2, -3)
|
||||
));
|
||||
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("activation-4-frontier", source);
|
||||
source.clear();
|
||||
source.add(new GenerationBoundary.ChunkCoordinate(9, 9));
|
||||
|
||||
assertEquals(1, boundary.historicalChunkCount());
|
||||
assertTrue(boundary.isHistoricalChunk(2, -3));
|
||||
assertFalse(boundary.isHistoricalChunk(9, 9));
|
||||
|
||||
long[] exported = boundary.packedHistoricalChunks();
|
||||
exported[0] = GenerationBoundary.packChunk(9, 9);
|
||||
assertTrue(boundary.isHistoricalChunk(2, -3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapsNegativeBlocksWithFloorDivision() {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("negative-frontier", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(-1, -1)
|
||||
));
|
||||
|
||||
assertTrue(boundary.isHistoricalBlock(-1, -1));
|
||||
assertTrue(boundary.isHistoricalBlock(-16, -16));
|
||||
assertFalse(boundary.isHistoricalBlock(-17, -16));
|
||||
assertEquals(1D, boundary.distanceToHistoricalChunks(-17, -16, 64), 0D);
|
||||
assertEquals(1D, boundary.distanceToHistoricalChunks(0, -1, 64), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void measuresIrregularAndEnclosedFrontiersInBlockSpace() {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("enclosed-frontier", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(-1, -1),
|
||||
new GenerationBoundary.ChunkCoordinate(0, -1),
|
||||
new GenerationBoundary.ChunkCoordinate(1, -1),
|
||||
new GenerationBoundary.ChunkCoordinate(-1, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(1, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(-1, 1),
|
||||
new GenerationBoundary.ChunkCoordinate(0, 1),
|
||||
new GenerationBoundary.ChunkCoordinate(1, 1)
|
||||
));
|
||||
|
||||
assertFalse(boundary.isHistoricalChunk(0, 0));
|
||||
assertEquals(8D, boundary.distanceToHistoricalChunks(8, 8, 64), 0D);
|
||||
assertEquals(4D, boundary.distanceToHistoricalChunks(100, 8, 4), 0D);
|
||||
|
||||
GenerationBoundary corner = GenerationBoundary.freeze("corner", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0)
|
||||
));
|
||||
assertEquals(Math.sqrt(2D), corner.distanceToHistoricalChunks(16, 16, 64), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void packedCoordinatesRoundTripAcrossIntegerRange() {
|
||||
long[] packed = {
|
||||
GenerationBoundary.packChunk(Integer.MIN_VALUE, Integer.MAX_VALUE),
|
||||
GenerationBoundary.packChunk(-1, 0),
|
||||
GenerationBoundary.packChunk(0, -1),
|
||||
GenerationBoundary.packChunk(Integer.MAX_VALUE, Integer.MIN_VALUE)
|
||||
};
|
||||
|
||||
GenerationBoundary boundary = GenerationBoundary.freezePacked("packed-frontier", packed);
|
||||
|
||||
assertArrayEquals(packed, boundary.packedHistoricalChunks());
|
||||
assertEquals(new GenerationBoundary.ChunkCoordinate(Integer.MIN_VALUE, Integer.MAX_VALUE),
|
||||
GenerationBoundary.unpackChunk(packed[0]));
|
||||
assertEquals(new GenerationBoundary.ChunkCoordinate(Integer.MAX_VALUE, Integer.MIN_VALUE),
|
||||
GenerationBoundary.unpackChunk(packed[3]));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void samplesEveryExposedEdgeBlockWithoutDuplicatingCorners() {
|
||||
GenerationBoundary isolated = GenerationBoundary.freeze("isolated", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(-1, -1)
|
||||
));
|
||||
|
||||
assertEquals(120, isolated.exposedBlockColumns().size());
|
||||
assertTrue(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(-16, -16)));
|
||||
assertTrue(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(-1, -1)));
|
||||
assertTrue(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(0, 0)));
|
||||
assertTrue(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(15, 15)));
|
||||
assertFalse(isolated.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(7, 7)));
|
||||
|
||||
GenerationBoundary adjacent = GenerationBoundary.freeze("adjacent", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(1, 0)
|
||||
));
|
||||
assertEquals(92, adjacent.exposedBlockColumns().size());
|
||||
assertFalse(adjacent.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(15, 8)));
|
||||
assertFalse(adjacent.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(16, 8)));
|
||||
assertTrue(adjacent.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(0, 8)));
|
||||
assertTrue(adjacent.exposedBlockColumns().contains(new GenerationBoundary.BlockColumn(31, 8)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fragmentedExplorationStreamsBeyondTheFormerMonolithicSnapshotLimit() {
|
||||
int chunkCount = 70_000;
|
||||
ArrayList<GenerationBoundary.ChunkCoordinate> chunks = new ArrayList<>(chunkCount);
|
||||
for (int index = 0; index < chunkCount; index++) {
|
||||
chunks.add(new GenerationBoundary.ChunkCoordinate(index * 2, 0));
|
||||
}
|
||||
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("fragmented", chunks);
|
||||
|
||||
assertEquals(4_200_000L, boundary.exposedBlockColumnCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidBoundaryInputs() {
|
||||
assertThrows(NullPointerException.class, () -> GenerationBoundary.freeze("boundary", null));
|
||||
ArrayList<GenerationBoundary.ChunkCoordinate> withNull = new ArrayList<>();
|
||||
withNull.add(new GenerationBoundary.ChunkCoordinate(0, 0));
|
||||
withNull.add(null);
|
||||
assertThrows(NullPointerException.class, () -> GenerationBoundary.freeze("boundary", withNull));
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationBoundary.freeze(" ", List.of()));
|
||||
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("boundary", List.of());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> boundary.distanceToHistoricalChunks(0, 0, 0));
|
||||
}
|
||||
}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionMode;
|
||||
import art.arcane.iris.engine.object.IrisDimensionModeType;
|
||||
import art.arcane.iris.engine.object.IrisDimensionType;
|
||||
import art.arcane.iris.engine.object.IrisDimensionTypeOptions;
|
||||
import art.arcane.iris.engine.object.IrisEnvironment;
|
||||
import art.arcane.iris.engine.object.IrisRange;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.Rule;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class GenerationEpochContractFactoryTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void derivesTheAuthoredDimensionTypeSemantics() {
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NORMAL);
|
||||
IrisDimensionType dimensionType = dimension.getDimensionType();
|
||||
|
||||
GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"worlds/overworld",
|
||||
"iris:overworld"
|
||||
);
|
||||
|
||||
assertEquals("worlds/overworld", contract.dimensionKey());
|
||||
assertEquals("iris:overworld", contract.dimensionTypeKey());
|
||||
assertEquals("NORMAL", contract.environment());
|
||||
assertEquals("OVERWORLD", contract.generationMode());
|
||||
assertEquals(127, contract.internalFluidHeight());
|
||||
assertEquals(-64, contract.minHeight());
|
||||
assertEquals(384, contract.height());
|
||||
assertEquals(256, contract.logicalHeight());
|
||||
assertEquals(1D, contract.coordinateScale(), 0D);
|
||||
assertEquals(false, contract.upperTerrainEnabled());
|
||||
assertEquals("none", contract.upperDimensionKey());
|
||||
assertEquals(0, contract.upperDimensionGap());
|
||||
assertEquals("0".repeat(64), contract.upperTerrainPackFingerprint());
|
||||
assertEquals(
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
contract.dimensionTypeFingerprintSchema()
|
||||
);
|
||||
assertEquals(
|
||||
GenerationEpochContractFactory.fingerprintDimensionType(dimensionType),
|
||||
contract.dimensionTypeFingerprint()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void capturesGenerationModeAndInternalFluidHeight() {
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NORMAL)
|
||||
.setMode(new IrisDimensionMode().setType(IrisDimensionModeType.ISLANDS))
|
||||
.setFluidHeight(100);
|
||||
|
||||
GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
);
|
||||
|
||||
assertEquals("ISLANDS", contract.generationMode());
|
||||
assertEquals(164, contract.internalFluidHeight());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void upperTerrainMustBeLocalAndPackIdentityDoesNotChangeItsLayout() throws Exception {
|
||||
File pack = temporary.newFolder("upper-pack");
|
||||
IrisData data = mock(IrisData.class);
|
||||
@SuppressWarnings("unchecked")
|
||||
ResourceLoader<IrisDimension> loader = mock(ResourceLoader.class);
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NORMAL).setUpperDimension("ceiling");
|
||||
IrisDimension ceiling = dimension(IrisEnvironment.NORMAL);
|
||||
dimension.setLoader(data);
|
||||
ceiling.setLoader(data);
|
||||
ceiling.setLoadKey("ceiling");
|
||||
when(data.getDataFolder()).thenReturn(pack);
|
||||
when(data.getDimensionLoader()).thenReturn(loader);
|
||||
when(loader.load("ceiling", false)).thenReturn(ceiling);
|
||||
|
||||
GenerationEpoch.DimensionContract first = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
);
|
||||
Files.writeString(
|
||||
pack.toPath().resolve("upper-content.json"),
|
||||
"{}",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
GenerationEpoch.DimensionContract changedPack = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
);
|
||||
|
||||
assertEquals(true, first.upperTerrainEnabled());
|
||||
assertEquals("ceiling", first.upperDimensionKey());
|
||||
assertEquals(32, first.upperDimensionGap());
|
||||
assertNotEquals(first.upperTerrainPackFingerprint(), changedPack.upperTerrainPackFingerprint());
|
||||
assertNotEquals(first, changedPack);
|
||||
assertEquals(true, first.hasSameLayout(changedPack));
|
||||
dimension.setUpperDimensionGap(33);
|
||||
assertEquals(false, first.hasSameLayout(GenerationEpochContractFactory.create(
|
||||
dimension, "overworld", "iris:overworld")));
|
||||
|
||||
when(loader.load("ceiling", false)).thenReturn(null);
|
||||
assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesBaseEnvironmentCoordinateScale() {
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NETHER);
|
||||
|
||||
GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"underworld",
|
||||
"iris:underworld"
|
||||
);
|
||||
|
||||
assertEquals("NETHER", contract.environment());
|
||||
assertEquals(8D, contract.coordinateScale(), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitCoordinateScaleOverridesTheBaseEnvironment() {
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NETHER);
|
||||
dimension.setDimensionOptions(new IrisDimensionTypeOptions().coordinateScale(3.5D));
|
||||
|
||||
GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"underworld",
|
||||
"iris:underworld"
|
||||
);
|
||||
|
||||
assertEquals(3.5D, contract.coordinateScale(), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serializerChangesDoNotAlterTheDimensionContract() {
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NORMAL);
|
||||
IrisDimensionType type = dimension.getDimensionType();
|
||||
String firstJson = type.toJson(new TaggedFixer("first"));
|
||||
String secondJson = type.toJson(new TaggedFixer("second"));
|
||||
|
||||
GenerationEpoch.DimensionContract first = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
);
|
||||
GenerationEpoch.DimensionContract second = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
);
|
||||
|
||||
assertNotEquals(firstJson, secondJson);
|
||||
assertEquals(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authoredDimensionTypeChangesAlterTheFingerprint() {
|
||||
IrisDimension normal = dimension(IrisEnvironment.NORMAL);
|
||||
IrisDimension fullbright = dimension(IrisEnvironment.NORMAL);
|
||||
fullbright.setFullbright(true);
|
||||
IrisDimension nether = dimension(IrisEnvironment.NETHER);
|
||||
|
||||
assertNotEquals(
|
||||
GenerationEpochContractFactory.create(
|
||||
normal,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
).dimensionTypeFingerprint(),
|
||||
GenerationEpochContractFactory.create(
|
||||
fullbright,
|
||||
"overworld",
|
||||
"iris:overworld"
|
||||
).dimensionTypeFingerprint()
|
||||
);
|
||||
assertNotEquals(
|
||||
GenerationEpochContractFactory.fingerprintDimensionType(normal.getDimensionType()),
|
||||
GenerationEpochContractFactory.fingerprintDimensionType(nether.getDimensionType())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recordsAndValidatesTheFingerprintSchema() {
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NORMAL);
|
||||
|
||||
GenerationEpoch.DimensionContract contract = GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld",
|
||||
GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE,
|
||||
contract.dimensionTypeFingerprintSchema()
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"iris:overworld",
|
||||
2
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationEpochContractFactory.fingerprintDimensionType(
|
||||
dimension.getDimensionType(),
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesInvalidKeys() {
|
||||
IrisDimension dimension = dimension(IrisEnvironment.NORMAL);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"Overworld",
|
||||
"iris:overworld"
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationEpochContractFactory.create(
|
||||
dimension,
|
||||
"overworld",
|
||||
"overworld"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static IrisDimension dimension(IrisEnvironment environment) {
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setLoadKey("overworld");
|
||||
dimension.setEnvironment(environment);
|
||||
dimension.setDimensionHeight(new IrisRange(-64, 320));
|
||||
dimension.setLogicalHeight(256);
|
||||
return dimension;
|
||||
}
|
||||
|
||||
private record TaggedFixer(String tag) implements IDataFixer {
|
||||
@Override
|
||||
public JSONObject resolve(Dimension dimension, IrisDimensionTypeOptions options) {
|
||||
double coordinateScale = options.coordinateScale() == -1D
|
||||
? dimension == Dimension.NETHER ? 8D : 1D
|
||||
: options.coordinateScale();
|
||||
return new JSONObject()
|
||||
.put("coordinate_scale", coordinateScale)
|
||||
.put("serializer", tag);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fixDimension(Dimension dimension, JSONObject json) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class GenerationEpochTest {
|
||||
private static final String PACK_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String PACK_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
@Test
|
||||
public void derivesStableIdentityFromEveryGenerationInput() {
|
||||
GenerationEpoch.DimensionContract contract = contract("iris:type_a", 1D);
|
||||
GenerationRegistryContract registry = registry(PACK_A);
|
||||
GenerationEpoch first = epoch(PACK_A, 1, 42L, 1, 1, 1, contract, registry);
|
||||
GenerationEpoch same = epoch(PACK_A, 1, 42L, 1, 1, 1, contract, registry);
|
||||
|
||||
assertEquals(first, same);
|
||||
assertEquals(first.epochId(), same.epochId());
|
||||
assertNotEquals(first.epochId(), epoch(PACK_B, 1, 42L, 1, 1, 1, contract, registry).epochId());
|
||||
assertNotEquals(first.epochId(), epoch(PACK_A, 2, 42L, 1, 1, 1, contract, registry).epochId());
|
||||
assertNotEquals(first.epochId(), epoch(PACK_A, 1, 43L, 1, 1, 1, contract, registry).epochId());
|
||||
assertNotEquals(first.epochId(), epoch(PACK_A, 1, 42L, 2, 1, 1, contract, registry).epochId());
|
||||
assertNotEquals(first.epochId(), epoch(PACK_A, 1, 42L, 1, 2, 1, contract, registry).epochId());
|
||||
assertNotEquals(first.epochId(), epoch(PACK_A, 1, 42L, 1, 1, 2, contract, registry).epochId());
|
||||
GenerationEpoch changedKernelImplementation = GenerationEpoch.create(new GenerationEpoch.Spec(
|
||||
PACK_A,
|
||||
1,
|
||||
42L,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
"d".repeat(64),
|
||||
contract,
|
||||
registry
|
||||
));
|
||||
assertNotEquals(first.epochId(), changedKernelImplementation.epochId());
|
||||
assertNotEquals(
|
||||
first.epochId(),
|
||||
epoch(PACK_A, 1, 42L, 1, 1, 1, contract("iris:type_b", 1D), registry).epochId()
|
||||
);
|
||||
assertNotEquals(
|
||||
first.epochId(),
|
||||
epoch(PACK_A, 1, 42L, 1, 1, 1, contract("iris:type_a", 8D), registry).epochId()
|
||||
);
|
||||
assertNotEquals(
|
||||
first.epochId(),
|
||||
epoch(PACK_A, 1, 42L, 1, 1, 1, contract, registry(PACK_B)).epochId()
|
||||
);
|
||||
assertNotEquals(first.epochId(), epoch(
|
||||
PACK_A,
|
||||
1,
|
||||
42L,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:type_a",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE,
|
||||
PACK_B
|
||||
),
|
||||
registry
|
||||
).epochId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesExactImmutableInputs() {
|
||||
GenerationRegistryContract registry = registry(PACK_A);
|
||||
GenerationEpoch epoch = epoch(PACK_A, 3, Long.MIN_VALUE, 7, 5, 9, contract("iris:type_a", 1D), registry);
|
||||
|
||||
assertEquals(PACK_A, epoch.packFingerprint());
|
||||
assertEquals(3, epoch.packFingerprintVersion());
|
||||
assertEquals(Long.MIN_VALUE, epoch.worldSeed());
|
||||
assertEquals(7, epoch.seedDerivationVersion());
|
||||
assertEquals(5, epoch.generatorAbi());
|
||||
assertEquals(9, epoch.rngVersion());
|
||||
assertEquals(GenerationKernelV1.IMPLEMENTATION_FINGERPRINT, epoch.kernelImplementationFingerprint());
|
||||
assertEquals(registry, epoch.registryContract());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void roundTripsStrictDeterministicJson() {
|
||||
GenerationEpoch epoch = epoch(
|
||||
PACK_A,
|
||||
1,
|
||||
Long.MAX_VALUE,
|
||||
1,
|
||||
3,
|
||||
7,
|
||||
contract("iris:type_a", 1D),
|
||||
registry(PACK_A)
|
||||
);
|
||||
JsonObject json = epoch.toJson();
|
||||
|
||||
assertEquals(epoch, GenerationEpoch.fromJson(json.deepCopy()));
|
||||
assertEquals(json.toString(), epoch.toJson().toString());
|
||||
|
||||
JsonObject unknownField = json.deepCopy();
|
||||
unknownField.addProperty("unexpected", true);
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(unknownField));
|
||||
|
||||
JsonObject missingSeed = json.deepCopy();
|
||||
missingSeed.remove("worldSeed");
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(missingSeed));
|
||||
|
||||
JsonObject missingKernelFingerprint = json.deepCopy();
|
||||
missingKernelFingerprint.remove("kernelImplementationFingerprint");
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(missingKernelFingerprint));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMalformedGenerationInputs() {
|
||||
GenerationEpoch.DimensionContract contract = contract("iris:type_a", 1D);
|
||||
GenerationRegistryContract registry = registry(PACK_A);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> epoch(
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
1,
|
||||
42L,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
contract,
|
||||
registry
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> epoch(PACK_A, 0, 42L, 1, 1, 1, contract, registry)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> epoch(PACK_A, 1, 42L, 0, 1, 1, contract, registry)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> epoch(PACK_A, 1, 42L, 1, 0, 1, contract, registry)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> epoch(PACK_A, 1, 42L, 1, 1, 0, contract, registry)
|
||||
);
|
||||
GenerationEpoch valid = epoch(PACK_A, 1, 42L, 1, 1, 1, contract, registry);
|
||||
JsonObject malformedKernelFingerprint = valid.toJson();
|
||||
malformedKernelFingerprint.addProperty("kernelImplementationFingerprint", "not-a-sha256");
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(malformedKernelFingerprint));
|
||||
assertThrows(
|
||||
NullPointerException.class,
|
||||
() -> epoch(PACK_A, 1, 42L, 1, 1, 1, contract, null)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:type_a",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
0,
|
||||
0,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE,
|
||||
PACK_A
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:type_a",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
385,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE,
|
||||
PACK_A
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:type_a",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
Double.NaN,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE,
|
||||
PACK_A
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:type_a",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
99,
|
||||
PACK_A
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsForgedContentAddress() {
|
||||
GenerationEpoch epoch = epoch(
|
||||
PACK_A,
|
||||
1,
|
||||
42L,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
contract("iris:type_a", 1D),
|
||||
registry(PACK_A)
|
||||
);
|
||||
JsonObject json = epoch.toJson();
|
||||
json.addProperty("epochId", PACK_B);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationEpoch.fromJson(json));
|
||||
}
|
||||
|
||||
private static GenerationEpoch epoch(
|
||||
String packFingerprint,
|
||||
int packFingerprintVersion,
|
||||
long worldSeed,
|
||||
int seedDerivationVersion,
|
||||
int generatorAbi,
|
||||
int rngVersion,
|
||||
GenerationEpoch.DimensionContract contract,
|
||||
GenerationRegistryContract registryContract
|
||||
) {
|
||||
return GenerationEpoch.create(new GenerationEpoch.Spec(
|
||||
packFingerprint,
|
||||
packFingerprintVersion,
|
||||
worldSeed,
|
||||
seedDerivationVersion,
|
||||
generatorAbi,
|
||||
rngVersion,
|
||||
GenerationKernelV1.IMPLEMENTATION_FINGERPRINT,
|
||||
contract,
|
||||
registryContract
|
||||
));
|
||||
}
|
||||
|
||||
private static GenerationEpoch.DimensionContract contract(String typeKey, double coordinateScale) {
|
||||
return new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
typeKey,
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
coordinateScale,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.DIMENSION_TYPE_FINGERPRINT_SCHEMA_VERSION_ONE,
|
||||
PACK_A
|
||||
);
|
||||
}
|
||||
|
||||
private static GenerationRegistryContract registry(String definitionSha256) {
|
||||
GenerationRegistryContract.PhysicalResourceKey key = new GenerationRegistryContract.PhysicalResourceKey(
|
||||
"minecraft:worldgen/biome",
|
||||
"minecraft:plains"
|
||||
);
|
||||
return GenerationRegistryContract.fromDefinitions(Map.of(key, definitionSha256));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.util.common.director.specialhandlers.LocatableObjectHandler;
|
||||
import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler;
|
||||
import art.arcane.iris.util.common.director.specialhandlers.ReachableRegionHandler;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class GenerationFindCatalogTest {
|
||||
@Test
|
||||
public void parsesRemovedNamesFromRetainedPackWithoutBuildingRuntime() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
try (MockedStatic<IrisData> data = mockStatic(IrisData.class)) {
|
||||
data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData);
|
||||
|
||||
assertSame(fixture.meadow, fixture.biomeHandler().parse("meadow", false));
|
||||
assertSame(fixture.oldRegion, fixture.regionHandler().parse("old-region", false));
|
||||
assertEquals("old-tree", fixture.objectHandler().parse("old-tree", false));
|
||||
assertTrue(GenerationFindCatalog.hasObjectPlacement(fixture.engine, "/OLD-TREE.iob"));
|
||||
assertFalse(GenerationFindCatalog.hasObjectPlacement(fixture.engine, "unplaced"));
|
||||
assertEquals("old meadow", GenerationFindCatalog.biome(fixture.engine, "meadow").getName());
|
||||
|
||||
data.verify(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile()), times(1));
|
||||
data.verifyNoMoreInteractions();
|
||||
verify(fixture.oldData).close();
|
||||
verify(fixture.history, never()).packRoot(2L);
|
||||
verify(fixture.engine, never()).getActiveGenerationRuntimeBinding();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void activeDefinitionsWinHistoricalKeyCollisions() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
IrisBiome activeMeadow = biome("meadow", "new meadow");
|
||||
IrisRegion activeRegion = region("old-region");
|
||||
when(fixture.engine.getAllBiomes()).thenReturn(new KList<>(activeMeadow));
|
||||
when(fixture.activeDimension.getAllRegions(fixture.engine)).thenReturn(new KList<>(activeRegion));
|
||||
try (MockedStatic<IrisData> data = mockStatic(IrisData.class)) {
|
||||
data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData);
|
||||
|
||||
assertSame(activeMeadow, fixture.biomeHandler().parse("MEADOW", false));
|
||||
assertSame(activeRegion, fixture.regionHandler().parse("OLD-REGION", false));
|
||||
assertEquals(1, GenerationFindCatalog.biomes(fixture.engine).size());
|
||||
assertEquals(1, GenerationFindCatalog.regions(fixture.engine).size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedAuthoringAndUnreachableFilesAreNotFallbackCandidates() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
try (MockedStatic<IrisData> data = mockStatic(IrisData.class)) {
|
||||
data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData);
|
||||
|
||||
assertThrows(DirectorParsingException.class, () -> fixture.biomeHandler().parse("unused", false));
|
||||
assertThrows(DirectorParsingException.class, () -> fixture.regionHandler().parse("unused", false));
|
||||
assertThrows(DirectorParsingException.class, () -> fixture.objectHandler().parse("unplaced", false));
|
||||
verify(fixture.biomeLoader, never()).load("unused");
|
||||
data.verify(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile()));
|
||||
data.verifyNoMoreInteractions();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingRetainedPackFailsInsteadOfSearchingAuthoringPacks() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
IOException missingPack = new IOException("missing immutable pack");
|
||||
when(fixture.history.packRoot(1L)).thenThrow(missingPack);
|
||||
|
||||
UncheckedIOException failure = assertThrows(UncheckedIOException.class,
|
||||
() -> GenerationFindCatalog.biomes(fixture.engine));
|
||||
|
||||
assertSame(missingPack, failure.getCause());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainsEditableStructureKeysFromEveryOwningPlacementScope() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.oldDimension.setStructures(new KList<>(structure("old-house")));
|
||||
fixture.oldRegion.setStructures(new KList<>(structure("old-ruin")));
|
||||
fixture.meadow.setStructures(new KList<>(structure("old-tower")));
|
||||
try (MockedStatic<IrisData> data = mockStatic(IrisData.class)) {
|
||||
data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData);
|
||||
|
||||
assertTrue(GenerationFindCatalog.hasRetainedStructurePlacement(fixture.engine, "OLD-HOUSE"));
|
||||
assertTrue(GenerationFindCatalog.hasRetainedStructurePlacement(fixture.engine, "old-ruin"));
|
||||
assertTrue(GenerationFindCatalog.hasRetainedStructurePlacement(fixture.engine, "old-tower"));
|
||||
assertFalse(GenerationFindCatalog.hasRetainedStructurePlacement(fixture.engine, "unused"));
|
||||
assertEquals(3, GenerationFindCatalog.retainedStructureKeys(fixture.engine).size());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parsesRemovedObjectsFromTheSeparateRetainedUpperDimension() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.oldDimension.setUpperDimension("ceiling");
|
||||
IrisDimension upper = new IrisDimension().setRegions(new KList<>("sky-region"));
|
||||
IrisRegion skyRegion = region("sky-region").setLandBiomes(new KList<>("sky-biome"));
|
||||
IrisBiome skyBiome = biome("sky-biome", "ceiling biome");
|
||||
skyRegion.setObjects(new KList<>(object("upper-region-object")));
|
||||
skyBiome.setObjects(new KList<>(object("upper-biome-object")));
|
||||
when(fixture.dimensionLoader.load("ceiling", false)).thenReturn(upper);
|
||||
when(fixture.regionLoader.load("sky-region")).thenReturn(skyRegion);
|
||||
when(fixture.biomeLoader.load("sky-biome")).thenReturn(skyBiome);
|
||||
try (MockedStatic<IrisData> data = mockStatic(IrisData.class)) {
|
||||
data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData);
|
||||
|
||||
assertEquals("upper-biome-object", fixture.objectHandler().parse("upper-biome-object", false));
|
||||
assertEquals("upper-region-object", fixture.objectHandler().parse("upper-region-object", false));
|
||||
assertTrue(GenerationFindCatalog.hasObjectPlacement(fixture.engine, "upper-biome-object"));
|
||||
assertTrue(GenerationFindCatalog.hasObjectPlacement(fixture.engine, "upper-region-object"));
|
||||
assertNull(GenerationFindCatalog.biome(fixture.engine, "sky-biome"));
|
||||
assertNull(GenerationFindCatalog.region(fixture.engine, "sky-region"));
|
||||
data.verify(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile()));
|
||||
data.verifyNoMoreInteractions();
|
||||
verify(fixture.oldData).close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingRetainedUpperDimensionDoesNotFallBackToAnotherPack() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.oldDimension.setUpperDimension("missing-upper");
|
||||
try (MockedStatic<IrisData> data = mockStatic(IrisData.class)) {
|
||||
data.when(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile())).thenReturn(fixture.oldData);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> GenerationFindCatalog.objectKeys(fixture.engine));
|
||||
data.verify(() -> IrisData.openDatapackCompiler(fixture.oldPack.toFile()));
|
||||
data.verifyNoMoreInteractions();
|
||||
verify(fixture.oldData).close();
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisStructurePlacement structure(String key) {
|
||||
return new IrisStructurePlacement().setStructures(new KList<>(key));
|
||||
}
|
||||
|
||||
private static IrisObjectPlacement object(String key) {
|
||||
return new IrisObjectPlacement().setPlace(new KList<>(key));
|
||||
}
|
||||
|
||||
private static IrisBiome biome(String key, String name) {
|
||||
IrisBiome biome = new IrisBiome().setName(name);
|
||||
biome.setLoadKey(key);
|
||||
return biome;
|
||||
}
|
||||
|
||||
private static IrisRegion region(String key) {
|
||||
IrisRegion region = new IrisRegion();
|
||||
region.setLoadKey(key);
|
||||
return region;
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final IrisEngine engine = mock(IrisEngine.class);
|
||||
private final GenerationHistory history = mock(GenerationHistory.class);
|
||||
private final IrisDimension activeDimension = mock(IrisDimension.class);
|
||||
private final IrisData oldData = mock(IrisData.class);
|
||||
private final ResourceLoader<IrisDimension> dimensionLoader;
|
||||
private final ResourceLoader<IrisRegion> regionLoader;
|
||||
private final ResourceLoader<IrisBiome> biomeLoader;
|
||||
private final IrisDimension oldDimension = new IrisDimension().setRegions(new KList<>("old-region"));
|
||||
private final Path oldPack = Path.of("retained-test-pack").toAbsolutePath();
|
||||
private final IrisBiome meadow = biome("meadow", "old meadow");
|
||||
private final IrisRegion oldRegion = region("old-region");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Fixture() throws IOException {
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
GenerationManifest manifest = mock(GenerationManifest.class);
|
||||
GenerationActivation active = mock(GenerationActivation.class);
|
||||
GenerationActivation old = mock(GenerationActivation.class);
|
||||
GenerationEpoch epoch = mock(GenerationEpoch.class);
|
||||
GenerationEpoch.DimensionContract contract = mock(GenerationEpoch.DimensionContract.class);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.history()).thenReturn(history);
|
||||
when(history.manifest()).thenReturn(manifest);
|
||||
when(manifest.activeActivation()).thenReturn(active);
|
||||
when(active.activationId()).thenReturn(2L);
|
||||
when(active.parentActivationId()).thenReturn(1L);
|
||||
when(manifest.activation(1L)).thenReturn(Optional.of(old));
|
||||
when(old.epochId()).thenReturn("old");
|
||||
when(old.parentActivationId()).thenReturn(null);
|
||||
when(manifest.epoch("old")).thenReturn(Optional.of(epoch));
|
||||
when(epoch.dimensionContract()).thenReturn(contract);
|
||||
when(epoch.packFingerprint()).thenReturn("old-pack");
|
||||
when(contract.dimensionKey()).thenReturn("world");
|
||||
when(history.packRoot(1L)).thenReturn(oldPack);
|
||||
|
||||
IrisData activeData = mock(IrisData.class);
|
||||
ResourceLoader<IrisObject> objectLoader = mock(ResourceLoader.class);
|
||||
when(engine.getData()).thenReturn(activeData);
|
||||
when(activeData.getObjectLoader()).thenReturn(objectLoader);
|
||||
when(objectLoader.getPossibleKeys()).thenReturn(new String[]{"new-tree"});
|
||||
when(engine.getDimension()).thenReturn(activeDimension);
|
||||
when(engine.getAllBiomes()).thenReturn(new KList<>(biome("plateau", "new plateau")));
|
||||
when(activeDimension.getAllRegions(engine)).thenReturn(new KList<>(region("new-region")));
|
||||
|
||||
dimensionLoader = mock(ResourceLoader.class);
|
||||
regionLoader = mock(ResourceLoader.class);
|
||||
biomeLoader = mock(ResourceLoader.class);
|
||||
oldRegion.setLandBiomes(new KList<>("meadow"));
|
||||
meadow.setObjects(new KList<>(new IrisObjectPlacement().setPlace(new KList<>("old-tree"))));
|
||||
when(oldData.getDimensionLoader()).thenReturn(dimensionLoader);
|
||||
when(oldData.getRegionLoader()).thenReturn(regionLoader);
|
||||
when(oldData.getBiomeLoader()).thenReturn(biomeLoader);
|
||||
when(dimensionLoader.load("world", false)).thenReturn(oldDimension);
|
||||
when(regionLoader.load("old-region")).thenReturn(oldRegion);
|
||||
when(biomeLoader.load("meadow")).thenReturn(meadow);
|
||||
}
|
||||
|
||||
private ReachableBiomeHandler biomeHandler() {
|
||||
return new ReachableBiomeHandler() {
|
||||
@Override
|
||||
public Engine engine() {
|
||||
return engine;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisData data() {
|
||||
throw new AssertionError("Find must not use the authoring context");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private ReachableRegionHandler regionHandler() {
|
||||
return new ReachableRegionHandler() {
|
||||
@Override
|
||||
public Engine engine() {
|
||||
return engine;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisData data() {
|
||||
throw new AssertionError("Find must not use the authoring context");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private LocatableObjectHandler objectHandler() {
|
||||
return new LocatableObjectHandler() {
|
||||
@Override
|
||||
public Engine engine() {
|
||||
return engine;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisData data() {
|
||||
throw new AssertionError("Find must not use the authoring context");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public final class GenerationHistoryPathsTest {
|
||||
@Test
|
||||
public void derivesTheCanonicalDimensionHistoryLayout() {
|
||||
Path dimensionRoot = Path.of("build", "world", "..", "world");
|
||||
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(dimensionRoot);
|
||||
Path expectedRoot = dimensionRoot.toAbsolutePath().normalize();
|
||||
String epochId = "a".repeat(64);
|
||||
|
||||
assertEquals(expectedRoot, paths.dimensionRoot());
|
||||
assertEquals(expectedRoot.resolve("iris"), paths.irisRoot());
|
||||
assertEquals(expectedRoot.resolve("iris/generation"), paths.generationRoot());
|
||||
assertEquals(expectedRoot.resolve("iris/generation/manifest.json"), paths.manifest());
|
||||
assertEquals(expectedRoot.resolve("iris/generation/epochs"), paths.epochsRoot());
|
||||
assertEquals(expectedRoot.resolve("iris/generation/activations"), paths.activationsRoot());
|
||||
assertEquals(expectedRoot.resolve("iris/generation/ownership"), paths.ownershipRoot());
|
||||
assertEquals(expectedRoot.resolve("iris/generation/epochs").resolve(epochId), paths.epochRoot(epochId));
|
||||
assertEquals(expectedRoot.resolve("iris/generation/epochs").resolve(epochId).resolve("pack"),
|
||||
paths.packRoot(epochId));
|
||||
assertEquals(expectedRoot.resolve("iris/generation/activations/2"), paths.activationRoot(2L));
|
||||
assertEquals(expectedRoot.resolve("iris/generation/activations/2/mantle-hydrology"),
|
||||
paths.activationMantleRoot(2L));
|
||||
assertEquals(expectedRoot.resolve("iris/pack"), paths.legacyPackRoot());
|
||||
assertEquals(expectedRoot.resolve("mantle-hydrology"), paths.legacyMantleRoot());
|
||||
assertEquals(expectedRoot.resolve("region"), paths.regionRoot());
|
||||
assertThrows(IllegalArgumentException.class, () -> paths.epochRoot("../escape"));
|
||||
assertThrows(IllegalArgumentException.class, () -> paths.activationRoot(0L));
|
||||
}
|
||||
}
|
||||
+1073
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,341 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
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 GenerationHistoryStoreTest {
|
||||
private static final String PACK_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String PACK_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
private static final Clock CLOCK = Clock.fixed(Instant.ofEpochMilli(123456789L), ZoneOffset.UTC);
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void persistsActiveAndPendingHistoryAcrossReopen() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("generation");
|
||||
GenerationEpoch epochA = epoch(PACK_A);
|
||||
GenerationEpoch epochB = epoch(PACK_B);
|
||||
GenerationHistoryStore store = GenerationHistoryStore.initialize(directory, epochA, CLOCK);
|
||||
|
||||
GenerationActivation pending = store.preparePendingActivation(epochB, 512);
|
||||
GenerationHistoryStore reopenedPending = GenerationHistoryStore.open(directory, CLOCK);
|
||||
|
||||
assertEquals(epochA, reopenedPending.activeEpoch());
|
||||
assertEquals(epochB, reopenedPending.pendingEpoch().orElseThrow());
|
||||
assertEquals(2L, pending.activationId());
|
||||
assertEquals(Long.valueOf(1L), pending.parentActivationId());
|
||||
|
||||
reopenedPending.completePendingTransition(pending.activationId(), digest('d'), digest('e'));
|
||||
reopenedPending.activatePending(pending.activationId());
|
||||
GenerationHistoryStore reopenedActive = GenerationHistoryStore.open(directory, CLOCK);
|
||||
|
||||
assertEquals(epochB, reopenedActive.activeEpoch());
|
||||
assertEquals(2L, reopenedActive.activeActivation().activationId());
|
||||
assertFalse(reopenedActive.pendingActivation().isPresent());
|
||||
assertEquals(2, reopenedActive.manifest().epochs().size());
|
||||
assertEquals(2, reopenedActive.manifest().activations().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedPreparationDoesNotRewriteOrRenumberPendingActivation() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("generation");
|
||||
GenerationHistoryStore store = GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK);
|
||||
GenerationEpoch epochB = epoch(PACK_B);
|
||||
GenerationActivation first = store.preparePendingActivation(epochB, 512);
|
||||
byte[] firstManifest = Files.readAllBytes(directory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME));
|
||||
|
||||
GenerationActivation repeated = store.preparePendingActivation(epochB, 512);
|
||||
byte[] repeatedManifest = Files.readAllBytes(directory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME));
|
||||
|
||||
assertEquals(first, repeated);
|
||||
assertEquals(2L, repeated.activationId());
|
||||
assertEquals(2, store.manifest().activations().size());
|
||||
assertArrayEquals(firstManifest, repeatedManifest);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manifestMutationsLeaveOnlyTheCanonicalFile() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("generation");
|
||||
GenerationHistoryStore store = GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK);
|
||||
GenerationActivation pending = store.preparePendingActivation(epoch(PACK_B), 512);
|
||||
store.completePendingTransition(pending.activationId(), digest('d'), digest('e'));
|
||||
store.activatePending(pending.activationId());
|
||||
|
||||
try (Stream<Path> entries = Files.list(directory)) {
|
||||
List<String> names = entries.map(path -> path.getFileName().toString()).toList();
|
||||
assertEquals(List.of(GenerationHistoryStore.MANIFEST_FILE_NAME), names);
|
||||
}
|
||||
GenerationHistoryStore reopened = GenerationHistoryStore.open(directory, CLOCK);
|
||||
assertEquals(store.manifest(), reopened.manifest());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failsClosedWhenManifestIsMissing() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("generation").toPath();
|
||||
|
||||
assertThrows(NoSuchFileException.class, () -> GenerationHistoryStore.open(directory, CLOCK));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failsClosedOnMalformedOrUnknownSchema() throws Exception {
|
||||
Path malformedDirectory = temporaryFolder.newFolder("malformed").toPath();
|
||||
Path malformedManifest = malformedDirectory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME);
|
||||
Files.writeString(malformedManifest, "{not-json", StandardCharsets.UTF_8);
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistoryStore.open(malformedDirectory, CLOCK));
|
||||
|
||||
Path unknownDirectory = temporaryFolder.getRoot().toPath().resolve("unknown-schema");
|
||||
GenerationHistoryStore.initialize(unknownDirectory, epoch(PACK_A), CLOCK);
|
||||
JsonObject unknown = readJson(unknownDirectory);
|
||||
unknown.addProperty("schemaVersion", GenerationManifest.CURRENT_SCHEMA_VERSION + 1);
|
||||
writeJson(unknownDirectory, unknown);
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistoryStore.open(unknownDirectory, CLOCK));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failsClosedOnMissingEpochReference() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("missing-epoch");
|
||||
GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK);
|
||||
JsonObject json = readJson(directory);
|
||||
json.getAsJsonArray("activations")
|
||||
.get(0)
|
||||
.getAsJsonObject()
|
||||
.addProperty("epochId", PACK_B);
|
||||
writeJson(directory, json);
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistoryStore.open(directory, CLOCK));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failsClosedOnForgedEpochIdentityAndUnknownFields() throws Exception {
|
||||
Path forgedDirectory = temporaryFolder.getRoot().toPath().resolve("forged");
|
||||
GenerationHistoryStore.initialize(forgedDirectory, epoch(PACK_A), CLOCK);
|
||||
JsonObject forged = readJson(forgedDirectory);
|
||||
forged.getAsJsonArray("epochs")
|
||||
.get(0)
|
||||
.getAsJsonObject()
|
||||
.addProperty("epochId", PACK_B);
|
||||
writeJson(forgedDirectory, forged);
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistoryStore.open(forgedDirectory, CLOCK));
|
||||
|
||||
Path unknownFieldDirectory = temporaryFolder.getRoot().toPath().resolve("unknown-field");
|
||||
GenerationHistoryStore.initialize(unknownFieldDirectory, epoch(PACK_A), CLOCK);
|
||||
JsonObject unknownField = readJson(unknownFieldDirectory);
|
||||
unknownField.addProperty("unexpected", true);
|
||||
writeJson(unknownFieldDirectory, unknownField);
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistoryStore.open(unknownFieldDirectory, CLOCK));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInitializationOverExistingManifest() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("generation");
|
||||
GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK);
|
||||
|
||||
assertThrows(
|
||||
FileAlreadyExistsException.class,
|
||||
() -> GenerationHistoryStore.initialize(directory, epoch(PACK_B), CLOCK)
|
||||
);
|
||||
GenerationHistoryStore reopened = GenerationHistoryStore.open(directory, CLOCK);
|
||||
assertEquals(PACK_A, reopened.activeEpoch().packFingerprint());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedActivationDoesNotChangePersistedState() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("generation");
|
||||
GenerationHistoryStore store = GenerationHistoryStore.initialize(directory, epoch(PACK_A), CLOCK);
|
||||
store.preparePendingActivation(epoch(PACK_B), 512);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> store.activatePending(99L));
|
||||
|
||||
GenerationHistoryStore reopened = GenerationHistoryStore.open(directory, CLOCK);
|
||||
assertEquals(PACK_A, reopened.activeEpoch().packFingerprint());
|
||||
assertTrue(reopened.pendingActivation().isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinkStorageRootAndParent() throws Exception {
|
||||
Path probe = temporaryFolder.newFolder("history-symlink-probe").toPath();
|
||||
Path probeTarget = temporaryFolder.newFolder("history-symlink-probe-target").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(probe.resolve("link"), probeTarget);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
Path rootContainer = temporaryFolder.newFolder("history-linked-root-container").toPath();
|
||||
Path linkedRoot = rootContainer.resolve("generation");
|
||||
Files.createSymbolicLink(linkedRoot, temporaryFolder.newFolder("history-linked-root-target").toPath());
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationHistoryStore.initialize(linkedRoot, epoch(PACK_A), CLOCK)
|
||||
);
|
||||
|
||||
Path parentContainer = temporaryFolder.newFolder("history-linked-parent-container").toPath();
|
||||
Path linkedParent = parentContainer.resolve("iris");
|
||||
Files.createSymbolicLink(linkedParent, temporaryFolder.newFolder("history-linked-parent-target").toPath());
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationHistoryStore.initialize(linkedParent.resolve("generation"), epoch(PACK_A), CLOCK)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentInitializationCannotClobberTheInitialManifest() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("generation-race");
|
||||
CyclicBarrier barrier = new CyclicBarrier(2);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Boolean> first = executor.submit(
|
||||
() -> initializeAfterBarrier(directory, epoch(PACK_A), barrier)
|
||||
);
|
||||
Future<Boolean> second = executor.submit(
|
||||
() -> initializeAfterBarrier(directory, epoch(PACK_B), barrier)
|
||||
);
|
||||
|
||||
assertTrue(first.get() ^ second.get());
|
||||
String fingerprint = GenerationHistoryStore.open(directory, CLOCK).activeEpoch().packFingerprint();
|
||||
assertTrue(fingerprint.equals(PACK_A) || fingerprint.equals(PACK_B));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void postRenameSyncFailureReconcilesTheLiveManifest() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("sync-reconcile");
|
||||
AtomicInteger syncCount = new AtomicInteger();
|
||||
GenerationHistoryStore.DirectorySync directorySync = ignored -> {
|
||||
if (syncCount.incrementAndGet() > 1) {
|
||||
throw new IOException("Injected directory sync failure");
|
||||
}
|
||||
};
|
||||
GenerationHistoryStore store = GenerationHistoryStore.initialize(
|
||||
directory,
|
||||
epoch(PACK_A),
|
||||
CLOCK,
|
||||
directorySync
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, () -> store.preparePendingActivation(epoch(PACK_B), 512));
|
||||
|
||||
assertEquals(PACK_B, store.pendingEpoch().orElseThrow().packFingerprint());
|
||||
assertEquals(store.manifest(), GenerationHistoryStore.open(directory, CLOCK).manifest());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedPostRenameReconciliationPoisonsTheLiveStore() throws Exception {
|
||||
Path directory = temporaryFolder.getRoot().toPath().resolve("sync-poison");
|
||||
AtomicInteger syncCount = new AtomicInteger();
|
||||
GenerationHistoryStore.DirectorySync directorySync = syncedDirectory -> {
|
||||
if (syncCount.incrementAndGet() > 1) {
|
||||
Files.writeString(syncedDirectory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME), "{");
|
||||
throw new IOException("Injected unrecoverable directory sync failure");
|
||||
}
|
||||
};
|
||||
GenerationHistoryStore store = GenerationHistoryStore.initialize(
|
||||
directory,
|
||||
epoch(PACK_A),
|
||||
CLOCK,
|
||||
directorySync
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, () -> store.preparePendingActivation(epoch(PACK_B), 512));
|
||||
assertThrows(IllegalStateException.class, store::activeEpoch);
|
||||
}
|
||||
|
||||
private static boolean initializeAfterBarrier(
|
||||
Path directory,
|
||||
GenerationEpoch epoch,
|
||||
CyclicBarrier barrier
|
||||
) throws Exception {
|
||||
barrier.await();
|
||||
try {
|
||||
GenerationHistoryStore.initialize(directory, epoch, CLOCK);
|
||||
return true;
|
||||
} catch (IOException expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static GenerationEpoch epoch(String packFingerprint) {
|
||||
GenerationEpoch.DimensionContract contract = new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:overworld_type",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
);
|
||||
return GenerationEpoch.create(new GenerationEpoch.Spec(
|
||||
packFingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
42L,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
GenerationKernelV1.IMPLEMENTATION_FINGERPRINT,
|
||||
contract,
|
||||
GenerationRegistryContract.empty()
|
||||
));
|
||||
}
|
||||
|
||||
private static String digest(char value) {
|
||||
return String.valueOf(value).repeat(64);
|
||||
}
|
||||
|
||||
private static JsonObject readJson(Path directory) throws IOException {
|
||||
String content = Files.readString(
|
||||
directory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
return JsonParser.parseString(content).getAsJsonObject();
|
||||
}
|
||||
|
||||
private static void writeJson(Path directory, JsonObject json) throws IOException {
|
||||
Files.writeString(
|
||||
directory.resolve(GenerationHistoryStore.MANIFEST_FILE_NAME),
|
||||
json.toString(),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.util.nbt.common.mca.MCAFile;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.NoSuchFileException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class GenerationHistoryTest {
|
||||
private static final int SECTOR_BYTES = 4_096;
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void upperContentUpdatesPreserveLayoutAndDistinctActivationsAcrossRestarts() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("upper-update-world").toPath();
|
||||
Path packA = createPack("upper-update-a", "alpha");
|
||||
Path packB = createPack("upper-update-b", "beta");
|
||||
GenerationEpoch.DimensionContract contractA = upperContract(fingerprint(packA));
|
||||
GenerationEpoch.DimensionContract contractB = upperContract(fingerprint(packB));
|
||||
GenerationHistory history = GenerationHistory.create(
|
||||
world, packA, fingerprint(packA), 42L, contractA, GenerationRegistryContract.empty());
|
||||
String epochA = history.activeEpoch().epochId();
|
||||
history.stageUpdate(packB, fingerprint(packB), contractB, GenerationRegistryContract.empty(), 32);
|
||||
history.promotePending(List.of());
|
||||
|
||||
GenerationHistory restarted = GenerationHistory.open(world);
|
||||
assertEquals(contractB, restarted.activeEpoch().dimensionContract());
|
||||
restarted.stageUpdate(packA, fingerprint(packA), contractA, GenerationRegistryContract.empty(), 32);
|
||||
restarted.promotePending(List.of());
|
||||
|
||||
GenerationHistory returned = GenerationHistory.open(world);
|
||||
assertEquals(3L, returned.activeActivation().activationId());
|
||||
assertEquals(epochA, returned.activeEpoch().epochId());
|
||||
assertFalse(returned.paths().activationMantleRoot(1L).equals(returned.paths().activationMantleRoot(3L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createPublishesThePackBeforeTheCanonicalManifest() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("create-world").toPath();
|
||||
Path pack = createPack("create-pack", "alpha");
|
||||
String fingerprint = fingerprint(pack);
|
||||
|
||||
GenerationHistory history = createHistory(world, pack);
|
||||
|
||||
assertTrue(Files.isRegularFile(history.paths().manifest()));
|
||||
assertTrue(Files.isRegularFile(history.activePackRoot().resolve("dimensions/main.json")));
|
||||
assertEquals(1L, history.activeActivation().activationId());
|
||||
assertEquals(1L, history.resolveActivation(40, -70).activationId());
|
||||
assertEquals(fingerprint, history.resolveEpoch(40, -70).packFingerprint());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedPackPublicationNeverCreatesAManifest() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("failed-create-world").toPath();
|
||||
Path pack = createPack("failed-create-pack", "alpha");
|
||||
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world);
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationHistory.create(
|
||||
world,
|
||||
pack,
|
||||
"f".repeat(64),
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
)
|
||||
);
|
||||
assertFalse(Files.exists(paths.manifest()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openFailsClosedWhenAnyReferencedPackIsMissingOrChanged() throws Exception {
|
||||
Path missingWorld = temporaryFolder.newFolder("missing-world").toPath();
|
||||
Path missingPack = createPack("missing-pack", "alpha");
|
||||
GenerationHistory missingHistory = createHistory(missingWorld, missingPack);
|
||||
Files.delete(missingHistory.activePackRoot().resolve("dimensions/main.json"));
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(missingWorld));
|
||||
|
||||
Path changedWorld = temporaryFolder.newFolder("changed-world").toPath();
|
||||
Path changedPack = createPack("changed-pack", "alpha");
|
||||
GenerationHistory changedHistory = createHistory(changedWorld, changedPack);
|
||||
Files.writeString(changedHistory.activePackRoot().resolve("dimensions/main.json"), "changed");
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(changedWorld));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openRejectsUnsupportedGeneratorAndRngVersions() throws Exception {
|
||||
Path abiWorld = temporaryFolder.newFolder("abi-world").toPath();
|
||||
Path abiPack = createPack("abi-pack", "alpha");
|
||||
initializeRawHistory(abiWorld, abiPack, 2, GenerationKernelRegistry.standard().current().rngVersion());
|
||||
IOException abiFailure = assertThrows(IOException.class, () -> GenerationHistory.open(abiWorld));
|
||||
assertTrue(abiFailure.getMessage().contains("generation ABI"));
|
||||
|
||||
Path rngWorld = temporaryFolder.newFolder("rng-world").toPath();
|
||||
Path rngPack = createPack("rng-pack", "alpha");
|
||||
initializeRawHistory(rngWorld, rngPack, GenerationKernelRegistry.standard().current().generatorAbi(), 2);
|
||||
IOException rngFailure = assertThrows(IOException.class, () -> GenerationHistory.open(rngWorld));
|
||||
assertTrue(rngFailure.getMessage().contains("RNG/seed derivation version"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promotionFreezesAllocatedChunksBeforePublishingTheNewActivation() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("promotion-world").toPath();
|
||||
Path packA = createPack("promotion-pack-a", "alpha");
|
||||
Path packB = createPack("promotion-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca");
|
||||
writeRegion(region, new int[][]{{3, 4}, {31, 31}});
|
||||
|
||||
GenerationActivation pending = stage(history, packB);
|
||||
GenerationActivation active = history.promotePending(signaturesForChunks(new int[][]{{3, 4}, {31, 31}}));
|
||||
|
||||
assertEquals(2L, pending.activationId());
|
||||
assertEquals(pending.activationId(), active.activationId());
|
||||
assertEquals(pending.epochId(), active.epochId());
|
||||
assertTrue(active.transition().isComplete());
|
||||
assertEquals(1L, history.resolveActivation(3, 4).activationId());
|
||||
assertEquals(1L, history.resolveActivation(31, 31).activationId());
|
||||
assertEquals(2L, history.resolveActivation(32, 31).activationId());
|
||||
GenerationBoundary boundary = history.boundary(2L);
|
||||
assertTrue(boundary.isHistoricalChunk(3, 4));
|
||||
assertTrue(boundary.isHistoricalChunk(31, 31));
|
||||
assertFalse(boundary.isHistoricalChunk(32, 31));
|
||||
assertEquals(history.paths().packRoot(history.activeEpoch().epochId()), history.activePackRoot());
|
||||
assertTrue(Files.isRegularFile(history.packRoot(1L).resolve("dimensions/main.json")));
|
||||
assertEquals(256, history.transitionPlan(2L).widthBlocks());
|
||||
assertEquals(boundary.exposedBlockColumns().size(), history.terrainSignatures(2L).size());
|
||||
|
||||
GenerationHistory reopened = GenerationHistory.open(world);
|
||||
assertEquals(1L, reopened.resolveActivation(3, 4).activationId());
|
||||
assertEquals(2L, reopened.resolveActivation(32, 31).activationId());
|
||||
assertEquals(2, reopened.explicitChunkCount());
|
||||
assertEquals(256, reopened.transitionPlan(2L).widthBlocks());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promotionCapturesSignaturesFromTheDurableBoundary() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("capture-boundary-world").toPath();
|
||||
Path packA = createPack("capture-boundary-pack-a", "alpha");
|
||||
Path packB = createPack("capture-boundary-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca");
|
||||
writeRegion(region, new int[][]{{3, 4}});
|
||||
stage(history, packB);
|
||||
|
||||
GenerationActivation active = history.promotePending(boundary -> {
|
||||
assertTrue(boundary.isHistoricalChunk(3, 4));
|
||||
assertFalse(boundary.isHistoricalChunk(4, 4));
|
||||
return GenerationHistoryTest::signature;
|
||||
});
|
||||
|
||||
assertEquals(2L, active.activationId());
|
||||
assertEquals(history.boundary(2L).identity(), active.transition().boundaryIdentity());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promotionIsIdempotentForEmptyAndRecoveredWorlds() throws Exception {
|
||||
Path emptyWorld = temporaryFolder.newFolder("empty-world").toPath();
|
||||
Path emptyPackA = createPack("empty-pack-a", "alpha");
|
||||
Path emptyPackB = createPack("empty-pack-b", "beta");
|
||||
GenerationHistory emptyHistory = createHistory(emptyWorld, emptyPackA);
|
||||
stage(emptyHistory, emptyPackB);
|
||||
|
||||
assertEquals(2L, emptyHistory.promotePending(List.of()).activationId());
|
||||
assertEquals(2L, emptyHistory.promotePending(List.of()).activationId());
|
||||
assertEquals(2L, emptyHistory.resolveActivation(0, 0).activationId());
|
||||
assertEquals(0, emptyHistory.explicitChunkCount());
|
||||
|
||||
Path recoveredWorld = temporaryFolder.newFolder("recovered-world").toPath();
|
||||
Path recoveredPackA = createPack("recovered-pack-a", "alpha");
|
||||
Path recoveredPackB = createPack("recovered-pack-b", "beta");
|
||||
GenerationHistory recovered = createHistory(recoveredWorld, recoveredPackA);
|
||||
stage(recovered, recoveredPackB);
|
||||
|
||||
GenerationHistory reopened = GenerationHistory.open(recoveredWorld);
|
||||
assertEquals(2L, reopened.promotePending(List.of()).activationId());
|
||||
assertEquals(2L, reopened.resolveActivation(8, 9).activationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stageUpdateIsIdempotentAndRejectsIncompatibleCandidates() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("stage-world").toPath();
|
||||
Path packA = createPack("stage-pack-a", "alpha");
|
||||
Path packB = createPack("stage-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
|
||||
GenerationActivation first = stage(history, packB);
|
||||
GenerationActivation repeated = stage(history, packB);
|
||||
|
||||
assertEquals(first, repeated);
|
||||
assertEquals(2, history.manifest().activations().size());
|
||||
GenerationEpoch.DimensionContract incompatible = new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:overworld_type",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
512,
|
||||
512,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> history.stageUpdate(
|
||||
packB,
|
||||
fingerprint(packB),
|
||||
incompatible,
|
||||
GenerationRegistryContract.empty(),
|
||||
256
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyAdoptionIsDurableExplicitAndIdempotent() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("legacy-world").toPath();
|
||||
Path legacy = Files.createDirectories(world.resolve("iris/pack/dimensions"));
|
||||
Files.writeString(legacy.resolve("main.json"), "legacy");
|
||||
Path legacyRoot = world.resolve("iris/pack");
|
||||
Path legacyMantle = Files.createDirectories(world.resolve("mantle-hydrology"));
|
||||
Files.writeString(legacyMantle.resolve("r.0.0.lz4b"), "mantle");
|
||||
String fingerprint = fingerprint(legacyRoot);
|
||||
|
||||
GenerationHistory adopted = GenerationHistory.adoptLegacyPack(
|
||||
world,
|
||||
fingerprint,
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
);
|
||||
|
||||
assertFalse(Files.exists(legacyRoot));
|
||||
assertFalse(Files.exists(legacyMantle));
|
||||
assertTrue(Files.isDirectory(adopted.activePackRoot()));
|
||||
Path activationMantle = adopted.paths().activationMantleRoot(1L);
|
||||
assertEquals("mantle", Files.readString(activationMantle.resolve("r.0.0.lz4b")));
|
||||
assertEquals(1L, adopted.activeActivation().activationId());
|
||||
GenerationHistory repeated = GenerationHistory.adoptLegacyPack(
|
||||
world,
|
||||
fingerprint,
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
);
|
||||
assertEquals(adopted.activeEpoch(), repeated.activeEpoch());
|
||||
assertEquals("mantle", Files.readString(repeated.paths().activationMantleRoot(1L)
|
||||
.resolve("r.0.0.lz4b")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyMantleAdoptionFailsClosedOnDualRoots() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("legacy-dual-mantle-world").toPath();
|
||||
Path legacyPack = Files.createDirectories(world.resolve("iris/pack/dimensions")).getParent();
|
||||
Files.writeString(legacyPack.resolve("dimensions/main.json"), "legacy");
|
||||
Path legacyMantle = Files.createDirectories(world.resolve("mantle-hydrology"));
|
||||
Files.writeString(legacyMantle.resolve("legacy.bin"), "legacy");
|
||||
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world);
|
||||
Path activationMantle = Files.createDirectories(paths.activationMantleRoot(1L));
|
||||
Files.writeString(activationMantle.resolve("activation.bin"), "activation");
|
||||
|
||||
IOException failure = assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationHistory.adoptLegacyPack(
|
||||
world,
|
||||
fingerprint(legacyPack),
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
)
|
||||
);
|
||||
|
||||
assertTrue(failure.getMessage().contains("mantle roots conflict"));
|
||||
assertEquals("legacy", Files.readString(legacyMantle.resolve("legacy.bin")));
|
||||
assertEquals("activation", Files.readString(activationMantle.resolve("activation.bin")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyMantleAdoptionRejectsRootAliases() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("legacy-aliased-mantle-world").toPath();
|
||||
Path legacyPack = Files.createDirectories(world.resolve("iris/pack/dimensions")).getParent();
|
||||
Files.writeString(legacyPack.resolve("dimensions/main.json"), "legacy");
|
||||
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(world);
|
||||
Path activationMantle = Files.createDirectories(paths.activationMantleRoot(1L));
|
||||
Path alias = paths.legacyMantleRoot();
|
||||
try {
|
||||
Files.createSymbolicLink(alias, activationMantle);
|
||||
} catch (UnsupportedOperationException exception) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationHistory.adoptLegacyPack(
|
||||
world,
|
||||
fingerprint(legacyPack),
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
)
|
||||
);
|
||||
assertTrue(Files.isSymbolicLink(alias));
|
||||
assertTrue(Files.isDirectory(activationMantle));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyAdoptionNeverDeletesMismatchedOrUnsafeInput() throws Exception {
|
||||
Path mismatchWorld = temporaryFolder.newFolder("legacy-mismatch-world").toPath();
|
||||
Path mismatchRoot = Files.createDirectories(mismatchWorld.resolve("iris/pack/dimensions")).getParent();
|
||||
Files.writeString(mismatchRoot.resolve("dimensions/main.json"), "legacy");
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationHistory.adoptLegacyPack(
|
||||
mismatchWorld,
|
||||
"f".repeat(64),
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
)
|
||||
);
|
||||
assertTrue(Files.isDirectory(mismatchRoot));
|
||||
assertFalse(Files.exists(GenerationHistoryPaths.forDimension(mismatchWorld).manifest()));
|
||||
|
||||
Path linkedWorld = temporaryFolder.newFolder("legacy-linked-world").toPath();
|
||||
Files.createDirectories(linkedWorld.resolve("iris"));
|
||||
Path outside = temporaryFolder.newFolder("legacy-outside").toPath();
|
||||
Path link = linkedWorld.resolve("iris/pack");
|
||||
try {
|
||||
Files.createSymbolicLink(link, outside);
|
||||
} catch (UnsupportedOperationException exception) {
|
||||
return;
|
||||
}
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationHistory.adoptLegacyPack(
|
||||
linkedWorld,
|
||||
"f".repeat(64),
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
)
|
||||
);
|
||||
assertTrue(Files.isDirectory(outside));
|
||||
assertTrue(Files.isSymbolicLink(link));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openFailsClosedOnDanglingOwnershipAndPartialHistory() throws Exception {
|
||||
Path danglingWorld = temporaryFolder.newFolder("dangling-world").toPath();
|
||||
Path pack = createPack("dangling-pack", "alpha");
|
||||
GenerationHistory history = createHistory(danglingWorld, pack);
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(history.paths().ownershipRoot());
|
||||
ownership.assign(1, 2, 99L);
|
||||
ownership.persist();
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(danglingWorld));
|
||||
|
||||
Path absentWorld = temporaryFolder.newFolder("absent-world").toPath();
|
||||
Optional<GenerationHistory> absent = GenerationHistory.openIfPresent(absentWorld);
|
||||
assertTrue(absent.isEmpty());
|
||||
|
||||
Path partialWorld = temporaryFolder.newFolder("partial-world").toPath();
|
||||
Files.createDirectories(GenerationHistoryPaths.forDimension(partialWorld).generationRoot());
|
||||
assertThrows(NoSuchFileException.class, () -> GenerationHistory.openIfPresent(partialWorld));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stagingTheActiveEpochIsANoOp() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("same-epoch-world").toPath();
|
||||
Path pack = createPack("same-epoch-pack", "alpha");
|
||||
GenerationHistory history = createHistory(world, pack);
|
||||
|
||||
GenerationActivation activation = stage(history, pack);
|
||||
|
||||
assertEquals(history.activeActivation(), activation);
|
||||
assertTrue(history.pendingActivation().isEmpty());
|
||||
assertEquals(1, history.manifest().activations().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returningToAnEarlierEpochCreatesANewActivation() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("return-world").toPath();
|
||||
Path packA = createPack("return-pack-a", "alpha");
|
||||
Path packB = createPack("return-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
String epochA = history.activeEpoch().epochId();
|
||||
|
||||
stage(history, packB);
|
||||
history.promotePending(List.of());
|
||||
GenerationActivation activationA2 = stage(history, packA);
|
||||
history.promotePending(List.of());
|
||||
|
||||
assertEquals(3L, activationA2.activationId());
|
||||
assertEquals(epochA, activationA2.epochId());
|
||||
assertEquals(2, history.manifest().epochs().size());
|
||||
assertEquals(3, history.manifest().activations().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retainedAbiRegistryReopensEveryHistoricalEpoch() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("retained-abi-world").toPath();
|
||||
Path packA = createPack("retained-abi-pack-a", "alpha");
|
||||
Path packB = createPack("retained-abi-pack-b", "beta");
|
||||
GenerationKernelRegistry.Version versionOne = new GenerationKernelRegistry.Version(1, 1, 1);
|
||||
GenerationKernelRegistry.Version versionTwo = new GenerationKernelRegistry.Version(2, 1, 1);
|
||||
GenerationKernelRegistry kernels = new GenerationKernelRegistry(
|
||||
versionTwo,
|
||||
List.of(
|
||||
new GenerationKernelRegistry.Kernel(
|
||||
1,
|
||||
"1".repeat(64),
|
||||
Map.of(
|
||||
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
|
||||
(engine, transitionPlan) -> {
|
||||
throw new AssertionError("History-only kernel factory was invoked.");
|
||||
}
|
||||
)
|
||||
),
|
||||
new GenerationKernelRegistry.Kernel(
|
||||
2,
|
||||
"2".repeat(64),
|
||||
Map.of(
|
||||
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
|
||||
(engine, transitionPlan) -> {
|
||||
throw new AssertionError("History-only kernel factory was invoked.");
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
GenerationHistory history = GenerationHistory.create(
|
||||
world,
|
||||
packA,
|
||||
fingerprint(packA),
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty(),
|
||||
versionOne,
|
||||
kernels
|
||||
);
|
||||
|
||||
history.stageUpdate(
|
||||
packB,
|
||||
fingerprint(packB),
|
||||
contract(),
|
||||
GenerationRegistryContract.empty(),
|
||||
256,
|
||||
versionTwo
|
||||
);
|
||||
history.promotePending(List.of());
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(world));
|
||||
GenerationHistory reopened = GenerationHistory.open(world, kernels);
|
||||
assertEquals(2, reopened.activeEpoch().generatorAbi());
|
||||
assertEquals(1, reopened.manifest().activation(1L).orElseThrow().activationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openRejectsWorldSeedMismatch() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("seed-world").toPath();
|
||||
Path pack = createPack("seed-pack", "alpha");
|
||||
createHistory(world, pack);
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(world, 43L));
|
||||
assertEquals(42L, GenerationHistory.open(world, 42L).activeEpoch().worldSeed());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openFailsClosedWhenAnOwnershipShardDisappears() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("missing-ownership-world").toPath();
|
||||
Path packA = createPack("missing-ownership-pack-a", "alpha");
|
||||
Path packB = createPack("missing-ownership-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca");
|
||||
writeRegion(region, new int[][]{{1, 2}});
|
||||
stage(history, packB);
|
||||
history.promotePending(signaturesForChunks(new int[][]{{1, 2}}));
|
||||
Files.delete(history.paths().ownershipRoot().resolve(RegionGenerationOwnership.fileName(0, 0)));
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(world));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pendingCutoverResumesAfterPartialOutgoingOwnershipPublication() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("partial-cutover-world").toPath();
|
||||
Path packA = createPack("partial-cutover-pack-a", "alpha");
|
||||
Path packB = createPack("partial-cutover-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca");
|
||||
int[][] chunks = {{1, 2}, {3, 4}};
|
||||
writeRegion(region, chunks);
|
||||
stage(history, packB);
|
||||
|
||||
ChunkGenerationOwnership partial = ChunkGenerationOwnership.load(history.paths().ownershipRoot());
|
||||
assertTrue(partial.assign(1, 2, 1L));
|
||||
partial.persist();
|
||||
|
||||
GenerationHistory recovered = GenerationHistory.open(world);
|
||||
assertEquals(1, recovered.explicitChunkCount());
|
||||
assertEquals(2L, recovered.promotePending(signaturesForChunks(chunks)).activationId());
|
||||
assertEquals(2, GenerationHistory.open(world).explicitChunkCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pendingCutoverStillRejectsMissingOlderOwnership() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("pending-missing-old-world").toPath();
|
||||
Path packA = createPack("pending-missing-old-pack-a", "alpha");
|
||||
Path packB = createPack("pending-missing-old-pack-b", "beta");
|
||||
Path packC = createPack("pending-missing-old-pack-c", "gamma");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
Path region = Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca");
|
||||
writeRegion(region, new int[][]{{1, 2}});
|
||||
stage(history, packB);
|
||||
history.promotePending(signaturesForChunks(new int[][]{{1, 2}}));
|
||||
stage(history, packC);
|
||||
Files.delete(history.paths().ownershipRoot().resolve(RegionGenerationOwnership.fileName(0, 0)));
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(world));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pendingCutoverRejectsOwnershipForUngeneratedChunks() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("pending-forged-world").toPath();
|
||||
Path packA = createPack("pending-forged-pack-a", "alpha");
|
||||
Path packB = createPack("pending-forged-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
stage(history, packB);
|
||||
ChunkGenerationOwnership forged = ChunkGenerationOwnership.load(history.paths().ownershipRoot());
|
||||
assertTrue(forged.assign(9, 9, 1L));
|
||||
forged.persist();
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(world));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sealedGenerationClaimSurvivesCrashBeforeChunkAllocationAndJoinsCutoverOwnership() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("semantic-claim-cutover-world").toPath();
|
||||
Path packA = createPack("semantic-claim-cutover-pack-a", "alpha");
|
||||
Path packB = createPack("semantic-claim-cutover-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
GenerationHistory.GenerationStage generation = history.openStage(9, 9);
|
||||
ChunkGenerationSemantics claim = ChunkGenerationSemantics.builder(9, 9, 1L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.seal()
|
||||
.build();
|
||||
|
||||
assertTrue(history.claimGeneratedSemantics(generation, claim));
|
||||
assertFalse(history.claimGeneratedSemantics(generation, claim));
|
||||
generation.close();
|
||||
assertThrows(IllegalStateException.class, () -> history.claimGeneratedSemantics(generation, claim));
|
||||
assertFalse(Files.exists(world.resolve("region")));
|
||||
stage(history, packB);
|
||||
|
||||
Path recoveredWorld = temporaryFolder.newFolder("restored-semantic-claim-world").toPath();
|
||||
try (Stream<Path> entries = Files.walk(world)) {
|
||||
for (Path source : entries.toList()) {
|
||||
Path target = recoveredWorld.resolve(world.relativize(source));
|
||||
if (Files.isDirectory(source)) {
|
||||
Files.createDirectories(target);
|
||||
} else {
|
||||
Files.copy(source, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
GenerationHistory recovered = GenerationHistory.open(recoveredWorld);
|
||||
recovered.promotePending(signaturesForChunks(new int[][]{{9, 9}}));
|
||||
|
||||
assertEquals(1L, recovered.resolveActivation(9, 9).activationId());
|
||||
assertEquals(2L, recovered.resolveActivation(10, 9).activationId());
|
||||
assertEquals(1, recovered.explicitChunkCount());
|
||||
GenerationHistory reopened = GenerationHistory.open(recoveredWorld);
|
||||
assertEquals(Optional.of(claim), reopened.semantics(9, 9));
|
||||
assertEquals(1L, reopened.resolveActivation(9, 9).activationId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openFailsClosedOnSemanticActivationMismatch() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("semantic-mismatch-world").toPath();
|
||||
Path packA = createPack("semantic-mismatch-pack-a", "alpha");
|
||||
Path packB = createPack("semantic-mismatch-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
stage(history, packB);
|
||||
history.promotePending(List.of());
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.loadRequired(world);
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(7, 8, 1L)
|
||||
.addSurfaceBiome("iris:old")
|
||||
.seal()
|
||||
.build());
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(world));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promotionIsRejectedAfterGenerationAdmissionOpens() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("admission-world").toPath();
|
||||
Path packA = createPack("admission-pack-a", "alpha");
|
||||
Path packB = createPack("admission-pack-b", "beta");
|
||||
GenerationHistory history = createHistory(world, packA);
|
||||
stage(history, packB);
|
||||
GenerationHistory.GenerationStage generationStage = history.openStage(2, 3);
|
||||
assertEquals(1L, generationStage.activation().activationId());
|
||||
IllegalStateException failure = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> history.promotePending(List.of())
|
||||
);
|
||||
assertTrue(failure.getMessage().contains("before generation admission opens"));
|
||||
generationStage.close();
|
||||
assertThrows(IllegalStateException.class, () -> history.promotePending(List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openFailsClosedOnDeletedOrCorruptTransitionSnapshots() throws Exception {
|
||||
Path deletedWorld = temporaryFolder.newFolder("deleted-snapshot-world").toPath();
|
||||
Path deletedPackA = createPack("deleted-snapshot-pack-a", "alpha");
|
||||
Path deletedPackB = createPack("deleted-snapshot-pack-b", "beta");
|
||||
GenerationHistory deletedHistory = createHistory(deletedWorld, deletedPackA);
|
||||
stage(deletedHistory, deletedPackB);
|
||||
deletedHistory.promotePending(List.of());
|
||||
Files.delete(new TerrainBoundarySignatureStore(deletedWorld).snapshotPath(2L));
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(deletedWorld));
|
||||
|
||||
Path corruptWorld = temporaryFolder.newFolder("corrupt-snapshot-world").toPath();
|
||||
Path corruptPackA = createPack("corrupt-snapshot-pack-a", "alpha");
|
||||
Path corruptPackB = createPack("corrupt-snapshot-pack-b", "beta");
|
||||
GenerationHistory corruptHistory = createHistory(corruptWorld, corruptPackA);
|
||||
stage(corruptHistory, corruptPackB);
|
||||
corruptHistory.promotePending(List.of());
|
||||
Files.write(new GenerationBoundaryStore(corruptWorld).snapshotPath(2L), new byte[]{1, 2, 3});
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationHistory.open(corruptWorld));
|
||||
}
|
||||
|
||||
private void initializeRawHistory(
|
||||
Path world,
|
||||
Path pack,
|
||||
int generatorAbi,
|
||||
int rngVersion
|
||||
) throws IOException {
|
||||
String fingerprint = fingerprint(pack);
|
||||
GenerationEpoch epoch = GenerationEpoch.create(new GenerationEpoch.Spec(
|
||||
fingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
42L,
|
||||
GenerationEpoch.CURRENT_SEED_DERIVATION_VERSION,
|
||||
generatorAbi,
|
||||
rngVersion,
|
||||
GenerationKernelV1.IMPLEMENTATION_FINGERPRINT,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
));
|
||||
GenerationPackRepository repository = new GenerationPackRepository(world);
|
||||
repository.publish(
|
||||
epoch.epochId(),
|
||||
fingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
pack
|
||||
);
|
||||
GenerationSemanticIndex.initialize(world);
|
||||
GenerationHistoryStore.initialize(repository.generationRoot(), epoch);
|
||||
}
|
||||
|
||||
private Path createPack(String name, String content) throws IOException {
|
||||
Path pack = temporaryFolder.newFolder(name).toPath();
|
||||
Files.createDirectories(pack.resolve("dimensions"));
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), content);
|
||||
return pack;
|
||||
}
|
||||
|
||||
private static String fingerprint(Path pack) throws IOException {
|
||||
return GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION);
|
||||
}
|
||||
|
||||
private static GenerationEpoch.DimensionContract upperContract(String packFingerprint) {
|
||||
GenerationEpoch.DimensionContract base = contract();
|
||||
return new GenerationEpoch.DimensionContract(
|
||||
base.dimensionKey(), base.dimensionTypeKey(), base.environment(), base.generationMode(),
|
||||
base.internalFluidHeight(), base.minHeight(), base.height(), base.logicalHeight(),
|
||||
base.coordinateScale(), true, "ceiling", 32, packFingerprint,
|
||||
base.dimensionTypeFingerprintSchema(), base.dimensionTypeFingerprint());
|
||||
}
|
||||
|
||||
private static GenerationEpoch.DimensionContract contract() {
|
||||
return new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
"iris:overworld_type",
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
);
|
||||
}
|
||||
|
||||
private static GenerationHistory createHistory(Path world, Path pack) throws IOException {
|
||||
return GenerationHistory.create(
|
||||
world,
|
||||
pack,
|
||||
fingerprint(pack),
|
||||
42L,
|
||||
contract(),
|
||||
GenerationRegistryContract.empty()
|
||||
);
|
||||
}
|
||||
|
||||
private static GenerationActivation stage(GenerationHistory history, Path pack) throws IOException {
|
||||
return history.stageUpdate(
|
||||
pack,
|
||||
fingerprint(pack),
|
||||
contract(),
|
||||
GenerationRegistryContract.empty(),
|
||||
256
|
||||
);
|
||||
}
|
||||
|
||||
private static List<TerrainBoundarySignature> signaturesForChunks(int[][] chunks) {
|
||||
ArrayList<GenerationBoundary.ChunkCoordinate> coordinates = new ArrayList<>(chunks.length);
|
||||
for (int[] chunk : chunks) {
|
||||
coordinates.add(new GenerationBoundary.ChunkCoordinate(chunk[0], chunk[1]));
|
||||
}
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("test-boundary", coordinates);
|
||||
ArrayList<TerrainBoundarySignature> signatures = new ArrayList<>(boundary.exposedBlockColumns().size());
|
||||
for (GenerationBoundary.BlockColumn column : boundary.exposedBlockColumns()) {
|
||||
signatures.add(signature(column.blockX(), column.blockZ()));
|
||||
}
|
||||
return List.copyOf(signatures);
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature signature(int blockX, int blockZ) {
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()),
|
||||
new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(-64, 64, 2),
|
||||
new TerrainBoundarySignature.BiomeEncoding(
|
||||
List.of("iris:test"),
|
||||
new short[]{0, 0}
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static void writeRegion(Path file, int[][] chunks) throws IOException {
|
||||
try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) {
|
||||
output.setLength((long) (2 + chunks.length) * SECTOR_BYTES);
|
||||
for (int index = 0; index < chunks.length; index++) {
|
||||
int chunkIndex = MCAFile.getChunkIndex(chunks[index][0], chunks[index][1]);
|
||||
output.seek((long) chunkIndex * Integer.BYTES);
|
||||
output.writeInt((2 + index) << Byte.SIZE | 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class GenerationKernelCatalogTest {
|
||||
@Test
|
||||
public void appendsNewRegistrationsWithoutChangingAnExistingSeal() throws Exception {
|
||||
GenerationKernelCatalog.Catalog catalog = parse("current\t2\t3\t4\nkernel\t1\nkernel\t2\n");
|
||||
assertEquals(new GenerationKernelRegistry.Version(2, 3, 4), catalog.current());
|
||||
assertEquals(List.of(1, 2), catalog.abis());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMissingCurrentDuplicateAndExecutableCatalogEntries() {
|
||||
assertThrows(IOException.class, () -> parse("current\t2\t1\t1\nkernel\t1\n"));
|
||||
assertThrows(IOException.class, () -> parse("current\t1\t1\t1\nkernel\t1\nkernel\t1\n"));
|
||||
assertThrows(IOException.class, () -> parse("current\t1\t1\t1\nkernel\t1\tfactory.Override\n"));
|
||||
assertThrows(IOException.class, () -> parse("current\t1\t1\t1\nkernel\t01\n"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void installedCatalogUsesOnlyItsSealedFactoryAndAlgorithms() throws Exception {
|
||||
GenerationKernelRegistry registry = GenerationKernelCatalog.load();
|
||||
GenerationKernelSourceSeal.Descriptor seal = GenerationKernelSourceSeal.load(1);
|
||||
assertEquals("art.arcane.iris.engine.history.GenerationKernelV1", seal.factoryClass());
|
||||
assertEquals(seal.fingerprint(), registry.select(new GenerationKernelRegistry.Version(1, 1, 1))
|
||||
.implementationFingerprint());
|
||||
assertThrows(IOException.class, () -> registry.select(new GenerationKernelRegistry.Version(1, 2, 1)));
|
||||
}
|
||||
|
||||
private static GenerationKernelCatalog.Catalog parse(String entries) throws IOException {
|
||||
return GenerationKernelCatalog.parse(("iris-generation-kernel-catalog-v1\n" + entries)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class GenerationKernelRegistryTest {
|
||||
@Test
|
||||
public void selectsAnExactImplementationFingerprint() throws Exception {
|
||||
GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(7, 3, 2);
|
||||
String fingerprint = "a".repeat(64);
|
||||
GenerationKernelRegistry registry = registry(version, fingerprint);
|
||||
|
||||
GenerationKernelRegistry.RuntimeKernel runtime = registry.select(version);
|
||||
|
||||
assertEquals(fingerprint, runtime.implementationFingerprint());
|
||||
assertEquals(fingerprint, registry.requireSupported(version, fingerprint).implementationFingerprint());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void algorithmTupleSelectsItsOwnSeedFactory() throws Exception {
|
||||
SeedManager first = mock(SeedManager.class);
|
||||
SeedManager second = mock(SeedManager.class);
|
||||
GenerationKernelRegistry.Version original = new GenerationKernelRegistry.Version(1, 1, 1);
|
||||
GenerationKernelRegistry.Version changed = new GenerationKernelRegistry.Version(1, 2, 3);
|
||||
GenerationKernelRegistry registry = new GenerationKernelRegistry(changed, Set.of(
|
||||
new GenerationKernelRegistry.Kernel(1, "a".repeat(64), Map.of(
|
||||
new GenerationKernelRegistry.AlgorithmVersion(1, 1), seedFactory(first),
|
||||
new GenerationKernelRegistry.AlgorithmVersion(2, 3), seedFactory(second)))));
|
||||
|
||||
assertSame(first, registry.select(original).createSeedManager(991L));
|
||||
assertSame(second, registry.select(changed).createSeedManager(991L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingSeedFactoryFailsClosed() throws Exception {
|
||||
GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(7, 3, 2);
|
||||
assertThrows(UnsupportedOperationException.class,
|
||||
() -> registry(version, "a".repeat(64)).select(version).createSeedManager(991L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAnEpochWhoseImplementationChangedWithoutAVersionChange() {
|
||||
GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(7, 3, 2);
|
||||
GenerationKernelRegistry registry = registry(version, "a".repeat(64));
|
||||
|
||||
assertThrows(IOException.class, () -> registry.requireSupported(version, "b".repeat(64)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMalformedImplementationFingerprints() {
|
||||
GenerationKernelRegistry.Version version = new GenerationKernelRegistry.Version(7, 3, 2);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> registry(version, "not-a-sha256"));
|
||||
}
|
||||
|
||||
private static GenerationKernelRegistry registry(
|
||||
GenerationKernelRegistry.Version version,
|
||||
String fingerprint
|
||||
) {
|
||||
return new GenerationKernelRegistry(
|
||||
version,
|
||||
Set.of(new GenerationKernelRegistry.Kernel(
|
||||
version.generatorAbi(),
|
||||
fingerprint,
|
||||
Map.of(
|
||||
new GenerationKernelRegistry.AlgorithmVersion(
|
||||
version.rngVersion(),
|
||||
version.seedDerivationVersion()
|
||||
),
|
||||
(engine, transitionPlan) -> mock(IrisComplex.class)
|
||||
)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private static GenerationKernelRegistry.RuntimeFactory seedFactory(SeedManager seedManager) {
|
||||
return new GenerationKernelRegistry.RuntimeFactory() {
|
||||
@Override
|
||||
public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan) {
|
||||
return mock(IrisComplex.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public SeedManager createSeedManager(long worldSeed) {
|
||||
assertEquals(991L, worldSeed);
|
||||
return seedManager;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class GenerationKernelSourceSealTest {
|
||||
@Test
|
||||
public void fingerprintUsesTheCompleteManifest() throws Exception {
|
||||
byte[] initial = seal("a".repeat(64));
|
||||
assertEquals(GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", initial),
|
||||
GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", initial));
|
||||
assertNotEquals(GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", initial),
|
||||
GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", seal("b".repeat(64))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMismatchedAbiFactoryAndMalformedHashes() {
|
||||
assertThrows(IOException.class,
|
||||
() -> GenerationKernelSourceSeal.fingerprint(2, "example.KernelV1", seal("a".repeat(64))));
|
||||
assertThrows(IOException.class,
|
||||
() -> GenerationKernelSourceSeal.fingerprint(1, "example.OtherKernel", seal("a".repeat(64))));
|
||||
assertThrows(IOException.class,
|
||||
() -> GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", seal("not-a-hash")));
|
||||
assertThrows(IOException.class,
|
||||
() -> GenerationKernelSourceSeal.fingerprint(1, "example.KernelV1", new byte[0]));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsDuplicateAndUnsortedEntries() {
|
||||
String source = new String(seal("a".repeat(64)), StandardCharsets.UTF_8);
|
||||
String duplicate = source + "dependency\tmath\t" + "a".repeat(64) + "\n";
|
||||
assertThrows(IOException.class, () -> GenerationKernelSourceSeal.fingerprint(1,
|
||||
"example.KernelV1", duplicate.getBytes(StandardCharsets.UTF_8)));
|
||||
String unordered = source + "scope\tother\n";
|
||||
assertThrows(IOException.class, () -> GenerationKernelSourceSeal.fingerprint(1,
|
||||
"example.KernelV1", unordered.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void standardKernelUsesThePackagedSourceSeal() {
|
||||
assertEquals(GenerationKernelSourceSeal.requireFingerprint(1, GenerationKernelV1.class),
|
||||
GenerationKernelV1.IMPLEMENTATION_FINGERPRINT);
|
||||
}
|
||||
|
||||
private static byte[] seal(String fingerprint) {
|
||||
return ("iris-generation-kernel-seal-v1\nabi\t1\nfactory\texample.KernelV1\n"
|
||||
+ "algorithm\t1\t1\nscope\tgeneration\nsource\tgeneration/Noise.java\t" + fingerprint
|
||||
+ "\ndependency\tmath\t" + "c".repeat(64) + "\n").getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class GenerationManifestTest {
|
||||
private static final String PACK_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String PACK_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
@Test
|
||||
public void keepsEpochsSeparateFromMonotonicActivations() {
|
||||
GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a"));
|
||||
GenerationEpoch epochB = epoch(PACK_B, contract("iris:type_a"));
|
||||
GenerationManifest initial = GenerationManifest.initial(epochA, 10L);
|
||||
GenerationManifest pendingB = initial.preparePending(epochB, 20L, 256);
|
||||
GenerationActivation activationB = pendingB.pendingActivation().orElseThrow();
|
||||
GenerationManifest activeB = complete(pendingB, activationB.activationId()).activatePending(
|
||||
activationB.activationId()
|
||||
);
|
||||
GenerationManifest pendingA = activeB.preparePending(epochA, 30L, 256);
|
||||
GenerationActivation activationA2 = pendingA.pendingActivation().orElseThrow();
|
||||
|
||||
assertEquals(3L, activationA2.activationId());
|
||||
assertEquals(Long.valueOf(2L), activationA2.parentActivationId());
|
||||
assertEquals(epochA.epochId(), activationA2.epochId());
|
||||
assertEquals(2, pendingA.epochs().size());
|
||||
assertEquals(List.of(1L, 2L, 3L), pendingA.activations().stream()
|
||||
.map(GenerationActivation::activationId)
|
||||
.toList());
|
||||
assertEquals(epochB, pendingA.activeEpoch());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedPendingPreparationIsIdempotent() {
|
||||
GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a"));
|
||||
GenerationEpoch epochB = epoch(PACK_B, contract("iris:type_a"));
|
||||
GenerationManifest pending = GenerationManifest.initial(epochA, 10L).preparePending(epochB, 20L, 256);
|
||||
|
||||
GenerationManifest repeated = pending.preparePending(epochB, 99L, 256);
|
||||
|
||||
assertSame(pending, repeated);
|
||||
assertEquals(2, repeated.activations().size());
|
||||
assertEquals(20L, repeated.pendingActivation().orElseThrow().createdAtEpochMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesCompetingPendingActivation() {
|
||||
GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a"));
|
||||
GenerationEpoch epochB = epoch(PACK_B, contract("iris:type_a"));
|
||||
GenerationEpoch epochC = epoch(
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
contract("iris:type_a")
|
||||
);
|
||||
GenerationManifest pending = GenerationManifest.initial(epochA, 10L).preparePending(epochB, 20L, 256);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> pending.preparePending(epochC, 30L, 256));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesImmutableDimensionContractChange() {
|
||||
GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a"));
|
||||
GenerationEpoch changedType = epoch(PACK_B, contract("iris:type_b"));
|
||||
GenerationManifest manifest = GenerationManifest.initial(epochA, 10L);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> manifest.preparePending(changedType, 20L, 256));
|
||||
assertFalse(manifest.pendingActivation().isPresent());
|
||||
assertEquals(epochA, manifest.activeEpoch());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void activatesOnlyTheExpectedPendingRecord() {
|
||||
GenerationEpoch epochA = epoch(PACK_A, contract("iris:type_a"));
|
||||
GenerationEpoch epochB = epoch(PACK_B, contract("iris:type_a"));
|
||||
GenerationManifest pending = GenerationManifest.initial(epochA, 10L).preparePending(epochB, 20L, 256);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> pending.activatePending(3L));
|
||||
assertThrows(IllegalArgumentException.class, () -> pending.activatePending(2L));
|
||||
|
||||
GenerationManifest active = complete(pending, 2L).activatePending(2L);
|
||||
assertEquals(2L, active.activeActivation().activationId());
|
||||
assertEquals(epochB, active.activeEpoch());
|
||||
assertTrue(active.pendingActivation().isEmpty());
|
||||
}
|
||||
|
||||
private static GenerationEpoch epoch(
|
||||
String packFingerprint,
|
||||
GenerationEpoch.DimensionContract contract
|
||||
) {
|
||||
return GenerationEpoch.create(new GenerationEpoch.Spec(
|
||||
packFingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
42L,
|
||||
GenerationEpoch.CURRENT_SEED_DERIVATION_VERSION,
|
||||
1,
|
||||
1,
|
||||
GenerationKernelV1.IMPLEMENTATION_FINGERPRINT,
|
||||
contract,
|
||||
GenerationRegistryContract.empty()
|
||||
));
|
||||
}
|
||||
|
||||
private static GenerationEpoch.DimensionContract contract(String typeKey) {
|
||||
return new GenerationEpoch.DimensionContract(
|
||||
"overworld",
|
||||
typeKey,
|
||||
"NORMAL",
|
||||
"OVERWORLD",
|
||||
127,
|
||||
-64,
|
||||
384,
|
||||
384,
|
||||
1D,
|
||||
false,
|
||||
"none",
|
||||
0,
|
||||
"0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA,
|
||||
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
);
|
||||
}
|
||||
|
||||
private static GenerationManifest complete(GenerationManifest manifest, long activationId) {
|
||||
return manifest.completePendingTransition(
|
||||
activationId,
|
||||
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class GenerationPackFingerprintTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void versionOneMatchesItsGoldenContentAddress() throws Exception {
|
||||
Path pack = temporaryFolder.newFolder("golden-pack").toPath();
|
||||
Files.createDirectories(pack.resolve("objects"));
|
||||
Files.writeString(pack.resolve("objects/tree.json"), "oak");
|
||||
Files.createDirectories(pack.resolve("dimensions"));
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), "{}");
|
||||
Files.createDirectories(pack.resolve(".git"));
|
||||
Files.writeString(pack.resolve(".git/config"), "ignored");
|
||||
Files.writeString(pack.resolve("workspace.code-workspace"), "ignored");
|
||||
|
||||
String fingerprint = GenerationPackFingerprint.compute(
|
||||
pack,
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
|
||||
assertEquals("ad7d39c2b95bd3001248778911ce6bbd6b4cbdcece28145838c2b923a0bf73b3", fingerprint);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentChangesProduceDifferentAddresses() throws Exception {
|
||||
Path pack = temporaryFolder.newFolder("changed-pack").toPath();
|
||||
Files.writeString(pack.resolve("dimension.json"), "first");
|
||||
String first = GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION);
|
||||
Files.writeString(pack.resolve("dimension.json"), "second");
|
||||
|
||||
String second = GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION);
|
||||
|
||||
assertNotEquals(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedVersionsAndSymbolicLinksFailClosed() throws Exception {
|
||||
Path pack = temporaryFolder.newFolder("unsafe-pack").toPath();
|
||||
assertThrows(IOException.class, () -> GenerationPackFingerprint.compute(pack, 2));
|
||||
|
||||
Path target = temporaryFolder.newFile("outside.json").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(pack.resolve("linked.json"), target);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
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 GenerationPackRepositoryTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void publishesAnImmutableFilteredPack() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("world").toPath();
|
||||
Path source = createPack("source", "{}");
|
||||
Files.createDirectories(source.resolve(".git"));
|
||||
Files.writeString(source.resolve(".git/config"), "local");
|
||||
Files.writeString(source.resolve("pack.code-workspace"), "{}");
|
||||
String fingerprint = fingerprint(source);
|
||||
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
|
||||
String epochId = digest('a');
|
||||
|
||||
Path published = repository.publish(epochId, fingerprint, GenerationPackFingerprint.CURRENT_VERSION, source);
|
||||
|
||||
assertTrue(Files.isRegularFile(published.resolve("dimensions/main.json")));
|
||||
assertFalse(Files.exists(published.resolve(".git")));
|
||||
assertFalse(Files.exists(published.resolve("pack.code-workspace")));
|
||||
assertEquals(published, repository.publish(
|
||||
epochId,
|
||||
fingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
source
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsMutationOfAnExistingEpoch() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("mutation-world").toPath();
|
||||
Path source = createPack("first", "{}");
|
||||
String fingerprint = fingerprint(source);
|
||||
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
|
||||
String epochId = digest('b');
|
||||
Path published = repository.publish(epochId, fingerprint, GenerationPackFingerprint.CURRENT_VERSION, source);
|
||||
Files.writeString(published.resolve("dimensions/main.json"), "{\"changed\":true}");
|
||||
|
||||
assertThrows(IOException.class, () -> repository.requireExactPack(
|
||||
epochId,
|
||||
fingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
));
|
||||
assertThrows(IOException.class, () -> repository.publish(
|
||||
epochId,
|
||||
fingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
source
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsNestedSymbolicLinks() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("linked-world").toPath();
|
||||
Path source = createPack("linked", "{}");
|
||||
Path outside = temporaryFolder.newFile("outside.json").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(source.resolve("dimensions/link.json"), outside);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
|
||||
|
||||
assertThrows(IOException.class, () -> repository.publish(
|
||||
digest('c'),
|
||||
digest('d'),
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
source));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidEpochIdentity() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("invalid-world").toPath();
|
||||
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> repository.packRoot("not-a-digest"));
|
||||
assertThrows(IllegalArgumentException.class, () -> repository.packRoot(digest('A')));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinksAtEveryStorageComponent() throws Exception {
|
||||
Path probe = temporaryFolder.newFolder("symlink-probe").toPath();
|
||||
Path probeTarget = temporaryFolder.newFolder("symlink-probe-target").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(probe.resolve("link"), probeTarget);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
for (String component : new String[]{"dimension", "iris", "generation", "epochs", "epoch", "pack"}) {
|
||||
assertStorageLinkRejected(component);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentRepositoryInstancesCannotClobberAnEpoch() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("race-world").toPath();
|
||||
Path firstSource = createPack("race-first", "{\"winner\":1}");
|
||||
Path secondSource = createPack("race-second", "{\"winner\":2}");
|
||||
String firstFingerprint = fingerprint(firstSource);
|
||||
String secondFingerprint = fingerprint(secondSource);
|
||||
String epochId = digest('e');
|
||||
GenerationPackRepository firstRepository = new GenerationPackRepository(dimensionRoot);
|
||||
GenerationPackRepository secondRepository = new GenerationPackRepository(dimensionRoot);
|
||||
CyclicBarrier barrier = new CyclicBarrier(2);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Boolean> first = executor.submit(() -> publishAfterBarrier(
|
||||
firstRepository,
|
||||
epochId,
|
||||
firstFingerprint,
|
||||
firstSource,
|
||||
barrier
|
||||
));
|
||||
Future<Boolean> second = executor.submit(() -> publishAfterBarrier(
|
||||
secondRepository,
|
||||
epochId,
|
||||
secondFingerprint,
|
||||
secondSource,
|
||||
barrier
|
||||
));
|
||||
|
||||
assertTrue(first.get() ^ second.get());
|
||||
String content = Files.readString(firstRepository.packRoot(epochId).resolve("dimensions/main.json"));
|
||||
assertTrue(content.equals("{\"winner\":1}") || content.equals("{\"winner\":2}"));
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertStorageLinkRejected(String component) throws Exception {
|
||||
Path container = temporaryFolder.newFolder("linked-storage-" + component).toPath();
|
||||
Path dimensionRoot = container.resolve("dimension");
|
||||
Path external = temporaryFolder.newFolder("linked-storage-target-" + component).toPath();
|
||||
String epochId = digest('f');
|
||||
if (component.equals("dimension")) {
|
||||
Files.createSymbolicLink(dimensionRoot, external);
|
||||
} else {
|
||||
Files.createDirectory(dimensionRoot);
|
||||
Path iris = dimensionRoot.resolve("iris");
|
||||
if (component.equals("iris")) {
|
||||
Files.createSymbolicLink(iris, external);
|
||||
} else {
|
||||
Files.createDirectory(iris);
|
||||
Path generation = iris.resolve("generation");
|
||||
if (component.equals("generation")) {
|
||||
Files.createSymbolicLink(generation, external);
|
||||
} else {
|
||||
Files.createDirectory(generation);
|
||||
Path epochs = generation.resolve("epochs");
|
||||
if (component.equals("epochs")) {
|
||||
Files.createSymbolicLink(epochs, external);
|
||||
} else {
|
||||
Files.createDirectory(epochs);
|
||||
Path epoch = epochs.resolve(epochId);
|
||||
if (component.equals("epoch")) {
|
||||
Files.createSymbolicLink(epoch, external);
|
||||
} else {
|
||||
Files.createDirectory(epoch);
|
||||
Files.createSymbolicLink(epoch.resolve("pack"), external);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Path source = createPack("linked-storage-source-" + component, "{}");
|
||||
String fingerprint = fingerprint(source);
|
||||
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
|
||||
|
||||
assertThrows(IOException.class, () -> repository.publish(
|
||||
epochId,
|
||||
fingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
source
|
||||
));
|
||||
}
|
||||
|
||||
private static boolean publishAfterBarrier(
|
||||
GenerationPackRepository repository,
|
||||
String epochId,
|
||||
String fingerprint,
|
||||
Path source,
|
||||
CyclicBarrier barrier
|
||||
) throws Exception {
|
||||
barrier.await();
|
||||
try {
|
||||
repository.publish(
|
||||
epochId,
|
||||
fingerprint,
|
||||
GenerationPackFingerprint.CURRENT_VERSION,
|
||||
source
|
||||
);
|
||||
return true;
|
||||
} catch (IOException expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private Path createPack(String name, String dimensionJson) throws IOException {
|
||||
Path source = temporaryFolder.newFolder(name).toPath();
|
||||
Files.createDirectories(source.resolve("dimensions"));
|
||||
Files.writeString(source.resolve("dimensions/main.json"), dimensionJson);
|
||||
return source;
|
||||
}
|
||||
|
||||
private static String digest(char value) {
|
||||
return String.valueOf(value).repeat(64);
|
||||
}
|
||||
|
||||
private static String fingerprint(Path source) throws IOException {
|
||||
return GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION);
|
||||
}
|
||||
}
|
||||
+810
@@ -0,0 +1,810 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.core.compat.ContentGate;
|
||||
import art.arcane.iris.core.compat.KeyStatus;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustomSpawn;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionType;
|
||||
import art.arcane.iris.engine.object.IrisDimensionTypeOptions;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructure;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.spi.PlatformGenerationRegistry;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class GenerationRegistryContractFactoryTest {
|
||||
private static final String HASH_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String HASH_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void capturesGeneratedAndReferencedPhysicalDefinitions() throws Exception {
|
||||
File pack = temporary.newFolder("pack");
|
||||
Files.writeString(
|
||||
pack.toPath().resolve("content.json"),
|
||||
"{\"block\":\"minecraft:stone[axis=y]\",\"type\":\"minecraft:zombie\"}",
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
IrisData data = mock(IrisData.class);
|
||||
when(data.getDataFolder()).thenReturn(pack);
|
||||
ContentGate contentGate = mock(ContentGate.class);
|
||||
when(data.getContentGate()).thenReturn(contentGate);
|
||||
IrisBiomeCustom custom = spy(new IrisBiomeCustom().setId("mist"));
|
||||
IDataFixer fixer = DataVersion.getLatest().get();
|
||||
doReturn("{\"effects\":{},\"temperature\":0.5}")
|
||||
.when(custom).generateJson(fixer, contentGate);
|
||||
IrisNativeStructure nativeStructure = new IrisNativeStructure().setStructure("minecraft:village_plains");
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement()
|
||||
.setNativeStructures(new KList<>(nativeStructure));
|
||||
IrisBiome biome = new IrisBiome()
|
||||
.setDerivative("minecraft:plains")
|
||||
.setVanillaDerivative("minecraft:forest")
|
||||
.setBiomeScatter(new KList<>("minecraft:desert", "overworld:mist"))
|
||||
.setBiomeSkyScatter(new KList<>("minecraft:taiga"))
|
||||
.setCustomDerivitives(new KList<>(custom));
|
||||
biome.setLoadKey("surface");
|
||||
IrisDimension dimension = spy(new IrisDimension()
|
||||
.setStructures(new KList<>(placement)));
|
||||
dimension.setLoadKey("overworld");
|
||||
stubDimensions(data, dimension);
|
||||
doReturn(new KList<>(biome)).when(dimension).getReachableBiomes(any());
|
||||
doReturn(new KList<>()).when(dimension).getAllRegions(any());
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.blockTypeKeys()).thenReturn(new KList<>("minecraft:stone", "minecraft:dirt"));
|
||||
when(registries.entityKeys()).thenReturn(new KList<>("minecraft:zombie", "minecraft:pig"));
|
||||
FakeGenerationRegistry generationRegistry = new FakeGenerationRegistry("test-runtime");
|
||||
String packFingerprint = GenerationPackFingerprint.compute(
|
||||
pack.toPath(),
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
|
||||
GenerationRegistryContract contract = GenerationRegistryContractFactory.create(
|
||||
data,
|
||||
dimension,
|
||||
packFingerprint,
|
||||
fixer,
|
||||
registries,
|
||||
generationRegistry
|
||||
);
|
||||
|
||||
Set<GenerationRegistryContract.PhysicalResourceKey> keys = contract.definitions().keySet();
|
||||
assertTrue(keys.contains(key(GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY, "test:overworld_type")));
|
||||
String customFingerprint = GenerationRegistryContractFactory.fingerprintCustomBiomeIdentity(
|
||||
GenerationRegistryContractFactory.fingerprintCustomBiomeAuthoredDefinition(custom),
|
||||
"overworld",
|
||||
"mist"
|
||||
);
|
||||
assertTrue(keys.contains(key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"test:biomes/" + customFingerprint
|
||||
)));
|
||||
assertFalse(keys.contains(key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"test:overworld/mist"
|
||||
)));
|
||||
assertFalse(keys.contains(key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"overworld:mist"
|
||||
)));
|
||||
assertTrue(keys.contains(key(GenerationRegistryContractFactory.BIOME_REGISTRY, "minecraft:plains")));
|
||||
assertTrue(keys.contains(key(GenerationRegistryContractFactory.BIOME_REGISTRY, "minecraft:forest")));
|
||||
assertTrue(keys.contains(key(GenerationRegistryContractFactory.BIOME_REGISTRY, "minecraft:desert")));
|
||||
assertTrue(keys.contains(key(GenerationRegistryContractFactory.BIOME_REGISTRY, "minecraft:taiga")));
|
||||
assertTrue(keys.contains(key(
|
||||
GenerationRegistryContractFactory.STRUCTURE_REGISTRY,
|
||||
"minecraft:village_plains"
|
||||
)));
|
||||
assertTrue(keys.contains(key(GenerationRegistryContractFactory.BLOCK_REGISTRY, "minecraft:stone")));
|
||||
assertTrue(keys.contains(key(GenerationRegistryContractFactory.ENTITY_TYPE_REGISTRY, "minecraft:zombie")));
|
||||
|
||||
GenerationRegistryContract adopted = GenerationRegistryContractFactory.create(
|
||||
data,
|
||||
dimension,
|
||||
packFingerprint,
|
||||
fixer,
|
||||
registries,
|
||||
generationRegistry,
|
||||
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES
|
||||
);
|
||||
assertTrue(adopted.definitions().containsKey(key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"test:overworld/mist"
|
||||
)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateLogicalBiomeIdsKeepExactDefinitionsAndChooseStableLegacyAlias() throws Exception {
|
||||
File pack = temporary.newFolder("duplicate-biomes");
|
||||
IrisData data = mock(IrisData.class);
|
||||
when(data.getDataFolder()).thenReturn(pack);
|
||||
ContentGate contentGate = mock(ContentGate.class);
|
||||
when(contentGate.entity("example:missing")).thenReturn(KeyStatus.MISSING);
|
||||
when(data.getContentGate()).thenReturn(contentGate);
|
||||
IrisBiomeCustom plateau = new IrisBiomeCustom().setId("savanna_plateau").setGrassColor("#FFCB31")
|
||||
.setSpawns(new KList<>(new IrisBiomeCustomSpawn().setType("example:missing")));
|
||||
IrisBiomeCustom vanilla = new IrisBiomeCustom().setId("savanna_plateau").setGrassColor("#BFB755");
|
||||
IrisBiome first = new IrisBiome().setCustomDerivitives(new KList<>(plateau));
|
||||
IrisBiome second = new IrisBiome().setCustomDerivitives(new KList<>(vanilla));
|
||||
IrisDimension dimension = spy(new IrisDimension());
|
||||
dimension.setLoadKey("overworld");
|
||||
stubDimensions(data, dimension);
|
||||
doReturn(new KList<>(first, second)).when(dimension).getAllBiomes(any());
|
||||
doReturn(new KList<>(first)).when(dimension).getReachableBiomes(any());
|
||||
doReturn(new KList<>()).when(dimension).getAllRegions(any());
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.blockTypeKeys()).thenReturn(List.of());
|
||||
when(registries.entityKeys()).thenReturn(List.of());
|
||||
FakeGenerationRegistry registry = new FakeGenerationRegistry("duplicate-runtime");
|
||||
String fingerprint = GenerationPackFingerprint.compute(
|
||||
pack.toPath(), GenerationPackFingerprint.CURRENT_VERSION);
|
||||
GenerationRegistryContract contract = GenerationRegistryContractFactory.create(
|
||||
data, dimension, fingerprint, DataVersion.getLatest().get(), registries, registry,
|
||||
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES);
|
||||
String plateauKey = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
"overworld", plateau, contentGate, registry);
|
||||
String vanillaKey = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
"overworld", vanilla, contentGate, registry);
|
||||
assertNotEquals(plateauKey, vanillaKey);
|
||||
assertTrue(contract.definitions().containsKey(key(GenerationRegistryContractFactory.BIOME_REGISTRY, plateauKey)));
|
||||
assertTrue(contract.definitions().containsKey(key(GenerationRegistryContractFactory.BIOME_REGISTRY, vanillaKey)));
|
||||
String selected = plateauKey.compareTo(vanillaKey) < 0 ? plateauKey : vanillaKey;
|
||||
assertEquals(contract.generatedSources().get(key(GenerationRegistryContractFactory.BIOME_REGISTRY, selected)).sourceJson(),
|
||||
contract.generatedSources().get(key(GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"test:overworld/savanna_plateau")).sourceJson());
|
||||
GenerationRegistryContractFactory.CustomBiomeResourceResolver resolver =
|
||||
new GenerationRegistryContractFactory.CustomBiomeResourceResolver(contract, registry::customBiomeResourceKey);
|
||||
assertEquals(plateauKey, resolver.resolve("overworld", plateau));
|
||||
assertEquals(vanillaKey, resolver.resolve("overworld", vanilla));
|
||||
assertNotEquals(plateauKey, GenerationRegistryContractFactory.customBiomeResourceKey("overworld", plateau, registry));
|
||||
assertThrows(IOException.class,
|
||||
() -> resolver.resolve("overworld", new IrisBiomeCustom().setId("savanna_plateau").setGrassColor("#FF0000")));
|
||||
|
||||
doReturn(new KList<>(second, first)).when(dimension).getAllBiomes(any());
|
||||
GenerationRegistryContract reversed = GenerationRegistryContractFactory.create(
|
||||
data, dimension, fingerprint, DataVersion.getLatest().get(), registries, registry,
|
||||
GenerationRegistryContractFactory.CustomBiomeAliasPolicy.RETAIN_LEGACY_ALIASES);
|
||||
assertEquals(contract, reversed);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void customBiomeIdentityUsesAuthoredDefinitionAndLogicalIdentity() {
|
||||
IrisBiomeCustom firstBiome = new IrisBiomeCustom().setId("mist").setTemperature(0.5D);
|
||||
IrisBiomeCustom changedBiome = new IrisBiomeCustom().setId("mist").setTemperature(0.7D);
|
||||
String firstDefinition = GenerationRegistryContractFactory
|
||||
.fingerprintCustomBiomeAuthoredDefinition(firstBiome);
|
||||
String changedDefinition = GenerationRegistryContractFactory
|
||||
.fingerprintCustomBiomeAuthoredDefinition(changedBiome);
|
||||
String first = GenerationRegistryContractFactory.fingerprintCustomBiomeIdentity(
|
||||
firstDefinition,
|
||||
"Overworld",
|
||||
"Mist"
|
||||
);
|
||||
String normalized = GenerationRegistryContractFactory.fingerprintCustomBiomeIdentity(
|
||||
firstDefinition,
|
||||
"overworld",
|
||||
"mist"
|
||||
);
|
||||
String changedBiomeIdentity = GenerationRegistryContractFactory.fingerprintCustomBiomeIdentity(
|
||||
changedDefinition,
|
||||
"overworld",
|
||||
"mist"
|
||||
);
|
||||
|
||||
assertEquals(first, normalized);
|
||||
assertNotEquals(first, changedBiomeIdentity);
|
||||
assertEquals("iris:biomes/" + first,
|
||||
PlatformGenerationRegistry.contentAddressedCustomBiomeResourceKey(first));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentGateEffectsParticipateInCustomBiomeIdentity() {
|
||||
IrisBiomeCustom custom = new IrisBiomeCustom()
|
||||
.setId("mist")
|
||||
.setSpawns(new KList<>(new IrisBiomeCustomSpawn().setType("example:animal")));
|
||||
ContentGate present = mock(ContentGate.class);
|
||||
ContentGate missing = mock(ContentGate.class);
|
||||
when(present.entity("example:animal")).thenReturn(KeyStatus.PRESENT);
|
||||
when(missing.entity("example:animal")).thenReturn(KeyStatus.MISSING);
|
||||
|
||||
String withSpawn = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
"overworld",
|
||||
custom,
|
||||
present
|
||||
);
|
||||
String withoutSpawn = GenerationRegistryContractFactory.customBiomeResourceKey(
|
||||
"overworld",
|
||||
custom,
|
||||
missing
|
||||
);
|
||||
|
||||
assertNotEquals(withSpawn, withoutSpawn);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedPackChangesReuseCustomBiomePhysicalIdentity() throws Exception {
|
||||
File firstPack = temporary.newFolder("identity-pack-a");
|
||||
File secondPack = temporary.newFolder("identity-pack-b");
|
||||
Files.writeString(firstPack.toPath().resolve("unrelated.json"), "{\"value\":1}", StandardCharsets.UTF_8);
|
||||
Files.writeString(secondPack.toPath().resolve("unrelated.json"), "{\"value\":2}", StandardCharsets.UTF_8);
|
||||
ContentGate contentGate = mock(ContentGate.class);
|
||||
IrisData firstData = mock(IrisData.class);
|
||||
when(firstData.getDataFolder()).thenReturn(firstPack);
|
||||
when(firstData.getContentGate()).thenReturn(contentGate);
|
||||
IrisData secondData = mock(IrisData.class);
|
||||
when(secondData.getDataFolder()).thenReturn(secondPack);
|
||||
when(secondData.getContentGate()).thenReturn(contentGate);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setLoadKey("overworld");
|
||||
IDataFixer fixer = mock(IDataFixer.class);
|
||||
IrisBiomeCustom custom = spy(new IrisBiomeCustom().setId("mist").setTemperature(0.5D));
|
||||
doReturn("{\"temperature\":0.5,\"effects\":{}}")
|
||||
.when(custom).generateJson(fixer, contentGate);
|
||||
FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime");
|
||||
|
||||
GenerationRegistryContractFactory.CustomBiomeDefinition first =
|
||||
GenerationRegistryContractFactory.customBiomeDefinition(
|
||||
firstData,
|
||||
dimension,
|
||||
custom,
|
||||
fixer,
|
||||
registry
|
||||
);
|
||||
GenerationRegistryContractFactory.CustomBiomeDefinition second =
|
||||
GenerationRegistryContractFactory.customBiomeDefinition(
|
||||
secondData,
|
||||
dimension,
|
||||
custom,
|
||||
fixer,
|
||||
registry
|
||||
);
|
||||
|
||||
assertNotEquals(
|
||||
GenerationPackFingerprint.compute(firstPack.toPath(), GenerationPackFingerprint.CURRENT_VERSION),
|
||||
GenerationPackFingerprint.compute(secondPack.toPath(), GenerationPackFingerprint.CURRENT_VERSION)
|
||||
);
|
||||
assertEquals(first.physicalKey(), second.physicalKey());
|
||||
assertEquals(first.identityFingerprint(), second.identityFingerprint());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serializerChangesDoNotAlterCustomBiomePhysicalIdentityOrContract() throws Exception {
|
||||
File pack = temporary.newFolder("serializer-stable-pack");
|
||||
IrisData data = mock(IrisData.class);
|
||||
when(data.getDataFolder()).thenReturn(pack);
|
||||
ContentGate contentGate = mock(ContentGate.class);
|
||||
when(data.getContentGate()).thenReturn(contentGate);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setLoadKey("overworld");
|
||||
IrisBiomeCustom custom = spy(new IrisBiomeCustom().setId("mist"));
|
||||
IDataFixer firstFixer = mock(IDataFixer.class);
|
||||
IDataFixer secondFixer = mock(IDataFixer.class);
|
||||
doReturn(
|
||||
"{\"temperature\":0.5,\"effects\":{}}"
|
||||
).when(custom).generateJson(firstFixer, contentGate);
|
||||
doReturn(
|
||||
"{\"temperature\":0.5,\"effects\":{},\"new_schema_default\":true}"
|
||||
).when(custom).generateJson(secondFixer, contentGate);
|
||||
FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime-a") {
|
||||
@Override
|
||||
public Definition canonicalDefinition(String registryKey, String resourceKey, String sourceJson) {
|
||||
return Definition.exactJson("{\"effects\":{},\"temperature\":0.5}");
|
||||
}
|
||||
};
|
||||
|
||||
GenerationRegistryContractFactory.CustomBiomeDefinition first =
|
||||
GenerationRegistryContractFactory.customBiomeDefinition(
|
||||
data,
|
||||
dimension,
|
||||
custom,
|
||||
firstFixer,
|
||||
registry
|
||||
);
|
||||
GenerationRegistryContractFactory.CustomBiomeDefinition second =
|
||||
GenerationRegistryContractFactory.customBiomeDefinition(
|
||||
data,
|
||||
dimension,
|
||||
custom,
|
||||
secondFixer,
|
||||
registry
|
||||
);
|
||||
|
||||
assertEquals(first.physicalKey(), second.physicalKey());
|
||||
assertEquals(first.identityFingerprint(), second.identityFingerprint());
|
||||
assertEquals(first.definition(), second.definition());
|
||||
assertEquals(first.contentFingerprint(), second.contentFingerprint());
|
||||
assertEquals(first.sourceJson(), second.sourceJson());
|
||||
assertEquals(
|
||||
GenerationRegistryContractFactory.fingerprintDefinition(
|
||||
first.physicalKey(),
|
||||
first.definition(),
|
||||
"runtime-a"
|
||||
),
|
||||
GenerationRegistryContractFactory.fingerprintDefinition(
|
||||
second.physicalKey(),
|
||||
second.definition(),
|
||||
"runtime-b"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void customBiomeDefinitionFingerprintCanonicalizesCurrentEmission() {
|
||||
String first = GenerationRegistryContractFactory.fingerprintCustomBiomeDefinition(
|
||||
"{\"temperature\":0.50,\"effects\":{\"sky_color\":1}}"
|
||||
);
|
||||
String reordered = GenerationRegistryContractFactory.fingerprintCustomBiomeDefinition(
|
||||
"{\"effects\":{\"sky_color\":1.0},\"temperature\":0.5}"
|
||||
);
|
||||
String changed = GenerationRegistryContractFactory.fingerprintCustomBiomeDefinition(
|
||||
"{\"effects\":{\"sky_color\":2},\"temperature\":0.5}"
|
||||
);
|
||||
|
||||
assertEquals(first, reordered);
|
||||
assertNotEquals(first, changed);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void canonicalJsonIgnoresObjectOrderAndNumericFormatting() {
|
||||
GenerationRegistryContract.PhysicalResourceKey key = key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"minecraft:plains"
|
||||
);
|
||||
|
||||
String first = GenerationRegistryContractFactory.fingerprintDefinition(
|
||||
key,
|
||||
PlatformGenerationRegistry.Definition.exactJson("{\"b\":2.0,\"a\":1}"),
|
||||
"runtime-a"
|
||||
);
|
||||
String second = GenerationRegistryContractFactory.fingerprintDefinition(
|
||||
key,
|
||||
PlatformGenerationRegistry.Definition.exactJson("{\"a\":1.00,\"b\":2}"),
|
||||
"runtime-b"
|
||||
);
|
||||
|
||||
assertEquals(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resourceIdentitySurvivesCompatibleRuntimeUpdates() {
|
||||
GenerationRegistryContract.PhysicalResourceKey key = key(
|
||||
GenerationRegistryContractFactory.BLOCK_REGISTRY,
|
||||
"minecraft:stone"
|
||||
);
|
||||
PlatformGenerationRegistry.Definition definition = PlatformGenerationRegistry.Definition.resourceIdentity(
|
||||
key.registryKey(),
|
||||
key.resourceKey()
|
||||
);
|
||||
|
||||
String first = GenerationRegistryContractFactory.fingerprintDefinition(key, definition, "runtime-a");
|
||||
String second = GenerationRegistryContractFactory.fingerprintDefinition(key, definition, "runtime-b");
|
||||
|
||||
assertEquals(first, second);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void capturesAndValidatesTheRetainedContractUnion() throws Exception {
|
||||
FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime");
|
||||
GenerationRegistryContract.PhysicalResourceKey plains = key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"minecraft:plains"
|
||||
);
|
||||
GenerationRegistryContract.PhysicalResourceKey village = key(
|
||||
GenerationRegistryContractFactory.STRUCTURE_REGISTRY,
|
||||
"minecraft:village_plains"
|
||||
);
|
||||
GenerationRegistryContract first = contract(registry, plains);
|
||||
GenerationRegistryContract second = contract(registry, village);
|
||||
|
||||
GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||
Set.of(first, second),
|
||||
registry
|
||||
);
|
||||
|
||||
assertEquals(Set.of(plains, village), available.definitions().keySet());
|
||||
first.requireDefinitionsAvailableIn(available);
|
||||
second.requireDefinitionsAvailableIn(available);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsConflictingRetainedPhysicalDefinitions() {
|
||||
GenerationRegistryContract.PhysicalResourceKey plains = key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"minecraft:plains"
|
||||
);
|
||||
GenerationRegistryContract first = GenerationRegistryContract.fromDefinitions(Map.of(plains, HASH_A));
|
||||
GenerationRegistryContract second = GenerationRegistryContract.fromDefinitions(Map.of(plains, HASH_B));
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||
Set.of(first, second),
|
||||
new FakeGenerationRegistry("runtime")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsChangedExternalRuntimeDetailsButRejectsMissingKeys() throws Exception {
|
||||
FakeGenerationRegistry original = new FakeGenerationRegistry("runtime");
|
||||
GenerationRegistryContract.PhysicalResourceKey plains = key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"minecraft:plains"
|
||||
);
|
||||
GenerationRegistryContract required = contract(original, plains);
|
||||
FakeGenerationRegistry changed = new FakeGenerationRegistry("different-runtime");
|
||||
changed.put(
|
||||
plains,
|
||||
PlatformGenerationRegistry.Definition.resourceIdentity(
|
||||
plains.registryKey(),
|
||||
plains.resourceKey()
|
||||
)
|
||||
);
|
||||
FakeGenerationRegistry missing = new FakeGenerationRegistry("runtime");
|
||||
missing.remove(plains);
|
||||
|
||||
assertEquals(
|
||||
required,
|
||||
GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), changed)
|
||||
);
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), missing)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatesGeneratedDefinitionsByRecordedCanonicalContent() throws Exception {
|
||||
FakeGenerationRegistry registry = new FakeGenerationRegistry("runtime-a");
|
||||
GenerationRegistryContract.PhysicalResourceKey generated = key(
|
||||
GenerationRegistryContractFactory.BIOME_REGISTRY,
|
||||
"iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
);
|
||||
IrisBiomeCustom customBiome = new IrisBiomeCustom().setId("mist").setTemperature(0.5D);
|
||||
String semantic = GenerationRegistryContractFactory.customBiomeEffectiveSemanticJson(customBiome, null);
|
||||
String semanticFingerprint = GenerationRegistryContractFactory
|
||||
.fingerprintCustomBiomeAuthoredDefinition(semantic);
|
||||
IDataFixer firstFixer = new TaggedBiomeFixer("first");
|
||||
String source = customBiome.generateJson(
|
||||
firstFixer,
|
||||
new ContentGate(null, Map.of(), null)
|
||||
);
|
||||
PlatformGenerationRegistry.Definition definition = PlatformGenerationRegistry.Definition.exactJson(source);
|
||||
registry.put(generated, definition);
|
||||
GenerationRegistryContract.GeneratedSource generatedSource = new GenerationRegistryContract.GeneratedSource(
|
||||
GenerationRegistryContractFactory.CUSTOM_BIOME_EFFECTIVE_SOURCE_SCHEMA,
|
||||
semanticFingerprint,
|
||||
semantic,
|
||||
registry.generatedDefinitionRendererIdentity(),
|
||||
GenerationRegistryContractFactory.fingerprintDefinition(
|
||||
generated,
|
||||
definition,
|
||||
"generated"
|
||||
),
|
||||
source
|
||||
);
|
||||
GenerationRegistryContract required = GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
|
||||
Map.of(generated, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(
|
||||
generated,
|
||||
generatedSource
|
||||
)),
|
||||
Map.of(generated, generatedSource)
|
||||
);
|
||||
|
||||
GenerationRegistryContract available = GenerationRegistryContractFactory.captureRequiredDefinitions(
|
||||
Set.of(required),
|
||||
registry
|
||||
);
|
||||
|
||||
required.requireDefinitionsAvailableIn(available);
|
||||
assertEquals(
|
||||
source,
|
||||
GenerationRegistryContractFactory.requireGeneratedSource(
|
||||
required,
|
||||
generated,
|
||||
firstFixer,
|
||||
registry
|
||||
)
|
||||
);
|
||||
|
||||
registry.put(generated, PlatformGenerationRegistry.Definition.exactJson(
|
||||
"{\"effects\":{},\"temperature\":0.7}"
|
||||
));
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), registry)
|
||||
);
|
||||
registry.remove(generated);
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationRegistryContractFactory.captureRequiredDefinitions(Set.of(required), registry)
|
||||
);
|
||||
|
||||
FakeGenerationRegistry changedRenderer = new FakeGenerationRegistry("runtime-b") {
|
||||
@Override
|
||||
public String generatedDefinitionRendererIdentity() {
|
||||
return "test-renderer-v2";
|
||||
}
|
||||
};
|
||||
IDataFixer secondFixer = new TaggedBiomeFixer("second");
|
||||
String rerendered = GenerationRegistryContractFactory.requireGeneratedSource(
|
||||
required,
|
||||
generated,
|
||||
secondFixer,
|
||||
changedRenderer
|
||||
);
|
||||
assertTrue(rerendered.contains("second"));
|
||||
assertNotEquals(source, rerendered);
|
||||
changedRenderer.put(generated, PlatformGenerationRegistry.Definition.exactJson(rerendered));
|
||||
GenerationRegistryContract availableAfterUpgrade = GenerationRegistryContractFactory
|
||||
.captureRequiredDefinitions(Set.of(required), secondFixer, changedRenderer);
|
||||
required.requireDefinitionsAvailableIn(availableAfterUpgrade);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rerendersDimensionTypeSemanticsForANewRenderer() throws Exception {
|
||||
assertDimensionRerender(new IrisDimension().getDimensionType());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dimensionRerenderPreservesNullableAndNondefaultOptions() throws Exception {
|
||||
IrisDimensionType original = new IrisDimension().getDimensionType();
|
||||
IrisDimensionTypeOptions options = new IrisDimensionTypeOptions()
|
||||
.coordinateScale(8.0)
|
||||
.ambientLight(0.375F)
|
||||
.fixedTime(null)
|
||||
.cloudHeight(null)
|
||||
.monsterSpawnBlockLightLimit(7)
|
||||
.ultrawarm(IrisDimensionTypeOptions.TriState.TRUE)
|
||||
.natural(IrisDimensionTypeOptions.TriState.FALSE)
|
||||
.ceiling(IrisDimensionTypeOptions.TriState.TRUE);
|
||||
assertDimensionRerender(new IrisDimensionType(original.base(), options, 128, 256, -64));
|
||||
options.fixedTime(6000L).cloudHeight(-256);
|
||||
assertDimensionRerender(new IrisDimensionType(original.base(), options, 128, 256, -64));
|
||||
}
|
||||
|
||||
private void assertDimensionRerender(IrisDimensionType dimensionType) throws Exception {
|
||||
GenerationRegistryContract.PhysicalResourceKey key = key(
|
||||
GenerationRegistryContractFactory.DIMENSION_TYPE_REGISTRY,
|
||||
"iris:overworld_type"
|
||||
);
|
||||
IDataFixer firstFixer = new TaggedBiomeFixer("first");
|
||||
String semantic = GenerationEpochContractFactory.dimensionTypeSemanticJson(
|
||||
dimensionType
|
||||
);
|
||||
String source = dimensionType.toJson(firstFixer);
|
||||
PlatformGenerationRegistry.Definition definition = PlatformGenerationRegistry.Definition.exactJson(source);
|
||||
GenerationRegistryContract.GeneratedSource generatedSource = new GenerationRegistryContract.GeneratedSource(
|
||||
GenerationRegistryContractFactory.DIMENSION_TYPE_EFFECTIVE_SOURCE_SCHEMA,
|
||||
GenerationEpochContractFactory.fingerprintDimensionType(dimensionType),
|
||||
semantic,
|
||||
"test-renderer-v1",
|
||||
GenerationRegistryContractFactory.fingerprintDefinition(key, definition, "generated"),
|
||||
source
|
||||
);
|
||||
GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
|
||||
Map.of(key, GenerationRegistryContractFactory.fingerprintGeneratedSemantic(key, generatedSource)),
|
||||
Map.of(key, generatedSource)
|
||||
);
|
||||
FakeGenerationRegistry upgraded = new FakeGenerationRegistry("runtime-b") {
|
||||
@Override
|
||||
public String generatedDefinitionRendererIdentity() {
|
||||
return "test-renderer-v2";
|
||||
}
|
||||
};
|
||||
|
||||
String rerendered = GenerationRegistryContractFactory.requireGeneratedSource(
|
||||
contract,
|
||||
key,
|
||||
new TaggedBiomeFixer("second"),
|
||||
upgraded
|
||||
);
|
||||
|
||||
assertTrue(rerendered.contains("second"));
|
||||
assertNotEquals(source, rerendered);
|
||||
JsonObject expected = JsonParser.parseString(source).getAsJsonObject();
|
||||
expected.addProperty("serializer", "second");
|
||||
assertEquals(expected, JsonParser.parseString(rerendered));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAChangedPackBeforeCapture() throws Exception {
|
||||
File pack = temporary.newFolder("changed-pack");
|
||||
String original = GenerationPackFingerprint.compute(
|
||||
pack.toPath(),
|
||||
GenerationPackFingerprint.CURRENT_VERSION
|
||||
);
|
||||
Files.writeString(pack.toPath().resolve("dimension.json"), "{}", StandardCharsets.UTF_8);
|
||||
IrisData data = mock(IrisData.class);
|
||||
when(data.getDataFolder()).thenReturn(pack);
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationRegistryContractFactory.create(
|
||||
data,
|
||||
new IrisDimension(),
|
||||
original,
|
||||
DataVersion.getLatest().get(),
|
||||
mock(PlatformRegistries.class),
|
||||
new FakeGenerationRegistry("runtime")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsAnUnloadableAdditionalDimensionBeforeCapturingItsRegistry() throws Exception {
|
||||
File pack = temporary.newFolder("missing-secondary-dimension");
|
||||
IrisData data = mock(IrisData.class);
|
||||
when(data.getDataFolder()).thenReturn(pack);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setLoadKey("overworld");
|
||||
ResourceLoader<IrisDimension> loader = stubDimensions(data, dimension);
|
||||
when(loader.getPossibleKeys()).thenReturn(new String[]{"overworld", "upper"});
|
||||
String fingerprint = GenerationPackFingerprint.compute(pack.toPath(), GenerationPackFingerprint.CURRENT_VERSION);
|
||||
|
||||
IOException exception = assertThrows(IOException.class, () -> GenerationRegistryContractFactory.create(
|
||||
data, dimension, fingerprint, DataVersion.getLatest().get(),
|
||||
mock(PlatformRegistries.class), new FakeGenerationRegistry("runtime")));
|
||||
|
||||
assertTrue(exception.getMessage().contains("Unable to load Iris dimension 'upper'"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static ResourceLoader<IrisDimension> stubDimensions(IrisData data, IrisDimension dimension) {
|
||||
ResourceLoader<IrisDimension> loader = mock(ResourceLoader.class);
|
||||
String dimensionKey = dimension.getLoadKey();
|
||||
when(data.getDimensionLoader()).thenReturn(loader);
|
||||
when(loader.getPossibleKeys()).thenReturn(new String[]{dimensionKey});
|
||||
return loader;
|
||||
}
|
||||
|
||||
private static GenerationRegistryContract contract(
|
||||
FakeGenerationRegistry registry,
|
||||
GenerationRegistryContract.PhysicalResourceKey... keys
|
||||
) {
|
||||
Map<GenerationRegistryContract.PhysicalResourceKey, String> definitions = new LinkedHashMap<>();
|
||||
for (GenerationRegistryContract.PhysicalResourceKey key : keys) {
|
||||
PlatformGenerationRegistry.Definition definition = registry.definition(
|
||||
key.registryKey(),
|
||||
key.resourceKey()
|
||||
);
|
||||
definitions.put(
|
||||
key,
|
||||
GenerationRegistryContractFactory.fingerprintDefinition(
|
||||
key,
|
||||
definition,
|
||||
registry.runtimeIdentity()
|
||||
)
|
||||
);
|
||||
}
|
||||
return GenerationRegistryContract.fromDefinitions(definitions);
|
||||
}
|
||||
|
||||
private static GenerationRegistryContract.PhysicalResourceKey key(String registry, String resource) {
|
||||
return new GenerationRegistryContract.PhysicalResourceKey(registry, resource);
|
||||
}
|
||||
|
||||
private record TaggedBiomeFixer(String tag) implements IDataFixer {
|
||||
@Override
|
||||
public JSONObject fixCustomBiome(IrisBiomeCustom biome, JSONObject json) {
|
||||
return json.put("renderer", tag);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject resolve(Dimension dimension, IrisDimensionTypeOptions options) {
|
||||
return new JSONObject().put("serializer", tag).put("options",
|
||||
new JSONObject(new GsonBuilder().serializeNulls().create().toJson(options)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fixDimension(Dimension dimension, JSONObject json) {
|
||||
}
|
||||
}
|
||||
|
||||
private static class FakeGenerationRegistry implements PlatformGenerationRegistry {
|
||||
private final String runtimeIdentity;
|
||||
private final Map<GenerationRegistryContract.PhysicalResourceKey, Definition> definitions =
|
||||
new LinkedHashMap<>();
|
||||
|
||||
private FakeGenerationRegistry(String runtimeIdentity) {
|
||||
this.runtimeIdentity = runtimeIdentity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String runtimeIdentity() {
|
||||
return runtimeIdentity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generatedDefinitionRendererIdentity() {
|
||||
return "test-renderer-v1";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String customBiomeResourceKey(String identitySha256) {
|
||||
return "test:biomes/" + identitySha256;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> legacyCustomBiomeResourceKeys(
|
||||
String packName,
|
||||
String dimensionKey,
|
||||
String customBiomeId
|
||||
) {
|
||||
return List.of("test:" + dimensionKey + "/" + customBiomeId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String dimensionTypeResourceKey(String packName, String dimensionKey, String dimensionTypeKey) {
|
||||
return "test:" + dimensionKey + "_type";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Definition definition(String registryKey, String resourceKey) {
|
||||
GenerationRegistryContract.PhysicalResourceKey key = key(registryKey, resourceKey);
|
||||
Definition definition = definitions.get(key);
|
||||
if (definition == null && !definitions.containsKey(key)) {
|
||||
definition = Definition.resourceIdentity(registryKey, resourceKey);
|
||||
definitions.put(key, definition);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Definition canonicalDefinition(String registryKey, String resourceKey, String sourceJson) {
|
||||
return Definition.exactJson(sourceJson);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Definition generatedDefinition(String registryKey, String resourceKey) {
|
||||
Definition definition = definitions.get(key(registryKey, resourceKey));
|
||||
if (definition == null || definition.encoding() != Encoding.JSON) {
|
||||
throw new IllegalStateException("Missing generated definition " + resourceKey);
|
||||
}
|
||||
return definition;
|
||||
}
|
||||
|
||||
private void put(GenerationRegistryContract.PhysicalResourceKey key, Definition definition) {
|
||||
definitions.put(key, definition);
|
||||
}
|
||||
|
||||
private void remove(GenerationRegistryContract.PhysicalResourceKey key) {
|
||||
definitions.put(key, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class GenerationRegistryContractTest {
|
||||
private static final String DEFINITION_A =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String DEFINITION_B =
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
@Test
|
||||
public void canonicalizesPhysicalResourceKeysAndRetainsCurrentFingerprint() {
|
||||
GenerationRegistryContract.PhysicalResourceKey plains = key(
|
||||
"minecraft:worldgen/biome",
|
||||
"minecraft:plains"
|
||||
);
|
||||
GenerationRegistryContract.PhysicalResourceKey desert = key(
|
||||
"minecraft:worldgen/biome",
|
||||
"minecraft:desert"
|
||||
);
|
||||
LinkedHashMap<GenerationRegistryContract.PhysicalResourceKey, String> reverseOrder = new LinkedHashMap<>();
|
||||
reverseOrder.put(plains, DEFINITION_A);
|
||||
reverseOrder.put(desert, DEFINITION_B);
|
||||
|
||||
GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitions(
|
||||
GenerationRegistryContract.FINGERPRINT_SCHEMA_VERSION_ONE,
|
||||
reverseOrder
|
||||
);
|
||||
|
||||
assertEquals(
|
||||
"b9165819aa1cdf4e7024a85ca1b63801c3cfaaefb1898de7fd83b0adf366b16e",
|
||||
contract.fingerprint()
|
||||
);
|
||||
assertEquals(desert, contract.definitions().firstKey());
|
||||
assertEquals(plains, contract.definitions().lastKey());
|
||||
assertThrows(
|
||||
UnsupportedOperationException.class,
|
||||
() -> contract.definitions().put(key("minecraft:block", "minecraft:stone"), DEFINITION_A)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void derivesStableFingerprintIndependentOfMapIterationOrder() {
|
||||
GenerationRegistryContract.PhysicalResourceKey plains = key(
|
||||
"minecraft:worldgen/biome",
|
||||
"minecraft:plains"
|
||||
);
|
||||
GenerationRegistryContract.PhysicalResourceKey desert = key(
|
||||
"minecraft:worldgen/biome",
|
||||
"minecraft:desert"
|
||||
);
|
||||
LinkedHashMap<GenerationRegistryContract.PhysicalResourceKey, String> firstOrder = new LinkedHashMap<>();
|
||||
firstOrder.put(plains, DEFINITION_A);
|
||||
firstOrder.put(desert, DEFINITION_B);
|
||||
LinkedHashMap<GenerationRegistryContract.PhysicalResourceKey, String> secondOrder = new LinkedHashMap<>();
|
||||
secondOrder.put(desert, DEFINITION_B);
|
||||
secondOrder.put(plains, DEFINITION_A);
|
||||
|
||||
GenerationRegistryContract first = GenerationRegistryContract.fromDefinitions(firstOrder);
|
||||
GenerationRegistryContract second = GenerationRegistryContract.fromDefinitions(secondOrder);
|
||||
|
||||
assertEquals(first, second);
|
||||
assertEquals(first.fingerprint(), second.fingerprint());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void definitionOrPhysicalKeyMutationChangesFingerprint() {
|
||||
GenerationRegistryContract.PhysicalResourceKey plains = key(
|
||||
"minecraft:worldgen/biome",
|
||||
"minecraft:plains"
|
||||
);
|
||||
GenerationRegistryContract.PhysicalResourceKey forest = key(
|
||||
"minecraft:worldgen/biome",
|
||||
"minecraft:forest"
|
||||
);
|
||||
GenerationRegistryContract original = GenerationRegistryContract.fromDefinitions(Map.of(plains, DEFINITION_A));
|
||||
GenerationRegistryContract changedDefinition = GenerationRegistryContract.fromDefinitions(
|
||||
Map.of(plains, DEFINITION_B)
|
||||
);
|
||||
GenerationRegistryContract changedKey = GenerationRegistryContract.fromDefinitions(Map.of(forest, DEFINITION_A));
|
||||
|
||||
assertNotEquals(original.fingerprint(), changedDefinition.fingerprint());
|
||||
assertNotEquals(original.fingerprint(), changedKey.fingerprint());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void roundTripsStrictDeterministicJson() {
|
||||
GenerationRegistryContract.PhysicalResourceKey generatedBiome = key(
|
||||
"minecraft:worldgen/biome",
|
||||
"iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
);
|
||||
GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitionsAndGeneratedSources(Map.of(
|
||||
key("minecraft:worldgen/biome", "minecraft:plains"),
|
||||
DEFINITION_A,
|
||||
key("minecraft:dimension_type", "minecraft:overworld"),
|
||||
DEFINITION_B,
|
||||
generatedBiome,
|
||||
DEFINITION_A
|
||||
), Map.of(generatedBiome, generatedSource("{\"temperature\":0.5}")));
|
||||
JsonObject json = contract.toJson();
|
||||
|
||||
assertEquals(contract, GenerationRegistryContract.fromJson(json.deepCopy()));
|
||||
assertEquals(json.toString(), contract.toJson().toString());
|
||||
|
||||
JsonObject unknownField = json.deepCopy();
|
||||
unknownField.addProperty("unexpected", true);
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(unknownField));
|
||||
|
||||
JsonObject missingDefinitions = json.deepCopy();
|
||||
missingDefinitions.remove("definitions");
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(missingDefinitions));
|
||||
|
||||
JsonObject missingGeneratedSources = json.deepCopy();
|
||||
missingGeneratedSources.remove("generatedSources");
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationRegistryContract.fromJson(missingGeneratedSources)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatedSourcesAreDefinitionBoundedAndFingerprintProtected() {
|
||||
GenerationRegistryContract.PhysicalResourceKey generatedBiome = key(
|
||||
"minecraft:worldgen/biome",
|
||||
"iris:biomes/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
);
|
||||
GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
|
||||
Map.of(generatedBiome, DEFINITION_A),
|
||||
Map.of(generatedBiome, generatedSource("{\"temperature\":0.5}"))
|
||||
);
|
||||
JsonObject changed = contract.toJson();
|
||||
changed.getAsJsonArray("generatedSources")
|
||||
.get(0)
|
||||
.getAsJsonObject()
|
||||
.addProperty("sourceJson", "{\"temperature\":0.7}");
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(changed));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
|
||||
Map.of(),
|
||||
Map.of(generatedBiome, generatedSource("{}"))
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
|
||||
Map.of(generatedBiome, DEFINITION_A),
|
||||
Map.of(generatedBiome, generatedSource("[]"))
|
||||
)
|
||||
);
|
||||
String oversized = "{\"value\":\"" + "a".repeat(1024 * 1024) + "\"}";
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationRegistryContract.fromDefinitionsAndGeneratedSources(
|
||||
Map.of(generatedBiome, DEFINITION_A),
|
||||
Map.of(generatedBiome, generatedSource(oversized))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesUnsortedPersistedDefinitionsAndForgedFingerprint() {
|
||||
LinkedHashMap<GenerationRegistryContract.PhysicalResourceKey, String> definitions = new LinkedHashMap<>();
|
||||
definitions.put(key("minecraft:worldgen/biome", "minecraft:desert"), DEFINITION_B);
|
||||
definitions.put(key("minecraft:worldgen/biome", "minecraft:plains"), DEFINITION_A);
|
||||
GenerationRegistryContract contract = GenerationRegistryContract.fromDefinitions(definitions);
|
||||
JsonObject unsorted = contract.toJson();
|
||||
JsonArray definitionArray = unsorted.getAsJsonArray("definitions");
|
||||
JsonElement first = definitionArray.get(0).deepCopy();
|
||||
JsonElement second = definitionArray.get(1).deepCopy();
|
||||
definitionArray.set(0, second);
|
||||
definitionArray.set(1, first);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(unsorted));
|
||||
|
||||
JsonObject forged = contract.toJson();
|
||||
forged.addProperty("fingerprint", DEFINITION_A);
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationRegistryContract.fromJson(forged));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesNonCanonicalKeysHashesAndUnknownSchemas() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> key("Minecraft:worldgen/biome", "minecraft:plains")
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> key("minecraft:worldgen/biome", "plains")
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationRegistryContract.fromDefinitions(Map.of(
|
||||
key("minecraft:worldgen/biome", "minecraft:plains"),
|
||||
"not-a-sha256"
|
||||
))
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationRegistryContract.fromDefinitions(2, Map.of())
|
||||
);
|
||||
}
|
||||
|
||||
private static GenerationRegistryContract.PhysicalResourceKey key(String registryKey, String resourceKey) {
|
||||
return new GenerationRegistryContract.PhysicalResourceKey(registryKey, resourceKey);
|
||||
}
|
||||
|
||||
private static GenerationRegistryContract.GeneratedSource generatedSource(String sourceJson) {
|
||||
return new GenerationRegistryContract.GeneratedSource(
|
||||
"test-source-v1",
|
||||
DEFINITION_A,
|
||||
"{\"authored\":true}",
|
||||
"test-renderer-v1",
|
||||
DEFINITION_B,
|
||||
sourceJson
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
|
||||
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Arrays;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
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 final class GenerationSemanticIndexTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void roundTripPreservesAllGeneratedTruthAndQueriesWithoutDiskAccess() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("dimension").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
ChunkGenerationSemantics semantics = completeSemantics(-33, -1, 7L);
|
||||
|
||||
assertTrue(index.recordAndPersist(semantics));
|
||||
assertFalse(index.recordAndPersist(semantics));
|
||||
Path expectedDirectory = dimensionRoot.resolve("iris/generation/semantics");
|
||||
assertEquals(expectedDirectory, index.storageDirectory());
|
||||
Path shard = onlyShard(expectedDirectory);
|
||||
assertTrue(shard.getFileName().toString().matches("r\\.-2\\.-1\\.[0-9a-f]{64}\\.isem"));
|
||||
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.load(dimensionRoot);
|
||||
assertEquals(Optional.of(semantics), loaded.get(-33, -1));
|
||||
assertEquals(1, loaded.recordCount());
|
||||
Files.delete(shard);
|
||||
|
||||
assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.SURFACE_BIOME, "iris:forest").chunk().chunkX());
|
||||
assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.CAVE_BIOME, "iris:limestone_caves").chunk().chunkX());
|
||||
assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.REGION, "temperate").chunk().chunkX());
|
||||
assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.RIVER_PROFILE, "rivers/default").chunk().chunkX());
|
||||
assertEquals(-33, requiredMatch(loaded, GenerationSemanticIndex.SemanticKind.OBJECT, "iris:oak").chunk().chunkX());
|
||||
GenerationSemanticIndex.Match structure = requiredMatch(
|
||||
loaded,
|
||||
GenerationSemanticIndex.SemanticKind.STRUCTURE,
|
||||
"minecraft:village_plains"
|
||||
);
|
||||
assertEquals(new ChunkGenerationSemantics.BlockPosition(-520, 72, -7), structure.exactPosition().orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stagedFactsMergeMonotonicallyAndSealDurably() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("staged").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
ChunkGenerationSemantics noiseStage = ChunkGenerationSemantics.builder(4, 5, 3L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.build();
|
||||
ChunkGenerationSemantics biomeStage = ChunkGenerationSemantics.builder(4, 5, 3L)
|
||||
.addRegion("temperate")
|
||||
.addRiverProfile("rivers/new")
|
||||
.build();
|
||||
ChunkGenerationSemantics fullStage = ChunkGenerationSemantics.builder(4, 5, 3L)
|
||||
.addObject("iris:oak")
|
||||
.addStructure("iris:tower", 72, 90, 88)
|
||||
.seal()
|
||||
.build();
|
||||
|
||||
assertTrue(index.recordAndPersist(noiseStage));
|
||||
assertFalse(index.recordAndPersist(noiseStage));
|
||||
assertTrue(index.recordAndPersist(biomeStage));
|
||||
assertTrue(index.recordAndPersist(fullStage));
|
||||
assertFalse(index.recordAndPersist(noiseStage));
|
||||
|
||||
ChunkGenerationSemantics expected = ChunkGenerationSemantics.builder(4, 5, 3L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.addRegion("temperate")
|
||||
.addRiverProfile("rivers/new")
|
||||
.addObject("iris:oak")
|
||||
.addStructure("iris:tower", 72, 90, 88)
|
||||
.seal()
|
||||
.build();
|
||||
assertEquals(Optional.of(expected), index.get(4, 5));
|
||||
assertEquals(1, index.recordCount());
|
||||
assertEquals(Optional.of(expected), GenerationSemanticIndex.load(dimensionRoot).get(4, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conflictingActivationsAndFactsAfterSealAreRejectedWithoutDiskChanges() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("sealed").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
ChunkGenerationSemantics sealed = ChunkGenerationSemantics.builder(4, 5, 3L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.seal()
|
||||
.build();
|
||||
assertTrue(index.recordAndPersist(sealed));
|
||||
Path shard = onlyShard(index.storageDirectory());
|
||||
byte[] persisted = Files.readAllBytes(shard);
|
||||
|
||||
ChunkGenerationSemantics additionalFact = ChunkGenerationSemantics.builder(4, 5, 3L)
|
||||
.addSurfaceBiome("iris:desert")
|
||||
.build();
|
||||
ChunkGenerationSemantics differentActivation = ChunkGenerationSemantics.builder(4, 5, 4L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.build();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> index.recordAndPersist(additionalFact));
|
||||
assertThrows(IllegalStateException.class, () -> index.recordAndPersist(differentActivation));
|
||||
assertArrayEquals(persisted, Files.readAllBytes(shard));
|
||||
assertEquals(Optional.of(sealed), index.get(4, 5));
|
||||
assertEquals(Optional.of(sealed), GenerationSemanticIndex.load(dimensionRoot).get(4, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nearestChunkUsesDistanceStableTiesRadiusAndActivationFilters() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("nearest-chunks").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(-1, 0, 1L).addRiverProfile("rivers/new").build());
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(1, 0, 2L).addRiverProfile("rivers/new").build());
|
||||
ChunkGenerationSemantics.BlockPosition origin = new ChunkGenerationSemantics.BlockPosition(0, 80, 0);
|
||||
|
||||
GenerationSemanticIndex.Match tied = index.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.RIVER_PROFILE,
|
||||
"rivers/new",
|
||||
origin,
|
||||
2
|
||||
)).orElseThrow();
|
||||
assertEquals(new GenerationSemanticIndex.ChunkReference(-1, 0, 1L), tied.chunk());
|
||||
|
||||
GenerationSemanticIndex.Match filtered = index.findNearest(GenerationSemanticIndex.Query.forActivation(
|
||||
GenerationSemanticIndex.SemanticKind.RIVER_PROFILE,
|
||||
"rivers/new",
|
||||
origin,
|
||||
2,
|
||||
2L
|
||||
)).orElseThrow();
|
||||
assertEquals(new GenerationSemanticIndex.ChunkReference(1, 0, 2L), filtered.chunk());
|
||||
|
||||
assertTrue(index.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.RIVER_PROFILE,
|
||||
"rivers/new",
|
||||
origin,
|
||||
0
|
||||
)).isEmpty());
|
||||
assertTrue(index.findNearest(GenerationSemanticIndex.Query.forActivation(
|
||||
GenerationSemanticIndex.SemanticKind.RIVER_PROFILE,
|
||||
"rivers/new",
|
||||
origin,
|
||||
2,
|
||||
99L
|
||||
)).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nearestStructureUsesExactHorizontalDistanceAndStablePositionTies() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("nearest-structures").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L)
|
||||
.addStructure("iris:tower", -10, 90, 0)
|
||||
.addStructure("iris:tower", 10, 70, 0)
|
||||
.build());
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(2, 0, 2L)
|
||||
.addStructure("iris:tower", 32, 64, 0)
|
||||
.build());
|
||||
ChunkGenerationSemantics.BlockPosition origin = new ChunkGenerationSemantics.BlockPosition(0, 0, 0);
|
||||
|
||||
GenerationSemanticIndex.Match tied = index.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.STRUCTURE,
|
||||
"iris:tower",
|
||||
origin,
|
||||
4
|
||||
)).orElseThrow();
|
||||
assertEquals(new ChunkGenerationSemantics.BlockPosition(-10, 90, 0), tied.exactPosition().orElseThrow());
|
||||
|
||||
GenerationSemanticIndex.Match filtered = index.findNearest(GenerationSemanticIndex.Query.forActivation(
|
||||
GenerationSemanticIndex.SemanticKind.STRUCTURE,
|
||||
"iris:tower",
|
||||
origin,
|
||||
4,
|
||||
2L
|
||||
)).orElseThrow();
|
||||
assertEquals(new ChunkGenerationSemantics.BlockPosition(32, 64, 0), filtered.exactPosition().orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shardBytesAreDeterministicAcrossRecordInsertionOrder() throws Exception {
|
||||
Path firstRoot = temporaryFolder.newFolder("deterministic-first").toPath();
|
||||
Path secondRoot = temporaryFolder.newFolder("deterministic-second").toPath();
|
||||
List<ChunkGenerationSemantics> semantics = List.of(
|
||||
completeSemantics(0, 0, 1L),
|
||||
completeSemantics(4, 7, 2L),
|
||||
completeSemantics(31, 31, 3L)
|
||||
);
|
||||
GenerationSemanticIndex first = GenerationSemanticIndex.load(firstRoot);
|
||||
GenerationSemanticIndex second = GenerationSemanticIndex.load(secondRoot);
|
||||
for (ChunkGenerationSemantics record : semantics) {
|
||||
first.recordAndPersist(record);
|
||||
}
|
||||
for (int index = semantics.size() - 1; index >= 0; index--) {
|
||||
second.recordAndPersist(semantics.get(index));
|
||||
}
|
||||
|
||||
assertArrayEquals(
|
||||
Files.readAllBytes(onlyShard(first.storageDirectory())),
|
||||
Files.readAllBytes(onlyShard(second.storageDirectory()))
|
||||
);
|
||||
assertArrayEquals(
|
||||
Files.readAllBytes(first.storageDirectory().resolve("index.isix")),
|
||||
Files.readAllBytes(second.storageDirectory().resolve("index.isix"))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedPublicationLeavesMemoryAndPersistedStateUnchanged() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("publication-failure").toPath();
|
||||
GenerationSemanticIndex initial = GenerationSemanticIndex.load(dimensionRoot);
|
||||
ChunkGenerationSemantics baseline = ChunkGenerationSemantics.builder(2, 3, 8L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.build();
|
||||
initial.recordAndPersist(baseline);
|
||||
byte[] persisted = Files.readAllBytes(onlyShard(initial.storageDirectory()));
|
||||
|
||||
GenerationSemanticIndex failing = GenerationSemanticIndex.load(
|
||||
dimensionRoot,
|
||||
(directory, regionX, regionZ, encoded) -> {
|
||||
throw new IOException("simulated publication failure");
|
||||
}
|
||||
);
|
||||
ChunkGenerationSemantics update = ChunkGenerationSemantics.builder(2, 3, 8L)
|
||||
.addRegion("temperate")
|
||||
.build();
|
||||
|
||||
IOException error = assertThrows(IOException.class, () -> failing.recordAndPersist(update));
|
||||
|
||||
assertEquals("simulated publication failure", error.getMessage());
|
||||
assertEquals(Optional.of(baseline), failing.get(2, 3));
|
||||
assertArrayEquals(persisted, Files.readAllBytes(onlyShard(initial.storageDirectory())));
|
||||
assertEquals(Optional.of(baseline), GenerationSemanticIndex.load(dimensionRoot).get(2, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCatalogPublicationLeavesAnUnreferencedShardAndReopensPreviousTruth() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("catalog-publication-failure").toPath();
|
||||
GenerationSemanticIndex initial = GenerationSemanticIndex.load(dimensionRoot);
|
||||
ChunkGenerationSemantics baseline = ChunkGenerationSemantics.builder(2, 3, 8L)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.build();
|
||||
initial.recordAndPersist(baseline);
|
||||
|
||||
GenerationSemanticIndex failing = GenerationSemanticIndex.loadWithCatalogPublisher(
|
||||
dimensionRoot,
|
||||
(directory, regionKeys) -> {
|
||||
throw new IOException("simulated catalog failure");
|
||||
}
|
||||
);
|
||||
ChunkGenerationSemantics update = ChunkGenerationSemantics.builder(32, 3, 9L)
|
||||
.addRegion("temperate")
|
||||
.build();
|
||||
|
||||
IOException error = assertThrows(IOException.class, () -> failing.recordAndPersist(update));
|
||||
|
||||
assertEquals("simulated catalog failure", error.getMessage());
|
||||
assertEquals(Optional.of(baseline), failing.get(2, 3));
|
||||
assertTrue(failing.get(32, 3).isEmpty());
|
||||
assertEquals(Optional.of(baseline), GenerationSemanticIndex.load(dimensionRoot).get(2, 3));
|
||||
assertTrue(GenerationSemanticIndex.load(dimensionRoot).get(32, 3).isEmpty());
|
||||
assertEquals(2, shardCount(initial.storageDirectory()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void snapshotIsImmutableAndUsesStableChunkOrder() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("snapshot").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
ChunkGenerationSemantics last = completeSemantics(7, -3, 1L);
|
||||
ChunkGenerationSemantics first = completeSemantics(-2, 9, 2L);
|
||||
index.recordAndPersist(last);
|
||||
index.recordAndPersist(first);
|
||||
|
||||
List<ChunkGenerationSemantics> snapshot = index.recordsSnapshot();
|
||||
|
||||
assertEquals(List.of(first, last), snapshot);
|
||||
assertThrows(UnsupportedOperationException.class, () -> snapshot.clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checksumCorruptionAndTruncationFailClosed() throws Exception {
|
||||
Path checksumRoot = persistedRoot("corrupt-checksum");
|
||||
Path checksumShard = onlyShard(checksumRoot.resolve("iris/generation/semantics"));
|
||||
byte[] corrupted = Files.readAllBytes(checksumShard);
|
||||
corrupted[20] ^= 0x10;
|
||||
replaceReferencedShard(checksumRoot, checksumShard, corrupted);
|
||||
|
||||
IOException checksumError = assertThrows(IOException.class, () -> GenerationSemanticIndex.load(checksumRoot));
|
||||
assertTrue(checksumError.getMessage().contains("checksum mismatch"));
|
||||
|
||||
Path truncatedRoot = persistedRoot("corrupt-truncated");
|
||||
Path truncatedShard = onlyShard(truncatedRoot.resolve("iris/generation/semantics"));
|
||||
byte[] complete = Files.readAllBytes(truncatedShard);
|
||||
replaceReferencedShard(truncatedRoot, truncatedShard, Arrays.copyOf(complete, complete.length - 5));
|
||||
|
||||
IOException truncatedError = assertThrows(IOException.class, () -> GenerationSemanticIndex.load(truncatedRoot));
|
||||
assertTrue(truncatedError.getMessage().contains("Invalid generation semantic shard"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void catalogDetectsMissingShardsAndFailsClosedWhenItIsMissingOrCorrupt() throws Exception {
|
||||
Path missingShardRoot = persistedRoot("missing-shard");
|
||||
Files.delete(onlyShard(missingShardRoot.resolve("iris/generation/semantics")));
|
||||
IOException missingShard = assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationSemanticIndex.load(missingShardRoot)
|
||||
);
|
||||
assertTrue(missingShard.getMessage().contains("catalog references a missing shard"));
|
||||
|
||||
Path missingPointerRoot = persistedRoot("missing-pointer");
|
||||
Files.delete(missingPointerRoot.resolve("iris/generation/semantics/r.0.0.isix"));
|
||||
IOException missingPointer = assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationSemanticIndex.load(missingPointerRoot)
|
||||
);
|
||||
assertTrue(missingPointer.getMessage().contains("catalog references a missing shard pointer"));
|
||||
|
||||
Path missingCatalogRoot = persistedRoot("missing-catalog");
|
||||
Files.delete(missingCatalogRoot.resolve("iris/generation/semantics/index.isix"));
|
||||
IOException missingCatalog = assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationSemanticIndex.load(missingCatalogRoot)
|
||||
);
|
||||
assertTrue(missingCatalog.getMessage().contains("shard catalog is missing"));
|
||||
|
||||
Path corruptCatalogRoot = persistedRoot("corrupt-catalog");
|
||||
Path catalog = corruptCatalogRoot.resolve("iris/generation/semantics/index.isix");
|
||||
byte[] corrupt = Files.readAllBytes(catalog);
|
||||
corrupt[8] ^= 0x01;
|
||||
Files.write(catalog, corrupt);
|
||||
IOException corruptCatalog = assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationSemanticIndex.load(corruptCatalogRoot)
|
||||
);
|
||||
assertTrue(corruptCatalog.getMessage().contains("semantic catalog"));
|
||||
assertTrue(corruptCatalog.getMessage().contains("checksum mismatch"));
|
||||
|
||||
Path corruptPointerRoot = persistedRoot("corrupt-pointer");
|
||||
Path pointer = corruptPointerRoot.resolve("iris/generation/semantics/r.0.0.isix");
|
||||
byte[] corruptPointer = Files.readAllBytes(pointer);
|
||||
corruptPointer[16] ^= 0x01;
|
||||
Files.write(pointer, corruptPointer);
|
||||
IOException pointerError = assertThrows(
|
||||
IOException.class,
|
||||
() -> GenerationSemanticIndex.load(corruptPointerRoot)
|
||||
);
|
||||
assertTrue(pointerError.getMessage().contains("semantic shard pointer"));
|
||||
assertTrue(pointerError.getMessage().contains("checksum mismatch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownSchemaFailsClosedEvenWithAValidChecksum() throws Exception {
|
||||
Path dimensionRoot = persistedRoot("unknown-schema");
|
||||
Path shard = onlyShard(dimensionRoot.resolve("iris/generation/semantics"));
|
||||
byte[] encoded = Files.readAllBytes(shard);
|
||||
encoded[4] = 0;
|
||||
encoded[5] = 6;
|
||||
rewriteChecksum(encoded);
|
||||
replaceReferencedShard(dimensionRoot, shard, encoded);
|
||||
|
||||
IOException error = assertThrows(IOException.class, () -> GenerationSemanticIndex.load(dimensionRoot));
|
||||
|
||||
assertTrue(error.getMessage().contains("unsupported format version 6"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sealedClaimJournalReplaysIntoKeyedAndRiverPostings() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("journal-postings").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot);
|
||||
ChunkGenerationSemantics claim = ChunkGenerationSemantics.builder(64, -32, 7L)
|
||||
.addRegion("iris:temperate")
|
||||
.addRiverFeature(
|
||||
"iris:rivers/default",
|
||||
HydrologyFeatureType.RIFFLE,
|
||||
17L,
|
||||
1031,
|
||||
71,
|
||||
-503
|
||||
)
|
||||
.seal()
|
||||
.build();
|
||||
|
||||
assertTrue(index.claimAndPersist(claim));
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot);
|
||||
|
||||
GenerationSemanticIndex.Match region = loaded.findNearest(
|
||||
GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:temperate",
|
||||
new ChunkGenerationSemantics.BlockPosition(1024, 0, -512),
|
||||
2
|
||||
)
|
||||
).orElseThrow();
|
||||
assertEquals(new GenerationSemanticIndex.ChunkReference(64, -32, 7L), region.chunk());
|
||||
GenerationSemanticIndex.RiverMatch river = loaded.findNearestRiver(
|
||||
GenerationSemanticIndex.RiverQuery.acrossActivations(
|
||||
java.util.Set.of(HydrologyFeatureType.RIFFLE),
|
||||
"iris:rivers/default",
|
||||
new ChunkGenerationSemantics.BlockPosition(1024, 0, -512),
|
||||
2
|
||||
),
|
||||
ChunkGenerationSemantics::sealed
|
||||
).orElseThrow();
|
||||
assertEquals(new ChunkGenerationSemantics.BlockPosition(1031, 71, -503), river.occurrence().position());
|
||||
assertTrue(sealedClaims(loaded, 7L).contains(ChunkGenerationOwnership.packChunk(64, -32)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void journalReplayTruncatesOnlyATornTerminalEntry() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("journal-torn-tail").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot);
|
||||
index.claimAndPersist(sealedRegion(0, 0, 1L, "iris:first"));
|
||||
index.claimAndPersist(sealedRegion(1, 0, 1L, "iris:second"));
|
||||
Path journal = onlyJournal(index.storageDirectory());
|
||||
byte[] bytes = Files.readAllBytes(journal);
|
||||
Files.write(journal, Arrays.copyOf(bytes, bytes.length - 3));
|
||||
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot);
|
||||
|
||||
assertEquals(Optional.of(sealedRegion(0, 0, 1L, "iris:first")), loaded.get(0, 0));
|
||||
assertTrue(loaded.get(1, 0).isEmpty());
|
||||
assertTrue(Files.size(journal) < bytes.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void journalReplayRejectsInteriorCorruption() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("journal-corrupt-interior").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot);
|
||||
index.claimAndPersist(sealedRegion(0, 0, 1L, "iris:first"));
|
||||
index.claimAndPersist(sealedRegion(1, 0, 1L, "iris:second"));
|
||||
Path journal = onlyJournal(index.storageDirectory());
|
||||
byte[] bytes = Files.readAllBytes(journal);
|
||||
bytes[16] ^= 0x20;
|
||||
Files.write(journal, bytes);
|
||||
|
||||
IOException error = assertThrows(IOException.class, () -> GenerationSemanticIndex.loadRequired(dimensionRoot));
|
||||
|
||||
assertTrue(error.getMessage().contains("interior entry"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void duplicateJournalEntriesReplayIdempotently() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("journal-idempotent").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot);
|
||||
ChunkGenerationSemantics claim = sealedRegion(0, 0, 1L, "iris:temperate");
|
||||
index.claimAndPersist(claim);
|
||||
Path journal = onlyJournal(index.storageDirectory());
|
||||
byte[] entry = Files.readAllBytes(journal);
|
||||
Files.write(journal, entry, StandardOpenOption.APPEND);
|
||||
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot);
|
||||
|
||||
assertEquals(1, loaded.recordCount());
|
||||
assertEquals(Optional.of(claim), loaded.get(0, 0));
|
||||
assertEquals(1, sealedClaims(loaded, 1L).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedCacheEvictsAndReloadsUncompactedClaimsWithoutLosingTruth() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("journal-cache-eviction").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot);
|
||||
for (int regionX = 0; regionX < 270; regionX++) {
|
||||
index.claimAndPersist(sealedRegion(regionX << 5, 0, 1L, "iris:temperate"));
|
||||
if (regionX < 10) {
|
||||
index.claimAndPersist(sealedRegion((regionX << 5) + 1, 0, 1L, "iris:temperate"));
|
||||
}
|
||||
assertTrue(index.cachedRegionCount() <= 64);
|
||||
assertTrue(index.cachedSummaryCount() <= 256);
|
||||
}
|
||||
|
||||
assertEquals(Optional.of(sealedRegion(0, 0, 1L, "iris:temperate")), index.get(0, 0));
|
||||
assertTrue(index.cachedSummaryCount() <= 256);
|
||||
assertTrue(index.cachedRegionCount() <= 64);
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot);
|
||||
assertEquals(280, loaded.recordCount());
|
||||
assertTrue(loaded.cachedSummaryCount() <= 256);
|
||||
assertEquals(280, sealedClaims(loaded, 1L).size());
|
||||
assertEquals(Optional.of(sealedRegion(0, 0, 1L, "iris:temperate")), loaded.get(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keyedNearestLoadsOnlyRegionsInThatPosting() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("keyed-nearest-lazy").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(dimensionRoot);
|
||||
for (int regionX = 0; regionX < 70; regionX++) {
|
||||
String region = regionX == 0 ? "iris:target" : "iris:unrelated";
|
||||
index.claimAndPersist(sealedRegion(regionX << 5, 0, 1L, region));
|
||||
}
|
||||
index.compactJournals();
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(dimensionRoot);
|
||||
long before = loaded.regionDecodeCount();
|
||||
assertEquals(0L, before);
|
||||
assertEquals(0, loaded.cachedRegionCount());
|
||||
|
||||
GenerationSemanticIndex.Match match = loaded.findNearest(
|
||||
GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:target",
|
||||
new ChunkGenerationSemantics.BlockPosition(8, 0, 8),
|
||||
5000
|
||||
)
|
||||
).orElseThrow();
|
||||
|
||||
assertEquals(0, match.chunk().chunkX());
|
||||
assertEquals(before + 1L, loaded.regionDecodeCount());
|
||||
assertTrue(loaded.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:missing",
|
||||
new ChunkGenerationSemantics.BlockPosition(8, 0, 8),
|
||||
5000
|
||||
)).isEmpty());
|
||||
assertEquals(before + 1L, loaded.regionDecodeCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lazilyDecodedUnknownRecordFlagsFailClosedEvenWithAValidChecksum() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("unknown-record-flags").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).seal().build());
|
||||
Path shard = onlyShard(index.storageDirectory());
|
||||
byte[] encoded = Files.readAllBytes(shard);
|
||||
encoded[firstRecordFlagsOffset(encoded)] = 2;
|
||||
rewriteChecksum(encoded);
|
||||
replaceReferencedShard(dimensionRoot, shard, encoded);
|
||||
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.load(dimensionRoot);
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, () -> loaded.get(0, 0));
|
||||
|
||||
assertTrue(error.getCause().getMessage().contains("unsupported semantic record flags 2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void symbolicLinkStorageAncestorsAreRejected() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("symlink-dimension").toPath();
|
||||
Path external = temporaryFolder.newFolder("symlink-external").toPath();
|
||||
Files.createDirectories(external.resolve("generation/semantics"));
|
||||
Files.createSymbolicLink(dimensionRoot.resolve("iris"), external);
|
||||
|
||||
assertThrows(IOException.class, () -> GenerationSemanticIndex.load(dimensionRoot));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queriesValidateKindsKeysRadiiAndActivationFilters() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("invalid-query").toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
ChunkGenerationSemantics.BlockPosition origin = new ChunkGenerationSemantics.BlockPosition(0, 0, 0);
|
||||
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
" Iris:Forest",
|
||||
origin,
|
||||
1
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:forest",
|
||||
origin,
|
||||
-1
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationSemanticIndex.Query.forActivation(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:forest",
|
||||
origin,
|
||||
1,
|
||||
0L
|
||||
)
|
||||
);
|
||||
assertTrue(index.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION,
|
||||
"iris:missing",
|
||||
origin,
|
||||
1
|
||||
)).isEmpty());
|
||||
}
|
||||
|
||||
private GenerationSemanticIndex.Match requiredMatch(
|
||||
GenerationSemanticIndex index,
|
||||
GenerationSemanticIndex.SemanticKind kind,
|
||||
String key
|
||||
) {
|
||||
return index.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
kind,
|
||||
key,
|
||||
new ChunkGenerationSemantics.BlockPosition(-520, 80, -8),
|
||||
2
|
||||
)).orElseThrow();
|
||||
}
|
||||
|
||||
private Path persistedRoot(String name) throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder(name).toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.load(dimensionRoot);
|
||||
index.recordAndPersist(completeSemantics(0, 0, 1L));
|
||||
return dimensionRoot;
|
||||
}
|
||||
|
||||
private static ChunkGenerationSemantics completeSemantics(int chunkX, int chunkZ, long activationId) {
|
||||
return ChunkGenerationSemantics.builder(chunkX, chunkZ, activationId)
|
||||
.addSurfaceBiome("iris:forest")
|
||||
.addSurfaceBiome("iris:wetlands")
|
||||
.addCaveBiome("iris:limestone_caves")
|
||||
.addRegion("temperate")
|
||||
.addRiverProfile("rivers/default")
|
||||
.addRiverFeature(
|
||||
"rivers/default",
|
||||
HydrologyFeatureType.RIFFLE,
|
||||
5L,
|
||||
(chunkX << 4) + 7,
|
||||
70,
|
||||
(chunkZ << 4) + 6
|
||||
)
|
||||
.addObject("iris:oak")
|
||||
.addStructure(
|
||||
"minecraft:village_plains",
|
||||
(chunkX << 4) + 8,
|
||||
72,
|
||||
(chunkZ << 4) + 9
|
||||
)
|
||||
.seal()
|
||||
.build();
|
||||
}
|
||||
|
||||
private static ChunkGenerationSemantics sealedRegion(
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
long activationId,
|
||||
String regionKey
|
||||
) {
|
||||
return ChunkGenerationSemantics.builder(chunkX, chunkZ, activationId)
|
||||
.addRegion(regionKey)
|
||||
.seal()
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Path onlyShard(Path directory) throws Exception {
|
||||
try (Stream<Path> files = Files.list(directory)) {
|
||||
List<Path> shards = files.filter(path -> path.getFileName().toString().endsWith(".isem")).toList();
|
||||
assertEquals(1, shards.size());
|
||||
return shards.getFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private static Path onlyJournal(Path directory) throws Exception {
|
||||
try (Stream<Path> files = Files.list(directory)) {
|
||||
List<Path> journals = files.filter(path -> path.getFileName().toString().endsWith(".iswal"))
|
||||
.toList();
|
||||
assertEquals(1, journals.size());
|
||||
return journals.getFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private static long shardCount(Path directory) throws Exception {
|
||||
try (Stream<Path> files = Files.list(directory)) {
|
||||
return files.filter(path -> path.getFileName().toString().endsWith(".isem")).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static void replaceReferencedShard(Path dimensionRoot, Path previousShard, byte[] encoded) throws Exception {
|
||||
int regionX = ByteBuffer.wrap(encoded, 8, Integer.BYTES).getInt();
|
||||
int regionZ = ByteBuffer.wrap(encoded, 12, Integer.BYTES).getInt();
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(encoded);
|
||||
String hash = HexFormat.of().formatHex(digest);
|
||||
Path directory = dimensionRoot.resolve("iris/generation/semantics");
|
||||
Path replacement = directory.resolve("r." + regionX + "." + regionZ + "." + hash + ".isem");
|
||||
Files.write(replacement, encoded);
|
||||
if (!replacement.equals(previousShard)) {
|
||||
Files.delete(previousShard);
|
||||
}
|
||||
|
||||
Path pointer = directory.resolve("r." + regionX + "." + regionZ + ".isix");
|
||||
byte[] pointerBytes = Files.readAllBytes(pointer);
|
||||
System.arraycopy(digest, 0, pointerBytes, 16, digest.length);
|
||||
rewriteChecksum(pointerBytes);
|
||||
Files.write(pointer, pointerBytes);
|
||||
}
|
||||
|
||||
private static void rewriteChecksum(byte[] encoded) {
|
||||
int bodyLength = encoded.length - Integer.BYTES;
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(encoded, 0, bodyLength);
|
||||
ByteBuffer.wrap(encoded, bodyLength, Integer.BYTES).putInt((int) checksum.getValue());
|
||||
}
|
||||
|
||||
private static LongOpenHashSet sealedClaims(
|
||||
GenerationSemanticIndex index,
|
||||
long activationId
|
||||
) throws IOException {
|
||||
LongOpenHashSet claims = new LongOpenHashSet();
|
||||
index.forEachSealedClaim(
|
||||
activationId,
|
||||
(chunkX, chunkZ) -> claims.add(ChunkGenerationOwnership.packChunk(chunkX, chunkZ))
|
||||
);
|
||||
return claims;
|
||||
}
|
||||
|
||||
private static int firstRecordFlagsOffset(byte[] encoded) {
|
||||
ByteBuffer input = ByteBuffer.wrap(encoded);
|
||||
int paletteSize = input.getInt(20);
|
||||
int summaryLength = input.getInt(24);
|
||||
input.position(28 + summaryLength);
|
||||
for (int index = 0; index < paletteSize; index++) {
|
||||
int keyLength = Short.toUnsignedInt(input.getShort());
|
||||
input.position(input.position() + keyLength);
|
||||
}
|
||||
return input.position() + Short.BYTES + Long.BYTES;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
|
||||
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public final class GenerationSemanticQueriesTest {
|
||||
@Test
|
||||
public void mergesExactHistoricalStructureStartsWithEligibleActiveCandidates() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
GenerationHistory history = mock(GenerationHistory.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.history()).thenReturn(history);
|
||||
when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true);
|
||||
when(complex.allowsNewGenerationChunk(1, 0)).thenReturn(false);
|
||||
when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true);
|
||||
when(history.isActiveUnowned(0, 0)).thenReturn(false);
|
||||
ChunkGenerationSemantics sealedEmpty = ChunkGenerationSemantics.builder(2, 0, 2L).seal().build();
|
||||
when(history.semantics(2, 0)).thenReturn(Optional.of(sealedEmpty));
|
||||
GenerationSemanticIndex.Match recorded = new GenerationSemanticIndex.Match(
|
||||
GenerationSemanticIndex.SemanticKind.STRUCTURE,
|
||||
"old-tower",
|
||||
new GenerationSemanticIndex.ChunkReference(6, 0, 1L),
|
||||
Optional.of(new ChunkGenerationSemantics.BlockPosition(100, 73, 5)));
|
||||
when(history.findRecorded(any())).thenReturn(Optional.of(recorded));
|
||||
IrisStructureLocator.LocateResult nearer = foundStructure(48, 90, 0);
|
||||
|
||||
try (MockedStatic<IrisStructureLocator> structures = mockStatic(IrisStructureLocator.class)) {
|
||||
structures.when(() -> IrisStructureLocator.locate(
|
||||
eq(engine), eq("old-tower"), eq(0), eq(0), eq(20), any()))
|
||||
.thenAnswer(invocation -> {
|
||||
IrisStructureLocator.CandidateFilter filter = invocation.getArgument(5);
|
||||
assertFalse(filter.accept(0, 0));
|
||||
assertFalse(filter.accept(1, 0));
|
||||
assertFalse(filter.accept(2, 0));
|
||||
assertTrue(filter.accept(3, 0));
|
||||
return nearer;
|
||||
});
|
||||
assertSame(nearer, GenerationSemanticQueries.nearestStructure(engine, "OLD-TOWER", 0, 0, 20));
|
||||
|
||||
structures.when(() -> IrisStructureLocator.locate(
|
||||
eq(engine), eq("old-tower"), eq(0), eq(0), eq(20), any()))
|
||||
.thenReturn(foundStructure(120, 90, 0));
|
||||
assertEquals(foundStructure(100, 73, 5),
|
||||
GenerationSemanticQueries.nearestStructure(engine, "old-tower", 0, 0, 20));
|
||||
|
||||
structures.when(() -> IrisStructureLocator.locate(
|
||||
eq(engine), eq("old-tower"), eq(0), eq(0), eq(20), any()))
|
||||
.thenReturn(foundStructure(100, 90, 5));
|
||||
assertEquals(foundStructure(100, 73, 5),
|
||||
GenerationSemanticQueries.nearestStructure(engine, "old-tower", 0, 0, 20));
|
||||
|
||||
structures.when(() -> IrisStructureLocator.locate(
|
||||
eq(engine), eq("old-tower"), eq(0), eq(0), eq(20), any()))
|
||||
.thenReturn(new IrisStructureLocator.LocateResult(IrisStructureLocator.LocateStatus.NOT_FOUND, 0, 0, 0));
|
||||
assertEquals(foundStructure(100, 73, 5),
|
||||
GenerationSemanticQueries.nearestStructure(engine, "old-tower", 0, 0, 20));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exhaustedStructureSearchDoesNotClaimARecordedResultIsNearest() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
GenerationHistory history = mock(GenerationHistory.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.history()).thenReturn(history);
|
||||
IrisStructureLocator.LocateResult limit = new IrisStructureLocator.LocateResult(
|
||||
IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0);
|
||||
try (MockedStatic<IrisStructureLocator> structures = mockStatic(IrisStructureLocator.class)) {
|
||||
structures.when(() -> IrisStructureLocator.locate(
|
||||
eq(engine), eq("tower"), eq(0), eq(0), eq(20), any())).thenReturn(limit);
|
||||
assertSame(limit, GenerationSemanticQueries.nearestStructure(engine, "tower", 0, 0, 20));
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisStructureLocator.LocateResult foundStructure(int x, int y, int z) {
|
||||
return new IrisStructureLocator.LocateResult(IrisStructureLocator.LocateStatus.FOUND, x, y, z);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsTaperedPredictionsAndMergesTheNearestEligibleRiver() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class);
|
||||
IrisDimension dimension = mock(IrisDimension.class);
|
||||
TransitionGenerationPlan transition = mock(TransitionGenerationPlan.class);
|
||||
GenerationHistory history = mock(GenerationHistory.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
GenerationActivation activation = mock(GenerationActivation.class);
|
||||
HydrologyFeatureRef tapered = feature(1L, 8, 20, 0);
|
||||
HydrologyFeatureRef eligible = feature(2L, 40, 20, 0);
|
||||
ChunkGenerationSemantics.RiverFeatureOccurrence recordedOccurrence =
|
||||
new ChunkGenerationSemantics.RiverFeatureOccurrence(
|
||||
"iris:river",
|
||||
HydrologyFeatureType.RIFFLE,
|
||||
3L,
|
||||
new ChunkGenerationSemantics.BlockPosition(100, 70, 0)
|
||||
);
|
||||
GenerationSemanticIndex.RiverMatch recorded = new GenerationSemanticIndex.RiverMatch(
|
||||
recordedOccurrence,
|
||||
new GenerationSemanticIndex.ChunkReference(6, 0, 1L)
|
||||
);
|
||||
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(complex.getHydrologyRuntime()).thenReturn(runtime);
|
||||
when(complex.getTransitionGenerationPlan()).thenReturn(transition);
|
||||
when(complex.allowsNewGenerationChunk(anyInt(), anyInt())).thenReturn(true);
|
||||
when(transition.hydrologyWeightAt(anyInt(), anyInt())).thenAnswer(invocation ->
|
||||
invocation.<Integer>getArgument(0) == tapered.x() ? 0.5D : 1D);
|
||||
when(dimension.getMinHeight()).thenReturn(-64);
|
||||
when(router.history()).thenReturn(history);
|
||||
when(history.findRecordedRiver(any())).thenReturn(Optional.of(recorded));
|
||||
when(history.isActiveUnowned(anyInt(), anyInt())).thenReturn(true);
|
||||
when(history.semantics(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
when(history.activeActivation()).thenReturn(activation);
|
||||
when(activation.activationId()).thenReturn(2L);
|
||||
when(runtime.nearestFeature(
|
||||
any(), nullable(String.class), anyInt(), anyInt(), anyInt(), any(), any()
|
||||
)).thenAnswer(invocation -> firstEligible(
|
||||
List.of(tapered, eligible),
|
||||
invocation.getArgument(5)
|
||||
));
|
||||
|
||||
GenerationSemanticQueries.RiverResult active = GenerationSemanticQueries.nearestRiver(
|
||||
engine,
|
||||
HydrologyFeatureQuery.parse("surface"),
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
ignored -> { }
|
||||
).orElseThrow();
|
||||
|
||||
assertEquals(GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION, active.source());
|
||||
assertEquals(40, active.x());
|
||||
assertEquals(-44, active.y());
|
||||
|
||||
HydrologyFeatureRef fartherActive = feature(4L, 120, 20, 0);
|
||||
doAnswer(invocation -> firstEligible(
|
||||
List.of(tapered, fartherActive),
|
||||
invocation.getArgument(5)
|
||||
)).when(runtime).nearestFeature(
|
||||
any(), nullable(String.class), anyInt(), anyInt(), anyInt(), any(), any()
|
||||
);
|
||||
|
||||
GenerationSemanticQueries.RiverResult historical = GenerationSemanticQueries.nearestRiver(
|
||||
engine,
|
||||
HydrologyFeatureQuery.parse("surface"),
|
||||
0,
|
||||
0,
|
||||
200,
|
||||
ignored -> { }
|
||||
).orElseThrow();
|
||||
|
||||
assertEquals(GenerationSemanticQueries.RiverSource.RECORDED, historical.source());
|
||||
assertEquals(100, historical.x());
|
||||
assertEquals(70, historical.y());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returnsRecordedRiverWhenTheActivePackHasNoHydrologyRuntime() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
GenerationHistory history = mock(GenerationHistory.class);
|
||||
GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
|
||||
ChunkGenerationSemantics.RiverFeatureOccurrence occurrence =
|
||||
new ChunkGenerationSemantics.RiverFeatureOccurrence(
|
||||
"iris:legacy_river",
|
||||
HydrologyFeatureType.WATERFALL,
|
||||
9L,
|
||||
new ChunkGenerationSemantics.BlockPosition(-30, 55, 12)
|
||||
);
|
||||
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
|
||||
when(router.history()).thenReturn(history);
|
||||
when(history.findRecordedRiver(any())).thenReturn(Optional.of(
|
||||
new GenerationSemanticIndex.RiverMatch(
|
||||
occurrence,
|
||||
new GenerationSemanticIndex.ChunkReference(-2, 0, 1L)
|
||||
)
|
||||
));
|
||||
|
||||
GenerationSemanticQueries.RiverResult result = GenerationSemanticQueries.nearestRiver(
|
||||
engine,
|
||||
HydrologyFeatureQuery.parse("waterfall"),
|
||||
0,
|
||||
0,
|
||||
100,
|
||||
ignored -> { }
|
||||
).orElseThrow();
|
||||
|
||||
assertEquals(GenerationSemanticQueries.RiverSource.RECORDED, result.source());
|
||||
assertEquals(HydrologyFeatureType.WATERFALL, result.type());
|
||||
assertEquals("iris:legacy_river", result.profileKey().orElseThrow());
|
||||
}
|
||||
|
||||
private static Optional<HydrologyFeatureRef> firstEligible(
|
||||
List<HydrologyFeatureRef> candidates,
|
||||
Predicate<HydrologyFeatureRef> eligibility
|
||||
) {
|
||||
for (HydrologyFeatureRef candidate : candidates) {
|
||||
if (eligibility.test(candidate)) {
|
||||
return Optional.of(candidate);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static HydrologyFeatureRef feature(long id, int x, int y, int z) {
|
||||
return new HydrologyFeatureRef(
|
||||
id,
|
||||
HydrologyFeatureType.RIFFLE,
|
||||
10L,
|
||||
11L,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
1,
|
||||
0,
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class GenerationTransitionTest {
|
||||
private static final String BOUNDARY =
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
private static final String TERRAIN =
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
@Test
|
||||
public void completionPreservesTheImmutableTransitionRecipe() {
|
||||
GenerationTransition pending = GenerationTransition.pending(256);
|
||||
|
||||
GenerationTransition completed = pending.complete(BOUNDARY, TERRAIN);
|
||||
|
||||
assertEquals(GenerationTransition.CURRENT_ALGORITHM_VERSION, completed.algorithmVersion());
|
||||
assertEquals(256, completed.widthBlocks());
|
||||
assertEquals(BOUNDARY, completed.boundaryIdentity());
|
||||
assertEquals(TERRAIN, completed.terrainSignatureIdentity());
|
||||
assertFalse(pending.isComplete());
|
||||
assertTrue(completed.isComplete());
|
||||
assertSame(completed, completed.complete(BOUNDARY, TERRAIN));
|
||||
assertThrows(IllegalStateException.class, () -> completed.complete(TERRAIN, BOUNDARY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void strictJsonRoundTripRetainsPendingAndCompleteStates() {
|
||||
GenerationTransition pending = GenerationTransition.pending(512);
|
||||
GenerationTransition completed = pending.complete(BOUNDARY, TERRAIN);
|
||||
|
||||
assertEquals(pending, GenerationTransition.fromJson(pending.toJson()));
|
||||
assertEquals(completed, GenerationTransition.fromJson(completed.toJson()));
|
||||
|
||||
JsonObject unknown = completed.toJson();
|
||||
unknown.addProperty("unexpected", true);
|
||||
assertThrows(IllegalArgumentException.class, () -> GenerationTransition.fromJson(unknown));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidWidthsAndPartiallyCompletedSnapshots() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationTransition.pending(GenerationTransition.MINIMUM_WIDTH_BLOCKS - 1)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> GenerationTransition.pending(GenerationTransition.MAXIMUM_WIDTH_BLOCKS + 1)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new GenerationTransition(
|
||||
GenerationTransition.CURRENT_ALGORITHM_VERSION,
|
||||
256,
|
||||
BOUNDARY,
|
||||
null
|
||||
)
|
||||
);
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> new GenerationTransition(0, 256, BOUNDARY, TERRAIN)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void activationRoundTripRetainsItsTransition() {
|
||||
GenerationActivation activation = GenerationActivation.next(2L, BOUNDARY, 1L, 99L, 256)
|
||||
.completeTransition(BOUNDARY, TERRAIN);
|
||||
|
||||
GenerationActivation restored = GenerationActivation.fromJson(activation.toJson());
|
||||
|
||||
assertEquals(activation, restored);
|
||||
assertEquals(256, restored.transition().widthBlocks());
|
||||
assertEquals(BOUNDARY, restored.transition().boundaryIdentity());
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.engine.DimensionStackContext;
|
||||
import art.arcane.iris.engine.DimensionStackLayout;
|
||||
import art.arcane.iris.engine.DimensionTerrainContext;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.UpperDimensionContext;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisBoundarySignatureSamplerTest {
|
||||
@Test
|
||||
public void recordsTheEffectiveCeilingInInternalCoordinatesAndBiomesInWorldCoordinates() throws Exception {
|
||||
IrisEngine engine = engine();
|
||||
UpperDimensionContext upper = mock(UpperDimensionContext.class);
|
||||
when(engine.getUpperContext()).thenReturn(upper);
|
||||
when(upper.getEffectiveSurfaceY(-17, 8)).thenReturn(200);
|
||||
|
||||
TerrainBoundarySignature signature = IrisBoundarySignatureSampler.INSTANCE.sample(engine, -17, 8);
|
||||
|
||||
assertEquals(OptionalInt.of(55), signature.upperCeilingDepth());
|
||||
assertEquals(64, signature.oceanFloorHeight());
|
||||
assertEquals(-64, signature.sampleY(0));
|
||||
assertEquals(188, signature.sampleY(63));
|
||||
verify(upper).getEffectiveSurfaceY(-17, 8);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recordsAbsentUpperMassWithAndWithoutAnUpperContext() throws Exception {
|
||||
IrisEngine engine = engine();
|
||||
assertTrue(IrisBoundarySignatureSampler.INSTANCE.sample(engine, -17, 8).upperCeilingDepth().isEmpty());
|
||||
UpperDimensionContext upper = mock(UpperDimensionContext.class);
|
||||
when(engine.getUpperContext()).thenReturn(upper);
|
||||
when(upper.getEffectiveSurfaceY(-17, 8)).thenReturn(256);
|
||||
assertTrue(IrisBoundarySignatureSampler.INSTANCE.sample(engine, -17, 8).upperCeilingDepth().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recordsTheOwningStackLayerBiomeAtEachVerticalSample() throws Exception {
|
||||
IrisEngine engine = engine();
|
||||
IrisComplex complex = engine.getComplex();
|
||||
when(complex.historicalPhysicalBiomeKeyAt(anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.empty());
|
||||
DimensionStackContext stackContext = mock(DimensionStackContext.class);
|
||||
DimensionStackLayout layout = mock(DimensionStackLayout.class);
|
||||
DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class);
|
||||
DimensionTerrainContext terrainContext = mock(DimensionTerrainContext.class);
|
||||
IrisBiome biome = mock(IrisBiome.class);
|
||||
when(engine.getDimensionStackContext()).thenReturn(stackContext);
|
||||
when(stackContext.getLayout(-17, 8)).thenReturn(layout);
|
||||
when(layout.layerAt(anyInt())).thenReturn(layer);
|
||||
when(layer.terrainContext()).thenReturn(terrainContext);
|
||||
when(layer.biome()).thenReturn(biome);
|
||||
when(biome.getSkyBiomeKey(any(RNG.class), same(engine), anyDouble(), anyDouble(), anyDouble()))
|
||||
.thenReturn("minecraft:forest");
|
||||
|
||||
TerrainBoundarySignature signature = IrisBoundarySignatureSampler.INSTANCE.sample(engine, -17, 8);
|
||||
|
||||
assertEquals("minecraft:forest", signature.biomeAtSample(0));
|
||||
assertEquals("minecraft:forest", signature.biomeAtSample(63));
|
||||
}
|
||||
|
||||
private static IrisEngine engine() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
IrisWorld world = mock(IrisWorld.class);
|
||||
when(world.getRawWorldSeed()).thenReturn(1337L);
|
||||
when(engine.getWorld()).thenReturn(world);
|
||||
when(engine.getHeight()).thenReturn(256);
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D));
|
||||
when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 64D));
|
||||
when(complex.historicalPhysicalBiomeKeyAt(anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(Optional.of("minecraft:plains"));
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
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 final class RegionGenerationOwnershipTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void roundTripPreservesAssignmentsAcrossNegativeRegionCoordinates() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("ownership").toPath();
|
||||
RegionGenerationOwnership ownership = new RegionGenerationOwnership(-1, -1);
|
||||
|
||||
assertTrue(ownership.assign(-32, -32, 7L));
|
||||
assertTrue(ownership.assign(-1, -1, 7L));
|
||||
assertTrue(ownership.assign(-17, -9, 19L));
|
||||
assertTrue(ownership.persist(directory));
|
||||
|
||||
Path file = directory.resolve(RegionGenerationOwnership.fileName(-1, -1));
|
||||
assertTrue(Files.size(file) < 512L);
|
||||
RegionGenerationOwnership loaded = RegionGenerationOwnership.read(file);
|
||||
|
||||
assertEquals(3, loaded.assignmentCount());
|
||||
assertEquals(7L, loaded.resolve(-32, -32, 31L));
|
||||
assertEquals(7L, loaded.resolve(-1, -1, 31L));
|
||||
assertEquals(19L, loaded.resolve(-17, -9, 31L));
|
||||
assertEquals(31L, loaded.resolve(-16, -8, 31L));
|
||||
assertFalse(loaded.persist(directory));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyPaletteWidthRoundTripsWithoutBitBoundaryLoss() throws Exception {
|
||||
Path directory = temporaryFolder.newFolder("wide-palette").toPath();
|
||||
RegionGenerationOwnership ownership = new RegionGenerationOwnership(0, 0);
|
||||
for (int chunkZ = 0; chunkZ < 32; chunkZ++) {
|
||||
for (int chunkX = 0; chunkX < 32; chunkX++) {
|
||||
int index = chunkX + chunkZ * 32;
|
||||
ownership.assign(chunkX, chunkZ, index + 1L);
|
||||
}
|
||||
}
|
||||
|
||||
ownership.persist(directory);
|
||||
Path file = directory.resolve(RegionGenerationOwnership.fileName(0, 0));
|
||||
RegionGenerationOwnership loaded = RegionGenerationOwnership.read(file);
|
||||
|
||||
assertEquals(1_024, loaded.assignmentCount());
|
||||
assertTrue(Files.size(file) < 10_000L);
|
||||
for (int chunkZ = 0; chunkZ < 32; chunkZ++) {
|
||||
for (int chunkX = 0; chunkX < 32; chunkX++) {
|
||||
int index = chunkX + chunkZ * 32;
|
||||
assertEquals(index + 1L, loaded.explicitActivation(chunkX, chunkZ));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void assignmentsAreIdempotentButCannotChangeOwner() {
|
||||
RegionGenerationOwnership ownership = new RegionGenerationOwnership(0, 0);
|
||||
|
||||
assertTrue(ownership.assign(3, 5, 12L));
|
||||
assertFalse(ownership.assign(3, 5, 12L));
|
||||
IllegalStateException error = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> ownership.assign(3, 5, 13L)
|
||||
);
|
||||
|
||||
assertTrue(error.getMessage().contains("already belongs to activation 12"));
|
||||
assertEquals(12L, ownership.explicitActivation(3, 5));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checksumCorruptionFailsClosed() throws Exception {
|
||||
Path file = persistedSingleAssignment("checksum");
|
||||
byte[] encoded = Files.readAllBytes(file);
|
||||
encoded[20] ^= 0x10;
|
||||
Files.write(file, encoded);
|
||||
|
||||
IOException error = assertThrows(IOException.class, () -> RegionGenerationOwnership.read(file));
|
||||
|
||||
assertTrue(error.getMessage().contains("checksum mismatch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void truncatedDataFailsClosed() throws Exception {
|
||||
Path file = persistedSingleAssignment("truncated");
|
||||
byte[] encoded = Files.readAllBytes(file);
|
||||
Files.write(file, java.util.Arrays.copyOf(encoded, encoded.length - 3));
|
||||
|
||||
IOException error = assertThrows(IOException.class, () -> RegionGenerationOwnership.read(file));
|
||||
|
||||
assertTrue(error.getMessage().contains("Invalid generation ownership file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unsupportedVersionFailsClosedEvenWithAValidChecksum() throws Exception {
|
||||
Path file = persistedSingleAssignment("version");
|
||||
byte[] encoded = Files.readAllBytes(file);
|
||||
encoded[4] = 0;
|
||||
encoded[5] = 2;
|
||||
rewriteChecksum(encoded);
|
||||
Files.write(file, encoded);
|
||||
|
||||
IOException error = assertThrows(IOException.class, () -> RegionGenerationOwnership.read(file));
|
||||
|
||||
assertTrue(error.getMessage().contains("unsupported format version 2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void regionCoordinatesOutsideTheChunkRangeFailClosed() throws Exception {
|
||||
Path file = persistedSingleAssignment("coordinate-range");
|
||||
byte[] encoded = Files.readAllBytes(file);
|
||||
ByteBuffer.wrap(encoded, 8, Integer.BYTES).putInt(Integer.MAX_VALUE);
|
||||
rewriteChecksum(encoded);
|
||||
Files.write(file, encoded);
|
||||
|
||||
IOException error = assertThrows(IOException.class, () -> RegionGenerationOwnership.read(file));
|
||||
|
||||
assertTrue(error.getMessage().contains("region coordinate is outside"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void activationIdsMustBePositive() {
|
||||
RegionGenerationOwnership ownership = new RegionGenerationOwnership(0, 0);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> ownership.assign(0, 0, 0L));
|
||||
assertThrows(IllegalArgumentException.class, () -> ownership.assign(0, 0, -1L));
|
||||
assertThrows(IllegalArgumentException.class, () -> ownership.resolve(0, 0, 0L));
|
||||
}
|
||||
|
||||
private Path persistedSingleAssignment(String directoryName) throws Exception {
|
||||
Path directory = temporaryFolder.newFolder(directoryName).toPath();
|
||||
RegionGenerationOwnership ownership = new RegionGenerationOwnership(0, 0);
|
||||
ownership.assign(0, 0, 1L);
|
||||
ownership.persist(directory);
|
||||
return directory.resolve(RegionGenerationOwnership.fileName(0, 0));
|
||||
}
|
||||
|
||||
private static void rewriteChecksum(byte[] encoded) {
|
||||
int bodyLength = encoded.length - Integer.BYTES;
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(encoded, 0, bodyLength);
|
||||
ByteBuffer.wrap(encoded, bodyLength, Integer.BYTES).putInt((int) checksum.getValue());
|
||||
}
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Assume;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.concurrent.CyclicBarrier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.zip.CRC32;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
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 TerrainBoundarySignatureStoreTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void preservesPresentAndAbsentUpperCeilingsAcrossShardReloads() throws Exception {
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
|
||||
temporaryFolder.newFolder("upper-ceiling-shards").toPath());
|
||||
TerrainBoundarySignature.Samples samples = new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(-64, 4, 0),
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of(), new short[0]));
|
||||
TerrainBoundarySignature present = new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(-17, 8, 64, 64, OptionalInt.empty(), OptionalInt.of(80)), samples);
|
||||
TerrainBoundarySignature absent = new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(257, 8, 64, 64, OptionalInt.empty(), OptionalInt.empty()), samples);
|
||||
store.publish(2L, List.of(present, absent));
|
||||
|
||||
TerrainBoundarySignatureStore.Snapshot reopened = store.load(2L);
|
||||
|
||||
assertEquals(OptionalInt.of(80), reopened.signatureAt(-17, 8).orElseThrow().upperCeilingDepth());
|
||||
assertTrue(reopened.signatureAt(257, 8).orElseThrow().upperCeilingDepth().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preservesEveryCompactFieldAtNegativeCoordinates() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("signature-dimension").toPath();
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
TerrainBoundarySignature negative = signature(
|
||||
-17,
|
||||
Integer.MIN_VALUE,
|
||||
93,
|
||||
-48,
|
||||
OptionalInt.of(62),
|
||||
-64,
|
||||
32,
|
||||
List.of("iris:forest", "iris:cavern", "iris:forest"),
|
||||
new short[]{2, 1, 0}
|
||||
);
|
||||
TerrainBoundarySignature positive = signature(
|
||||
Integer.MAX_VALUE,
|
||||
4,
|
||||
77,
|
||||
41,
|
||||
OptionalInt.empty(),
|
||||
0,
|
||||
1,
|
||||
List.of(),
|
||||
new short[0]
|
||||
);
|
||||
|
||||
TerrainBoundarySignatureStore.Snapshot published = store.publish(19L, List.of(positive, negative));
|
||||
TerrainBoundarySignatureStore.Snapshot loaded = store.load(19L);
|
||||
|
||||
assertEquals(dimensionRoot.toAbsolutePath().resolve("iris/generation/boundaries"), store.directory());
|
||||
assertEquals(19L, published.activationId());
|
||||
assertEquals(published.identity(), loaded.identity());
|
||||
assertEquals(2, loaded.size());
|
||||
assertSignatureEquals(negative, loaded.signatureAt(-17, Integer.MIN_VALUE).orElseThrow());
|
||||
assertSignatureEquals(positive, loaded.signatureAt(Integer.MAX_VALUE, 4).orElseThrow());
|
||||
assertTrue(loaded.signatureAt(0, 0).isEmpty());
|
||||
assertThrows(UnsupportedOperationException.class, () -> loaded.signatures().clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writesDeterministicallyRegardlessOfCollectionOrder() throws Exception {
|
||||
Path firstRoot = temporaryFolder.newFolder("deterministic-first").toPath();
|
||||
Path secondRoot = temporaryFolder.newFolder("deterministic-second").toPath();
|
||||
TerrainBoundarySignatureStore firstStore = new TerrainBoundarySignatureStore(firstRoot);
|
||||
TerrainBoundarySignatureStore secondStore = new TerrainBoundarySignatureStore(secondRoot);
|
||||
TerrainBoundarySignature first = signature(
|
||||
-1,
|
||||
9,
|
||||
80,
|
||||
40,
|
||||
OptionalInt.empty(),
|
||||
-32,
|
||||
16,
|
||||
List.of("iris:snowy_森林"),
|
||||
new short[]{0, 0}
|
||||
);
|
||||
TerrainBoundarySignature second = signature(
|
||||
12,
|
||||
-8,
|
||||
70,
|
||||
35,
|
||||
OptionalInt.of(50),
|
||||
5,
|
||||
4,
|
||||
List.of("iris:desert"),
|
||||
new short[]{0}
|
||||
);
|
||||
|
||||
TerrainBoundarySignatureStore.Snapshot firstSnapshot = firstStore.publish(8L, List.of(first, second));
|
||||
TerrainBoundarySignatureStore.Snapshot secondSnapshot = secondStore.publish(8L, List.of(second, first));
|
||||
|
||||
assertEquals(firstSnapshot.identity(), secondSnapshot.identity());
|
||||
assertArrayEquals(
|
||||
Files.readAllBytes(firstStore.snapshotPath(8L)),
|
||||
Files.readAllBytes(secondStore.snapshotPath(8L))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void streamedBoundaryMatchesCollectionPublicationAcrossCells() throws Exception {
|
||||
TerrainBoundarySignatureStore streamed = new TerrainBoundarySignatureStore(
|
||||
temporaryFolder.newFolder("streamed-boundary").toPath()
|
||||
);
|
||||
TerrainBoundarySignatureStore collected = new TerrainBoundarySignatureStore(
|
||||
temporaryFolder.newFolder("collected-boundary").toPath()
|
||||
);
|
||||
List<GenerationBoundary.ChunkCoordinate> chunks = new ArrayList<>();
|
||||
for (int index = -40; index < 40; index++) {
|
||||
chunks.add(new GenerationBoundary.ChunkCoordinate(index * 3, index % 5));
|
||||
}
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("a".repeat(64), chunks);
|
||||
List<TerrainBoundarySignature> signatures = new ArrayList<>();
|
||||
boundary.forEachExposedBlockColumn((x, z) -> signatures.add(simpleSignature(x, z, 81)));
|
||||
|
||||
TerrainBoundarySignatureStore.Snapshot first = streamed.publish(
|
||||
2L, boundary, (x, z) -> simpleSignature(x, z, 81)
|
||||
);
|
||||
TerrainBoundarySignatureStore.Snapshot second = collected.publish(2L, signatures);
|
||||
|
||||
assertEquals(boundary.exposedBlockColumnCount(), first.size());
|
||||
assertEquals(second.identity(), first.identity());
|
||||
assertArrayEquals(Files.readAllBytes(collected.snapshotPath(2L)), Files.readAllBytes(streamed.snapshotPath(2L)));
|
||||
boundary.forEachExposedBlockColumn((x, z) -> assertTrue(first.signatureAt(x, z).isPresent()));
|
||||
assertTrue(first.cachedShardCount() <= 64);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void streamedBoundaryDoesNotPublishOnMissingOrMisplacedSamples() throws Exception {
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
|
||||
temporaryFolder.newFolder("failed-streamed-boundary").toPath()
|
||||
);
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze(
|
||||
"a".repeat(64), List.of(new GenerationBoundary.ChunkCoordinate(0, 0))
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, () -> store.publish(2L, boundary, (x, z) -> {
|
||||
throw new IOException("sample failed");
|
||||
}));
|
||||
assertFalse(Files.exists(store.snapshotPath(2L)));
|
||||
assertThrows(IOException.class, () -> store.publish(2L, boundary, (x, z) -> simpleSignature(x + 1, z, 81)));
|
||||
assertFalse(Files.exists(store.snapshotPath(2L)));
|
||||
assertEquals(60, store.publish(2L, boundary, (x, z) -> simpleSignature(x, z, 81)).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void canonicalizesEquivalentBiomePalettesBeforeIdentityAndPublication() throws Exception {
|
||||
Path firstRoot = temporaryFolder.newFolder("palette-first").toPath();
|
||||
Path secondRoot = temporaryFolder.newFolder("palette-second").toPath();
|
||||
TerrainBoundarySignature first = signature(
|
||||
5,
|
||||
-7,
|
||||
80,
|
||||
40,
|
||||
OptionalInt.empty(),
|
||||
-64,
|
||||
32,
|
||||
List.of("iris:z", "iris:a", "iris:z", "iris:unused"),
|
||||
new short[]{0, 1, 2}
|
||||
);
|
||||
TerrainBoundarySignature equivalent = signature(
|
||||
5,
|
||||
-7,
|
||||
80,
|
||||
40,
|
||||
OptionalInt.empty(),
|
||||
-64,
|
||||
32,
|
||||
List.of("iris:a", "iris:z"),
|
||||
new short[]{1, 0, 1}
|
||||
);
|
||||
TerrainBoundarySignatureStore firstStore = new TerrainBoundarySignatureStore(firstRoot);
|
||||
TerrainBoundarySignatureStore secondStore = new TerrainBoundarySignatureStore(secondRoot);
|
||||
|
||||
TerrainBoundarySignatureStore.Snapshot firstSnapshot = firstStore.publish(13L, List.of(first));
|
||||
TerrainBoundarySignatureStore.Snapshot repeated = firstStore.publish(13L, List.of(equivalent));
|
||||
TerrainBoundarySignatureStore.Snapshot secondSnapshot = secondStore.publish(13L, List.of(equivalent));
|
||||
|
||||
assertEquals(firstSnapshot.identity(), repeated.identity());
|
||||
assertEquals(firstSnapshot.identity(), secondSnapshot.identity());
|
||||
assertArrayEquals(
|
||||
Files.readAllBytes(firstStore.snapshotPath(13L)),
|
||||
Files.readAllBytes(secondStore.snapshotPath(13L))
|
||||
);
|
||||
TerrainBoundarySignature loaded = firstStore.load(13L).signatureAt(5, -7).orElseThrow();
|
||||
assertEquals(List.of("iris:a", "iris:z"), loaded.samples().biomes().palette());
|
||||
assertArrayEquals(new short[]{1, 0, 1}, loaded.samples().biomes().paletteIndices());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void keepsActivationSnapshotImmutableAndRejectsDuplicateCoordinates() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("immutable-signature-dimension").toPath();
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
TerrainBoundarySignature original = simpleSignature(-5, 6, 81);
|
||||
TerrainBoundarySignature changed = simpleSignature(-5, 6, 82);
|
||||
|
||||
TerrainBoundarySignatureStore.Snapshot first = store.publish(2L, List.of(original));
|
||||
TerrainBoundarySignatureStore.Snapshot repeated = store.publish(2L, List.of(original));
|
||||
|
||||
assertEquals(first.identity(), repeated.identity());
|
||||
assertThrows(FileAlreadyExistsException.class, () -> store.publish(2L, List.of(changed)));
|
||||
assertThrows(IllegalArgumentException.class, () -> store.publish(3L, List.of(original, changed)));
|
||||
assertEquals(81, store.load(2L).signatureAt(-5, 6).orElseThrow().surfaceHeight());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failsClosedForChecksumCorruptionAndTruncation() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("corrupt-signature-dimension").toPath();
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
store.publish(4L, List.of(simpleSignature(1, -2, 70)));
|
||||
Path corrupted = store.snapshotPath(4L);
|
||||
byte[] corruptedBytes = Files.readAllBytes(corrupted);
|
||||
corruptedBytes[8] ^= 0x01;
|
||||
Files.write(corrupted, corruptedBytes);
|
||||
|
||||
IOException checksumFailure = assertThrows(IOException.class, () -> store.load(4L));
|
||||
assertTrue(checksumFailure.getMessage().contains("checksum"));
|
||||
|
||||
store.publish(5L, List.of(simpleSignature(-3, 7, 72)));
|
||||
Path truncated = store.snapshotPath(5L);
|
||||
byte[] complete = Files.readAllBytes(truncated);
|
||||
Files.write(truncated, Arrays.copyOf(complete, complete.length - 2));
|
||||
assertThrows(IOException.class, () -> store.load(5L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verifiesContentIdentityAndDeclaredBounds() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("verified-signature-dimension").toPath();
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
store.publish(9L, List.of(simpleSignature(-9, -10, 90)));
|
||||
Path identitySnapshot = store.snapshotPath(9L);
|
||||
byte[] identityBytes = Files.readAllBytes(identitySnapshot);
|
||||
identityBytes[20] ^= 0x01;
|
||||
updateChecksum(identityBytes);
|
||||
Files.write(identitySnapshot, identityBytes);
|
||||
|
||||
IOException identityFailure = assertThrows(IOException.class, () -> store.load(9L));
|
||||
assertTrue(identityFailure.getMessage().contains("identity mismatch"));
|
||||
|
||||
store.publish(10L, List.of());
|
||||
Path boundedSnapshot = store.snapshotPath(10L);
|
||||
byte[] boundedBytes = Files.readAllBytes(boundedSnapshot);
|
||||
ByteBuffer.wrap(boundedBytes).putInt(16, Integer.MAX_VALUE);
|
||||
updateChecksum(boundedBytes);
|
||||
Files.write(boundedSnapshot, boundedBytes);
|
||||
assertThrows(IOException.class, () -> store.load(10L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidActivationAndMalformedUtf8() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("invalid-signature-dimension").toPath();
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
assertThrows(IllegalArgumentException.class, () -> store.publish(0L, List.of()));
|
||||
String malformed = new String(new char[]{'i', 'r', 'i', 's', ':', '\uD800'});
|
||||
TerrainBoundarySignature invalid = signature(
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
OptionalInt.empty(),
|
||||
0,
|
||||
1,
|
||||
List.of(malformed),
|
||||
new short[]{0}
|
||||
);
|
||||
assertThrows(IOException.class, () -> store.publish(1L, List.of(invalid)));
|
||||
assertFalse(Files.exists(store.snapshotPath(1L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsSymbolicLinksAtEveryStorageComponent() throws Exception {
|
||||
Path probe = temporaryFolder.newFolder("terrain-symlink-probe").toPath();
|
||||
Path probeTarget = temporaryFolder.newFolder("terrain-symlink-probe-target").toPath();
|
||||
try {
|
||||
Files.createSymbolicLink(probe.resolve("link"), probeTarget);
|
||||
} catch (IOException | UnsupportedOperationException exception) {
|
||||
Assume.assumeNoException(exception);
|
||||
}
|
||||
|
||||
for (String component : new String[]{"dimension", "iris", "generation", "boundaries"}) {
|
||||
assertStorageLinkRejected(component);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentStoreInstancesCannotClobberTerrainSignatures() throws Exception {
|
||||
Path dimensionRoot = temporaryFolder.newFolder("terrain-race").toPath();
|
||||
TerrainBoundarySignatureStore firstStore = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
TerrainBoundarySignatureStore secondStore = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
TerrainBoundarySignature firstSignature = simpleSignature(1, 2, 70);
|
||||
TerrainBoundarySignature secondSignature = simpleSignature(3, 4, 90);
|
||||
CyclicBarrier barrier = new CyclicBarrier(2);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Boolean> first = executor.submit(
|
||||
() -> publishAfterBarrier(firstStore, 22L, firstSignature, barrier)
|
||||
);
|
||||
Future<Boolean> second = executor.submit(
|
||||
() -> publishAfterBarrier(secondStore, 22L, secondSignature, barrier)
|
||||
);
|
||||
|
||||
assertTrue(first.get() ^ second.get());
|
||||
TerrainBoundarySignatureStore.Snapshot loaded = firstStore.load(22L);
|
||||
assertTrue(loaded.signatureAt(1, 2).isPresent() ^ loaded.signatureAt(3, 4).isPresent());
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void assertStorageLinkRejected(String component) throws Exception {
|
||||
Path container = temporaryFolder.newFolder("terrain-linked-" + component).toPath();
|
||||
Path dimensionRoot = container.resolve("dimension");
|
||||
Path external = temporaryFolder.newFolder("terrain-linked-target-" + component).toPath();
|
||||
if (component.equals("dimension")) {
|
||||
Files.createSymbolicLink(dimensionRoot, external);
|
||||
} else {
|
||||
Files.createDirectory(dimensionRoot);
|
||||
Path iris = dimensionRoot.resolve("iris");
|
||||
if (component.equals("iris")) {
|
||||
Files.createSymbolicLink(iris, external);
|
||||
} else {
|
||||
Files.createDirectory(iris);
|
||||
Path generation = iris.resolve("generation");
|
||||
if (component.equals("generation")) {
|
||||
Files.createSymbolicLink(generation, external);
|
||||
} else {
|
||||
Files.createDirectory(generation);
|
||||
Files.createSymbolicLink(generation.resolve("boundaries"), external);
|
||||
}
|
||||
}
|
||||
}
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
|
||||
assertThrows(IOException.class, () -> store.publish(1L, List.of()));
|
||||
}
|
||||
|
||||
private static boolean publishAfterBarrier(
|
||||
TerrainBoundarySignatureStore store,
|
||||
long activationId,
|
||||
TerrainBoundarySignature signature,
|
||||
CyclicBarrier barrier
|
||||
) throws Exception {
|
||||
barrier.await();
|
||||
try {
|
||||
store.publish(activationId, List.of(signature));
|
||||
return true;
|
||||
} catch (IOException expected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature simpleSignature(int blockX, int blockZ, int surfaceHeight) {
|
||||
return signature(
|
||||
blockX,
|
||||
blockZ,
|
||||
surfaceHeight,
|
||||
surfaceHeight - 20,
|
||||
OptionalInt.of(surfaceHeight - 10),
|
||||
-64,
|
||||
32,
|
||||
List.of("iris:plains"),
|
||||
new short[]{0, 0}
|
||||
);
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature signature(
|
||||
int blockX,
|
||||
int blockZ,
|
||||
int surfaceHeight,
|
||||
int oceanFloorHeight,
|
||||
OptionalInt fluidHeight,
|
||||
int minimumY,
|
||||
int sampleStep,
|
||||
List<String> palette,
|
||||
short[] paletteIndices
|
||||
) {
|
||||
TerrainBoundarySignature.VerticalLayout layout = new TerrainBoundarySignature.VerticalLayout(
|
||||
minimumY,
|
||||
sampleStep,
|
||||
paletteIndices.length
|
||||
);
|
||||
TerrainBoundarySignature.BiomeEncoding biomeEncoding =
|
||||
new TerrainBoundarySignature.BiomeEncoding(palette, paletteIndices);
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(
|
||||
blockX,
|
||||
blockZ,
|
||||
surfaceHeight,
|
||||
oceanFloorHeight,
|
||||
fluidHeight,
|
||||
OptionalInt.empty()
|
||||
),
|
||||
new TerrainBoundarySignature.Samples(layout, biomeEncoding)
|
||||
);
|
||||
}
|
||||
|
||||
private static void assertSignatureEquals(
|
||||
TerrainBoundarySignature expected,
|
||||
TerrainBoundarySignature actual
|
||||
) {
|
||||
assertEquals(expected.column(), actual.column());
|
||||
assertEquals(expected.samples().layout(), actual.samples().layout());
|
||||
for (int index = 0; index < expected.sampleCount(); index++) {
|
||||
assertEquals(expected.biomeAtSample(index), actual.biomeAtSample(index));
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateChecksum(byte[] encoded) {
|
||||
int bodyLength = encoded.length - Integer.BYTES;
|
||||
CRC32 checksum = new CRC32();
|
||||
checksum.update(encoded, 0, bodyLength);
|
||||
ByteBuffer.wrap(encoded, bodyLength, Integer.BYTES).putInt((int) checksum.getValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class TerrainBoundarySignatureTest {
|
||||
@Test
|
||||
public void storesPlatformNeutralTerrainSamples() {
|
||||
ArrayList<String> palette = new ArrayList<>(List.of("iris:forest", "iris:cave"));
|
||||
short[] biomeIndices = {0, 1, 1};
|
||||
|
||||
TerrainBoundarySignature.BiomeEncoding biomeEncoding =
|
||||
new TerrainBoundarySignature.BiomeEncoding(palette, biomeIndices);
|
||||
TerrainBoundarySignature signature = new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(-1, 32, 91, 48, OptionalInt.of(63), OptionalInt.of(48)),
|
||||
new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(-64, 32, 3),
|
||||
biomeEncoding
|
||||
)
|
||||
);
|
||||
|
||||
palette.set(0, "changed");
|
||||
biomeIndices[0] = 1;
|
||||
|
||||
assertEquals(-1, signature.blockX());
|
||||
assertEquals(32, signature.blockZ());
|
||||
assertEquals(91, signature.surfaceHeight());
|
||||
assertEquals(48, signature.oceanFloorHeight());
|
||||
assertEquals(63, signature.fluidHeight().orElseThrow());
|
||||
assertEquals(3, signature.sampleCount());
|
||||
assertEquals(-64, signature.sampleY(0));
|
||||
assertEquals(0, signature.sampleY(2));
|
||||
assertEquals("iris:forest", signature.biomeAtSample(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returnedCompactSamplesCannotMutateSignature() {
|
||||
TerrainBoundarySignature.BiomeEncoding biomes = new TerrainBoundarySignature.BiomeEncoding(
|
||||
List.of("iris:forest"), new short[]{0}
|
||||
);
|
||||
|
||||
short[] exportedBiomes = biomes.paletteIndices();
|
||||
exportedBiomes[0] = -1;
|
||||
|
||||
assertEquals("iris:forest", biomes.biomeAtSample(0));
|
||||
assertThrows(UnsupportedOperationException.class, () -> biomes.palette().add("iris:other"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void supportsColumnsWithoutFluid() {
|
||||
TerrainBoundarySignature.Column column =
|
||||
new TerrainBoundarySignature.Column(0, 0, 80, 50, OptionalInt.empty(), OptionalInt.empty());
|
||||
assertTrue(column.fluidHeight().isEmpty());
|
||||
assertTrue(column.upperCeilingDepth().isEmpty());
|
||||
assertThrows(IllegalArgumentException.class, () -> new TerrainBoundarySignature.Column(
|
||||
0, 0, 80, 50, OptionalInt.empty(), OptionalInt.of(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidSampleEncodings() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new TerrainBoundarySignature.VerticalLayout(0, 0, 1));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new TerrainBoundarySignature.BiomeEncoding(List.of("iris:forest"), new short[]{1}));
|
||||
|
||||
TerrainBoundarySignature.VerticalLayout layout =
|
||||
new TerrainBoundarySignature.VerticalLayout(0, 4, 2);
|
||||
TerrainBoundarySignature.BiomeEncoding oneBiome =
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("iris:forest"), new short[]{0});
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new TerrainBoundarySignature.Samples(layout, oneBiome));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
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.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class TransitionGenerationPlanTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void interpolatesPresentAndAbsentUpperDepthUsingTheSameBoundedNeighbours() throws Exception {
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(
|
||||
temporaryFolder.newFolder("upper-depth-neighbours").toPath());
|
||||
TerrainBoundarySignature.Samples samples = new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(-64, 4, 0),
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of(), new short[0]));
|
||||
TerrainBoundarySignature present = new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(15, 0, 64, 64, OptionalInt.empty(), OptionalInt.of(100)), samples);
|
||||
TerrainBoundarySignature absent = new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(15, 2, 64, 64, OptionalInt.empty(), OptionalInt.empty()), samples);
|
||||
TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, store.publish(2L, List.of(present, absent)));
|
||||
|
||||
assertEquals(100D, sampler.sample(16, 0).historicalUpperCeilingDepth(), 0D);
|
||||
assertEquals(0D, sampler.sample(16, 2).historicalUpperCeilingDepth(), 0D);
|
||||
assertEquals(50D, sampler.sample(17, 1).historicalUpperCeilingDepth(), 0D);
|
||||
assertEquals(1L, sampler.candidateBuildCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bindsImmutableActivationEpochWidthAndBoundaryInputs() throws Exception {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("frontier-sha256", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0)
|
||||
));
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(9L, boundary);
|
||||
TransitionGenerationPlan.Specification specification = new TransitionGenerationPlan.Specification(
|
||||
9L,
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
GenerationTransition.CURRENT_ALGORITHM_VERSION,
|
||||
32,
|
||||
"frontier-sha256",
|
||||
snapshot.identity()
|
||||
);
|
||||
|
||||
TransitionGenerationPlan plan = new TransitionGenerationPlan(specification, boundary, snapshot);
|
||||
|
||||
assertEquals(9L, plan.activationId());
|
||||
assertEquals(specification.oldEpochId(), plan.oldEpochId());
|
||||
assertEquals(specification.newEpochId(), plan.newEpochId());
|
||||
assertEquals(32, plan.widthBlocks());
|
||||
assertEquals(GenerationTransition.CURRENT_ALGORITHM_VERSION, plan.algorithmVersion());
|
||||
assertEquals("frontier-sha256", plan.boundaryIdentity());
|
||||
assertEquals(snapshot.identity(), plan.terrainSignatures().identity());
|
||||
assertTrue(plan.isHistoricalBlock(15, 15));
|
||||
assertEquals(0D, plan.newEpochWeightAt(15, 15), 0D);
|
||||
assertEquals(1D, plan.newEpochWeightAt(47, 15), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void computesTransitionForEnclosedNewTerritory() throws Exception {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("ring", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(-1, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(1, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(0, -1),
|
||||
new GenerationBoundary.ChunkCoordinate(0, 1)
|
||||
));
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(2L, boundary);
|
||||
TransitionGenerationPlan plan = new TransitionGenerationPlan(
|
||||
specification(2L, "old", "new", 16, "ring", snapshot.identity()),
|
||||
boundary,
|
||||
snapshot
|
||||
);
|
||||
|
||||
assertEquals(8D, plan.distanceToHistoricalChunks(8, 8), 0D);
|
||||
assertEquals(0.5D, plan.newEpochWeightAt(8, 8), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void samplesFrozenTerrainAndSeparatelyTapersHydrology() throws Exception {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("terrain", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0)
|
||||
));
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(3L, boundary);
|
||||
TransitionGenerationPlan plan = new TransitionGenerationPlan(
|
||||
specification(3L, "old", "new", 32, "terrain", snapshot.identity()),
|
||||
boundary,
|
||||
snapshot
|
||||
);
|
||||
|
||||
TransitionGenerationPlan.TerrainSample edge = plan.terrainSampleAt(16, 8);
|
||||
assertNotNull(edge.nearestSignature());
|
||||
assertEquals(1D, edge.distanceToHistoricalTerrain(), 0D);
|
||||
assertEquals(64D, edge.historicalSurfaceHeight(), 0D);
|
||||
assertEquals(0D, edge.hydrologyWeight(), 0D);
|
||||
assertTrue(edge.newEpochWeight() > 0D);
|
||||
assertTrue(edge.newEpochWeight() < 0.001D);
|
||||
|
||||
assertEquals(1D, plan.newEpochWeightAt(47, 8), 0D);
|
||||
assertEquals(0D, plan.hydrologyWeightAt(47, 8), 0D);
|
||||
assertEquals(0.5D, plan.hydrologyWeightAt(63, 8), 0D);
|
||||
assertEquals(1D, plan.hydrologyWeightAt(79, 8), 0D);
|
||||
assertFalse(plan.allowsNewFootprint(31, 8, 48, 8));
|
||||
assertTrue(plan.allowsNewFootprint(47, 8, 63, 8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void eachNewBoundaryColumnStartsAtItsTouchingHistoricalEdgeHeight() throws Exception {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("varying-edge", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0)
|
||||
));
|
||||
Path dimensionRoot = temporaryFolder.getRoot().toPath().resolve("varying-edge");
|
||||
Files.createDirectory(dimensionRoot);
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = store.publish(
|
||||
7L,
|
||||
boundary.exposedBlockColumns().stream()
|
||||
.map(column -> heightSignature(
|
||||
column.blockX(),
|
||||
column.blockZ(),
|
||||
column.blockX() == 15 ? 40 + column.blockZ() * 7 : 180
|
||||
))
|
||||
.toList()
|
||||
);
|
||||
TransitionGenerationPlan plan = new TransitionGenerationPlan(
|
||||
specification(7L, "old", "new", 16, "varying-edge", snapshot.identity()),
|
||||
boundary,
|
||||
snapshot
|
||||
);
|
||||
|
||||
for (int blockZ = 1; blockZ < 15; blockZ++) {
|
||||
assertEquals(
|
||||
40D + blockZ * 7D,
|
||||
plan.terrainSampleAt(16, blockZ).historicalSurfaceHeight(),
|
||||
0D
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsFootprintsWhoseInteriorCrossesHistoryOrTheTerrainBand() throws Exception {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("footprints", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0)
|
||||
));
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(5L, boundary);
|
||||
TransitionGenerationPlan plan = new TransitionGenerationPlan(
|
||||
specification(5L, "old", "new", 16, "footprints", snapshot.identity()),
|
||||
boundary,
|
||||
snapshot
|
||||
);
|
||||
|
||||
assertFalse(plan.allowsNewFootprint(-20, -20, 40, 40));
|
||||
assertFalse(plan.allowsNewFootprint(16, -20, 16, 35));
|
||||
assertTrue(plan.allowsNewFootprint(31, 8, 31, 8));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exposesTheNearestFrozenPhysicalBiomeUntilDiscreteHandoff() throws Exception {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("biomes", List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0)
|
||||
));
|
||||
Path dimensionRoot = temporaryFolder.getRoot().toPath().resolve("activation-biomes");
|
||||
Files.createDirectory(dimensionRoot);
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = store.publish(6L, boundary.exposedBlockColumns().stream()
|
||||
.map(column -> biomeSignature(column.blockX(), column.blockZ()))
|
||||
.toList());
|
||||
TransitionGenerationPlan plan = new TransitionGenerationPlan(
|
||||
specification(6L, "old", "new", 16, "biomes", snapshot.identity()),
|
||||
boundary,
|
||||
snapshot
|
||||
);
|
||||
|
||||
assertEquals("iris:middle", plan.historicalPhysicalBiomeKeyAt(16, 14, 8).orElseThrow());
|
||||
assertEquals("iris:high", plan.historicalPhysicalBiomeKeyAt(16, 18, 8).orElseThrow());
|
||||
assertTrue(plan.historicalPhysicalBiomeKeyAt(31, 14, 8).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyHistoryUsesTheNewEpochWithoutAStoredTerrainSample() throws Exception {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("empty", List.of());
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(4L, boundary);
|
||||
TransitionGenerationPlan plan = new TransitionGenerationPlan(
|
||||
specification(4L, "old", "new", 32, "empty", snapshot.identity()),
|
||||
boundary,
|
||||
snapshot
|
||||
);
|
||||
|
||||
TransitionGenerationPlan.TerrainSample sample = plan.terrainSampleAt(-120, 240);
|
||||
assertFalse(sample.hasHistoricalSignature());
|
||||
assertEquals(1D, sample.newEpochWeight(), 0D);
|
||||
assertEquals(1D, sample.hydrologyWeight(), 0D);
|
||||
assertTrue(plan.allowsNewDiscreteContentAt(-120, 240));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedChunkCandidatesMatchExhaustiveNearestWeightingAndTies() throws Exception {
|
||||
List<TerrainBoundarySignature> signatures = new ArrayList<>();
|
||||
for (int x = -160; x <= 160; x += 11) {
|
||||
for (int z = -160; z <= 160; z += 13) {
|
||||
signatures.add(heightSignature(x, z, Math.floorMod(x * 7 + z * 17, 220)));
|
||||
}
|
||||
}
|
||||
signatures.add(heightSignature(-1, 0, 77));
|
||||
signatures.add(heightSignature(1, 0, 101));
|
||||
signatures.add(heightSignature(0, -1, 125));
|
||||
signatures.add(heightSignature(0, 1, 149));
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot("nearest-oracle", signatures);
|
||||
for (int width : new int[]{32, GenerationTransition.MAXIMUM_WIDTH_BLOCKS}) {
|
||||
TransitionBoundarySampler sampler = new TransitionBoundarySampler(width, snapshot);
|
||||
for (GenerationBoundary.ChunkCoordinate chunk : List.of(
|
||||
new GenerationBoundary.ChunkCoordinate(-1, -1),
|
||||
new GenerationBoundary.ChunkCoordinate(0, 0),
|
||||
new GenerationBoundary.ChunkCoordinate(4, 5)
|
||||
)) {
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
assertMatchesExhaustive(signatures, sampler, chunk.chunkX() * 16 + localX,
|
||||
chunk.chunkZ() * 16 + localZ, width * 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void includesSearchRadiusTiesAndExcludesMoreDistantSignatures() throws Exception {
|
||||
List<TerrainBoundarySignature> signatures = List.of(
|
||||
heightSignature(-64, 0, 40), heightSignature(64, 0, 60),
|
||||
heightSignature(0, -64, 80), heightSignature(0, 64, 100),
|
||||
heightSignature(65, 0, 500)
|
||||
);
|
||||
TransitionBoundarySampler sampler = new TransitionBoundarySampler(32, snapshot("radius-ties", signatures));
|
||||
|
||||
assertMatchesExhaustive(signatures, sampler, 0, 0, 64);
|
||||
assertEquals(70D, sampler.sample(0, 0).historicalSurfaceHeight(), 0D);
|
||||
assertEquals(-64, sampler.sample(0, 0).nearestSignature().blockX());
|
||||
assertMatchesExhaustive(signatures, sampler, 0, 1, 64);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void wideFragmentedBoundaryOnlyLoadsCompetitiveCellsAndKeepsBoundedCandidates() throws Exception {
|
||||
List<TerrainBoundarySignature> signatures = fragmentedSignatures();
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot("fragmented-nearest", signatures);
|
||||
|
||||
List<TerrainBoundarySignature> candidates = snapshot.nearestCandidatesForChunk(0, 0, 32_768);
|
||||
|
||||
assertTrue(signatures.size() > 1_024);
|
||||
assertTrue(candidates.size() <= 1_024);
|
||||
assertEquals(4, candidates.size());
|
||||
assertEquals(1L, snapshot.shardLoadCount());
|
||||
assertTrue(snapshot.catalogProbeCount() <= 4_356L);
|
||||
TransitionBoundarySampler sampler = new TransitionBoundarySampler(16_384, snapshot);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
assertMatchesExhaustive(signatures, sampler, x, z, 32_768);
|
||||
}
|
||||
}
|
||||
assertEquals(1L, sampler.candidateBuildCount());
|
||||
assertEquals(1L, snapshot.shardLoadCount());
|
||||
assertTrue(snapshot.cachedShardCount() <= 64);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void farOutsideChunkDoesBoundedCatalogWorkOnceAndDoesNotReadShards() throws Exception {
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot("distant-nearest", fragmentedSignatures());
|
||||
TransitionBoundarySampler sampler = new TransitionBoundarySampler(16_384, snapshot);
|
||||
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
assertFalse(sampler.sample(1_000_000_000 + x, -1_000_000_000 + z).hasHistoricalSignature());
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(1L, sampler.candidateBuildCount());
|
||||
assertEquals(0L, sampler.shardLoadCount());
|
||||
assertTrue(sampler.catalogProbeCount() <= 4_356L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void footprintIntersectionStopsAtTheFirstWitnessWithoutCollectingSignatures() throws Exception {
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot("footprint-stream", fragmentedSignatures());
|
||||
TransitionBoundarySampler sampler = new TransitionBoundarySampler(16_384, snapshot);
|
||||
|
||||
assertTrue(sampler.intersectsTerrainBand(Integer.MIN_VALUE, Integer.MIN_VALUE,
|
||||
Integer.MAX_VALUE, Integer.MAX_VALUE));
|
||||
assertEquals(1L, sampler.shardLoadCount());
|
||||
assertEquals(1L, sampler.catalogProbeCount());
|
||||
|
||||
TerrainBoundarySignatureStore.Snapshot exact = snapshot("footprint-edge", List.of(heightSignature(0, 0, 70)));
|
||||
TransitionBoundarySampler edge = new TransitionBoundarySampler(32, exact);
|
||||
assertFalse(edge.intersectsTerrainBand(32, 0, 32, 0));
|
||||
assertTrue(edge.intersectsTerrainBand(31, 0, 31, 0));
|
||||
assertFalse(edge.intersectsTerrainBand(23, 23, 23, 23));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidOrMismatchedImmutableInputs() throws Exception {
|
||||
GenerationBoundary boundary = GenerationBoundary.freeze("actual", List.of());
|
||||
TerrainBoundarySignatureStore.Snapshot snapshot = snapshot(1L, boundary);
|
||||
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> specification(0L, "old", "new", 16, "actual", snapshot.identity()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> specification(1L, "same", "same", 16, "actual", snapshot.identity()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> specification(1L, "old", "new", 0, "actual", snapshot.identity()));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new TransitionGenerationPlan(
|
||||
specification(1L, "old", "new", 16, "different", snapshot.identity()),
|
||||
boundary,
|
||||
snapshot
|
||||
));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new TransitionGenerationPlan(
|
||||
specification(2L, "old", "new", 16, "actual", snapshot.identity()),
|
||||
boundary,
|
||||
snapshot
|
||||
));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new TransitionGenerationPlan(
|
||||
specification(1L, "old", "new", 16, "actual", "f".repeat(64)),
|
||||
boundary,
|
||||
snapshot
|
||||
));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new TransitionGenerationPlan(
|
||||
new TransitionGenerationPlan.Specification(
|
||||
1L,
|
||||
"old",
|
||||
"new",
|
||||
GenerationTransition.CURRENT_ALGORITHM_VERSION + 1,
|
||||
16,
|
||||
"actual",
|
||||
snapshot.identity()
|
||||
),
|
||||
boundary,
|
||||
snapshot
|
||||
));
|
||||
}
|
||||
|
||||
private TerrainBoundarySignatureStore.Snapshot snapshot(
|
||||
long activationId,
|
||||
GenerationBoundary boundary
|
||||
) throws IOException {
|
||||
Path dimensionRoot = temporaryFolder.getRoot().toPath().resolve("activation-" + activationId);
|
||||
Files.createDirectory(dimensionRoot);
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(dimensionRoot);
|
||||
return store.publish(activationId, boundary.exposedBlockColumns().stream()
|
||||
.map(column -> signature(column.blockX(), column.blockZ()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private TerrainBoundarySignatureStore.Snapshot snapshot(
|
||||
String name,
|
||||
List<TerrainBoundarySignature> signatures
|
||||
) throws IOException {
|
||||
TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(temporaryFolder.newFolder(name).toPath());
|
||||
store.publish(1L, signatures);
|
||||
return store.load(1L);
|
||||
}
|
||||
|
||||
private static List<TerrainBoundarySignature> fragmentedSignatures() {
|
||||
List<TerrainBoundarySignature> signatures = new ArrayList<>();
|
||||
for (int x = -16; x <= 16; x++) {
|
||||
for (int z = -16; z <= 16; z++) {
|
||||
for (int local = 0; local < 4; local++) {
|
||||
signatures.add(heightSignature(x * 1_024 + local * 4, z * 1_024 + local * 4, 60 + local));
|
||||
}
|
||||
}
|
||||
}
|
||||
return signatures;
|
||||
}
|
||||
|
||||
private static void assertMatchesExhaustive(
|
||||
List<TerrainBoundarySignature> signatures,
|
||||
TransitionBoundarySampler sampler,
|
||||
int blockX,
|
||||
int blockZ,
|
||||
int searchWidth
|
||||
) {
|
||||
List<TerrainBoundarySignature> nearest = signatures.stream()
|
||||
.filter(signature -> distanceSquared(signature, blockX, blockZ) <= (double) searchWidth * searchWidth)
|
||||
.sorted(Comparator.comparingDouble((TerrainBoundarySignature signature) -> distanceSquared(signature, blockX, blockZ))
|
||||
.thenComparingInt(TerrainBoundarySignature::blockX)
|
||||
.thenComparingInt(TerrainBoundarySignature::blockZ))
|
||||
.limit(4)
|
||||
.toList();
|
||||
TransitionGenerationPlan.TerrainSample sampled = sampler.sample(blockX, blockZ);
|
||||
assertEquals(!nearest.isEmpty(), sampled.hasHistoricalSignature());
|
||||
if (nearest.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
assertEquals(nearest.getFirst().blockX(), sampled.nearestSignature().blockX());
|
||||
assertEquals(nearest.getFirst().blockZ(), sampled.nearestSignature().blockZ());
|
||||
assertEquals(Math.sqrt(distanceSquared(nearest.getFirst(), blockX, blockZ)), sampled.distanceToHistoricalTerrain(), 0D);
|
||||
double surface = nearest.getFirst().surfaceHeight();
|
||||
double floor = nearest.getFirst().oceanFloorHeight();
|
||||
if (distanceSquared(nearest.getFirst(), blockX, blockZ) > 1D) {
|
||||
surface = 0D;
|
||||
floor = 0D;
|
||||
double totalWeight = 0D;
|
||||
for (TerrainBoundarySignature signature : nearest) {
|
||||
double weight = 1D / distanceSquared(signature, blockX, blockZ);
|
||||
surface += signature.surfaceHeight() * weight;
|
||||
floor += signature.oceanFloorHeight() * weight;
|
||||
totalWeight += weight;
|
||||
}
|
||||
surface /= totalWeight;
|
||||
floor /= totalWeight;
|
||||
}
|
||||
assertEquals(surface, sampled.historicalSurfaceHeight(), 0D);
|
||||
assertEquals(floor, sampled.historicalOceanFloorHeight(), 0D);
|
||||
}
|
||||
|
||||
private static double distanceSquared(TerrainBoundarySignature signature, int blockX, int blockZ) {
|
||||
long x = (long) signature.blockX() - blockX;
|
||||
long z = (long) signature.blockZ() - blockZ;
|
||||
return (double) x * x + (double) z * z;
|
||||
}
|
||||
|
||||
private static TransitionGenerationPlan.Specification specification(
|
||||
long activationId,
|
||||
String oldEpochId,
|
||||
String newEpochId,
|
||||
int widthBlocks,
|
||||
String boundaryIdentity,
|
||||
String terrainSignatureIdentity
|
||||
) {
|
||||
return new TransitionGenerationPlan.Specification(
|
||||
activationId,
|
||||
oldEpochId,
|
||||
newEpochId,
|
||||
GenerationTransition.CURRENT_ALGORITHM_VERSION,
|
||||
widthBlocks,
|
||||
boundaryIdentity,
|
||||
terrainSignatureIdentity
|
||||
);
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature signature(int blockX, int blockZ) {
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()),
|
||||
new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(0, 1, 1),
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("iris:test"), new short[]{0})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature biomeSignature(int blockX, int blockZ) {
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(blockX, blockZ, 64, 63, OptionalInt.empty(), OptionalInt.empty()),
|
||||
new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(0, 10, 3),
|
||||
new TerrainBoundarySignature.BiomeEncoding(
|
||||
List.of("iris:low", "iris:middle", "iris:high"),
|
||||
new short[]{0, 1, 2})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature heightSignature(int blockX, int blockZ, int height) {
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(blockX, blockZ, height, height - 1, OptionalInt.empty(), OptionalInt.empty()),
|
||||
new TerrainBoundarySignature.Samples(
|
||||
new TerrainBoundarySignature.VerticalLayout(0, 1, 1),
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("iris:test"), new short[]{0})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import art.arcane.iris.util.nbt.common.mca.MCAFile;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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 final class WorldChunkInventoryTest {
|
||||
private static final int SECTOR_BYTES = 4_096;
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void scanReadsAllocationTablesAcrossNegativeRegionBoundaries() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("world").toPath();
|
||||
Path regionDirectory = Files.createDirectories(world.resolve("region"));
|
||||
writeRegion(regionDirectory.resolve("r.-1.-1.mca"), new int[][]{{0, 0}, {31, 31}});
|
||||
writeRegion(regionDirectory.resolve("r.0.0.mca"), new int[][]{{0, 0}, {31, 31}});
|
||||
writeRegion(regionDirectory.resolve("r.1.-2.mca"), new int[][]{{0, 31}});
|
||||
|
||||
WorldChunkInventory inventory = WorldChunkInventory.scan(world);
|
||||
|
||||
assertEquals(5, inventory.size());
|
||||
assertTrue(inventory.contains(-32, -32));
|
||||
assertTrue(inventory.contains(-1, -1));
|
||||
assertTrue(inventory.contains(0, 0));
|
||||
assertTrue(inventory.contains(31, 31));
|
||||
assertTrue(inventory.contains(32, -33));
|
||||
assertFalse(inventory.contains(32, -32));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scanNeverReadsOrDecompressesAllocatedChunkPayloads() throws Exception {
|
||||
Path regionDirectory = temporaryFolder.newFolder("region-payload").toPath();
|
||||
Path region = regionDirectory.resolve("r.0.0.mca");
|
||||
writeRegion(region, new int[][]{{7, 11}});
|
||||
try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) {
|
||||
file.seek(SECTOR_BYTES * 2L);
|
||||
file.writeInt(Integer.MAX_VALUE);
|
||||
file.writeByte(127);
|
||||
}
|
||||
|
||||
WorldChunkInventory inventory = WorldChunkInventory.scanRegionDirectory(regionDirectory);
|
||||
|
||||
assertEquals(1, inventory.size());
|
||||
assertTrue(inventory.contains(7, 11));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inventoryRemainsUsableAfterTheRegionFilesDisappear() throws Exception {
|
||||
Path regionDirectory = temporaryFolder.newFolder("region-memory").toPath();
|
||||
Path region = regionDirectory.resolve("r.0.0.mca");
|
||||
writeRegion(region, new int[][]{{1, 2}});
|
||||
WorldChunkInventory inventory = WorldChunkInventory.scanRegionDirectory(regionDirectory);
|
||||
Files.delete(region);
|
||||
Files.delete(regionDirectory);
|
||||
|
||||
assertTrue(inventory.contains(1, 2));
|
||||
assertEquals(1, inventory.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void truncatedRegionFileFailsClosed() throws Exception {
|
||||
Path regionDirectory = temporaryFolder.newFolder("region-truncated").toPath();
|
||||
Path region = regionDirectory.resolve("r.0.0.mca");
|
||||
try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) {
|
||||
file.setLength(SECTOR_BYTES);
|
||||
}
|
||||
|
||||
IOException error = assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldChunkInventory.scanRegionDirectory(regionDirectory)
|
||||
);
|
||||
|
||||
assertTrue(error.getMessage().contains("Truncated world region file"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allocationOutsideTheFileFailsClosed() throws Exception {
|
||||
Path regionDirectory = temporaryFolder.newFolder("region-outside").toPath();
|
||||
Path region = regionDirectory.resolve("r.0.0.mca");
|
||||
try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) {
|
||||
file.setLength(SECTOR_BYTES * 2L);
|
||||
writeLocation(file, 0, 0, 2, 1);
|
||||
}
|
||||
|
||||
IOException error = assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldChunkInventory.scanRegionDirectory(regionDirectory)
|
||||
);
|
||||
|
||||
assertTrue(error.getMessage().contains("Invalid chunk allocation"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlappingAllocationsFailClosed() throws Exception {
|
||||
Path regionDirectory = temporaryFolder.newFolder("region-overlap").toPath();
|
||||
Path region = regionDirectory.resolve("r.0.0.mca");
|
||||
try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) {
|
||||
file.setLength(SECTOR_BYTES * 3L);
|
||||
writeLocation(file, 0, 0, 2, 1);
|
||||
writeLocation(file, 1, 0, 2, 1);
|
||||
}
|
||||
|
||||
IOException error = assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldChunkInventory.scanRegionDirectory(regionDirectory)
|
||||
);
|
||||
|
||||
assertTrue(error.getMessage().contains("Overlapping chunk allocations"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedRegionFilenameFailsClosed() throws Exception {
|
||||
Path regionDirectory = temporaryFolder.newFolder("region-name").toPath();
|
||||
Path region = regionDirectory.resolve("not-a-region.mca");
|
||||
try (RandomAccessFile file = new RandomAccessFile(region.toFile(), "rw")) {
|
||||
file.setLength(SECTOR_BYTES * 2L);
|
||||
}
|
||||
|
||||
IOException error = assertThrows(
|
||||
IOException.class,
|
||||
() -> WorldChunkInventory.scanRegionDirectory(regionDirectory)
|
||||
);
|
||||
|
||||
assertTrue(error.getMessage().contains("Invalid world region filename"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void packedInventoryIsDeduplicatedAndDefensivelyCopied() throws Exception {
|
||||
long first = ChunkGenerationOwnership.packChunk(-1, 2);
|
||||
long second = ChunkGenerationOwnership.packChunk(3, -4);
|
||||
long[] packed = new long[]{first, second, first};
|
||||
WorldChunkInventory inventory = WorldChunkInventory.ofPackedChunks(packed);
|
||||
packed[0] = 0L;
|
||||
List<Long> visited = new ArrayList<>();
|
||||
|
||||
inventory.forEach((chunkX, chunkZ) -> visited.add(ChunkGenerationOwnership.packChunk(chunkX, chunkZ)));
|
||||
|
||||
assertEquals(2, inventory.size());
|
||||
assertTrue(inventory.contains(-1, 2));
|
||||
assertTrue(inventory.contains(3, -4));
|
||||
assertEquals(List.of(first, second), visited);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void largeInventoryRetainsOnlyRegionMasksAndStreamsOwnership() throws Exception {
|
||||
Path world = temporaryFolder.newFolder("many-regions").toPath();
|
||||
Path regions = Files.createDirectory(world.resolve("region"));
|
||||
int[][] chunks = new int[1024][2];
|
||||
for (int index = 0; index < chunks.length; index++) {
|
||||
chunks[index][0] = index & 31;
|
||||
chunks[index][1] = index >> 5;
|
||||
}
|
||||
for (int regionX = -48; regionX < 48; regionX++) {
|
||||
writeRegion(regions.resolve("r." + regionX + ".-2.mca"), chunks);
|
||||
}
|
||||
|
||||
WorldChunkInventory inventory = WorldChunkInventory.scan(world);
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(world.resolve("ownership"));
|
||||
|
||||
assertEquals(96 * 1024, inventory.size());
|
||||
assertEquals(96 * 128, inventory.retainedAllocationBytes());
|
||||
assertEquals(inventory.size(), ownership.assignUnassigned(inventory, 1L));
|
||||
assertEquals(0, ownership.assignUnassigned(inventory, 2L));
|
||||
inventory.forEach((chunkX, chunkZ) -> assertEquals(1L, ownership.resolve(chunkX, chunkZ, 2L)));
|
||||
assertEquals(inventory.size(), ownership.explicitChunkCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingRegionDirectoryProducesAnEmptyInventory() throws Exception {
|
||||
Path missing = temporaryFolder.getRoot().toPath().resolve("missing");
|
||||
|
||||
WorldChunkInventory inventory = WorldChunkInventory.scanRegionDirectory(missing);
|
||||
|
||||
assertTrue(inventory.isEmpty());
|
||||
}
|
||||
|
||||
private static void writeRegion(Path file, int[][] chunks) throws IOException {
|
||||
try (RandomAccessFile output = new RandomAccessFile(file.toFile(), "rw")) {
|
||||
output.setLength((long) (2 + chunks.length) * SECTOR_BYTES);
|
||||
for (int index = 0; index < chunks.length; index++) {
|
||||
writeLocation(output, chunks[index][0], chunks[index][1], 2 + index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeLocation(
|
||||
RandomAccessFile file,
|
||||
int localChunkX,
|
||||
int localChunkZ,
|
||||
int sectorOffset,
|
||||
int sectorCount
|
||||
) throws IOException {
|
||||
int chunkIndex = MCAFile.getChunkIndex(localChunkX, localChunkZ);
|
||||
file.seek((long) chunkIndex * Integer.BYTES);
|
||||
file.writeInt(sectorOffset << Byte.SIZE | sectorCount);
|
||||
}
|
||||
}
|
||||
@@ -668,15 +668,56 @@ public class HydrologyModelTest {
|
||||
HydrologyColumnSample column = new HydrologyColumnSample(
|
||||
1000, 0, 80, 63, false, "parent", List.of(layer)
|
||||
);
|
||||
HydrologyFeatureRef middle = new HydrologyFeatureRef(
|
||||
203L,
|
||||
HydrologyFeatureType.DEEP_CHANNEL,
|
||||
course.id(),
|
||||
segment.id(),
|
||||
500,
|
||||
20,
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
false
|
||||
);
|
||||
HydrologyColumnLayer middleLayer = new HydrologyColumnLayer(
|
||||
middle,
|
||||
18,
|
||||
20,
|
||||
24,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"acid",
|
||||
"surface",
|
||||
"mouth",
|
||||
"shore",
|
||||
"dry",
|
||||
"flooded"
|
||||
);
|
||||
HydrologyColumnSample middleColumn = new HydrologyColumnSample(
|
||||
500, 0, 80, 63, false, "parent", List.of(middleLayer)
|
||||
);
|
||||
HashMap<CavePosition, HydrologyCaveAction> actions = new HashMap<>();
|
||||
HashMap<CavePosition, CaveVoxelPrecondition> preconditions = new HashMap<>();
|
||||
for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) {
|
||||
CavePosition position = new CavePosition(column.x(), y, column.z());
|
||||
actions.put(
|
||||
position,
|
||||
y > layer.fluidHeadY() ? HydrologyCaveAction.DRY_AIR : HydrologyCaveAction.WET_SOURCE
|
||||
);
|
||||
preconditions.put(position, new CaveVoxelPrecondition(CaveVoxel.SOLID, false));
|
||||
for (HydrologyColumnSample plannedColumn : List.of(column, middleColumn)) {
|
||||
HydrologyColumnLayer plannedLayer = plannedColumn.layers().getFirst();
|
||||
for (int y = plannedLayer.bedY() + 1; y <= plannedLayer.ceilingY(); y++) {
|
||||
CavePosition position = new CavePosition(plannedColumn.x(), y, plannedColumn.z());
|
||||
actions.put(
|
||||
position,
|
||||
y > plannedLayer.fluidHeadY()
|
||||
? HydrologyCaveAction.DRY_AIR
|
||||
: HydrologyCaveAction.WET_SOURCE
|
||||
);
|
||||
preconditions.put(position, new CaveVoxelPrecondition(CaveVoxel.SOLID, false));
|
||||
}
|
||||
}
|
||||
HydrologyCavePlan plan = new HydrologyCavePlan(
|
||||
new HydrologyCaveSource(
|
||||
@@ -702,13 +743,24 @@ public class HydrologyModelTest {
|
||||
List.of(course),
|
||||
List.of(plan),
|
||||
List.of(),
|
||||
new RiverFootprint(Map.of(RiverFootprint.pack(1000, 0), column))
|
||||
new RiverFootprint(Map.of(
|
||||
RiverFootprint.pack(1000, 0), column,
|
||||
RiverFootprint.pack(500, 0), middleColumn
|
||||
))
|
||||
);
|
||||
|
||||
assertEquals(endpoint, tile.nearestFeature(
|
||||
Set.of(HydrologyFeatureType.DEEP_CHANNEL), "acid", 1000, 0, 10).orElseThrow());
|
||||
assertTrue(tile.nearestFeature(
|
||||
Set.of(HydrologyFeatureType.DEEP_CHANNEL), "deep_lava", 1000, 0, 10).isEmpty());
|
||||
assertEquals(middle, tile.nearestFeature(
|
||||
Set.of(HydrologyFeatureType.DEEP_CHANNEL),
|
||||
"acid",
|
||||
1000,
|
||||
0,
|
||||
1000,
|
||||
feature -> feature.x() != 1000
|
||||
).orElseThrow());
|
||||
}
|
||||
|
||||
private HydrologyFeatureRef feature(HydrologyFeatureType type, long id, int y) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package art.arcane.iris.engine.mantle;
|
||||
|
||||
import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
@@ -43,6 +45,8 @@ public class MantleWriterOverlayTest {
|
||||
private MatterSlice<MatterCavern> cavernSlice;
|
||||
private MatterSlice<HydrologyCaveCell> hydrologySlice;
|
||||
private IrisDimension dimension;
|
||||
private EngineMantle engineMantle;
|
||||
private Mantle<Matter> mantle;
|
||||
|
||||
@Before
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -55,10 +59,10 @@ public class MantleWriterOverlayTest {
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
engineMantle = mock(EngineMantle.class);
|
||||
Engine engine = mock(Engine.class);
|
||||
dimension = mock(IrisDimension.class);
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
mantle = mock(Mantle.class);
|
||||
chunk = mock(MantleChunk.class);
|
||||
matter = mock(Matter.class);
|
||||
blockSlice = mock(MatterSlice.class);
|
||||
@@ -184,6 +188,29 @@ public class MantleWriterOverlayTest {
|
||||
verify(cavernSlice, never()).set(X, Y, Z, cavern);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void historicalChunkRejectsAllMutationKinds() {
|
||||
writer.close();
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
TransitionGenerationPlan transition = mock(TransitionGenerationPlan.class);
|
||||
when(engineMantle.getComplex()).thenReturn(complex);
|
||||
when(complex.getTransitionGenerationPlan()).thenReturn(transition);
|
||||
when(transition.isHistoricalBlock(X, Z)).thenReturn(true);
|
||||
writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false);
|
||||
PlatformBlockState replacement = mock(PlatformBlockState.class);
|
||||
MatterCavern cavern = new MatterCavern(true, "", (byte) 3);
|
||||
|
||||
writer.setData(X, Y, Z, replacement);
|
||||
assertFalse(writer.setDataIfAbsent(X, Y, Z, cavern));
|
||||
assertFalse(writer.carveDataIfAbsent(X, Y, Z, cavern));
|
||||
writer.setForcedCarve(X, Y, Z, cavern);
|
||||
writer.clearBlock(X, Y, Z);
|
||||
writer.clearData(X, Y, Z, PlatformBlockState.class);
|
||||
|
||||
verify(blockSlice, never()).set(anyInt(), anyInt(), anyInt(), any());
|
||||
verify(cavernSlice, never()).set(anyInt(), anyInt(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hydrologyOverridesBaselineCarvedQueriesWithoutChangingIt() {
|
||||
MatterCavern baseline = new MatterCavern(true, "", (byte) 0);
|
||||
|
||||
+32
@@ -3,12 +3,18 @@ package art.arcane.iris.engine.mantle;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EnginePlatformHooks;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import art.arcane.volmlib.util.mantle.flag.ReservedFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
@@ -19,11 +25,34 @@ import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class MatterGeneratorCarvePassRadiusTest {
|
||||
private static IrisPlatform previousPlatform;
|
||||
|
||||
@BeforeClass
|
||||
public static void bindPlatform() {
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
PlatformBlockState air = mock(PlatformBlockState.class);
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(registries.block(anyString())).thenReturn(air);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void restorePlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
if (previousPlatform != null) {
|
||||
IrisPlatforms.bind(previousPlatform);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void platformFilterRemovesSkippedComponentFromGenerationReach() {
|
||||
@@ -345,6 +374,9 @@ public class MatterGeneratorCarvePassRadiusTest {
|
||||
private final List<MantlePass> components;
|
||||
|
||||
private TestMatterGenerator(Engine engine, Mantle<Matter> mantle, List<MantlePass> components) {
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
when(engine.getMantle()).thenReturn(engineMantle);
|
||||
when(engineMantle.getEngine()).thenReturn(engine);
|
||||
this.engine = engine;
|
||||
this.mantle = mantle;
|
||||
this.components = components;
|
||||
|
||||
+20
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.engine.mantle.components;
|
||||
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IObjectPlacer;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
@@ -126,6 +127,25 @@ public class CaveObjectPlacementTransactionTest {
|
||||
verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void transitionIntersectionRejectsTheWholePlacement() {
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getHeight()).thenReturn(128);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(complex.allowsNewDiscreteContentAt(4, 7)).thenReturn(false);
|
||||
IObjectPlacer delegate = mock(IObjectPlacer.class);
|
||||
when(delegate.getEngine()).thenReturn(engine);
|
||||
CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10);
|
||||
|
||||
transaction.set(4, 30, 7, mock(PlatformBlockState.class));
|
||||
transaction.setData(4, 30, 7, "object@1");
|
||||
|
||||
assertEquals(CaveObjectPlacementTransaction.CommitResult.REJECTED_TRANSITION, transaction.commit());
|
||||
verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any());
|
||||
verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any());
|
||||
}
|
||||
|
||||
private IObjectPlacer createPlacer(int worldHeight, int surfaceHeight, int caveFloor, int caveCeiling) {
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getHeight()).thenReturn(worldHeight);
|
||||
|
||||
+19
@@ -3,6 +3,7 @@ package art.arcane.iris.engine.mantle.components;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.UpperDimensionContext;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.mantle.MantleComponent;
|
||||
@@ -47,6 +48,7 @@ import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -54,6 +56,7 @@ import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class MantleObjectComponentBoundaryRadiusTest {
|
||||
@Rule
|
||||
@@ -102,6 +105,22 @@ public class MantleObjectComponentBoundaryRadiusTest {
|
||||
assertEquals(43, MantleObjectComponent.calculatePlacementReach(new IrisBlockVector(5, 4, 3), placement));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void knownObjectFootprintMustClearTheTransitionBand() {
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
IrisObject object = new IrisObject(5, 4, 3);
|
||||
IrisObjectPlacement placement = new IrisObjectPlacement();
|
||||
when(complex.allowsNewGenerationFootprint(95, 195, 105, 205)).thenReturn(false);
|
||||
|
||||
assertFalse(MantleObjectComponent.allowsObjectPlacement(
|
||||
complex,
|
||||
object,
|
||||
placement,
|
||||
100,
|
||||
200));
|
||||
verify(complex).allowsNewGenerationFootprint(95, 195, 105, 205);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceReplayUsesAscendingSignedCoordinates() {
|
||||
List<String> replayedSources = new ArrayList<>();
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package art.arcane.iris.engine.platform;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class BukkitChunkGeneratorGenerationHistoryContractTest {
|
||||
@Test
|
||||
public void setupPromotesBeforePublicationAndRebuildsFullServicesFromTheNewPack() throws IOException {
|
||||
String source = source();
|
||||
String setup = method(source, "private void setupEngine()");
|
||||
|
||||
assertBefore(setup, "IrisEngine createdEngine = createEngine(engineTarget);",
|
||||
"createdEngine.attachGenerationHistory(");
|
||||
assertBefore(setup, "createdEngine.attachGenerationHistory(",
|
||||
"generationHistory.activeActivation().activationId() != initialActivationId");
|
||||
assertBefore(setup, "createdEngine.close();", "createEngine(loadActiveGenerationHistoryTarget())");
|
||||
assertTrue(count(setup, "createdEngine.attachGenerationHistory(") == 2);
|
||||
assertBefore(setup, "createEngine(loadActiveGenerationHistoryTarget())", "engine = createdEngine;");
|
||||
assertBefore(setup, "IrisBoundarySignatureSampler.INSTANCE", "engine = createdEngine;");
|
||||
assertTrue(setup.contains("getGenerationTransitionWidthBlocks()"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void historyEngineStartsWithTheActiveMantleKernelAndTransition() throws IOException {
|
||||
String createEngine = method(source(), "private IrisEngine createEngine(EngineTarget engineTarget)");
|
||||
|
||||
assertTrue(createEngine.contains("generationHistory.paths().activationMantleRoot(active.activationId())"));
|
||||
assertTrue(createEngine.contains("epoch.kernelVersion()"));
|
||||
assertTrue(createEngine.contains("generationHistory.transitionPlan(active.activationId())"));
|
||||
assertTrue(createEngine.contains("if (generationHistory == null)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publicBukkitTerrainClaimsSemanticsOnlyAfterSuccessfulApplication() throws IOException {
|
||||
String generateNoise = method(
|
||||
source(),
|
||||
"public void generateNoise(@NotNull WorldInfo world, @NotNull Random random, int x, int z, @NotNull ChunkGenerator.ChunkData d)"
|
||||
);
|
||||
|
||||
assertBefore(generateNoise, "engine.generate(x << 4, z << 4", "blocks.apply()");
|
||||
assertBefore(generateNoise, "blocks.apply()", "historyScope.claimGeneratedSemantics()");
|
||||
}
|
||||
|
||||
private static String source() throws IOException {
|
||||
return Files.readString(Path.of(System.getProperty("iris.bukkitChunkGeneratorSource")))
|
||||
.replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue("Missing source contract token: " + signature, start >= 0);
|
||||
int openingBrace = source.indexOf('{', start);
|
||||
assertTrue(openingBrace >= 0);
|
||||
int depth = 0;
|
||||
for (int index = openingBrace; index < source.length(); index++) {
|
||||
char character = source.charAt(index);
|
||||
if (character == '{') {
|
||||
depth++;
|
||||
} else if (character == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Method body did not close: " + signature);
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
|
||||
}
|
||||
|
||||
private static int count(String source, String token) {
|
||||
int count = 0;
|
||||
int index = 0;
|
||||
while ((index = source.indexOf(token, index)) >= 0) {
|
||||
count++;
|
||||
index += token.length();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user