mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 13:30:08 +00:00
Expanded terrain generation tooling
- Standalone Minecraft 26.3 generation uses native registries, biome planning and structure planning with bounded concurrency - Offline export publishes fresh TERRAIN checkpoints for native server completion - Checkpoints retain mantle data, immutable generation history and captured server configuration - Output publication uses staged directories, durability checks and atomic moves - Offline distribution tasks provide an installed launcher and reusable native fixtures - Dimension settings control terrain, biome-bound and cave-density sampling with unchanged defaults - Pack validation and schemas enforce the supported sampling intervals - Biome layer caches isolate data, engine and seed identities - Floating biome palettes keep stable thickness patterns across generation orders - Hydrology batches terrain samples within the owning generation runtime - Hydrology planners share bounded basis caches and reuse raster geometry and distance samples - Demand planning admits new tiles as active plans finish - Volumetric height queries avoid unnecessary density evaluation and bound cached anchors - Cave generation reuses chunk-scoped inputs and semantic capture - Object replay indexes mutations by destination and reuses resolved records - Source-plan caches coordinate concurrent builds and reduce overlay allocation - Prerequisite carving reads preserve the underlying hydrology before object placement - Tree material decoding preserves stored native block identities - Engine shutdown drains generation outside the lifecycle lock and coordinates runtime retirement - Pregeneration sizes temporary worker boosts from processor and heap capacity - Pregeneration restores native worker counts after drain and failure paths - Native structure queries retain per-origin coordination without broad window locks - Saved biome writes drain pending batches under their write stripe - Boundary column lookups avoid repeated voxel decoding - Modded registry consumers use explicit VolmLib providers and version-specific native source overlays - Regression tests cover generation parity, cache isolation, concurrency, shutdown, persistence and offline checkpoints
This commit is contained in:
1 parent
98ddb6644d
commit
ca9f28c9b0
142 files changed
+11159
-773
No files matched your search
@@ -11,7 +11,6 @@ import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
@@ -28,7 +27,6 @@ public class IrisBiomeCeilingLayerTest {
|
||||
new IrisDimension(), 13, -27, new RNG(1), 8, 64, null, null);
|
||||
|
||||
assertEquals(List.of(first, first, second, third, third), result);
|
||||
assertNull(biome.getLayerHeightGenerators().getIfPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -45,8 +43,8 @@ public class IrisBiomeCeilingLayerTest {
|
||||
assertEquals(firstCeiling.size(), secondCeiling.size());
|
||||
}
|
||||
first.getLayerHeightGenerators(rng, null);
|
||||
assertNotSame(first.getLayerHeightGenerators().getIfPresent().get(0),
|
||||
first.getLayerCeilingHeightGenerators().getIfPresent().get(0));
|
||||
assertNotSame(first.getLayerHeightGenerators(rng, null).get(0),
|
||||
first.getLayerCeilingHeightGenerators().get(first.getCaveCeilingLayers(), 7235, rng, null).get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package art.arcane.iris.generation.biome;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.noise.CNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisBiomeLayerHeightCacheTest {
|
||||
private static final long TERRAIN_SEED = 7191L;
|
||||
private static final long CARVE_SEED = -872315L;
|
||||
private static final long FLOATING_SEED = (TERRAIN_SEED ^ 0x7EB0A73F1DCE514DL)
|
||||
+ (0x7A4E ^ "magnetics/glass-shard".hashCode());
|
||||
|
||||
@Test
|
||||
public void layerHeightUsesEachCallerSeedInEitherOrder() {
|
||||
IrisBiomePaletteLayer layer = layer();
|
||||
assertNoise(layer().getHeightGenerator(new RNG(CARVE_SEED), null),
|
||||
layer.getHeightGenerator(new RNG(CARVE_SEED), null));
|
||||
assertNoise(layer().getHeightGenerator(new RNG(FLOATING_SEED), null),
|
||||
layer.getHeightGenerator(new RNG(FLOATING_SEED), null));
|
||||
IrisBiomePaletteLayer reversed = layer();
|
||||
assertNoise(layer.getHeightGenerator(new RNG(FLOATING_SEED), null),
|
||||
reversed.getHeightGenerator(new RNG(FLOATING_SEED), null));
|
||||
assertNoise(layer.getHeightGenerator(new RNG(CARVE_SEED), null),
|
||||
reversed.getHeightGenerator(new RNG(CARVE_SEED), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedSurfaceCarveAndFloatingBiomeIgnoresWarmOrder() {
|
||||
IrisBiome biome = biome();
|
||||
long[] seeds = {FLOATING_SEED, CARVE_SEED, TERRAIN_SEED, FLOATING_SEED + 733L};
|
||||
for (long seed : seeds) {
|
||||
assertGenerators(biome().getLayerHeightGenerators(new RNG(seed), null),
|
||||
biome.getLayerHeightGenerators(new RNG(seed), null));
|
||||
}
|
||||
IrisBiome reversed = biome();
|
||||
for (int i = seeds.length - 1; i >= 0; i--) {
|
||||
assertGenerators(biome.getLayerHeightGenerators(new RNG(seeds[i]), null),
|
||||
reversed.getLayerHeightGenerators(new RNG(seeds[i]), null));
|
||||
}
|
||||
assertNotEquals(biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null).get(0).noise(13, -29),
|
||||
biome.getLayerHeightGenerators(new RNG(FLOATING_SEED), null).get(0).noise(13, -29), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentSurfaceCarveAndFloatingCallersMatchIsolatedBiomes() throws Exception {
|
||||
IrisBiome shared = biome();
|
||||
ExecutorService executor = Executors.newFixedThreadPool(8);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<?>> tasks = new ArrayList<>();
|
||||
long[] seeds = {TERRAIN_SEED, CARVE_SEED, FLOATING_SEED, FLOATING_SEED + 733L};
|
||||
try {
|
||||
for (int worker = 0; worker < 8; worker++) {
|
||||
long seed = seeds[worker % seeds.length];
|
||||
KList<CNG> expected = biome().getLayerHeightGenerators(new RNG(seed), null);
|
||||
tasks.add(executor.submit(() -> {
|
||||
assertTrue(start.await(5, TimeUnit.SECONDS));
|
||||
for (int sample = 0; sample < 30; sample++) {
|
||||
assertGenerators(expected, shared.getLayerHeightGenerators(new RNG(seed), null));
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
for (Future<?> task : tasks) {
|
||||
task.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void runtimeAndDataIdentitiesDoNotReuseHeightGenerators() {
|
||||
IrisBiome biome = biome();
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisData otherData = mock(IrisData.class);
|
||||
Engine first = mock(Engine.class);
|
||||
Engine second = mock(Engine.class);
|
||||
when(data.getEngine()).thenReturn(first);
|
||||
when(otherData.getEngine()).thenReturn(first);
|
||||
RNG rng = new RNG(TERRAIN_SEED);
|
||||
KList<CNG> original = biome.getLayerHeightGenerators(rng, data);
|
||||
assertSame(original, biome.getLayerHeightGenerators(rng, data));
|
||||
KList<CNG> other = biome.getLayerHeightGenerators(rng, otherData);
|
||||
assertNotSame(original, other);
|
||||
assertNotSame(original.get(0), other.get(0));
|
||||
assertGenerators(original, other);
|
||||
when(data.getEngine()).thenReturn(second);
|
||||
KList<CNG> replacement = biome.getLayerHeightGenerators(rng, data);
|
||||
assertNotSame(original, replacement);
|
||||
assertNotSame(original.get(0), replacement.get(0));
|
||||
assertGenerators(original, replacement);
|
||||
when(data.getEngine()).thenReturn(first);
|
||||
assertSame(original, biome.getLayerHeightGenerators(rng, data));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void materialBandsMatchIsolatedGenerationAcrossSurfaceSeaAndCeiling() {
|
||||
NativeBlockState first = mock(NativeBlockState.class);
|
||||
NativeBlockState second = mock(NativeBlockState.class);
|
||||
IrisBiome shared = materialBiome(first, second);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
long[] seeds = {FLOATING_SEED, TERRAIN_SEED, CARVE_SEED, FLOATING_SEED + 733L};
|
||||
for (long seed : seeds) {
|
||||
IrisBiome isolated = materialBiome(first, second);
|
||||
RNG rng = new RNG(seed);
|
||||
for (int x = -65; x <= 65; x += 7) {
|
||||
assertEquals(isolated.generateLayers(dimension, x, -x, rng, 64, 120, null, null),
|
||||
shared.generateLayers(dimension, x, -x, rng, 64, 120, null, null));
|
||||
assertEquals(isolated.generateCeilingLayers(dimension, x, -x, rng, 64, 120, null, null),
|
||||
shared.generateCeilingLayers(dimension, x, -x, rng, 64, 120, null, null));
|
||||
assertEquals(isolated.generateSeaLayers(x, -x, rng, 64, null),
|
||||
shared.generateSeaLayers(x, -x, rng, 64, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void evictionRecomputesTheSameNoise() {
|
||||
IrisBiome biome = biome();
|
||||
KList<CNG> original = biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null);
|
||||
for (int seed = 0; seed < 96; seed++) {
|
||||
biome.getLayerHeightGenerators(new RNG(seed), null);
|
||||
}
|
||||
KList<CNG> recomputed = biome.getLayerHeightGenerators(new RNG(TERRAIN_SEED), null);
|
||||
assertNotSame(original, recomputed);
|
||||
assertNotSame(original.get(0), recomputed.get(0));
|
||||
assertGenerators(original, recomputed);
|
||||
}
|
||||
|
||||
private static IrisBiomePaletteLayer layer() {
|
||||
return new IrisBiomePaletteLayer().zero().setMinHeight(1).setMaxHeight(12);
|
||||
}
|
||||
|
||||
private static IrisBiome biome() {
|
||||
return new IrisBiome().setLayers(new KList<>(layer(), layer().setMinHeight(3).setMaxHeight(5)));
|
||||
}
|
||||
|
||||
private static IrisBiome materialBiome(NativeBlockState first, NativeBlockState second) {
|
||||
IrisBiomePaletteLayer upper = IrisBiomeCeilingLayerTest.layer(first, 1).setMaxHeight(5);
|
||||
IrisBiomePaletteLayer lower = IrisBiomeCeilingLayerTest.layer(second, 3).setMaxHeight(12);
|
||||
KList<IrisBiomePaletteLayer> layers = new KList<>(upper, lower);
|
||||
return new IrisBiome().setLayers(layers).setCaveCeilingLayers(layers).setSeaLayers(layers);
|
||||
}
|
||||
|
||||
private static void assertGenerators(KList<CNG> expected, KList<CNG> actual) {
|
||||
assertEquals(expected.size(), actual.size());
|
||||
for (int index = 0; index < expected.size(); index++) {
|
||||
assertNoise(expected.get(index), actual.get(index));
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNoise(CNG expected, CNG actual) {
|
||||
for (int x = -97; x <= 97; x += 13) {
|
||||
assertEquals(expected.noise(x, x * -3.25), actual.noise(x, x * -3.25), 0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package art.arcane.iris.generation.chunk;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitBiome;
|
||||
import art.arcane.iris.testsupport.BukkitTestServer;
|
||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||
import art.arcane.iris.world.history.NativeTerrainReceipt;
|
||||
import art.arcane.iris.world.history.SavedTerrainChunk;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBiome;
|
||||
import io.papermc.paper.InternalAPIBridge;
|
||||
import io.papermc.paper.registry.RegistryAccess;
|
||||
import net.kyori.adventure.key.Key;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.block.Biome;
|
||||
import org.bukkit.generator.ChunkGenerator.ChunkData;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.RETURNS_DEFAULTS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class LinkedTerrainChunkTest {
|
||||
@BeforeClass
|
||||
public static void initializeBiomes() throws Exception {
|
||||
BukkitTestServer.install();
|
||||
AtomicReference<Object> registered = new AtomicReference<>();
|
||||
RegistryAccess access = mock(RegistryAccess.class, invocation -> {
|
||||
if (invocation.getMethod().getName().equals("getRegistry")) {
|
||||
if (registered.get() == null) {
|
||||
registered.set(Proxy.newProxyInstance(Registry.class.getClassLoader(), new Class<?>[]{Registry.class},
|
||||
(proxy, method, arguments) -> method.getName().equals("getOrThrow")
|
||||
? biome((Key) arguments[0]) : null));
|
||||
}
|
||||
return registered.get();
|
||||
}
|
||||
return RETURNS_DEFAULTS.answer(invocation);
|
||||
});
|
||||
InternalAPIBridge bridge = mock(InternalAPIBridge.class, invocation ->
|
||||
invocation.getMethod().getName().equals("constructLegacyCustomBiome")
|
||||
? biome(Key.key("minecraft:custom")) : RETURNS_DEFAULTS.answer(invocation));
|
||||
try (MockedStatic<RegistryAccess> registries = mockStatic(RegistryAccess.class);
|
||||
MockedStatic<InternalAPIBridge> internals = mockStatic(InternalAPIBridge.class)) {
|
||||
registries.when(RegistryAccess::registryAccess).thenReturn(access);
|
||||
internals.when(InternalAPIBridge::get).thenReturn(bridge);
|
||||
Class.forName(Registry.class.getName());
|
||||
Class.forName(Biome.class.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void biomeReadsReuseCanonicalHandlesAndPreserveReceiptBytes() throws Exception {
|
||||
LinkedTerrainChunk chunk = chunk();
|
||||
NativeBiome plains = BukkitBiome.of(Biome.PLAINS);
|
||||
NativeBiome desert = BukkitBiome.of(Biome.DESERT);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
chunk.fillBiomeColumn(x, z, plains);
|
||||
}
|
||||
}
|
||||
NativeBiome alternate = mock(NativeBiome.class);
|
||||
when(alternate.nativeHandle()).thenReturn(Biome.DESERT);
|
||||
when(alternate.key()).thenReturn("ignored:alias");
|
||||
chunk.setBiome(0, -12, 0, alternate);
|
||||
chunk.setBiome(17, 100, -1, desert);
|
||||
AtomicInteger conversions = new AtomicInteger();
|
||||
try (MockedStatic<BukkitBiome> wrappers = mockStatic(BukkitBiome.class, invocation -> {
|
||||
conversions.incrementAndGet();
|
||||
return CALLS_REAL_METHODS.answer(invocation);
|
||||
})) {
|
||||
assertSame(desert, chunk.getBiome(0, -12, 0));
|
||||
assertSame(desert, chunk.getBiome(1, -1, 15));
|
||||
assertSame(plains, chunk.getBiome(0, -100, 0));
|
||||
for (boolean boundary : new boolean[]{false, true}) {
|
||||
SavedTerrainChunk.VoxelSource actual = source((x, y, z) -> chunk.getBiome(x, y, z).key());
|
||||
SavedTerrainChunk.VoxelSource expected = source((x, y, z) ->
|
||||
x == 0 && y == -12 && z == 0 || x == 1 && y == -1 && z == 15
|
||||
? "minecraft:desert" : "minecraft:plains");
|
||||
SavedTerrainChunk captured = capture(actual, boundary);
|
||||
assertArrayEquals(NativeTerrainReceipt.encode(capture(expected, boundary), 7L, "epoch"),
|
||||
NativeTerrainReceipt.encode(captured, 7L, "epoch"));
|
||||
}
|
||||
assertEquals(0, conversions.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyColumnsResolveTheDefaultBiomeOnlyOnce() {
|
||||
LinkedTerrainChunk chunk = chunk();
|
||||
NativeBiome plains = BukkitBiome.of(Biome.PLAINS);
|
||||
AtomicInteger conversions = new AtomicInteger();
|
||||
try (MockedStatic<BukkitBiome> wrappers = mockStatic(BukkitBiome.class, invocation -> {
|
||||
conversions.incrementAndGet();
|
||||
return CALLS_REAL_METHODS.answer(invocation);
|
||||
})) {
|
||||
for (int y = -32; y < 16; y++) {
|
||||
assertSame(plains, chunk.getBiome(0, y, 0));
|
||||
}
|
||||
assertEquals(1, conversions.get());
|
||||
}
|
||||
}
|
||||
|
||||
private static LinkedTerrainChunk chunk() {
|
||||
ChunkData data = mock(ChunkData.class);
|
||||
when(data.getMinHeight()).thenReturn(-16);
|
||||
when(data.getMaxHeight()).thenReturn(0);
|
||||
return new LinkedTerrainChunk(data);
|
||||
}
|
||||
|
||||
private static SavedTerrainChunk capture(SavedTerrainChunk.VoxelSource source, boolean boundary) throws Exception {
|
||||
return boundary ? SavedTerrainChunk.captureBoundary(-1, 2, -16, 16, "minecraft:noise", source)
|
||||
: SavedTerrainChunk.capture(-1, 2, -16, 16, "minecraft:noise", source);
|
||||
}
|
||||
|
||||
private static SavedTerrainChunk.VoxelSource source(BiomeKey source) {
|
||||
BoundaryColumnGeometry.Voxel stone = new BoundaryColumnGeometry.Voxel(
|
||||
"minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false);
|
||||
return new SavedTerrainChunk.VoxelSource() {
|
||||
@Override
|
||||
public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) {
|
||||
return stone;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String biome(int x, int y, int z) {
|
||||
return source.get(x, y, z);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Biome biome(Key key) {
|
||||
NamespacedKey namespaced = new NamespacedKey(key.namespace(), key.value());
|
||||
return (Biome) Proxy.newProxyInstance(Biome.class.getClassLoader(), new Class<?>[]{Biome.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getKey" -> namespaced;
|
||||
case "hashCode" -> namespaced.hashCode();
|
||||
case "equals" -> proxy == arguments[0];
|
||||
case "toString", "name" -> key.value();
|
||||
default -> null;
|
||||
});
|
||||
}
|
||||
|
||||
private interface BiomeKey {
|
||||
String get(int x, int y, int z);
|
||||
}
|
||||
}
|
||||
+31
@@ -1,11 +1,42 @@
|
||||
package art.arcane.iris.generation.decoration.tree;
|
||||
|
||||
import org.junit.Test;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class TreeBlockMaterialTest {
|
||||
@Test
|
||||
public void nativeMaterialUsesTheMemoizedStringWithoutReadingTheStateKey() {
|
||||
NativeBlockState state = mock(NativeBlockState.class);
|
||||
String material = new String("minecraft:oak_log");
|
||||
when(state.materialKey()).thenReturn(material);
|
||||
|
||||
TreeBlockMaterial first = TreeBlockMaterial.of(state);
|
||||
TreeBlockMaterial second = TreeBlockMaterial.of(state);
|
||||
|
||||
assertSame(material, first.materialKey());
|
||||
assertSame(first.materialKey(), second.materialKey());
|
||||
verify(state, never()).key();
|
||||
assertEquals(TreeBlockMaterial.of("minecraft:oak_log[axis=x]"), first);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeMaterialFallsBackToTheStateKeyWhenNotMemoized() {
|
||||
NativeBlockState state = mock(NativeBlockState.class);
|
||||
when(state.key()).thenReturn("minecraft:oak_log[axis=z]");
|
||||
|
||||
assertEquals(TreeBlockMaterial.of("minecraft:oak_log"), TreeBlockMaterial.of(state));
|
||||
verify(state).key();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void materialComparisonIgnoresBlockStateProperties() {
|
||||
TreeBlockMaterial expected = TreeBlockMaterial.of("minecraft:oak_log[axis=y]");
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyBasisCacheTest {
|
||||
@Test
|
||||
public void batchLookupPreservesNullAndFirstPublishedSample() {
|
||||
HydrologyBasisCache cache = new HydrologyBasisCache(1);
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[1];
|
||||
long key = RiverFootprint.pack(-17, Integer.MAX_VALUE);
|
||||
assertTrue(!cache.findWithoutSlope(key, samples, 0));
|
||||
assertNull(cache.rememberWithoutSlope(key, null));
|
||||
assertTrue(cache.findWithoutSlope(key, samples, 0));
|
||||
assertNull(samples[0]);
|
||||
HydrologyTerrainSample later = HydrologyTerrainSample.openLand(73, 0D, "land");
|
||||
assertNull(cache.rememberWithoutSlope(key, later));
|
||||
cache.rememberWithoutSlope(key + 1, later);
|
||||
assertTrue(!cache.findWithoutSlope(key, samples, 0));
|
||||
assertEquals(later, cache.rememberWithoutSlope(key + 1, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void channelsNullsFailuresAndEvictionPreserveSamples() {
|
||||
CountingSampler sampler = new CountingSampler();
|
||||
HydrologyBasisCache cache = new HydrologyBasisCache(1);
|
||||
assertEquals(3D, cache.sample(1, -2, sampler).slope(), 0D);
|
||||
assertEquals(0D, cache.sampleWithoutSlope(1, -2, sampler).slope(), 0D);
|
||||
assertEquals(3D, cache.sample(1, -2, sampler).slope(), 0D);
|
||||
assertEquals(0D, cache.sampleWithoutSlope(1, -2, sampler).slope(), 0D);
|
||||
assertEquals(1, sampler.fullCalls.get());
|
||||
assertEquals(1, sampler.slopeFreeCalls.get());
|
||||
assertNull(cache.sampleWithoutSlope(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler));
|
||||
assertNull(cache.sampleWithoutSlope(Integer.MIN_VALUE, Integer.MAX_VALUE, sampler));
|
||||
assertEquals(2, sampler.slopeFreeCalls.get());
|
||||
assertThrows(IllegalStateException.class, () -> cache.sampleWithoutSlope(99, 0, sampler));
|
||||
assertEquals(87, cache.sampleWithoutSlope(99, 0, sampler).naturalHeight());
|
||||
assertEquals(87, cache.sampleWithoutSlope(99, 0, sampler).naturalHeight());
|
||||
assertEquals(4, sampler.slopeFreeCalls.get());
|
||||
assertEquals(87, cache.sampleWithoutSlope(1, -2, sampler).naturalHeight());
|
||||
assertEquals(5, sampler.slopeFreeCalls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void plannerTrialsPreserveLocalSlopePrecedenceAndOwnerIsolation() {
|
||||
CountingSampler natural = new CountingSampler();
|
||||
HydrologyTerrainSampler detailed = (x, z) -> {
|
||||
throw new AssertionError("Basis caching must not sample caves");
|
||||
};
|
||||
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
|
||||
HydrologyPlanner planner = new HydrologyPlanner(23L, settings, detailed, natural,
|
||||
HydrologyGeometrySampler.deterministic(detailed), -4096,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(detailed, settings.seaLevel(), -4096, 4096));
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
try {
|
||||
assertEquals(3D, planner.sampleBasis(1, 1).slope(), 0D);
|
||||
assertEquals(0D, planner.sampleBasisWithoutSlope(2, 1).slope(), 0D);
|
||||
HydrologyBasisCache shared = planner.planningSamples.get()
|
||||
.fallbackBasis(new Long2ObjectOpenHashMap<>());
|
||||
for (int trial = 0; trial < 4; trial++) {
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(null, shared));
|
||||
assertEquals(0D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D);
|
||||
assertEquals(3D, planner.sampleBasis(1, 1).slope(), 0D);
|
||||
assertEquals(3D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D);
|
||||
assertEquals(0D, planner.sampleBasisWithoutSlope(2, 1).slope(), 0D);
|
||||
}
|
||||
assertEquals(1, natural.fullCalls.get());
|
||||
assertEquals(2, natural.slopeFreeCalls.get());
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
assertEquals(0D, planner.sampleBasisWithoutSlope(1, 1).slope(), 0D);
|
||||
assertEquals(3, natural.slopeFreeCalls.get());
|
||||
} finally {
|
||||
planner.planningSamples.remove();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parallelTrialsComputeEachCoordinateOnce() throws Exception {
|
||||
CountingSampler sampler = new CountingSampler();
|
||||
HydrologyBasisCache cache = new HydrologyBasisCache();
|
||||
CountDownLatch ready = new CountDownLatch(4);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
ExecutorService workers = Executors.newFixedThreadPool(4);
|
||||
try {
|
||||
List<Future<List<HydrologyTerrainSample>>> results = new ArrayList<>();
|
||||
for (int trial = 0; trial < 4; trial++) {
|
||||
results.add(workers.submit(() -> {
|
||||
ready.countDown();
|
||||
assertTrue(start.await(5, TimeUnit.SECONDS));
|
||||
List<HydrologyTerrainSample> values = new ArrayList<>();
|
||||
for (int x = -128; x < 0; x++) {
|
||||
values.add(cache.sampleWithoutSlope(x, -17, sampler));
|
||||
}
|
||||
return values;
|
||||
}));
|
||||
}
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS));
|
||||
start.countDown();
|
||||
List<HydrologyTerrainSample> expected = results.getFirst().get(5, TimeUnit.SECONDS);
|
||||
for (Future<List<HydrologyTerrainSample>> result : results) {
|
||||
assertEquals(expected, result.get(5, TimeUnit.SECONDS));
|
||||
}
|
||||
assertEquals(128, sampler.slopeFreeCalls.get());
|
||||
} finally {
|
||||
start.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CountingSampler implements HydrologyNaturalTerrainSampler {
|
||||
private final AtomicInteger fullCalls = new AtomicInteger();
|
||||
private final AtomicInteger slopeFreeCalls = new AtomicInteger();
|
||||
private final AtomicInteger failures = new AtomicInteger();
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasis(int x, int z) {
|
||||
fullCalls.incrementAndGet();
|
||||
return HydrologyTerrainSample.openLand(87, 3D, "land");
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasisWithoutSlope(int x, int z) {
|
||||
slopeFreeCalls.incrementAndGet();
|
||||
if (x == Integer.MIN_VALUE) {
|
||||
return null;
|
||||
}
|
||||
if (x == 99 && failures.getAndIncrement() == 0) {
|
||||
throw new IllegalStateException("retry");
|
||||
}
|
||||
return HydrologyTerrainSample.openLand(87, 0D, "land");
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
throw new AssertionError("Basis caching must not sample grids");
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int x, int z) {
|
||||
throw new AssertionError("Basis caching must not classify separately");
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -34,6 +34,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void terrainLoadsLeaveDiagnosticsLazyAndQueriesRetainImmutableResults() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
HydrologyDiagnosticCandidate candidate = mock(HydrologyDiagnosticCandidate.class);
|
||||
@@ -59,6 +60,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void forbiddenThreadsCanReadOnlyCompleteCachedDiagnostics() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
AtomicBoolean forbidden = new AtomicBoolean(true);
|
||||
@@ -77,6 +79,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void interruptedWaiterLeavesTheSharedDiagnosticLoadRunning() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
List<HydrologyDiagnosticCandidate> expected = List.of(mock(HydrologyDiagnosticCandidate.class));
|
||||
@@ -127,6 +130,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void clearDoesNotPublishAnOlderDiagnosticResultIntoTheNewEpoch() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
List<HydrologyDiagnosticCandidate> old = List.of(mock(HydrologyDiagnosticCandidate.class));
|
||||
@@ -166,6 +170,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void closeCancelsQueuedDiagnosticsAndDrainsActiveDiagnostics() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey firstKey = new HydrologyTileKey(0, 0);
|
||||
HydrologyTileKey secondKey = new HydrologyTileKey(1, 0);
|
||||
@@ -213,6 +218,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void aDiagnosticLoadCannotCloseItsOwnCache() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
when(planner.plan(key)).thenReturn(tile);
|
||||
@@ -229,6 +235,7 @@ public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void diagnosticFailureIsRetriedAndCompletedListsRemainBounded() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(planner.diagnosticCandidates(tile)).thenThrow(new IllegalStateException("expected")).thenReturn(List.of());
|
||||
|
||||
+33
-2
@@ -113,6 +113,34 @@ public class HydrologyFootprintCompilerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fallbackCompilersShareBasisWithoutChangingValidationOrFootprints() {
|
||||
HydraulicSegment segment = new HydraulicSegment(
|
||||
9001L, 9000L, HydrologyFeatureType.SURFACE_POOL, 70, 70, 6, 3, false, false,
|
||||
List.of(new HydrologyPoint(-12, 70, -3), new HydrologyPoint(32, 70, 5)),
|
||||
HydraulicChannelProfile.uniform(6, 3));
|
||||
RiverCourse course = course(9000L, RiverCourseType.SURFACE, segment);
|
||||
SlopeCountingSampler sampler = new SlopeCountingSampler(true);
|
||||
HydrologyFootprintCompiler owner = new HydrologyFootprintCompiler(
|
||||
HydrologyPlannerSettings.defaults(),
|
||||
new HydrologyFootprintCompiler.Sampling(sampler, request -> request.minimum(), sampler));
|
||||
HydrologyFootprintCompiler.ValidationRaster expectedValidation = owner.compileValidation(List.of(course));
|
||||
RiverFootprint expected = owner.compile(List.of(course));
|
||||
int ownerCalls = sampler.slopeFreeCalls;
|
||||
assertTrue(ownerCalls > 0);
|
||||
HydrologyBasisCache shared = new HydrologyBasisCache();
|
||||
shared.seedWithoutSlope(owner.terrainBases);
|
||||
for (int trial = 0; trial < 3; trial++) {
|
||||
HydrologyFootprintCompiler alternative = new HydrologyFootprintCompiler(
|
||||
HydrologyPlannerSettings.defaults(),
|
||||
new HydrologyFootprintCompiler.Sampling(sampler, request -> request.minimum(), sampler));
|
||||
alternative.sharedBasis = shared;
|
||||
assertEquals(expectedValidation.columns(), alternative.compileValidation(List.of(course)).columns());
|
||||
assertEquals(expected, alternative.compile(List.of(course)));
|
||||
}
|
||||
assertEquals(ownerCalls, sampler.slopeFreeCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void surfaceChannelNeverRaisesTerrainOrPublishesUnsupportedFluid() {
|
||||
HydraulicSegment segment = new HydraulicSegment(
|
||||
@@ -536,11 +564,14 @@ public class HydrologyFootprintCompilerTest {
|
||||
);
|
||||
RiverCourse course = course(42L, RiverCourseType.SURFACE, segment);
|
||||
HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 1D, "parent");
|
||||
RiverFootprint footprint = new HydrologyFootprintCompiler(
|
||||
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(
|
||||
HydrologyPlannerSettings.defaults(),
|
||||
terrain,
|
||||
request -> request.minimum()
|
||||
).compile(List.of(course));
|
||||
);
|
||||
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
|
||||
compiler.surfaceFootprintForPublication(course).rejection());
|
||||
RiverFootprint footprint = compiler.compile(List.of(course));
|
||||
|
||||
int channelEdge = 0;
|
||||
while (layerAt(footprint, channelEdge + 1, 0).channel()) {
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ public class HydrologyPlannerRoutingSamplerTest {
|
||||
assertEquals(6, heightCalls.get());
|
||||
HydrologyLandHeightCache shared = planner.planningSamples.get().fallbackLandHeights();
|
||||
for (int trial = 0; trial < 3; trial++) {
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared));
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples(shared, null));
|
||||
assertEquals(Double.NaN, planner.sampleLandHeight(0, -17), 0D);
|
||||
assertEquals(0D, planner.sampleLandHeight(1, -17), 0D);
|
||||
assertEquals(Integer.MIN_VALUE, planner.sampleLandHeight(2, -17), 0D);
|
||||
|
||||
@@ -2118,13 +2118,15 @@ public class HydrologyPlannerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fallbackAlternatesCoastalAndInlandTrials() {
|
||||
public void fallbackAlternatesCoastalAndInlandTrials() throws Exception {
|
||||
// An island whose coast is walled by a berm between the lattice columns: every mouth course
|
||||
// needs a cut deeper than the channel may make, so the surface routing falls back. The coast
|
||||
// offers more fallback mouths than the trial budget, so a sinkhole only gets its trial when
|
||||
// the fallback alternates coastal and inland candidates.
|
||||
HydrologyPlannerSettings settings = standardSettings(1D, 0D, true, true, List.of());
|
||||
AtomicInteger terrainSamples = new AtomicInteger();
|
||||
HydrologyTerrainSampler island = (int x, int z) -> {
|
||||
terrainSamples.incrementAndGet();
|
||||
if (x < -16 || x > 144 || z < -16 || z > 144) {
|
||||
return oceanTerrain();
|
||||
}
|
||||
@@ -2135,6 +2137,7 @@ public class HydrologyPlannerTest {
|
||||
};
|
||||
|
||||
HydrologyTile tile = new HydrologyPlanner(885L, settings, island, solidCaveView()).plan(TILE);
|
||||
assertTrue("Fallback terrain samples: " + terrainSamples.get(), terrainSamples.get() < 37_500);
|
||||
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty());
|
||||
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) ->
|
||||
@@ -2144,6 +2147,46 @@ public class HydrologyPlannerTest {
|
||||
assertFalse("outlet=" + outlet, outlet.directOcean());
|
||||
assertTrue(course.surfaceSinkholeContinuation());
|
||||
}
|
||||
|
||||
HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample sampleBasis(int x, int z) {
|
||||
return island.sample(x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()];
|
||||
for (int z = 0; z < request.width(); z++) {
|
||||
for (int x = 0; x < request.width(); x++) {
|
||||
samples[z * request.width() + x] = island.sample(
|
||||
request.minimumX() + x * request.spacing(), request.minimumZ() + z * request.spacing());
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int x, int z) {
|
||||
return island.sample(x, z).ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsSharedGridSamples() {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
HydrologyPlanner sharedPlanner = new HydrologyPlanner(885L, settings, island, natural,
|
||||
HydrologyGeometrySampler.deterministic(island), Integer.MIN_VALUE, footprint -> solidCaveView());
|
||||
ForkJoinPool workers = new ForkJoinPool(4);
|
||||
try {
|
||||
HydrologyTile shared = workers.submit(() -> sharedPlanner.plan(TILE)).get(30, TimeUnit.SECONDS);
|
||||
assertTileContentsEqual(tile, shared);
|
||||
assertEquals(tile.courses(), shared.courses());
|
||||
assertEquals(tile.localDiagnosticCandidates(), shared.localDiagnosticCandidates());
|
||||
} finally {
|
||||
workers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyPlannerSettings mixedCoastSettings(int maximumCoastalPerTile) {
|
||||
|
||||
+4
@@ -40,7 +40,9 @@ public class HydrologyPlanningAdmissionIntegrationTest {
|
||||
AtomicInteger peak = new AtomicInteger();
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class);
|
||||
when(firstPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class);
|
||||
when(secondPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
when(firstPlanner.plan(key)).thenAnswer(invocation -> {
|
||||
peak.accumulateAndGet(active.incrementAndGet(), Math::max);
|
||||
@@ -86,6 +88,7 @@ public class HydrologyPlanningAdmissionIntegrationTest {
|
||||
ExecutorService workers = Executors.newSingleThreadExecutor();
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
try {
|
||||
@@ -101,6 +104,7 @@ public class HydrologyPlanningAdmissionIntegrationTest {
|
||||
releaseRoots(held);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyPlanner nextPlanner = mock(HydrologyPlanner.class);
|
||||
when(nextPlanner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
when(nextPlanner.plan(key)).thenReturn(tile);
|
||||
try (HydrologyTileCache next = new HydrologyTileCache(nextPlanner, 4, workers)) {
|
||||
assertSame(tile, callers.submit(() -> next.get(key)).get(5L, TimeUnit.SECONDS));
|
||||
|
||||
@@ -280,6 +280,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void interruptedWaiterExitsWithoutCancellingTheSharedPlan() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
@@ -331,6 +332,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void closeDrainsBackgroundPlanningAndRejectsNewDemand() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
@@ -402,6 +404,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void closingFromAnOwnedLoadFailsWithoutDeadlockingOrClosingTheCache() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
@@ -707,6 +710,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void equivalentStudioRuntimesReuseCompletedTiles() {
|
||||
HydrologyPlanner firstPlanner = mock(HydrologyPlanner.class);
|
||||
when(firstPlanner.settings()).thenReturn(emptySettings());
|
||||
HydrologyPlanner secondPlanner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(2, -3);
|
||||
@@ -739,7 +743,9 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void failedPlansDoNotEnterTheSharedStudioCache() {
|
||||
HydrologyPlanner failingPlanner = mock(HydrologyPlanner.class);
|
||||
when(failingPlanner.settings()).thenReturn(emptySettings());
|
||||
HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class);
|
||||
when(succeedingPlanner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile emptyTile = mock(HydrologyTile.class);
|
||||
HydrologyTile plannedTile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(-4, 5);
|
||||
@@ -759,6 +765,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void closeWaitsForActivePlansWithoutPublishingTheirSharedTiles() throws Exception {
|
||||
HydrologyPlanner closingPlanner = mock(HydrologyPlanner.class);
|
||||
when(closingPlanner.settings()).thenReturn(emptySettings());
|
||||
HydrologyPlanner succeedingPlanner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile lateTile = mock(HydrologyTile.class);
|
||||
HydrologyTile plannedTile = mock(HydrologyTile.class);
|
||||
@@ -956,6 +963,106 @@ public class HydrologyTileCacheTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleColumnContributionReusesItsImmutableSample() {
|
||||
HydrologyColumnSample sample = compositionSample(90, List.of(compositionLayer(11L, 80)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) {
|
||||
assertSame(sample, cache.columnAt(-1, 0).orElseThrow());
|
||||
assertTrue(cache.columnAt(-2, 0).isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleColumnContributionPreservesCanonicalLayerOrder() {
|
||||
HydrologyColumnSample sample = compositionSample(90,
|
||||
List.of(compositionLayer(11L, 80), compositionLayer(12L, 78)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) {
|
||||
assertSame(sample, cache.columnAt(-1, 0).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleColumnContributionDeduplicatesIdenticalFeatureIds() {
|
||||
HydrologyColumnLayer layer = compositionLayer(11L, 80);
|
||||
HydrologyColumnSample sample = compositionSample(90, List.of(layer, layer));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) {
|
||||
assertEquals(compositionSample(90, List.of(layer)), cache.columnAt(-1, 0).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void singleColumnContributionRejectsConflictingFeatureIds() {
|
||||
HydrologyColumnSample sample = compositionSample(90,
|
||||
List.of(compositionLayer(11L, 80), compositionLayer(11L, 79)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(new HydrologyTileKey(-1, 0), sample))) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0));
|
||||
assertEquals("Hydrology feature id collision at -1,0.", failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlappingColumnContributionsMergeAndDeduplicateLayers() {
|
||||
HydrologyColumnLayer shared = compositionLayer(11L, 80);
|
||||
HydrologyColumnLayer additional = compositionLayer(12L, 78);
|
||||
HydrologyColumnSample first = compositionSample(90, List.of(shared));
|
||||
HydrologyColumnSample second = compositionSample(90, List.of(additional, shared));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(
|
||||
new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) {
|
||||
assertEquals(compositionSample(90, List.of(shared, additional)), cache.columnAt(-1, 0).orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlappingColumnContributionsRejectConflictingFeatureIds() {
|
||||
HydrologyColumnSample first = compositionSample(90, List.of(compositionLayer(11L, 80)));
|
||||
HydrologyColumnSample second = compositionSample(90, List.of(compositionLayer(11L, 79)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(
|
||||
new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0));
|
||||
assertEquals("Hydrology feature id collision at -1,0.", failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlappingColumnContributionsRejectDifferentNaturalTerrain() {
|
||||
HydrologyColumnSample first = compositionSample(90, List.of(compositionLayer(11L, 80)));
|
||||
HydrologyColumnSample second = compositionSample(91, List.of(compositionLayer(12L, 78)));
|
||||
try (HydrologyTileCache cache = compositionCache(Map.of(
|
||||
new HydrologyTileKey(-1, 0), first, new HydrologyTileKey(0, 0), second))) {
|
||||
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> cache.columnAt(-1, 0));
|
||||
assertEquals("Hydrology plans disagree on terrain metadata at -1,0.", failure.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyTileCache compositionCache(Map<HydrologyTileKey, HydrologyColumnSample> samples) {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(featureSettings());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenAnswer(invocation -> {
|
||||
HydrologyTileKey key = invocation.getArgument(0);
|
||||
HydrologyColumnSample sample = samples.get(key);
|
||||
RiverFootprint footprint = sample == null ? RiverFootprint.empty()
|
||||
: new RiverFootprint(Map.of(RiverFootprint.pack(sample.x(), sample.z()), sample));
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(tile.footprint()).thenReturn(footprint);
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenAnswer(query ->
|
||||
footprint.sample(query.getArgument(0), query.getArgument(1)));
|
||||
return tile;
|
||||
});
|
||||
return new HydrologyTileCache(planner, 64);
|
||||
}
|
||||
|
||||
private static HydrologyColumnSample compositionSample(int naturalHeight, List<HydrologyColumnLayer> layers) {
|
||||
return new HydrologyColumnSample(-1, 0, naturalHeight, 63, false, "parent", layers);
|
||||
}
|
||||
|
||||
private static HydrologyColumnLayer compositionLayer(long featureId, int bedY) {
|
||||
HydrologyFeatureRef feature = new HydrologyFeatureRef(featureId, HydrologyFeatureType.SURFACE_POOL,
|
||||
1L, 1L, -1, 82, 0, 1, 0, false);
|
||||
return new HydrologyColumnLayer(feature, bedY, 82, 82,
|
||||
true, false, false, true, false, false, true, true, false,
|
||||
"default", "river", "mouth", "shore", "bank", "cave");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentDifferentTileAndCompositeQueriesRemainDeterministic() throws Exception {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(1211L, featureSettings(), this::featureTerrain);
|
||||
@@ -1338,6 +1445,7 @@ public class HydrologyTileCacheTest {
|
||||
|
||||
private void assertConcurrentTilePlanning(List<HydrologyTileKey> keys) throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
CountDownLatch started = new CountDownLatch(Math.min(keys.size(), HydrologyPlanningAdmission.maximumRoots()));
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
@@ -1449,6 +1557,7 @@ public class HydrologyTileCacheTest {
|
||||
@Test
|
||||
public void clearDoesNotJoinOrPublishAnOldTilePlan() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
HydrologyTile oldTile = mock(HydrologyTile.class);
|
||||
HydrologyTile newTile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
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;
|
||||
import static org.junit.Assume.assumeTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyTileDemandWindowTest {
|
||||
@Test
|
||||
public void completedSlotsRefillBeforeTheSlowestTileFinishes() throws Exception {
|
||||
assumeTrue(HydrologyPlanningAdmission.maximumRoots() >= 2);
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologyTile first = mock(HydrologyTile.class);
|
||||
HydrologyTile second = mock(HydrologyTile.class);
|
||||
HydrologyTile third = mock(HydrologyTile.class);
|
||||
List<HydrologyTile> expected = List.of(first, second, third);
|
||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
CountDownLatch thirdStarted = new CountDownLatch(1);
|
||||
AtomicInteger active = new AtomicInteger();
|
||||
AtomicInteger peak = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
HydrologyTileKey key = invocation.getArgument(0);
|
||||
peak.accumulateAndGet(active.incrementAndGet(), Math::max);
|
||||
try {
|
||||
if (key.tileX() == 0) {
|
||||
firstStarted.countDown();
|
||||
assertTrue(releaseFirst.await(5, TimeUnit.SECONDS));
|
||||
} else {
|
||||
assertTrue(firstStarted.await(5, TimeUnit.SECONDS));
|
||||
if (key.tileX() == 2) {
|
||||
thirdStarted.countDown();
|
||||
}
|
||||
}
|
||||
return expected.get(key.tileX());
|
||||
} finally {
|
||||
active.decrementAndGet();
|
||||
}
|
||||
}).when(planner).plan(any(HydrologyTileKey.class));
|
||||
ExecutorService workers = Executors.newFixedThreadPool(3);
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
|
||||
try {
|
||||
Future<List<HydrologyTile>> result = caller.submit(() -> cache.tiles(keys()));
|
||||
assertTrue("a completed slot must admit the third tile while the first is blocked",
|
||||
thirdStarted.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(result.isDone());
|
||||
releaseFirst.countDown();
|
||||
assertEquals(expected, result.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(2, peak.get());
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
caller.shutdownNow();
|
||||
workers.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancellationStopsRefillWithoutCancellingSharedSiblingWork() throws Exception {
|
||||
assumeTrue(HydrologyPlanningAdmission.maximumRoots() >= 2);
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
CountDownLatch firstStarted = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
AtomicInteger planned = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
HydrologyTileKey key = invocation.getArgument(0);
|
||||
planned.incrementAndGet();
|
||||
if (key.tileX() == 0) {
|
||||
firstStarted.countDown();
|
||||
assertTrue(releaseFirst.await(5, TimeUnit.SECONDS));
|
||||
return tile;
|
||||
}
|
||||
assertTrue(firstStarted.await(5, TimeUnit.SECONDS));
|
||||
throw new CancellationException("Cancelled demand tile");
|
||||
}).when(planner).plan(any(HydrologyTileKey.class));
|
||||
ExecutorService workers = Executors.newFixedThreadPool(3);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
|
||||
try {
|
||||
Future<List<HydrologyTile>> result = callers.submit(() -> cache.tiles(keys()));
|
||||
ExecutionException failure = assertThrows(ExecutionException.class,
|
||||
() -> result.get(5, TimeUnit.SECONDS));
|
||||
assertTrue(failure.getCause() instanceof CancellationException);
|
||||
assertEquals(2, planned.get());
|
||||
Future<HydrologyTile> shared = callers.submit(() -> cache.get(keys().getFirst()));
|
||||
releaseFirst.countDown();
|
||||
assertSame(tile, shared.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(2, planned.get());
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
callers.shutdownNow();
|
||||
workers.shutdownNow();
|
||||
assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void capturedCachedTilesSurviveInvalidationDuringAnotherPlan() {
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologyTile cached = mock(HydrologyTile.class);
|
||||
HydrologyTile generated = mock(HydrologyTile.class);
|
||||
HydrologyTileKey cachedKey = new HydrologyTileKey(2, 0);
|
||||
when(planner.plan(cachedKey)).thenReturn(cached);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, Runnable::run);
|
||||
try {
|
||||
assertSame(cached, cache.get(cachedKey));
|
||||
doAnswer(invocation -> {
|
||||
cache.clear();
|
||||
return generated;
|
||||
}).when(planner).plan(keys().getFirst());
|
||||
when(planner.plan(cachedKey)).thenThrow(new AssertionError("Captured tile was replanned"));
|
||||
assertEquals(List.of(generated, cached), cache.tiles(List.of(keys().getFirst(), cachedKey)));
|
||||
} finally {
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static HydrologyPlanner planner() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
return planner;
|
||||
}
|
||||
|
||||
private static List<HydrologyTileKey> keys() {
|
||||
return List.of(new HydrologyTileKey(0, 0), new HydrologyTileKey(1, 0), new HydrologyTileKey(2, 0));
|
||||
}
|
||||
}
|
||||
+73
@@ -2,6 +2,7 @@ package art.arcane.iris.generation.hydrology.runtime;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
|
||||
import art.arcane.iris.generation.hydrology.RiverFootprint;
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
@@ -26,6 +27,9 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
@@ -36,6 +40,75 @@ import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
@Test
|
||||
public void boundedBasisBatchUsesScopedWorkersAndDrainsNestedPool() throws Exception {
|
||||
ThreadLocal<Integer> scope = new ThreadLocal<>();
|
||||
AtomicInteger opened = new AtomicInteger();
|
||||
AtomicInteger closed = new AtomicInteger();
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
CountDownLatch simultaneous = new CountDownLatch(2);
|
||||
ForkJoinPool pool = new ForkJoinPool(2);
|
||||
try {
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources((x, z, height) -> {
|
||||
assertEquals(Integer.valueOf(7), scope.get());
|
||||
if (x >= 10_000) {
|
||||
throw new IllegalStateException("terrain failure");
|
||||
}
|
||||
simultaneous.countDown();
|
||||
try {
|
||||
assertTrue(simultaneous.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
samples.incrementAndGet();
|
||||
return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height,
|
||||
HydrologyTerrainSample.openLand((int) height, 0D, "land"));
|
||||
}, (x, z) -> 100 + Math.floorMod(x + z, 17), (x, z) -> false, 60),
|
||||
new IrisHydrologyRoutingTerrainSampler.SamplingOptions(8192, 2, pool, () -> () -> {
|
||||
Integer previous = scope.get();
|
||||
scope.set(7);
|
||||
opened.incrementAndGet();
|
||||
return () -> {
|
||||
if (previous == null) {
|
||||
scope.remove();
|
||||
} else {
|
||||
scope.set(previous);
|
||||
}
|
||||
closed.incrementAndGet();
|
||||
};
|
||||
}));
|
||||
long[] coordinates = new long[4096];
|
||||
for (int index = 0; index < coordinates.length; index++) {
|
||||
coordinates[index] = RiverFootprint.pack(index - 2048, -index);
|
||||
}
|
||||
HydrologyTerrainSample[] result = pool.submit(() -> sampler.sampleBasisWithoutSlopeBatch(
|
||||
coordinates, coordinates.length)).get(10, TimeUnit.SECONDS);
|
||||
for (int index = 0; index < result.length; index++) {
|
||||
assertEquals(100 + Math.floorMod(-2048, 17), result[index].naturalHeight());
|
||||
}
|
||||
assertEquals(coordinates.length, samples.get());
|
||||
assertEquals(2, opened.get());
|
||||
assertEquals(opened.get(), closed.get());
|
||||
assertEquals(null, pool.submit(scope::get).get(5, TimeUnit.SECONDS));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> sampler.sampleBasisWithoutSlopeBatch(new long[4097], 4097));
|
||||
long[] failureCoordinates = new long[4096];
|
||||
for (int index = 0; index < failureCoordinates.length; index++) {
|
||||
failureCoordinates[index] = RiverFootprint.pack(10_000 + index, -index);
|
||||
}
|
||||
assertThrows(ExecutionException.class, () -> pool.submit(() -> sampler.sampleBasisWithoutSlopeBatch(
|
||||
failureCoordinates, failureCoordinates.length)).get(10, TimeUnit.SECONDS));
|
||||
assertEquals(4, opened.get());
|
||||
assertEquals(opened.get(), closed.get());
|
||||
assertEquals(null, pool.submit(scope::get).get(5, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bankHeightsAtSeaLevelSkipOceanIntentSampling() {
|
||||
AtomicInteger classifierCalls = new AtomicInteger();
|
||||
|
||||
+231
@@ -2,6 +2,12 @@ package art.arcane.iris.generation.hydrology.surface;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.HydraulicChannelProfile;
|
||||
import art.arcane.iris.generation.hydrology.HydraulicSegment;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
|
||||
import art.arcane.iris.generation.hydrology.HydrologySurfaceDropRaster;
|
||||
import art.arcane.iris.generation.hydrology.RiverCourse;
|
||||
import art.arcane.iris.generation.hydrology.RiverCourseType;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
|
||||
@@ -13,10 +19,18 @@ import art.arcane.iris.generation.hydrology.IrisRiverBedProfile;
|
||||
import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
|
||||
@@ -33,6 +47,223 @@ public class ErosionFieldCompilerTest {
|
||||
default -> request.minimum();
|
||||
};
|
||||
|
||||
@Test
|
||||
public void wetIncisionPreflightMatchesFullVerdictsAcrossPondsCrossingsStepsAndPolicies() {
|
||||
int accepted = 0;
|
||||
int unsupported = 0;
|
||||
for (int scenario = 0; scenario < 24; scenario++) {
|
||||
int fixture = scenario;
|
||||
boolean crossing = scenario % 3 == 1;
|
||||
boolean coastal = scenario % 6 == 5;
|
||||
HydrologyPlannerSettings.Ponds ponds = scenario % 4 == 0 ? noPonds()
|
||||
: new HydrologyPlannerSettings.Ponds(new HydrologyPlannerSettings.Pond(true, 3, 8, 3),
|
||||
new HydrologyPlannerSettings.Pond(true, 3, 7, 2));
|
||||
HydrologyPlannerSettings.Erosion erosion = shapedErosion(
|
||||
IrisRiverBlendStyle.values()[scenario % IrisRiverBlendStyle.values().length], 4, 0.5D,
|
||||
IrisRiverBedProfile.values()[scenario % IrisRiverBedProfile.values().length], 1D, 2D);
|
||||
HydrologyPlannerSettings.Surface surface = surfaceWith(scenario % 2 == 0 ? 0D : 0.6D,
|
||||
0, 1.5D, erosion, ponds, HydrologyPlannerSettings.Channel.defaults(),
|
||||
HydrologyPlannerSettings.Flow.defaults());
|
||||
HydrologyTerrainSampler terrain = (x, z) -> {
|
||||
if (coastal && x > 12) {
|
||||
return HydrologyTerrainSample.ocean(50, "ocean");
|
||||
}
|
||||
int natural = coastal ? SEA_LEVEL : 90;
|
||||
if (fixture >= 12 && x >= -2 && x <= 2) {
|
||||
natural += 30;
|
||||
}
|
||||
if (fixture % 4 == 2 && z > 4) {
|
||||
natural += 12;
|
||||
}
|
||||
HydrologyTerrainSample sample = HydrologyTerrainSample.openLand(natural, 0D, "land")
|
||||
.withErosion(fixture % 7 != 0).withShoreWidth(z < 0 ? 0.5D : 3D);
|
||||
return fixture % 3 == 2 ? sample.withSurfacePolicy(new SurfaceRiverPolicy(
|
||||
"local", null, null, null, null, null, null, x < 0 ? 3 : 16)) : sample;
|
||||
};
|
||||
List<HydrologyPoint> path = crossing
|
||||
? List.of(new HydrologyPoint(-18, 90, -8), new HydrologyPoint(18, 90, 8),
|
||||
new HydrologyPoint(-18, 90, 8), new HydrologyPoint(18, 90, -8))
|
||||
: List.of(new HydrologyPoint(-18, 90, 0), new HydrologyPoint(18, 90, 0));
|
||||
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
|
||||
double[] widths = new double[centerline.size()];
|
||||
double[] depths = new double[centerline.size()];
|
||||
double[] multipliers = new double[centerline.size()];
|
||||
int[] heads = new int[centerline.size()];
|
||||
Arrays.fill(widths, 3D);
|
||||
Arrays.fill(depths, 3D);
|
||||
Arrays.fill(multipliers, 1D);
|
||||
for (int station = 0; station < heads.length; station++) {
|
||||
heads[station] = coastal ? SEA_LEVEL : 89 - (scenario % 4 == 3 ? station / 8 : 0);
|
||||
}
|
||||
ChannelProfile channel = new ChannelProfile(widths, depths, multipliers);
|
||||
ValleyProfile valley = ValleyProfile.fromHeads(heads, heads.length);
|
||||
SurfaceTerminal terminal = coastal ? SurfaceTerminal.OCEAN_MOUTH : SurfaceTerminal.SINKHOLE;
|
||||
HydrologyPlannerSettings settings = rasterSettings(surface);
|
||||
HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.empty();
|
||||
if (scenario % 4 == 3 && !coastal) {
|
||||
HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
|
||||
89, 81, 6, 3, true, true,
|
||||
List.of(new HydrologyPoint(-5, 89, 0), new HydrologyPoint(-4, 81, 0)),
|
||||
HydraulicChannelProfile.uniform(6, 3));
|
||||
RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(9L),
|
||||
OptionalLong.of(8L), "water", 1, List.of(), List.of(fall));
|
||||
drops = HydrologySurfaceDropRaster.compile(settings, terrain, CONSTANT_GEOMETRY, course);
|
||||
assertTrue("Drop raster scenario " + scenario, !drops.columns().isEmpty());
|
||||
}
|
||||
SurfaceRasterContext context = new SurfaceRasterContext(null, drops,
|
||||
scenario % 4 == 1 ? new SurfaceRunBoundary(
|
||||
new SurfaceRunBoundary.Plane(-18, 0, 1, 0, 10D),
|
||||
new SurfaceRunBoundary.Plane(18, 0, 1, 0, 10D)) : SurfaceRunBoundary.unbounded());
|
||||
ErosionFieldCompiler compiler = new ErosionFieldCompiler(settings, terrain);
|
||||
ErosionFieldCompiler.PreparedField prepared = compiler.prepare(centerline, channel, valley,
|
||||
terminal, coastal ? Math.max(0, centerline.size() - 12) : centerline.size());
|
||||
for (long seed = 0; seed < 4; seed++) {
|
||||
ErosionField full = compiler.compile(seed, centerline, channel, valley, terminal, 8,
|
||||
ponds, context, prepared);
|
||||
ErosionField publication = compiler.compileForPublication(seed, centerline, channel, valley,
|
||||
terminal, 8, ponds, context, prepared);
|
||||
assertEquals("scenario=" + scenario + " seed=" + seed, full.rejection(), publication.rejection());
|
||||
if (full.rejection() == HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED) {
|
||||
unsupported++;
|
||||
for (SurfaceColumn wet : publication.columns().values()) {
|
||||
if (wet.role() == SurfaceRole.CHANNEL) {
|
||||
assertEquals(full.column(wet.x(), wet.z()), wet);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assertEqualFields(full, publication);
|
||||
if (full.rejection() == null) {
|
||||
accepted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue("Accepted oracle cases: " + accepted, accepted >= 8);
|
||||
assertTrue("Unsupported oracle cases: " + unsupported, unsupported >= 8);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rasterBatchesPreserveExactDemandAndColumnOrder() {
|
||||
Map<Long, Integer> serialCalls = new HashMap<>();
|
||||
Map<Long, Integer> batchCalls = new HashMap<>();
|
||||
AtomicInteger batches = new AtomicInteger();
|
||||
HydrologyTerrainSampler serial = (x, z) -> {
|
||||
serialCalls.merge(RiverFootprint.pack(x, z), 1, Integer::sum);
|
||||
return HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 7), 0D, "land");
|
||||
};
|
||||
HydrologyTerrainSampler batched = new HydrologyTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample sample(int x, int z) {
|
||||
batchCalls.merge(RiverFootprint.pack(x, z), 1, Integer::sum);
|
||||
return HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 7), 0D, "land");
|
||||
}
|
||||
|
||||
public HydrologyTerrainSample[] sampleBatch(long[] coordinates, int count) {
|
||||
assertTrue(count > 0 && count <= 4096);
|
||||
batches.incrementAndGet();
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count];
|
||||
for (int index = 0; index < count; index++) {
|
||||
samples[index] = sample(RiverFootprint.unpackX(coordinates[index]), RiverFootprint.unpackZ(coordinates[index]));
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
};
|
||||
ErosionField expected = compile(serial, 300, SurfaceTerminal.SINKHOLE, 40).field();
|
||||
ErosionField actual = compile(batched, 300, SurfaceTerminal.SINKHOLE, 40).field();
|
||||
assertEquals(new ArrayList<>(expected.columns().values()), new ArrayList<>(actual.columns().values()));
|
||||
assertEquals(expected.rejection(), actual.rejection());
|
||||
assertEquals(expected.rejectionDetail(), actual.rejectionDetail());
|
||||
assertEquals(expected.uncontainedWetCells(), actual.uncontainedWetCells());
|
||||
assertEquals(expected.bankExcavation(), actual.bankExcavation());
|
||||
assertEquals(serialCalls, batchCalls);
|
||||
assertTrue(batches.get() > 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pondExclusionsPreserveCompleteAndClippedFieldsWithFewerTerrainSamples() throws Exception {
|
||||
List<String> fingerprints = new ArrayList<>();
|
||||
List<Integer> sampleCounts = new ArrayList<>();
|
||||
for (int scenario = 0; scenario < 8; scenario++) {
|
||||
double shore = switch (scenario) {
|
||||
case 0 -> Math.nextDown(1.75D);
|
||||
case 1 -> 1.75D;
|
||||
case 2 -> Math.nextUp(1.75D);
|
||||
default -> 1.5D;
|
||||
};
|
||||
boolean enabled = scenario != 4;
|
||||
boolean erode = scenario != 5;
|
||||
boolean coastal = scenario == 7;
|
||||
HydrologyPlannerSettings.Erosion defaults = HydrologyPlannerSettings.Erosion.defaults();
|
||||
HydrologyPlannerSettings.Erosion erosion = new HydrologyPlannerSettings.Erosion(enabled,
|
||||
defaults.smoothingRadius(), defaults.thalwegFraction(), defaults.blendCurve(), defaults.bedNoise(),
|
||||
defaults.style(), defaults.terraceSteps(), defaults.cliffFraction(), defaults.bedProfile(),
|
||||
defaults.shoreRise(), defaults.blendBaseWidth(),
|
||||
new HydrologyPlannerSettings.Excavation(16, scenario == 6 ? 5 : 48, 256));
|
||||
HydrologyPlannerSettings.Ponds ponds = new HydrologyPlannerSettings.Ponds(
|
||||
new HydrologyPlannerSettings.Pond(true, 6, 6, 3),
|
||||
new HydrologyPlannerSettings.Pond(true, 5, 5, 2));
|
||||
HydrologyPlannerSettings.Surface surface = surfaceWith(scenario == 3 ? 0.65D : 0D, 0, shore,
|
||||
erosion, ponds, HydrologyPlannerSettings.Channel.defaults(), HydrologyPlannerSettings.Flow.defaults());
|
||||
HydrologyTerrainSampler terrain = (x, z) -> coastal && x >= 4
|
||||
? HydrologyTerrainSample.ocean(50, "ocean")
|
||||
: HydrologyTerrainSample.openLand(coastal ? SEA_LEVEL : 80 + Math.max(0, z) / 3, 0D, "land")
|
||||
.withErosion(erode);
|
||||
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(
|
||||
new HydrologyPoint(-12, 80, 0), new HydrologyPoint(12, 80, 0)));
|
||||
double[] widths = new double[centerline.size()];
|
||||
double[] depths = new double[centerline.size()];
|
||||
double[] multipliers = new double[centerline.size()];
|
||||
int[] heads = new int[centerline.size()];
|
||||
Arrays.fill(widths, 3D);
|
||||
Arrays.fill(depths, 2D);
|
||||
Arrays.fill(multipliers, 1D);
|
||||
Arrays.fill(heads, coastal ? SEA_LEVEL : 80);
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
ErosionField complete = null;
|
||||
for (SurfaceBounds bounds : new SurfaceBounds[]{null, new SurfaceBounds(-20, -12, -1, 12),
|
||||
new SurfaceBounds(0, -12, 20, 12)}) {
|
||||
ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), (x, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return terrain.sample(x, z);
|
||||
}).compile(1234L, centerline, new ChannelProfile(widths, depths, multipliers),
|
||||
ValleyProfile.fromHeads(heads, coastal ? 17 : heads.length),
|
||||
coastal ? SurfaceTerminal.OCEAN_MOUTH : SurfaceTerminal.SINKHOLE, 8, ponds,
|
||||
SurfaceRasterContext.bounded(bounds));
|
||||
List<SurfaceColumn> ordered = new ArrayList<>(field.columns().values());
|
||||
ordered.sort(Comparator.comparingLong(column -> RiverFootprint.pack(column.x(), column.z())));
|
||||
for (SurfaceColumn column : ordered) {
|
||||
digest.update(column.toString().getBytes(StandardCharsets.UTF_8));
|
||||
if (bounds != null && bounds.contains(column.x(), column.z())) {
|
||||
assertEquals(complete.column(column.x(), column.z()), column);
|
||||
}
|
||||
}
|
||||
digest.update((field.rejection() + ":" + field.rejectionDetail() + ":"
|
||||
+ field.uncontainedWetCells() + ":" + field.bankExcavation()).getBytes(StandardCharsets.UTF_8));
|
||||
if (bounds == null) {
|
||||
complete = field;
|
||||
}
|
||||
}
|
||||
fingerprints.add(HexFormat.of().formatHex(digest.digest()));
|
||||
sampleCounts.add(samples.get());
|
||||
}
|
||||
assertEquals(List.of(
|
||||
"66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a",
|
||||
"66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a",
|
||||
"66595850077d44389c5c78594276de4568f85f08854d3333137dfc05ebf3455a",
|
||||
"744b15e684ba996f9c730ab1409ada2643985c902a6da1aadb5cc2b4882d640b",
|
||||
"2e7e4af1e433ccd0a7392f0d2e93bb85ac122088cd2cf8d55107f7dcfb4ec283",
|
||||
"7beee0b78c37b1287510fa0719375e14e0f0013eb8af81df8e20a3b5628612b7",
|
||||
"ad35016194318efa42dc862df4e2bb3d9ec398d3bacec79588576d74bfd1723a",
|
||||
"a2fe6d20c87be6b6ebf1f4503da75ee767e65d5c8e8c43d5c344957f48f3085a"), fingerprints);
|
||||
int[] previousSamples = {16724, 16724, 16724, 19594, 15164, 15464, 16724, 7367};
|
||||
int[] maximumSamples = {15500, 15500, 15500, 17000, 3000, 3300, 5000, 7367};
|
||||
for (int scenario = 0; scenario < previousSamples.length; scenario++) {
|
||||
assertTrue("scenario " + scenario + " samples " + sampleCounts.get(scenario)
|
||||
+ "/" + previousSamples[scenario], sampleCounts.get(scenario) <= maximumSamples[scenario]);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void geometricExclusionAvoidsTerrainSamplesWithoutChangingAsymmetricBanks() {
|
||||
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package art.arcane.iris.generation.hydrology.surface;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class SurfaceDistanceTableTest {
|
||||
@Test
|
||||
public void latticeResidualsReuseExactDistancesIncludingSignedZero() {
|
||||
SurfaceDistanceTable table = new SurfaceDistanceTable(32, StrictMath::hypot);
|
||||
for (int x = -66; x <= 66; x++) {
|
||||
for (int z = -66; z <= 66; z++) {
|
||||
equalBits(StrictMath.hypot(x / 2D, z / 2D), table.hypot(x / 2D, z / 2D));
|
||||
}
|
||||
}
|
||||
double[] special = {-0D, 0D, Double.MIN_VALUE, Double.MAX_VALUE, Double.NaN,
|
||||
Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY, 0.25D, 31.75D, 32.5D};
|
||||
for (double x : special) {
|
||||
for (double z : special) {
|
||||
equalBits(StrictMath.hypot(x, z), table.hypot(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scalarProjectionMatchesAcrossDirectionsAndExtremeCoordinates() {
|
||||
SurfaceDistanceTable table = new SurfaceDistanceTable(64, StrictMath::hypot);
|
||||
int[] origins = {Integer.MIN_VALUE, Integer.MIN_VALUE + 64, -30000000, -1, 0,
|
||||
30000000, Integer.MAX_VALUE - 64, Integer.MAX_VALUE};
|
||||
for (int origin : origins) {
|
||||
for (int directionX = -1; directionX <= 1; directionX++) {
|
||||
for (int directionZ = -1; directionZ <= 1; directionZ++) {
|
||||
SurfaceCenterline centerline = segment(origin, -origin, origin + directionX, -origin + directionZ);
|
||||
for (int station = 0; station < 2; station++) {
|
||||
for (int x = -34; x <= 34; x++) {
|
||||
for (int z = -34; z <= 34; z++) {
|
||||
double pointX = origin + x;
|
||||
double pointZ = -origin + z;
|
||||
equalBits(centerline.distanceToSegment(station, pointX, pointZ),
|
||||
centerline.distanceToSegment(station, pointX, pointZ, table));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SurfaceCenterline arbitrary = segment(-1700000000, 1500000000, 1900000000, -1600000000);
|
||||
for (int x = -80; x <= 80; x++) {
|
||||
equalBits(arbitrary.distanceToSegment(0, x + 0.125D, x - 0.75D),
|
||||
arbitrary.distanceToSegment(0, x + 0.125D, x - 0.75D, table));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedStationSquaresOnlyCalculateTheBoundedTable() {
|
||||
AtomicInteger calculations = new AtomicInteger();
|
||||
SurfaceDistanceTable table = new SurfaceDistanceTable(33, (x, z) -> {
|
||||
calculations.incrementAndGet();
|
||||
return StrictMath.hypot(x, z);
|
||||
});
|
||||
int initialization = calculations.get();
|
||||
assertEquals(34 * 34 + 33, initialization);
|
||||
long samples = 0;
|
||||
for (int station = 0; station < 1000; station++) {
|
||||
SurfaceCenterline centerline = segment(station, -station, station + 1, -station + 1);
|
||||
for (int x = -32; x <= 32; x++) {
|
||||
for (int z = -32; z <= 32; z++) {
|
||||
centerline.distanceToSegment(0, station + x, -station + z, table);
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(4_225_000L, samples);
|
||||
assertEquals(initialization, calculations.get());
|
||||
table.hypot(34D, 0D);
|
||||
assertEquals(initialization + 1, calculations.get());
|
||||
}
|
||||
|
||||
private static SurfaceCenterline segment(int x, int z, int endX, int endZ) {
|
||||
return new SurfaceCenterline(new int[]{x, endX}, new int[]{z, endZ},
|
||||
new double[2], new double[2], new int[2]);
|
||||
}
|
||||
|
||||
private static void equalBits(double expected, double actual) {
|
||||
assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual));
|
||||
}
|
||||
}
|
||||
+21
@@ -37,6 +37,27 @@ public class SurfaceFootprintCompilerTest {
|
||||
default -> request.minimum();
|
||||
};
|
||||
|
||||
@Test
|
||||
public void impossibleWetIncisionRejectsBeforeSamplingWideBanks() {
|
||||
AtomicInteger bankSamples = new AtomicInteger();
|
||||
HydrologyTerrainSampler sampler = (x, z) -> {
|
||||
if (Math.abs(z) > 8) {
|
||||
bankSamples.incrementAndGet();
|
||||
}
|
||||
return HydrologyTerrainSample.openLand(x >= 60 && x <= 68 ? 170 : 90, 0D, "land");
|
||||
};
|
||||
RiverCourse course = course(List.of(segment(1L, HydrologyFeatureType.SURFACE_POOL,
|
||||
89, 89, points(0, 200, 89))));
|
||||
|
||||
SurfaceFootprint footprint = compiler(sampler).compileForPublication(course);
|
||||
|
||||
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, footprint.rejection());
|
||||
assertTrue("Wide-bank terrain samples: " + bankSamples.get(), bankSamples.get() < 1_000);
|
||||
SurfaceFootprint complete = compiler(sampler).compile(course);
|
||||
assertEquals(complete.rejection(), footprint.rejection());
|
||||
assertTrue(complete.columns().size() > footprint.columns().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mouthUsesConnectedFloodedLandWithoutChangingTerrainMetadataOrOwningWater() {
|
||||
int seaLevel = HydrologyPlannerSettings.defaults().seaLevel();
|
||||
|
||||
@@ -2,6 +2,7 @@ package art.arcane.iris.generation.image;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.terrain.ProceduralTerrainHeightSampler;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import com.google.gson.Gson;
|
||||
import org.junit.Rule;
|
||||
@@ -48,6 +49,33 @@ public class IrisImageMapRuntimeTest {
|
||||
assertEquals(first.hash(), second.hash());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heightImagesRetainPixelDetailAfterProceduralLatticeSampling() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("sampled-height-pack");
|
||||
writeMap(pack, "terrain", "terrain", grayscale(0, 255, 0, 255), new IrisImageMap()
|
||||
.setSource("terrain")
|
||||
.setType(IrisImageMapType.GRAYSCALE_HEIGHT)
|
||||
.setMinimumHeight(-64D)
|
||||
.setMaximumHeight(320D)
|
||||
.setOutOfBounds(IrisImageMapOutOfBounds.CLAMP));
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.getImageMaps().add(new IrisImageMapBinding()
|
||||
.setKey("terrain")
|
||||
.setMap("terrain")
|
||||
.setApplication(IrisImageMapApplication.TERRAIN_HEIGHT));
|
||||
ProceduralTerrainHeightSampler terrain = new ProceduralTerrainHeightSampler((x, z) -> x + 100D, 4);
|
||||
IrisData data = IrisData.openDatapackCompiler(pack);
|
||||
try {
|
||||
IrisImageMapRuntime runtime = IrisImageMapRuntime.compile(data, dimension, -64);
|
||||
for (int x = 0; x < 4; x++) {
|
||||
assertEquals(x % 2 == 0 ? 0D : 384D,
|
||||
runtime.sampleTerrainHeight(x, 0D, terrain.sample(x, 0D)), EPSILON);
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composesNamedMasksInDeclarationOrder() throws Exception {
|
||||
File pack = temporaryFolder.newFolder("mask-pack");
|
||||
|
||||
+170
@@ -126,6 +126,176 @@ public class IrisCaveCarver3DNearParityTest {
|
||||
carveForcedAirField.setAccessible(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verticalDensityPlanesUseGlobalAnchorsAndReduceSamplesAcrossChunkEdges() throws Exception {
|
||||
Engine engine = createEngine(128, 110);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
doReturn(4).when(dimension).getCaveDensitySamplingStep();
|
||||
doReturn(IrisWorld.builder().minHeight(-63).maxHeight(65).build()).when(engine).getWorld();
|
||||
IrisCaveCarver3D carver = new IrisCaveCarver3D(engine,
|
||||
createProfile(false, false).setDetailWeight(0D).setAllowFluid(false));
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
Set<Integer> sampledY = new HashSet<>();
|
||||
CNG density = mock(CNG.class);
|
||||
doAnswer(invocation -> {
|
||||
calls.incrementAndGet();
|
||||
double x = invocation.getArgument(0);
|
||||
double y = invocation.getArgument(1);
|
||||
double z = invocation.getArgument(2);
|
||||
sampledY.add((int) y);
|
||||
return x * 0.0001D + y * 0.01D + z * 0.0002D;
|
||||
}).when(density).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble());
|
||||
baseDensityField.set(carver, density);
|
||||
Method prepare = IrisCaveCarver3D.class.getDeclaredMethod("prepareVerticalDensityPlane",
|
||||
CaveCarveScratch.class, int.class, int.class, int.class);
|
||||
prepare.setAccessible(true);
|
||||
CaveCarveScratch scratch = new CaveCarveScratch();
|
||||
for (int y = 0; y < 64; y++) {
|
||||
prepare.invoke(carver, scratch, -16, 32, y);
|
||||
for (int x = 0; x < 3; x++) {
|
||||
for (int z = 0; z < 3; z++) {
|
||||
assertEquals((-16 + x * 8) * 0.0001D + y * 0.01D + (32 + z * 8) * 0.0002D,
|
||||
scratch.adaptivePlaneDensity[x * 3 + z], 1.0E-14D);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(153, calls.get());
|
||||
assertFalse(sampledY.contains(1));
|
||||
int interpolatedCalls = calls.get();
|
||||
doReturn(1).when(dimension).getCaveDensitySamplingStep();
|
||||
IrisCaveCarver3D scalar = new IrisCaveCarver3D(engine,
|
||||
createProfile(false, false).setDetailWeight(0D).setAllowFluid(false));
|
||||
baseDensityField.set(scalar, density);
|
||||
CaveCarveScratch scalarScratch = new CaveCarveScratch();
|
||||
for (int index = 0; index < 256; index++) {
|
||||
scalarScratch.planeColumnIndices[index] = index;
|
||||
}
|
||||
Method classify = IrisCaveCarver3D.class.getDeclaredMethod("classifyDensityPlaneAdaptive",
|
||||
CaveCarveScratch.class, int.class, int.class, int.class, int[].class, double[].class,
|
||||
int.class, boolean[].class, int.class, double.class);
|
||||
classify.setAccessible(true);
|
||||
calls.set(0);
|
||||
for (int y = 0; y < 64; y++) {
|
||||
classify.invoke(scalar, scalarScratch, -16, 32, y, scalarScratch.planeColumnIndices,
|
||||
scalarScratch.planeThresholdLimit, 256, scalarScratch.planeCarve, 8, 0.04D);
|
||||
}
|
||||
assertEquals(576, calls.get());
|
||||
assertTrue(interpolatedCalls < calls.get() * 0.3D);
|
||||
assertTrue(sampledY.containsAll(List.of(0, 3, 7, 63)));
|
||||
CaveCarveScratch neighboring = new CaveCarveScratch();
|
||||
prepare.invoke(carver, neighboring, 0, 32, 63);
|
||||
for (int z = 0; z < 3; z++) {
|
||||
assertEquals(scratch.adaptivePlaneDensity[6 + z], neighboring.adaptivePlaneDensity[z], 0D);
|
||||
}
|
||||
prepare.invoke(carver, scratch, 48, -32, 9);
|
||||
assertEquals(48 * 0.0001D + 0.09D - 32 * 0.0002D, scratch.adaptivePlaneDensity[0], 1.0E-14D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expressionStylesKeepTheOriginalSamplingAndCarvingBehavior() throws Exception {
|
||||
CNG density = mock(CNG.class);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
doAnswer(invocation -> {
|
||||
calls.incrementAndGet();
|
||||
double y = invocation.getArgument(1);
|
||||
return Math.sin(y * 0.2D);
|
||||
}).when(density).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble());
|
||||
IrisGeneratorStyle style = mock(IrisGeneratorStyle.class);
|
||||
doReturn("density_expression").when(style).getExpression();
|
||||
doReturn(density).when(style).create(any(RNG.class), any(IrisData.class));
|
||||
IrisCaveProfile profile = createProfile(false, false).setBaseDensityStyle(style)
|
||||
.setDetailWeight(0D).setAllowFluid(false)
|
||||
.setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT)));
|
||||
WriterCapture expected = null;
|
||||
int expectedSamples = 0;
|
||||
for (int step : new int[]{1, 4}) {
|
||||
Engine engine = createEngine(128, 110);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
doReturn(step).when(dimension).getCaveDensitySamplingStep();
|
||||
WriterCapture capture = createWriterCapture(128);
|
||||
calls.set(0);
|
||||
new IrisCaveCarver3D(engine, profile).carve(capture.writer, 0, 0,
|
||||
fullWeights(), 0D, 0D, null, filledHeights(110));
|
||||
assertFalse(capture.carvedCells.isEmpty());
|
||||
if (expected == null) {
|
||||
expected = capture;
|
||||
expectedSamples = calls.get();
|
||||
} else {
|
||||
assertEquals(expectedSamples, calls.get());
|
||||
assertEquals(expected.carvedCells, capture.carvedCells);
|
||||
assertEquals(expected.carvedLiquids, capture.carvedLiquids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verticalDensityPreservesTwoBlockModuleTunnelsWithoutBleedingAcrossBands() throws Exception {
|
||||
for (int step : new int[]{2, 4, 8}) {
|
||||
Engine engine = createEngine(128, 110);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
doReturn(step).when(dimension).getCaveDensitySamplingStep();
|
||||
IrisCaveProfile profile = createProfile(false, false).setDetailWeight(0D).setAllowFluid(false)
|
||||
.setVerticalEdgeFade(0).setVerticalEdgeFadeStrength(0D).setAllowSurfaceBreak(false)
|
||||
.setDensityThreshold(new IrisStyledRange(0.25D, 0.25D, new IrisGeneratorStyle(NoiseStyle.FLAT)))
|
||||
.setModules(new KList<>(new IrisCaveFieldModule(new IrisGeneratorStyle(NoiseStyle.FLAT),
|
||||
1D, 0D, new IrisRange(5D, 6D), false)));
|
||||
IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile);
|
||||
CNG base = mock(CNG.class);
|
||||
doReturn(1D).when(base).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble());
|
||||
baseDensityField.set(carver, base);
|
||||
CNG module = mock(CNG.class);
|
||||
doReturn(-1D).when(module).noiseFastSigned3D(anyDouble(), anyDouble(), anyDouble());
|
||||
moduleDensityField.set(((CaveFieldModuleState[]) modulesField.get(carver))[0], module);
|
||||
WriterCapture capture = createWriterCapture(128);
|
||||
carver.carve(capture.writer, -1, 2, fullWeights(), 0D, 0D, null, filledHeights(110));
|
||||
assertEquals(512, capture.carvedCells.size());
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
assertTrue(capture.carvedCells.contains(cellKey(x, 5, z)));
|
||||
assertTrue(capture.carvedCells.contains(cellKey(x, 6, z)));
|
||||
assertFalse(capture.carvedCells.contains(cellKey(x, 4, z)));
|
||||
assertFalse(capture.carvedCells.contains(cellKey(x, 7, z)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verticalDensityMatchesParallelDecisionsAndKeepsSurfaceFluidBarriers() throws Exception {
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
for (boolean warp : new boolean[]{false, true}) {
|
||||
Engine engine = createEngine(128, 110);
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
doReturn(4).when(dimension).getCaveDensitySamplingStep();
|
||||
doReturn(IrisWorld.builder().minHeight(-63).maxHeight(65).build()).when(engine).getWorld();
|
||||
IrisCaveProfile profile = createProfile(warp, true).setFluidRequiresFloor(false)
|
||||
.setDensityThreshold(new IrisStyledRange(0.15D, 0.15D, new IrisGeneratorStyle(NoiseStyle.FLAT)));
|
||||
IrisCaveCarver3D carver = new IrisCaveCarver3D(engine, profile);
|
||||
long[] boundaries = new long[256];
|
||||
Arrays.fill(boundaries, SurfaceFluidBoundaryPlan.boundary(
|
||||
SurfaceFluidBoundaryPlan.NO_BOUNDARY, Integer.MIN_VALUE));
|
||||
boundaries[16] = SurfaceFluidBoundaryPlan.boundary(61, 64);
|
||||
WriterCapture serial = createWriterCapture(128);
|
||||
WriterCapture parallel = createWriterCapture(128);
|
||||
int[] heights = filledHeights(110);
|
||||
int expected = carver.carve(serial.writer, -17, 19, fullWeights(), 0D, 0D,
|
||||
null, heights, boundaries, null, new CaveFluidSupportPlan());
|
||||
int actual = pool.submit(() -> carver.carve(parallel.writer, -17, 19, fullWeights(), 0D, 0D,
|
||||
null, heights, boundaries, null, new CaveFluidSupportPlan())).get(20, TimeUnit.SECONDS);
|
||||
assertTrue(expected > 0);
|
||||
assertEquals(expected, actual);
|
||||
assertEquals(serial.carvedCells, parallel.carvedCells);
|
||||
assertEquals(serial.carvedLiquids, parallel.carvedLiquids);
|
||||
for (int y = 61; y <= 64; y++) {
|
||||
assertFalse(serial.carvedCells.contains(cellKey(1, y, 0)));
|
||||
}
|
||||
assertTrue(countLiquid(serial, (byte) 2) > 0);
|
||||
assertTrue(countLiquid(serial, (byte) 0) > 0);
|
||||
assertTrue(maxY(serial.carvedCells) <= 110);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carvingReleasesSectionReferencesAfterSuccessAndFailure() throws Exception {
|
||||
for (boolean fail : new boolean[]{false, true}) {
|
||||
|
||||
+104
-1
@@ -37,6 +37,10 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class MantleWriterPreObjectJournalTest {
|
||||
@@ -72,7 +76,7 @@ public class MantleWriterPreObjectJournalTest {
|
||||
when(engineMantle.getComplex()).thenReturn(mock(IrisComplex.class));
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
matter = new IrisMatter(16, 16, 16);
|
||||
matter = spy(new IrisMatter(16, 16, 16));
|
||||
when(mantle.getWorldHeight()).thenReturn(32);
|
||||
when(mantle.getChunk(0, 0)).thenReturn(chunk);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
@@ -90,6 +94,88 @@ public class MantleWriterPreObjectJournalTest {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentTransactionsKeepOverlaysPrivateDuringObjectPublication() throws Exception {
|
||||
NativeBlockState original = mock(NativeBlockState.class);
|
||||
NativeBlockState published = mock(NativeBlockState.class);
|
||||
NativeBlockState firstOverlay = mock(NativeBlockState.class);
|
||||
NativeBlockState secondOverlay = mock(NativeBlockState.class);
|
||||
matter.<NativeBlockState>slice(NativeBlockState.class).set(X, Y, Z, original);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
try {
|
||||
Future<?> first = executor.submit(() -> verifyTransactionReads(original, firstOverlay, ready, start));
|
||||
Future<?> second = executor.submit(() -> verifyTransactionReads(original, secondOverlay, ready, start));
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS));
|
||||
start.countDown();
|
||||
writer.withComponentPriority(2, () -> {
|
||||
for (int pass = 0; pass < 100; pass++) {
|
||||
writer.setData(X, Y, Z, published);
|
||||
}
|
||||
});
|
||||
first.get(5, TimeUnit.SECONDS);
|
||||
second.get(5, TimeUnit.SECONDS);
|
||||
assertSame(published, writer.getDataIfPresent(X, Y, Z, NativeBlockState.class));
|
||||
assertSame(original, writer.getPrerequisiteBlock(X, Y, Z));
|
||||
} finally {
|
||||
start.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void integerPrerequisitesDoNotReadTheObjectJournal() {
|
||||
matter.<Integer>slice(Integer.class).set(X, Y, Z, 17);
|
||||
clearInvocations(matter);
|
||||
|
||||
assertEquals(Integer.valueOf(17), writer.getPrerequisiteDataIfPresent(X, Y, Z, Integer.class));
|
||||
|
||||
verify(matter, never()).getSlice(PreObjectMatterCell.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hydrologyPrerequisitesHonorCapturedOriginalsAndNulls() {
|
||||
HydrologyCaveCell original = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR);
|
||||
HydrologyCaveCell live = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD);
|
||||
matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class).set(X, Y, Z, live);
|
||||
matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class).set(X, Y + 1, Z, live);
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class)
|
||||
.set(X, Y, Z, PreObjectMatterCell.hydrology(original));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class)
|
||||
.set(X, Y + 1, Z, PreObjectMatterCell.hydrology(null));
|
||||
|
||||
assertSame(original, writer.getPrerequisiteDataIfPresent(X, Y, Z, HydrologyCaveCell.class));
|
||||
assertNull(writer.getPrerequisiteDataIfPresent(X, Y + 1, Z, HydrologyCaveCell.class));
|
||||
assertSame(live, writer.getDataIfPresent(X, Y, Z, HydrologyCaveCell.class));
|
||||
assertSame(live, writer.getDataIfPresent(X, Y + 1, Z, HydrologyCaveCell.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carvedColumnsHonorCapturedHydrologyBeforeLiveCells() {
|
||||
HydrologyCaveCell original = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR);
|
||||
HydrologyCaveCell live = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD);
|
||||
MatterSlice<HydrologyCaveCell> hydrology = matter.slice(HydrologyCaveCell.class);
|
||||
MatterSlice<PreObjectMatterCell> journal = matter.slice(PreObjectMatterCell.class);
|
||||
hydrology.set(X, Y, Z, live);
|
||||
hydrology.set(X, Y + 1, Z, live);
|
||||
hydrology.set(X, Y + 2, Z, original);
|
||||
journal.set(X, Y, Z, PreObjectMatterCell.hydrology(original));
|
||||
journal.set(X, Y + 1, Z, PreObjectMatterCell.hydrology(null));
|
||||
journal.set(X, Y + 2, Z, PreObjectMatterCell.hydrology(live));
|
||||
matter.<MatterCavern>slice(MatterCavern.class)
|
||||
.set(X, Y + 1, Z, new MatterCavern(true, "cave", (byte) 0));
|
||||
|
||||
byte[] carved = writer.getPrerequisiteCarvedColumn(X, Z, 32);
|
||||
|
||||
assertEquals(1, carved[Y]);
|
||||
assertEquals(1, carved[Y + 1]);
|
||||
assertEquals(0, carved[Y + 2]);
|
||||
for (int y = 0; y < carved.length; y++) {
|
||||
assertEquals(writer.isPrerequisiteCarved(X, y, Z), carved[y] != 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstMutationCapturesEachSupportedOriginalIndependently() {
|
||||
NativeBlockState originalBlock = mock(NativeBlockState.class);
|
||||
@@ -207,6 +293,23 @@ public class MantleWriterPreObjectJournalTest {
|
||||
assertFalse(writer.restorePrerequisiteCell(X + 1, Y, Z));
|
||||
}
|
||||
|
||||
private void verifyTransactionReads(
|
||||
NativeBlockState original,
|
||||
NativeBlockState overlay,
|
||||
CountDownLatch ready,
|
||||
CountDownLatch start
|
||||
) {
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
ready.countDown();
|
||||
await(start);
|
||||
for (int pass = 0; pass < 100; pass++) {
|
||||
assertSame(original, transaction.get(X, Y, Z));
|
||||
}
|
||||
transaction.setData(X, Y, Z, overlay);
|
||||
assertSame(overlay, transaction.get(X, Y, Z));
|
||||
assertSame(original, writer.getPrerequisiteBlock(X, Y, Z));
|
||||
}
|
||||
|
||||
private void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS));
|
||||
|
||||
+263
-1
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.generation.mantle;
|
||||
|
||||
import art.arcane.iris.integration.Identifier;
|
||||
import art.arcane.iris.generation.decoration.tree.TreeBlockMaterial;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.world.history.BoundaryColumnGeometry;
|
||||
@@ -27,6 +28,9 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
@@ -47,6 +51,219 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ObjectDestinationTransactionTest {
|
||||
@Test
|
||||
public void commonAndGenericMetadataSurviveBlockReplacementAndSourceReplay() {
|
||||
MantleWriter writer = writer();
|
||||
NativeBlockState custom = mock(NativeBlockState.class);
|
||||
NativeBlockState base = mock(NativeBlockState.class);
|
||||
NativeBlockState replacement = mock(NativeBlockState.class);
|
||||
Identifier identifier = Identifier.fromString("iris:tree_block");
|
||||
when(custom.isCustom()).thenReturn(true);
|
||||
when(custom.deferredPlacementKey()).thenReturn(identifier.toString());
|
||||
when(custom.placementBaseState()).thenReturn(base);
|
||||
TreeBlockMaterial firstMaterial = new TreeBlockMaterial("minecraft:oak_log");
|
||||
TreeBlockMaterial secondMaterial = new TreeBlockMaterial("minecraft:birch_log");
|
||||
Marker generic = new Marker("generic");
|
||||
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
int[][] positions = {{-17, 31}, {Integer.MIN_VALUE, Integer.MAX_VALUE}};
|
||||
for (int[] position : positions) {
|
||||
int x = position[0];
|
||||
int z = position[1];
|
||||
source.setData(x, 7, z, "first");
|
||||
source.setData(x, 7, z, firstMaterial);
|
||||
source.setData(x, 7, z, generic);
|
||||
source.set(x, 7, z, custom);
|
||||
source.setData(x, 7, z, "second");
|
||||
source.setData(x, 7, z, secondMaterial);
|
||||
source.setData(x, 7, z, Integer.valueOf(42));
|
||||
source.setData(x, 7, z, replacement);
|
||||
source.setData(x, 7, z, null);
|
||||
assertNull(source.getDataIfPresent(x, 7, z, Identifier.class));
|
||||
}
|
||||
ObjectSourcePlan plan = source.sourcePlanSince(0);
|
||||
assertEquals(16, source.mutationCheckpoint());
|
||||
assertEquals(35, plan.mutationWeight());
|
||||
for (int[] position : positions) {
|
||||
int x = position[0];
|
||||
int z = position[1];
|
||||
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, x >> 4, z >> 4);
|
||||
destination.apply(plan);
|
||||
List<ObjectDestinationTransaction.Mutation> sourceMutations = plan.mutationsFor(x >> 4, z >> 4);
|
||||
List<ObjectDestinationTransaction.Mutation> replayed = destination.sourcePlanSince(0).mutationsFor(x >> 4, z >> 4);
|
||||
assertEquals(sourceMutations.size(), replayed.size());
|
||||
for (int index = 0; index < sourceMutations.size(); index++) {
|
||||
assertSame(sourceMutations.get(index), replayed.get(index));
|
||||
}
|
||||
assertEquals(8, destination.mutationCheckpoint());
|
||||
assertSame(replacement, destination.get(x, 7, z));
|
||||
assertNull(destination.getDataIfPresent(x, 7, z, Identifier.class));
|
||||
assertEquals("second", destination.getDataIfPresent(x, 7, z, String.class));
|
||||
assertSame(secondMaterial, destination.getDataIfPresent(x, 7, z, TreeBlockMaterial.class));
|
||||
assertSame(generic, destination.getDataIfPresent(x, 7, z, Marker.class));
|
||||
assertEquals(Integer.valueOf(42), destination.getDataIfPresent(x, 7, z, Integer.class));
|
||||
destination.commit();
|
||||
InOrder order = inOrder(writer);
|
||||
order.verify(writer).setData(x, 7, z, "first");
|
||||
order.verify(writer).setData(x, 7, z, firstMaterial);
|
||||
order.verify(writer).setData(x, 7, z, generic);
|
||||
order.verify(writer).set(x, 7, z, custom);
|
||||
order.verify(writer).setData(x, 7, z, "second");
|
||||
order.verify(writer).setData(x, 7, z, secondMaterial);
|
||||
order.verify(writer).setData(x, 7, z, Integer.valueOf(42));
|
||||
order.verify(writer).setData(x, 7, z, replacement);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replayRechecksDestinationProtectionAndHeightBeforeRetainingMutations() {
|
||||
MantleWriter sourceWriter = writer();
|
||||
when(sourceWriter.getMantle().getWorldHeight()).thenReturn(128);
|
||||
NativeBlockState block = mock(NativeBlockState.class);
|
||||
NativeBlockState custom = mock(NativeBlockState.class);
|
||||
when(custom.isCustom()).thenReturn(true);
|
||||
when(custom.deferredPlacementKey()).thenReturn("iris:tree_block");
|
||||
when(custom.placementBaseState()).thenReturn(block);
|
||||
MatterCavern cavern = new MatterCavern(true, "", (byte) 0);
|
||||
TreeBlockMaterial material = new TreeBlockMaterial("minecraft:oak_log");
|
||||
ObjectDestinationTransaction source = new ObjectDestinationTransaction(sourceWriter, 0, 0);
|
||||
source.setData(0, 4, 0, block);
|
||||
source.setData(1, 4, 0, cavern);
|
||||
source.set(2, 4, 0, custom);
|
||||
source.setData(3, 0, 0, block);
|
||||
source.setData(4, 80, 0, block);
|
||||
source.setData(0, 4, 0, "tree");
|
||||
source.setData(0, 4, 0, material);
|
||||
ObjectSourcePlan plan = source.sourcePlanSince(0);
|
||||
assertEquals(7, plan.mutationsFor(0, 0).size());
|
||||
MantleWriter destinationWriter = writer();
|
||||
when(destinationWriter.getEngine().getDimension().isBedrock()).thenReturn(true);
|
||||
for (int x = 0; x < 3; x++) {
|
||||
when(destinationWriter.getPrerequisiteDataIfPresent(x, 4, 0, HydrologyCaveCell.class))
|
||||
.thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD));
|
||||
}
|
||||
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(destinationWriter, 0, 0);
|
||||
|
||||
destination.apply(plan);
|
||||
|
||||
List<ObjectDestinationTransaction.Mutation> replayed = destination.sourcePlanSince(0).mutationsFor(0, 0);
|
||||
assertEquals(2, replayed.size());
|
||||
assertSame(plan.mutationsFor(0, 0).get(5), replayed.get(0));
|
||||
assertSame(plan.mutationsFor(0, 0).get(6), replayed.get(1));
|
||||
assertNull(destination.getDataIfPresent(2, 4, 0, Identifier.class));
|
||||
assertNull(destination.getDataIfPresent(1, 4, 0, MatterCavern.class));
|
||||
assertEquals("tree", destination.getDataIfPresent(0, 4, 0, String.class));
|
||||
assertSame(material, destination.getDataIfPresent(0, 4, 0, TreeBlockMaterial.class));
|
||||
destination.commit();
|
||||
verify(destinationWriter, never()).setData(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class));
|
||||
verify(destinationWriter, never()).set(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class));
|
||||
verify(destinationWriter, never()).setData(anyInt(), anyInt(), anyInt(), any(MatterCavern.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCommonMetadataPublicationRestoresOriginalValuesAndMissingTypes() {
|
||||
MantleWriter writer = writer();
|
||||
TreeBlockMaterial originalMaterial = new TreeBlockMaterial("minecraft:oak_log");
|
||||
TreeBlockMaterial replacementMaterial = new TreeBlockMaterial("minecraft:birch_log");
|
||||
Marker generic = new Marker("generic");
|
||||
Marker failure = new Marker("failure");
|
||||
when(writer.getPrerequisiteDataIfPresent(0, 4, 0, String.class)).thenReturn("original");
|
||||
when(writer.getPrerequisiteDataIfPresent(0, 4, 0, TreeBlockMaterial.class)).thenReturn(originalMaterial);
|
||||
doThrow(new IllegalStateException("publication failed"))
|
||||
.when(writer).setData(1, 4, 0, failure);
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
assertEquals("original", transaction.getDataIfPresent(0, 4, 0, String.class));
|
||||
assertSame(originalMaterial, transaction.getDataIfPresent(0, 4, 0, TreeBlockMaterial.class));
|
||||
transaction.setData(0, 4, 0, "replacement");
|
||||
transaction.setData(0, 4, 0, replacementMaterial);
|
||||
transaction.setData(0, 4, 0, generic);
|
||||
transaction.setData(1, 4, 0, failure);
|
||||
|
||||
assertThrows(IllegalStateException.class, transaction::commit);
|
||||
|
||||
verify(writer).clearData(0, 4, 0, String.class);
|
||||
verify(writer).setData(0, 4, 0, "original");
|
||||
verify(writer).clearData(0, 4, 0, TreeBlockMaterial.class);
|
||||
verify(writer).setData(0, 4, 0, originalMaterial);
|
||||
verify(writer).clearData(0, 4, 0, Marker.class);
|
||||
verify(writer).setData(0, 4, 0, generic);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sparseOverlayMatchesScalarAndColumnReadsAcrossSignedCoordinates() {
|
||||
MantleWriter writer = writer();
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
int[][] columns = {{0, 0}, {-1, -1}, {1, -1}, {-1, 1},
|
||||
{Integer.MIN_VALUE, Integer.MAX_VALUE}, {Integer.MAX_VALUE, Integer.MIN_VALUE},
|
||||
{0, 1 << 26}, {1 << 26, 0}};
|
||||
NativeBlockState solid = mock(NativeBlockState.class);
|
||||
NativeBlockState air = mock(NativeBlockState.class);
|
||||
NativeBlockState fluid = mock(NativeBlockState.class);
|
||||
when(air.isAir()).thenReturn(true);
|
||||
when(fluid.isFluid()).thenReturn(true);
|
||||
MatterCavern cavern = new MatterCavern(true, "", (byte) 0);
|
||||
Object[] values = {solid, air, fluid, cavern,
|
||||
HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD),
|
||||
HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)};
|
||||
Map<ObjectDestinationTransaction.DataKey, Object> expected = new HashMap<>();
|
||||
Random random = new Random(918241L);
|
||||
for (int pass = 0; pass < 12; pass++) {
|
||||
for (int write = 0; write < 80; write++) {
|
||||
int[] column = columns[random.nextInt(columns.length)];
|
||||
int y = random.nextInt(64);
|
||||
Object value = values[random.nextInt(values.length)];
|
||||
transaction.setData(column[0], y, column[1], value);
|
||||
HydrologyCaveCell hydrology = (HydrologyCaveCell) expected.get(
|
||||
new ObjectDestinationTransaction.DataKey(column[0], y, column[1], HydrologyCaveCell.class));
|
||||
if (!(value instanceof HydrologyCaveCell)
|
||||
&& hydrology != null && hydrology.protectsPlacement()) {
|
||||
continue;
|
||||
}
|
||||
Class<?> type = value instanceof NativeBlockState ? NativeBlockState.class : value.getClass();
|
||||
expected.put(new ObjectDestinationTransaction.DataKey(column[0], y, column[1], type), value);
|
||||
}
|
||||
for (int[] column : columns) {
|
||||
byte[] carved = new byte[64];
|
||||
for (int y = 0; y < 64; y++) {
|
||||
NativeBlockState block = (NativeBlockState) expected.get(
|
||||
new ObjectDestinationTransaction.DataKey(column[0], y, column[1], NativeBlockState.class));
|
||||
HydrologyCaveCell hydrology = (HydrologyCaveCell) expected.get(
|
||||
new ObjectDestinationTransaction.DataKey(column[0], y, column[1], HydrologyCaveCell.class));
|
||||
MatterCavern expectedCavern = (MatterCavern) expected.get(
|
||||
new ObjectDestinationTransaction.DataKey(column[0], y, column[1], MatterCavern.class));
|
||||
boolean isCarved = block != solid && (hydrology == null
|
||||
? expectedCavern != null : hydrology.carves());
|
||||
carved[y] = isCarved ? (byte) 1 : 0;
|
||||
assertSame(block, transaction.get(column[0], y, column[1]));
|
||||
assertSame(hydrology, transaction.getDataIfPresent(column[0], y, column[1], HydrologyCaveCell.class));
|
||||
assertSame(expectedCavern, transaction.getDataIfPresent(column[0], y, column[1], MatterCavern.class));
|
||||
assertEquals(isCarved, transaction.isCarved(column[0], y, column[1]));
|
||||
}
|
||||
assertArrayEquals(carved, transaction.getCarvedColumn(column[0], column[1], 64));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlayReadsLeavePrerequisiteUpdatesVisibleAndReturnedColumnsIndependent() {
|
||||
MantleWriter writer = writer();
|
||||
NativeBlockState first = mock(NativeBlockState.class);
|
||||
NativeBlockState second = mock(NativeBlockState.class);
|
||||
when(writer.getPrerequisiteBlock(-1, 5, -1)).thenReturn(first, second);
|
||||
byte[] prerequisite = new byte[64];
|
||||
prerequisite[5] = 1;
|
||||
when(writer.getPrerequisiteCarvedColumn(-1, -1, 64)).thenReturn(prerequisite);
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, -1, -1);
|
||||
|
||||
assertSame(first, transaction.get(-1, 5, -1));
|
||||
assertSame(second, transaction.get(-1, 5, -1));
|
||||
byte[] column = transaction.getCarvedColumn(-1, -1, 64);
|
||||
column[5] = 0;
|
||||
assertEquals(1, transaction.getCarvedColumn(-1, -1, 64)[5]);
|
||||
transaction.setData(-1, 5, -1, second);
|
||||
assertEquals(0, transaction.getCarvedColumn(-1, -1, 64)[5]);
|
||||
assertEquals(1, prerequisite[5]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlayReadsEarlierWritesAndCommitsOnlyDestinationChunk() {
|
||||
MantleWriter writer = writer();
|
||||
@@ -122,6 +339,46 @@ public class ObjectDestinationTransactionTest {
|
||||
verify(writer, never()).setData(-1, 4, 16, foreign);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void indexedSourceReplayPreservesCustomIdentityAndWriteOrderAtNegativeChunks() {
|
||||
MantleWriter writer = writer();
|
||||
NativeBlockState custom = mock(NativeBlockState.class);
|
||||
NativeBlockState base = mock(NativeBlockState.class);
|
||||
NativeBlockState replacement = mock(NativeBlockState.class);
|
||||
Identifier identifier = Identifier.fromString("iris:custom_block");
|
||||
when(custom.isCustom()).thenReturn(true);
|
||||
when(custom.deferredPlacementKey()).thenReturn(identifier.toString());
|
||||
when(custom.placementBaseState()).thenReturn(base);
|
||||
Marker first = new Marker("first");
|
||||
Marker second = new Marker("second");
|
||||
Marker foreign = new Marker("foreign");
|
||||
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
source.setData(-17, 4, 31, first);
|
||||
source.set(-17, 4, 31, custom);
|
||||
source.setData(-16, 4, 31, foreign);
|
||||
source.setData(-17, 4, 31, replacement);
|
||||
source.setData(-17, 4, 32, foreign);
|
||||
source.set(-17, 4, 31, custom);
|
||||
source.setData(-17, 4, 31, second);
|
||||
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, -2, 1);
|
||||
|
||||
destination.apply(source.sourcePlanSince(0));
|
||||
|
||||
assertEquals(5, destination.mutationCheckpoint());
|
||||
assertSame(base, destination.get(-17, 4, 31));
|
||||
assertEquals(identifier, destination.getDataIfPresent(-17, 4, 31, Identifier.class));
|
||||
assertSame(second, destination.getDataIfPresent(-17, 4, 31, Marker.class));
|
||||
destination.commit();
|
||||
InOrder order = inOrder(writer);
|
||||
order.verify(writer).setData(-17, 4, 31, first);
|
||||
order.verify(writer).set(-17, 4, 31, custom);
|
||||
order.verify(writer).setData(-17, 4, 31, replacement);
|
||||
order.verify(writer).set(-17, 4, 31, custom);
|
||||
order.verify(writer).setData(-17, 4, 31, second);
|
||||
verify(writer, never()).setData(-16, 4, 31, foreign);
|
||||
verify(writer, never()).setData(-17, 4, 32, foreign);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourcePlanRejectsAnInvalidCheckpoint() {
|
||||
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer(), 0, 0);
|
||||
@@ -340,8 +597,13 @@ public class ObjectDestinationTransactionTest {
|
||||
transaction.setData(0, 4, 0, block);
|
||||
assertNull(transaction.getDataIfPresent(0, 4, 0, Identifier.class));
|
||||
transaction.setData(1, 4, 0, failure);
|
||||
ObjectSourcePlan plan = transaction.sourcePlanSince(0);
|
||||
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 0, 0);
|
||||
destination.apply(plan);
|
||||
assertNull(destination.getDataIfPresent(0, 4, 0, Identifier.class));
|
||||
assertSame(plan.mutationsFor(0, 0).getFirst(), destination.sourcePlanSince(0).mutationsFor(0, 0).getFirst());
|
||||
|
||||
assertThrows(IllegalStateException.class, transaction::commit);
|
||||
assertThrows(IllegalStateException.class, destination::commit);
|
||||
verify(writer).clearData(0, 4, 0, Identifier.class);
|
||||
verify(writer).setData(0, 4, 0, originalIdentifier);
|
||||
}
|
||||
|
||||
+147
-1
@@ -1,22 +1,91 @@
|
||||
package art.arcane.iris.generation.mantle;
|
||||
|
||||
import art.arcane.volmlib.util.cache.CacheKey;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ObjectSourcePlanCacheTest {
|
||||
@Test
|
||||
public void defaultCapacityRetainsNeighboringSourceWorkingSetWithoutRebuilding() {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache();
|
||||
AtomicInteger builds = new AtomicInteger();
|
||||
ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(
|
||||
0, 4, 0, String.class);
|
||||
ObjectDestinationTransaction.Mutation mutation = new ObjectDestinationTransaction.SetMutation(key, "marker");
|
||||
ObjectSourcePlan expected = new ObjectSourcePlan(Collections.nCopies(2048, mutation));
|
||||
assertTrue(expected.mutationWeight() * 1024L > 1_048_576L);
|
||||
for (int sweep = 0; sweep < 3; sweep++) {
|
||||
for (int x = -16; x < 16; x++) {
|
||||
for (int z = -16; z < 16; z++) {
|
||||
assertSame(expected, cache.get(x, z, () -> {
|
||||
builds.incrementAndGet();
|
||||
return expected;
|
||||
}));
|
||||
}
|
||||
}
|
||||
assertEquals(1024L, cache.estimatedSize());
|
||||
assertEquals(1024, builds.get());
|
||||
}
|
||||
cache.clear();
|
||||
assertEquals(0L, cache.estimatedSize());
|
||||
assertSame(expected, cache.get(-16, -16, () -> {
|
||||
builds.incrementAndGet();
|
||||
return expected;
|
||||
}));
|
||||
assertEquals(1025, builds.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedCoordinatesBuildWhileAnotherPlanIsPending() throws Exception {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
ObjectSourcePlan first = planAt(1);
|
||||
ObjectSourcePlan second = planAt(2);
|
||||
CountDownLatch firstEntered = new CountDownLatch(1);
|
||||
CountDownLatch releaseFirst = new CountDownLatch(1);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
assertEquals(Long.hashCode(CacheKey.mix(CacheKey.key(32113, 37))),
|
||||
Long.hashCode(CacheKey.mix(CacheKey.key(32667, 37))));
|
||||
Future<ObjectSourcePlan> firstResult = executor.submit(() -> cache.get(32113, 37, () -> {
|
||||
firstEntered.countDown();
|
||||
try {
|
||||
assertTrue(releaseFirst.await(10L, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
return first;
|
||||
}));
|
||||
assertTrue(firstEntered.await(5L, TimeUnit.SECONDS));
|
||||
Future<ObjectSourcePlan> secondResult = executor.submit(() -> cache.get(32667, 37, () -> second));
|
||||
|
||||
assertSame(second, secondResult.get(3L, TimeUnit.SECONDS));
|
||||
releaseFirst.countDown();
|
||||
assertSame(first, firstResult.get(5L, TimeUnit.SECONDS));
|
||||
assertSame(first, cache.get(32113, 37, () -> second));
|
||||
assertSame(second, cache.get(32667, 37, () -> first));
|
||||
} finally {
|
||||
releaseFirst.countDown();
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentRequestsBuildOneSourcePlan() throws Exception {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
@@ -51,9 +120,9 @@ public class ObjectSourcePlanCacheTest {
|
||||
|
||||
@Test
|
||||
public void cacheEvictsByMutationWeightAndCanBeCleared() {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(3L);
|
||||
ObjectSourcePlan first = planAt(0);
|
||||
ObjectSourcePlan second = planAt(1);
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(first.mutationWeight());
|
||||
|
||||
cache.get(0, 0, () -> first);
|
||||
cache.get(1, 0, () -> second);
|
||||
@@ -68,6 +137,83 @@ public class ObjectSourcePlanCacheTest {
|
||||
assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlanCache(0L));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearingPendingWorkKeepsNewGenerationIsolated() throws Exception {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
ObjectSourcePlan old = planAt(1);
|
||||
ObjectSourcePlan replacement = planAt(2);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
|
||||
Future<ObjectSourcePlan> first = executor.submit(() -> cache.get(7, 8, () -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
return old;
|
||||
}));
|
||||
try {
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
cache.clear();
|
||||
assertSame(replacement, cache.get(7, 8, () -> replacement));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
assertSame(old, first.get(5L, TimeUnit.SECONDS));
|
||||
assertSame(replacement, cache.get(7, 8, () -> old));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void waitingForkJoinWorkerAllowsQueuedDependencyToRun() throws Exception {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
ObjectSourcePlan expected = planAt(1);
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch waiting = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try (ExecutorService owner = Executors.newSingleThreadExecutor();
|
||||
ForkJoinPool pool = new ForkJoinPool(1)) {
|
||||
Future<ObjectSourcePlan> first = owner.submit(() -> cache.get(7, 8, () -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
return expected;
|
||||
}));
|
||||
try {
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
Future<ObjectSourcePlan> second = pool.submit(() -> {
|
||||
waiting.countDown();
|
||||
return cache.get(7, 8, () -> { throw new AssertionError("Duplicate build"); });
|
||||
});
|
||||
assertTrue(waiting.await(5L, TimeUnit.SECONDS));
|
||||
pool.submit(release::countDown).get(5L, TimeUnit.SECONDS);
|
||||
assertSame(expected, first.get(5L, TimeUnit.SECONDS));
|
||||
assertSame(expected, second.get(5L, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedAndNullBuildsAreRetryableAndRecursionFailsImmediately() {
|
||||
ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L);
|
||||
IllegalArgumentException failure = new IllegalArgumentException("Build failed");
|
||||
assertSame(failure, assertThrows(IllegalArgumentException.class,
|
||||
() -> cache.get(7, 8, () -> { throw failure; })));
|
||||
assertNull(cache.get(7, 8, () -> null));
|
||||
assertThrows(IllegalStateException.class, () -> cache.get(7, 8,
|
||||
() -> cache.get(7, 8, () -> planAt(1))));
|
||||
ObjectSourcePlan expected = planAt(1);
|
||||
assertSame(expected, cache.get(7, 8, () -> expected));
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(10L, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static ObjectSourcePlan planAt(int x) {
|
||||
ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey(
|
||||
x,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package art.arcane.iris.generation.mantle;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ObjectSourcePlanTest {
|
||||
@Test
|
||||
public void destinationLookupRetainsImmutableIndexedOrder() {
|
||||
List<ObjectDestinationTransaction.Mutation> mutations = new ArrayList<>();
|
||||
int[][] positions = {{-1, -1}, {0, 0}, {-16, -16}, {-17, -1},
|
||||
{Integer.MIN_VALUE, Integer.MAX_VALUE}, {Integer.MAX_VALUE, Integer.MIN_VALUE}};
|
||||
for (int pass = 0; pass < 3; pass++) {
|
||||
for (int[] position : positions) {
|
||||
mutations.add(mutationAt(position[0], position[1]));
|
||||
}
|
||||
}
|
||||
ObjectSourcePlan plan = new ObjectSourcePlan(mutations);
|
||||
List<ObjectDestinationTransaction.Mutation> expected = List.of(
|
||||
mutations.get(0), mutations.get(2), mutations.get(6), mutations.get(8), mutations.get(12), mutations.get(14));
|
||||
mutations.clear();
|
||||
|
||||
List<ObjectDestinationTransaction.Mutation> local = plan.mutationsFor(-1, -1);
|
||||
assertEquals(expected, local);
|
||||
assertSame(local, plan.mutationsFor(-1, -1));
|
||||
assertEquals(3, plan.mutationsFor(Integer.MIN_VALUE >> 4, Integer.MAX_VALUE >> 4).size());
|
||||
assertEquals(3, plan.mutationsFor(Integer.MAX_VALUE >> 4, Integer.MIN_VALUE >> 4).size());
|
||||
assertTrue(plan.mutationsFor(1, -1).isEmpty());
|
||||
assertTrue(plan.mutationsFor(-1, 1).isEmpty());
|
||||
assertThrows(UnsupportedOperationException.class, () -> local.clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cacheWeightIncludesEachDestinationIndex() {
|
||||
ObjectDestinationTransaction.Mutation first = mutationAt(0, 0);
|
||||
ObjectDestinationTransaction.Mutation nearby = mutationAt(1, 0);
|
||||
ObjectDestinationTransaction.Mutation remote = mutationAt(16, 0);
|
||||
ObjectSourcePlan compact = new ObjectSourcePlan(List.of(first, nearby));
|
||||
ObjectSourcePlan scattered = new ObjectSourcePlan(List.of(first, remote));
|
||||
|
||||
assertTrue(compact.mutationWeight() > 1 + first.weight() + nearby.weight());
|
||||
assertTrue(scattered.mutationWeight() > compact.mutationWeight());
|
||||
assertThrows(NullPointerException.class, () -> new ObjectSourcePlan(Arrays.asList(first, null)));
|
||||
}
|
||||
|
||||
private static ObjectDestinationTransaction.Mutation mutationAt(int x, int z) {
|
||||
return new ObjectDestinationTransaction.SetMutation(
|
||||
new ObjectDestinationTransaction.DataKey(x, 4, z, String.class), "marker");
|
||||
}
|
||||
}
|
||||
+136
-2
@@ -34,6 +34,11 @@ import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
@@ -97,6 +102,128 @@ public class EngineShutdownDrainTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closingRuntimeAllowsItsBackgroundWorkersToOpenCapturedScopes() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
doCallRealMethod().when(fixture.engine).openGenerationRuntimeScope(any());
|
||||
IrisEngine.GenerationRuntimeBinding binding = new IrisEngine.GenerationRuntimeBinding(
|
||||
fixture.engine, fixture.generation);
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
doAnswer(invocation -> {
|
||||
Future<?> continuation = worker.submit(() -> {
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = fixture.engine.openGenerationRuntimeScope(binding)) {
|
||||
assertSame(binding, fixture.engine.generationRuntimeScopes.current());
|
||||
assertSame(fixture.runtime, fixture.engine.runtime);
|
||||
assertTrue(fixture.engine.getClosing().get());
|
||||
assertThrows(IllegalStateException.class, () -> fixture.engine.requireRunning("start generation"));
|
||||
}
|
||||
});
|
||||
continuation.get(5, TimeUnit.SECONDS);
|
||||
return null;
|
||||
}).when(fixture.complex).close();
|
||||
|
||||
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
|
||||
fixture.shutdown.close();
|
||||
assertTrue(fixture.engine.closed);
|
||||
assertThrows(IllegalStateException.class, () -> fixture.engine.openGenerationRuntimeScope(binding));
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentCloseWaitsForTheRunningShutdown() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
ExecutorService closer = Executors.newSingleThreadExecutor();
|
||||
CompletableFuture<Future<?>> concurrentClose = new CompletableFuture<>();
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
doAnswer(invocation -> {
|
||||
concurrentClose.complete(closer.submit(() -> {
|
||||
entered.countDown();
|
||||
fixture.shutdown.close();
|
||||
}));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(fixture.complex).close();
|
||||
|
||||
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
|
||||
fixture.shutdown.close();
|
||||
concurrentClose.get(5, TimeUnit.SECONDS).get(5, TimeUnit.SECONDS);
|
||||
verify(fixture.complex).close();
|
||||
verify(fixture.mantle).close();
|
||||
assertTrue(fixture.engine.closed);
|
||||
} finally {
|
||||
closer.shutdownNow();
|
||||
assertTrue(closer.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shutdownWaitsForConcurrentDetachedRetirementWithoutClosingItTwice() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
GenerationRuntime detached = generation(target(), mock(EngineMantle.class));
|
||||
IrisComplex detachedComplex = detached.complex();
|
||||
fixture.engine.detachedGenerationRuntimes.add(detached);
|
||||
CountDownLatch retirementStarted = new CountDownLatch(1);
|
||||
CountDownLatch allowRetirement = new CountDownLatch(1);
|
||||
CountDownLatch backgroundDrained = new CountDownLatch(1);
|
||||
doAnswer(invocation -> {
|
||||
retirementStarted.countDown();
|
||||
assertTrue(allowRetirement.await(5, TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(detachedComplex).close();
|
||||
when(fixture.engine.backgroundTasks.drainBackgroundTasks("close")).thenAnswer(invocation -> {
|
||||
backgroundDrained.countDown();
|
||||
return new EngineBackgroundTasks.BackgroundTaskDrain(null, true);
|
||||
});
|
||||
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<?> retiring = workers.submit(() -> fixture.engine.closeDetachedGenerationRuntime(
|
||||
new IrisEngine.GenerationRuntimeBinding(fixture.engine, detached)));
|
||||
assertTrue(retirementStarted.await(5, TimeUnit.SECONDS));
|
||||
Future<?> shutdown = workers.submit(() -> {
|
||||
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
|
||||
fixture.shutdown.close();
|
||||
}
|
||||
});
|
||||
assertTrue(backgroundDrained.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(shutdown.isDone());
|
||||
verify(fixture.target, never()).close();
|
||||
allowRetirement.countDown();
|
||||
retiring.get(5, TimeUnit.SECONDS);
|
||||
shutdown.get(5, TimeUnit.SECONDS);
|
||||
verify(detached.complex()).close();
|
||||
verify(detached.target()).close();
|
||||
assertTrue(fixture.engine.closed);
|
||||
} finally {
|
||||
allowRetirement.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedDetachedRetirementRetainsOwnershipForShutdownRetry() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
GenerationRuntime detached = generation(target(), mock(EngineMantle.class));
|
||||
IrisComplex detachedComplex = detached.complex();
|
||||
fixture.engine.detachedGenerationRuntimes.add(detached);
|
||||
doThrow(new IllegalStateException("Background work is still active")).doNothing()
|
||||
.when(detachedComplex).close();
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> fixture.engine.closeDetachedGenerationRuntime(
|
||||
new IrisEngine.GenerationRuntimeBinding(fixture.engine, detached)));
|
||||
assertTrue(fixture.engine.detachedGenerationRuntimes.contains(detached));
|
||||
verify(detached.target(), never()).close();
|
||||
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
|
||||
fixture.shutdown.close();
|
||||
}
|
||||
verify(detached.complex(), times(2)).close();
|
||||
verify(detached.target()).close();
|
||||
assertTrue(fixture.engine.closed);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void settledStructureBootstrapFailureDoesNotPreventEngineCleanup() throws Exception {
|
||||
ShutdownFixture fixture = new ShutdownFixture();
|
||||
@@ -726,14 +853,21 @@ public class EngineShutdownDrainTest {
|
||||
setField(engine, "lifecycleLock", new Object());
|
||||
setField(engine, "runtimeAssembly", new ThreadLocal<RuntimeAssembly>());
|
||||
setField(engine, "detachedGenerationRuntimes", ConcurrentHashMap.newKeySet());
|
||||
when(engine.getClosing()).thenReturn(new AtomicBoolean());
|
||||
setField(engine, "retiringGenerationRuntimes", ConcurrentHashMap.newKeySet());
|
||||
setField(engine, "generationRuntimeScopes", new GenerationRuntimeScopeState());
|
||||
AtomicBoolean closing = new AtomicBoolean();
|
||||
setField(engine, "closing", closing);
|
||||
when(engine.getClosing()).thenReturn(closing);
|
||||
when(engine.getGenerationSessions()).thenReturn(sessions);
|
||||
when(engine.getPlatformHooks()).thenReturn(mock(EnginePlatformHooks.class));
|
||||
when(engine.getMantle()).thenReturn(mantle);
|
||||
IrisWorld world = target.getWorld();
|
||||
when(engine.getWorld()).thenReturn(world);
|
||||
when(engine.beginShutdown()).thenReturn(true);
|
||||
doCallRealMethod().when(engine).beginShutdown();
|
||||
doCallRealMethod().when(engine).requireRunning(anyString());
|
||||
doCallRealMethod().when(engine).closeDetachedGenerationRuntime(any());
|
||||
engine.runtime = runtime;
|
||||
engine.lifecycleState = IrisEngine.LifecycleState.RUNNING;
|
||||
engine.publishedTarget = target;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-7
@@ -19,6 +19,8 @@ import art.arcane.volmlib.util.function.Consumer4;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import org.junit.Test;
|
||||
@@ -208,6 +210,7 @@ public class IrisCarveWallPaletteScopeTest {
|
||||
engine.runtime = published;
|
||||
doReturn(IrisWorld.builder().minHeight(-64).maxHeight(0).build()).when(engine).getWorld();
|
||||
doReturn(64).when(engine).getHeight();
|
||||
doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks();
|
||||
doReturn(mock(EngineMetrics.class, RETURNS_DEEP_STUBS)).when(engine).getMetrics();
|
||||
doReturn(48).when(context).getRoundedHeight(anyInt(), anyInt());
|
||||
IrisComplex activeComplex = active.complex();
|
||||
@@ -230,13 +233,15 @@ public class IrisCarveWallPaletteScopeTest {
|
||||
paletteCalls.incrementAndGet();
|
||||
return wall;
|
||||
}).when(palette).get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class));
|
||||
doAnswer(call -> {
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(
|
||||
new IrisEngine.GenerationRuntimeBinding(engine, active))) {
|
||||
assertSame(engine.runtimeAssembly.get() == null ? active.data() : assemblyData, engine.getData());
|
||||
}
|
||||
return biome;
|
||||
}).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
for (IrisComplex complex : new IrisComplex[]{activeComplex, detachedComplex, assemblyComplex}) {
|
||||
doReturn(ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> null)))
|
||||
.when(complex).getTrueBiomeStream();
|
||||
doReturn(ProceduralStream.of((x, z) -> scopedBiome(biome), Interpolated.of(value -> 0D, value -> null)))
|
||||
.when(complex).getCaveBiomeStream();
|
||||
doReturn(ProceduralStream.of((x, z) -> 48D, Interpolated.DOUBLE))
|
||||
.when(complex).getHeightStream();
|
||||
}
|
||||
doAnswer(call -> scopedBiome(biome)).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doAnswer(call -> {
|
||||
dataLookups.incrementAndGet();
|
||||
@@ -246,5 +251,14 @@ public class IrisCarveWallPaletteScopeTest {
|
||||
field(modifier, IrisCarveModifier.class, "AIR", air);
|
||||
field(modifier, IrisCarveModifier.class, "LAVA", air);
|
||||
}
|
||||
|
||||
private IrisBiome scopedBiome(IrisBiome biome) {
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(
|
||||
new IrisEngine.GenerationRuntimeBinding(engine, active))) {
|
||||
assertSame(engine.runtimeAssembly.get() == null ? active.data() : assemblyData, engine.getData());
|
||||
}
|
||||
return biome;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+90
-3
@@ -84,6 +84,72 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
}
|
||||
}
|
||||
|
||||
public static class CoarseBounds {
|
||||
@Test
|
||||
public void coarseBoundsUseGlobalCornersAcrossNegativeChunkSeams() throws Exception {
|
||||
for (int step : new int[]{8, 16, 32}) {
|
||||
IrisComplex complex = createComplex();
|
||||
setStep(complex, step);
|
||||
CoordinateInterpolator interpolator = new CoordinateInterpolator();
|
||||
for (double x : new double[]{-33D, -32D, -17D, -16D, -1D, 0D, 15D, 16D, 31D, 32D}) {
|
||||
for (double z : new double[]{-17D, -0.25D, 0D, 15.5D, 16D}) {
|
||||
NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBoundsMethod(), interpolator, x, z);
|
||||
assertBoundsBitsEqual(expectedGridSampleBounds(x, z, step), actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coarseGridReducesDistinctEnvelopeEvaluationsForDenseTerrainNodes() throws Exception {
|
||||
for (int step : new int[]{4, 8, 16, 32}) {
|
||||
IrisComplex complex = createComplex();
|
||||
setStep(complex, step);
|
||||
CountingInterpolator interpolator = new CountingInterpolator(1D, 9D);
|
||||
for (int x = 0; x < 32; x += 4) {
|
||||
for (int z = 0; z < 32; z += 4) {
|
||||
NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBoundsMethod(), interpolator, x, z);
|
||||
assertEquals(1D, actual.min(), 0D);
|
||||
assertEquals(9D, actual.max(), 0D);
|
||||
}
|
||||
}
|
||||
int side = step == 4 ? 8 : 32 / step + 1;
|
||||
assertEquals(side * side, interpolator.getInvocations());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyInterpolatorGroupKeepsItsOwnEnvelopeAndGenerators() throws Exception {
|
||||
IrisComplex complex = createComplex();
|
||||
setStep(complex, 16);
|
||||
CountingInterpolator first = new CountingInterpolator(1D, 2D);
|
||||
CountingInterpolator second = new CountingInterpolator(10D, 20D);
|
||||
SampleGenerator firstGenerator = new SampleGenerator(0.25D);
|
||||
SampleGenerator secondGenerator = new SampleGenerator(0.75D);
|
||||
setField(complex, "frozenInterpolators", new IrisInterpolator[]{first, second});
|
||||
setField(complex, "frozenGenerators", new IrisGenerator[][]{{firstGenerator}, {secondGenerator}});
|
||||
setField(complex, "biomeBuffetGenerators", new HashMap<>());
|
||||
Method method = IrisComplex.class.getDeclaredMethod("getInterpolatedHeight", Engine.class,
|
||||
double.class, double.class, long.class);
|
||||
method.setAccessible(true);
|
||||
assertEquals(18.75D, (double) method.invoke(complex, null, -1D, 17D, 31L), 0D);
|
||||
assertEquals(4, first.getInvocations());
|
||||
assertEquals(4, second.getInvocations());
|
||||
assertEquals(1, firstGenerator.invocations);
|
||||
assertEquals(1, secondGenerator.invocations);
|
||||
}
|
||||
|
||||
private static void setStep(IrisComplex complex, int step) throws Exception {
|
||||
setField(complex, "biomeBoundsSamplingStep", step);
|
||||
}
|
||||
|
||||
private static void setField(IrisComplex complex, String name, Object value) throws Exception {
|
||||
Field field = IrisComplex.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(complex, value);
|
||||
}
|
||||
}
|
||||
|
||||
@RunWith(Parameterized.class)
|
||||
public static class LegacyBilerpParity {
|
||||
@Parameters(name = "x={0} z={1}")
|
||||
@@ -121,6 +187,9 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
|
||||
private static IrisComplex createComplex() throws Exception {
|
||||
IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
|
||||
Field biomeBoundsSamplingStep = IrisComplex.class.getDeclaredField("biomeBoundsSamplingStep");
|
||||
biomeBoundsSamplingStep.setAccessible(true);
|
||||
biomeBoundsSamplingStep.setInt(complex, 4);
|
||||
|
||||
Field generatorBounds = IrisComplex.class.getDeclaredField("generatorBounds");
|
||||
generatorBounds.setAccessible(true);
|
||||
@@ -192,7 +261,10 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
}
|
||||
|
||||
private static NoiseBounds legacyGridSampleBounds(double x, double z) {
|
||||
int grid = 4;
|
||||
return expectedGridSampleBounds(x, z, 4);
|
||||
}
|
||||
|
||||
private static NoiseBounds expectedGridSampleBounds(double x, double z, int grid) {
|
||||
int xi = (int) Math.floor(x);
|
||||
int zi = (int) Math.floor(z);
|
||||
int mask = grid - 1;
|
||||
@@ -262,6 +334,21 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SampleGenerator extends IrisGenerator {
|
||||
private final double value;
|
||||
private int invocations;
|
||||
|
||||
private SampleGenerator(double value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double getHeight(double x, double z, long seed) {
|
||||
invocations++;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CoordinateInterpolator extends IrisInterpolator {
|
||||
private final AtomicInteger invocations = new AtomicInteger();
|
||||
|
||||
@@ -272,11 +359,11 @@ public class IrisComplexGridBoundsCacheTest {
|
||||
}
|
||||
|
||||
private static double low(double x, double z) {
|
||||
return x * 0.125D - z * 0.0625D - 7.75D;
|
||||
return x * 0.125D - z * 0.0625D - 7.75D + x * x * 0.000125D;
|
||||
}
|
||||
|
||||
private static double high(double x, double z) {
|
||||
return x * -0.03125D + z * 0.1875D + 12.5D;
|
||||
return x * -0.03125D + z * 0.1875D + 12.5D + z * z * 0.00015625D;
|
||||
}
|
||||
|
||||
private int getInvocations() {
|
||||
|
||||
+2
@@ -4,6 +4,7 @@ import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.world.pregen.PregenPerformanceProfile;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyPlanner;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTile;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTileKey;
|
||||
@@ -335,6 +336,7 @@ public class IrisComplexHydrologyNoiseCacheTest {
|
||||
|
||||
private HydrologyTileCache planningCache(HydrologyNoiseCacheBudget budget) {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.settings()).thenReturn(HydrologyPlannerSettings.defaults());
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(mock(HydrologyTile.class));
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
cache.setTerrainPreparation(() -> complex.expandHydrologyNoiseCaches(budget));
|
||||
|
||||
+53
@@ -1,6 +1,9 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntimeContext;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeGenerationScope;
|
||||
import art.arcane.iris.generation.runtime.GenerationRuntime.BiomeMaxes;
|
||||
import art.arcane.iris.world.history.SavedBiomeRuntime;
|
||||
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
|
||||
@@ -32,6 +35,7 @@ import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Collections;
|
||||
@@ -47,6 +51,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
@@ -70,6 +77,52 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
@ClassRule
|
||||
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void hydrologyBasisWorkersRetainHistoricalBindingAndRestoreAmbientContext() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture historical = runtime(2, 2D, 2D, 2D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
setField(engine, "runtimeBuilder", new EngineRuntimeBuilder(engine));
|
||||
IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class, CALLS_REAL_METHODS);
|
||||
when(historical.data.getEngine()).thenReturn(engine);
|
||||
when(historical.complex.getHydrologyRuntime()).thenReturn(hydrology);
|
||||
IrisHydrologyRuntimeContext context = new IrisHydrologyRuntimeContext(17L, 384, historical.dimension,
|
||||
historical.data, (x, z, height) -> null, (x, z) -> 80D, (x, z) -> "constant",
|
||||
(x, z) -> false, footprint -> null, () -> false, () -> {});
|
||||
Field runtimeContext = IrisHydrologyRuntime.class.getDeclaredField("context");
|
||||
runtimeContext.setAccessible(true);
|
||||
runtimeContext.set(hydrology, context);
|
||||
Method capture = IrisHydrologyRuntime.class.getDeclaredMethod("captureBasisScope");
|
||||
capture.setAccessible(true);
|
||||
assertNull(capture.invoke(hydrology));
|
||||
Supplier<NativeGenerationScope> workerScope;
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(detachedBinding(engine, historical.runtime));
|
||||
IrisContext.Scope execution = IrisContext.open(engine, 73L, mock(ChunkContext.class))) {
|
||||
workerScope = (Supplier<NativeGenerationScope>) capture.invoke(hydrology);
|
||||
}
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
worker.submit(() -> {
|
||||
assertSame(active.complex, engine.getComplex());
|
||||
try (IrisContext.Scope ambient = IrisContext.open(engine, 99L, null)) {
|
||||
try (NativeGenerationScope selected = workerScope.get()) {
|
||||
assertSame(historical.complex, engine.getComplex());
|
||||
assertSame(historical.data, IrisContext.require().getData());
|
||||
assertEquals(73L, IrisContext.require().getGenerationSessionId());
|
||||
assertNull(IrisContext.require().getChunkContext());
|
||||
}
|
||||
assertSame(active.complex, engine.getComplex());
|
||||
assertEquals(99L, IrisContext.require().getGenerationSessionId());
|
||||
}
|
||||
assertNull(IrisContext.get());
|
||||
}).get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carvingResolverRestoresNestedRuntimeDefinitions() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
|
||||
+2
-2
@@ -128,7 +128,7 @@ public class IrisCarveModifierBiomeBlendTest {
|
||||
private static IrisCarveModifier modifier(Engine engine, RNG rng) throws ReflectiveOperationException {
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(mock(IrisComplex.class)).when(modifier).getComplex();
|
||||
doReturn(mock(IrisComplex.class)).when(engine).getComplex();
|
||||
Field field = IrisCarveModifier.class.getDeclaredField("rng");
|
||||
field.setAccessible(true);
|
||||
field.set(modifier, rng);
|
||||
@@ -138,7 +138,7 @@ public class IrisCarveModifierBiomeBlendTest {
|
||||
private static IrisBiome resolve(IrisCarveModifier modifier, int[] coordinate,
|
||||
Long2ObjectOpenHashMap<IrisBiome> cache) {
|
||||
return modifier.resolveCaveBoundaryBiome(null, coordinate[0], coordinate[1], coordinate[2],
|
||||
new IrisDimensionCarvingResolver.State(), cache, new HashMap<>());
|
||||
new IrisCarveModifier.CaveInputs(modifier.getEngine()), cache, new HashMap<>());
|
||||
}
|
||||
|
||||
private static int[] coordinate(int roll, int sign) {
|
||||
|
||||
+6
-4
@@ -50,7 +50,7 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(mock(IrisComplex.class)).when(modifier).getComplex();
|
||||
doReturn(mock(IrisComplex.class)).when(engine).getComplex();
|
||||
Field rng = IrisCarveModifier.class.getDeclaredField("rng");
|
||||
rng.setAccessible(true);
|
||||
rng.set(modifier, new RNG(71L));
|
||||
@@ -73,7 +73,7 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
|
||||
Long2ObjectOpenHashMap<IrisBiome> caveBiomeCache = new Long2ObjectOpenHashMap<>();
|
||||
Map<String, IrisBiome> customBiomeCache = new HashMap<>();
|
||||
IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State();
|
||||
IrisCarveModifier.CaveInputs resolverState = new IrisCarveModifier.CaveInputs(engine);
|
||||
|
||||
IrisBiome floor = modifier.resolveCaveBoundaryBiome(
|
||||
mantleChunk, 1, 6, 2, 40, 44, resolverState, caveBiomeCache, customBiomeCache);
|
||||
@@ -94,11 +94,13 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
doReturn(biomes).when(complex).getTrueBiomeStream();
|
||||
doReturn(surface).when(biomes).get(40, 44);
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(complex).when(modifier).getComplex();
|
||||
Engine engine = mock(Engine.class);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(complex).when(engine).getComplex();
|
||||
|
||||
IrisBiome biome = modifier.resolveCaveBoundaryBiome(
|
||||
new MatterCavern(true, "carving/deep", (byte) 0), 40, 42, 44,
|
||||
new IrisDimensionCarvingResolver.State(), new Long2ObjectOpenHashMap<>(), new HashMap<>());
|
||||
new IrisCarveModifier.CaveInputs(engine), new Long2ObjectOpenHashMap<>(), new HashMap<>());
|
||||
|
||||
assertSame(surface, biome);
|
||||
}
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package art.arcane.iris.generation.stage;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.context.ChunkContext;
|
||||
import art.arcane.iris.generation.context.IrisContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.EnginePlatformHooks;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.pack.loading.ResourceLoader;
|
||||
import art.arcane.iris.pack.value.IrisRange;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisCarveModifierCaveInputsTest {
|
||||
@Test
|
||||
public void capturedColumnsMatchScalarAcrossDepthStackAndCoordinateCollisions() {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
DimensionStackContext stack = mock(DimensionStackContext.class);
|
||||
when(fixture.engine.getDimensionStackContext()).thenReturn(stack);
|
||||
when(fixture.scalar.getDimensionStackContext()).thenReturn(stack);
|
||||
when(stack.getLayout(anyInt(), anyInt())).thenAnswer(call -> {
|
||||
DimensionStackLayout layout = mock(DimensionStackLayout.class);
|
||||
DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class);
|
||||
when(layer.biome()).thenReturn((int) call.getArgument(0) % 2 == 0 ? fixture.stacked : null);
|
||||
when(layout.surfaceLayer()).thenReturn(layer);
|
||||
return layout;
|
||||
});
|
||||
IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine);
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
for (int x : new int[]{-35, -32, -17, -16, 0, 16, 1024, Integer.MIN_VALUE, Integer.MAX_VALUE}) {
|
||||
for (int z : new int[]{-35, -16, 0, 3, 1024, Integer.MIN_VALUE, Integer.MAX_VALUE}) {
|
||||
for (int y : new int[]{0, 30, 32, 33, 34, 39, 40, 41}) {
|
||||
assertSame(fixture.scalar.getCaveBiome(x, y, z, state), inputs.resolve(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedHeightsReuseCapturedColumnAndIgnoreAmbientRuntimeReplacement() {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine);
|
||||
IrisComplex replacement = mock(IrisComplex.class);
|
||||
when(fixture.engine.getComplex()).thenReturn(replacement);
|
||||
when(fixture.engine.getDimension()).thenReturn(new IrisDimension());
|
||||
when(fixture.engine.getData()).thenReturn(mock(IrisData.class));
|
||||
for (int y = 0; y < 40; y++) {
|
||||
assertSame(y <= 33 ? fixture.cave : fixture.surface, inputs.resolve(-31, y, 6));
|
||||
}
|
||||
assertEquals(1, fixture.samples.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void configuredWorldHeightRangeRetainsCapturedPackAndPrecedesFallback() {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
IrisBiome configured = biome("configured", 0);
|
||||
ResourceLoader<IrisBiome> loader = mock(ResourceLoader.class);
|
||||
when(fixture.data.getBiomeLoader()).thenReturn(loader);
|
||||
when(loader.load("configured")).thenReturn(configured);
|
||||
IrisDimensionCarvingEntry root = new IrisDimensionCarvingEntry();
|
||||
root.setBiome("configured");
|
||||
root.setChildRecursionDepth(0);
|
||||
root.setWorldYRange(new IrisRange(-64, -48));
|
||||
fixture.dimension.setCarving(new KList<>(List.of(root)));
|
||||
IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(fixture.engine);
|
||||
when(fixture.engine.getDimension()).thenReturn(new IrisDimension());
|
||||
when(fixture.engine.getData()).thenReturn(mock(IrisData.class));
|
||||
assertSame(configured, inputs.resolve(-31, 0, 6));
|
||||
assertSame(configured, inputs.resolve(-31, 16, 6));
|
||||
assertEquals(0, fixture.samples.get());
|
||||
assertSame(fixture.cave, inputs.resolve(-31, 17, 6));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void genericAndMainThreadEnginesRetainCustomBiomeDispatch() {
|
||||
IrisBiome custom = biome("custom", 0);
|
||||
Engine generic = mock(Engine.class);
|
||||
when(generic.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(custom);
|
||||
assertSame(custom, new IrisCarveModifier.CaveInputs(generic).resolve(1, 2, 3));
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
when(fixture.hooks.isMainThread()).thenReturn(true);
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(custom);
|
||||
assertSame(custom, new IrisCarveModifier.CaveInputs(fixture.engine).resolve(1, 2, 3));
|
||||
assertEquals(0, fixture.samples.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentStageKeepsCoordinateHistoryDispatch() {
|
||||
try (Fixture fixture = new Fixture()) {
|
||||
when(fixture.context.isNaturalTerrain()).thenReturn(false);
|
||||
IrisBiome historical = biome("historical", 0);
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenReturn(historical);
|
||||
assertSame(historical, new IrisCarveModifier.CaveInputs(fixture.engine).resolve(-17, 20, 16));
|
||||
assertEquals(0, fixture.samples.get());
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisBiome biome(String key, int depth) {
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoadKey(key);
|
||||
biome.setCaveMinDepthBelowSurface(depth);
|
||||
return biome;
|
||||
}
|
||||
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
private final IrisContext.Scope scope;
|
||||
private final ChunkContext context = mock(ChunkContext.class);
|
||||
private final IrisEngine engine = mock(IrisEngine.class);
|
||||
private final Engine scalar = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
private final IrisComplex complex = mock(IrisComplex.class);
|
||||
private final IrisDimension dimension = new IrisDimension();
|
||||
private final IrisData data = mock(IrisData.class);
|
||||
private final EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||
private final IrisBiome surface = biome("surface", 3);
|
||||
private final IrisBiome cave = biome("cave", 7);
|
||||
private final IrisBiome unkeyed = biome(null, 2);
|
||||
private final IrisBiome stacked = biome("stacked", 5);
|
||||
private final AtomicInteger samples = new AtomicInteger();
|
||||
|
||||
private Fixture() {
|
||||
when(context.getComplex()).thenReturn(complex);
|
||||
when(context.isNaturalTerrain()).thenReturn(true);
|
||||
scope = IrisContext.open(engine, 1L, context);
|
||||
when(engine.hasGenerationRuntimeScope()).thenReturn(true);
|
||||
for (Engine target : new Engine[]{engine, scalar}) {
|
||||
doReturn(complex).when(target).getComplex();
|
||||
doReturn(dimension).when(target).getDimension();
|
||||
doReturn(data).when(target).getData();
|
||||
doReturn(hooks).when(target).getPlatformHooks();
|
||||
doReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build()).when(target).getWorld();
|
||||
}
|
||||
when(complex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> surface, Interpolated.of(value -> 0D, value -> null)));
|
||||
when(complex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> switch (Math.floorMod(x.intValue(), 3)) {
|
||||
case 0 -> null;
|
||||
case 1 -> unkeyed;
|
||||
default -> cave;
|
||||
}, Interpolated.of(value -> 0D, value -> null)));
|
||||
when(complex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return 40D;
|
||||
}, Interpolated.DOUBLE));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
scope.close();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -64,7 +64,7 @@ public class IrisCarveModifierInferenceIsolationTest {
|
||||
int.class,
|
||||
int.class,
|
||||
int.class,
|
||||
IrisDimensionCarvingResolver.State.class,
|
||||
IrisCarveModifier.CaveInputs.class,
|
||||
Long2ObjectOpenHashMap.class,
|
||||
Map.class
|
||||
);
|
||||
@@ -80,7 +80,7 @@ public class IrisCarveModifierInferenceIsolationTest {
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
new IrisDimensionCarvingResolver.State(),
|
||||
new IrisCarveModifier.CaveInputs(engine),
|
||||
new Long2ObjectOpenHashMap<IrisBiome>(),
|
||||
customBiomes
|
||||
);
|
||||
|
||||
+19
@@ -1,14 +1,33 @@
|
||||
package art.arcane.iris.generation.stage;
|
||||
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.iris.generation.biome.IrisFloatingChildBiomes;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisFloatingChildBiomeModifierStackTest {
|
||||
@Test
|
||||
public void floatingPaletteSeedDependsOnWorldAndBiomeInsteadOfColumnVisitOrder() {
|
||||
IrisFloatingChildBiomes first = new IrisFloatingChildBiomes().setBiome("magnetics/glass-shard");
|
||||
IrisFloatingChildBiomes repeated = new IrisFloatingChildBiomes().setBiome("magnetics/glass-shard");
|
||||
IrisFloatingChildBiomes other = new IrisFloatingChildBiomes().setBiome("other/floating");
|
||||
RNG world = new RNG(7191L ^ 0x7EB0A73F1DCE514DL);
|
||||
long expected = IrisFloatingChildBiomeModifier.paletteRng(world, first).getSeed();
|
||||
for (int column = -256; column < 256; column++) {
|
||||
world.nextLong();
|
||||
assertEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(world, repeated).getSeed());
|
||||
}
|
||||
assertNotEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(world, other).getSeed());
|
||||
assertNotEquals(expected, IrisFloatingChildBiomeModifier.paletteRng(new RNG(8191L), first).getSeed());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void floatingBiomeMarkersSkipStackProtectedCells() {
|
||||
DimensionStackLayout layout = mock(DimensionStackLayout.class);
|
||||
|
||||
+129
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.generation.terrain;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.noise.IrisGeneratorStyle;
|
||||
import art.arcane.iris.pack.value.IrisRange;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
|
||||
@@ -48,6 +49,7 @@ import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class IrisDimensionCarvingResolverParityTest {
|
||||
@@ -59,6 +61,67 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
BukkitTestServer.install();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedSnapshotMatchesScalarRootsAndReadsRuntimeIdentityOnce() {
|
||||
Fixture fixture = createFixture();
|
||||
Map<String, IrisBiome> expected = new HashMap<>();
|
||||
for (int y = -80; y <= 340; y += 13) {
|
||||
for (int x = -17; x <= 17; x += 17) {
|
||||
expected.put(x + ":" + y, IrisDimensionCarvingResolver.resolveBiome(
|
||||
fixture.engine, x, y, -23, new IrisDimensionCarvingResolver.State()));
|
||||
}
|
||||
}
|
||||
clearInvocations(fixture.engine);
|
||||
IrisDimensionCarvingResolver.Snapshot snapshot = IrisDimensionCarvingResolver.snapshot(fixture.engine);
|
||||
for (int y = -80; y <= 340; y += 13) {
|
||||
for (int x = -17; x <= 17; x += 17) {
|
||||
assertSame(expected.get(x + ":" + y), snapshot.resolveBiome(x, y, -23));
|
||||
}
|
||||
}
|
||||
verify(fixture.engine).getDimension();
|
||||
verify(fixture.engine).getData();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedSnapshotsBindReplacementRuntimeWithoutSharingPriorResults() {
|
||||
Fixture first = createFixture();
|
||||
Fixture replacement = createMixedDepthFixture();
|
||||
IrisBiome expectedFirst = IrisDimensionCarvingResolver.resolveBiome(first.engine, -17, 80, 23, null);
|
||||
IrisBiome expectedReplacement = IrisDimensionCarvingResolver.resolveBiome(replacement.engine, -17, 80, 23, null);
|
||||
assertSame(expectedFirst, IrisDimensionCarvingResolver.snapshot(first.engine).resolveBiome(-17, 80, 23));
|
||||
doReturn(replacement.engine.getData()).when(first.engine).getData();
|
||||
doReturn(replacement.engine.getDimension()).when(first.engine).getDimension();
|
||||
doReturn(replacement.engine.getSeedManager()).when(first.engine).getSeedManager();
|
||||
assertSame(expectedReplacement, IrisDimensionCarvingResolver.snapshot(first.engine).resolveBiome(-17, 80, 23));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedSnapshotsKeepExpressionSamplingPerImplicitCell() {
|
||||
for (boolean fracture : new boolean[]{false, true}) {
|
||||
Fixture fixture = createFixture();
|
||||
IrisDimension dimension = fixture.engine.getDimension();
|
||||
IrisDimensionCarvingEntry parent = spy(dimension.getCarvingEntryIndex().get("root-low"));
|
||||
parent.setChildRecursionDepth(1);
|
||||
if (fracture) {
|
||||
parent.getChildStyle().setFracture(new IrisGeneratorStyle().setExpression("live-height"));
|
||||
} else {
|
||||
parent.getChildStyle().setExpression("live-height");
|
||||
}
|
||||
KList<IrisDimensionCarvingEntry> entries = new KList<>(dimension.getCarving());
|
||||
entries.set(0, parent);
|
||||
doReturn(entries).when(dimension).getCarving();
|
||||
dimension.getCarvingEntryIndex().put("root-low", parent);
|
||||
CNG generator = mock(CNG.class);
|
||||
doReturn(0.5D, 0D).when(generator).noiseFast2D(-17D, 23D);
|
||||
doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class));
|
||||
IrisDimensionCarvingResolver.Snapshot snapshot = IrisDimensionCarvingResolver.snapshot(fixture.engine);
|
||||
|
||||
assertSame(fixture.engine.getData().getBiomeLoader().load("child-a"), snapshot.resolveBiome(-17, 40, 23));
|
||||
assertSame(fixture.engine.getData().getBiomeLoader().load("root-low"), snapshot.resolveBiome(-17, 41, 23));
|
||||
verify(generator, times(2)).noiseFast2D(-17D, 23D);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void extremeChildRarityRemainsReachableWithOneNoiseSample() {
|
||||
Fixture fixture = createFixture();
|
||||
@@ -84,6 +147,72 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
verify(generator, never()).fit2D(anyInt(), anyInt(), anyDouble(), anyDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedColumnResolutionSamplesChildrenOnceAcrossHeights() {
|
||||
Fixture fixture = createFixture();
|
||||
IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low"));
|
||||
parent.setChildRecursionDepth(1);
|
||||
CNG generator = mock(CNG.class);
|
||||
doReturn(0.5D).when(generator).noiseFast2D(anyDouble(), anyDouble());
|
||||
doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class));
|
||||
IrisDimensionCarvingEntry child = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a");
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
|
||||
for (int height = 0; height < 16; height++) {
|
||||
for (int x = -16; x < 0; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, x, z, state));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
verify(generator, times(256)).noiseFast2D(anyDouble(), anyDouble());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expressionChildStylesKeepSamplingTheCurrentContext() {
|
||||
for (boolean fracture : new boolean[]{false, true}) {
|
||||
Fixture fixture = createFixture();
|
||||
IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low"));
|
||||
parent.setChildRecursionDepth(1);
|
||||
if (fracture) {
|
||||
parent.getChildStyle().setFracture(new IrisGeneratorStyle().setExpression("live-height"));
|
||||
} else {
|
||||
parent.getChildStyle().setExpression("live-height");
|
||||
}
|
||||
CNG generator = mock(CNG.class);
|
||||
doReturn(0.5D, 0D).when(generator).noiseFast2D(-17D, 23D);
|
||||
doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class));
|
||||
IrisDimensionCarvingEntry child = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a");
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
|
||||
assertSame(child, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state));
|
||||
assertSame(parent, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cachedColumnResolutionInvalidatesWithDataAndEngineChanges() {
|
||||
Fixture fixture = createFixture();
|
||||
Fixture replacement = createFixture();
|
||||
IrisDimensionCarvingEntry parent = spy(fixture.engine.getDimension().getCarvingEntryIndex().get("root-low"));
|
||||
parent.setChildRecursionDepth(1);
|
||||
CNG generator = mock(CNG.class);
|
||||
doReturn(0.5D).when(generator).noiseFast2D(anyDouble(), anyDouble());
|
||||
doReturn(generator).when(parent).getChildrenGenerator(anyLong(), any(IrisData.class));
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
IrisDimensionCarvingEntry firstChild = fixture.engine.getDimension().getCarvingEntryIndex().get("child-a");
|
||||
IrisDimensionCarvingEntry replacementChild = replacement.engine.getDimension().getCarvingEntryIndex().get("child-a");
|
||||
|
||||
assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state));
|
||||
doReturn(replacement.engine.getData()).when(fixture.engine).getData();
|
||||
assertSame(firstChild, IrisDimensionCarvingResolver.resolveFromRoot(fixture.engine, parent, -17, 23, state));
|
||||
assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, 23, state));
|
||||
assertSame(replacementChild, IrisDimensionCarvingResolver.resolveFromRoot(replacement.engine, parent, -17, 23, state));
|
||||
|
||||
verify(generator, times(3)).noiseFast2D(-17D, 23D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolverStatefulOverloadsMatchLegacyResolverAcrossSampleGrid() {
|
||||
Fixture fixture = createFixture();
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
package art.arcane.iris.generation.terrain;
|
||||
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.DoubleBinaryOperator;
|
||||
|
||||
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 ProceduralTerrainHeightSamplerTest {
|
||||
@Test
|
||||
public void unitStepPreservesOriginalFractionalSamples() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> {
|
||||
calls.incrementAndGet();
|
||||
return terrain(x, z);
|
||||
}, 1);
|
||||
for (double x : new double[]{-17.25D, -4D, -0.5D, 0D, 1.75D, 32D}) {
|
||||
assertEquals(terrain(x, -2.25D), sampler.sample(x, -2.25D), 0D);
|
||||
}
|
||||
assertEquals(6, calls.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void denseTerrainReusesLatticeNodesInsteadOfSamplingEveryColumn() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> {
|
||||
calls.incrementAndGet();
|
||||
return terrain(x, z);
|
||||
}, 4);
|
||||
for (int x = -32; x < 32; x++) {
|
||||
for (int z = -32; z < 32; z++) {
|
||||
assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, 4),
|
||||
sampler.sample(x, z), 0D);
|
||||
}
|
||||
}
|
||||
assertTrue("Procedural sample calls: " + calls.get(), calls.get() < 400);
|
||||
assertTrue(calls.get() >= 289);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void neighboringRegionsRetainHeightsAcrossRepeatedSweeps() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
DoubleBinaryOperator linear = (x, z) -> x * 0.25D - z * 0.125D + 96D;
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> {
|
||||
calls.incrementAndGet();
|
||||
return linear.applyAsDouble(x, z);
|
||||
}, 4);
|
||||
int uniqueNodes = 261 * 261;
|
||||
for (int sweep = 0; sweep < 2; sweep++) {
|
||||
int before = calls.get();
|
||||
for (int chunkX = -32; chunkX <= 32; chunkX++) {
|
||||
for (int chunkZ = -32; chunkZ <= 32; chunkZ++) {
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
for (int localZ = 0; localZ < 16; localZ++) {
|
||||
int x = chunkX * 16 + localX;
|
||||
int z = chunkZ * 16 + localZ;
|
||||
assertEquals(linear.applyAsDouble(x, z), sampler.sample(x, z), 0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
int sourceCalls = calls.get() - before;
|
||||
assertTrue("Procedural sample calls in sweep " + sweep + ": " + sourceCalls,
|
||||
sourceCalls < (sweep == 0 ? uniqueNodes * 1.05D : uniqueNodes * 0.25D));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allStepsPreserveNodesLinearFieldsAndSignedWorldCoordinates() {
|
||||
DoubleBinaryOperator linear = (x, z) -> x * 0.25D - z * 0.125D + 96D;
|
||||
for (int step : new int[]{2, 4, 8}) {
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler(linear, step);
|
||||
ProceduralTerrainHeightSampler shaped = new ProceduralTerrainHeightSampler(
|
||||
ProceduralTerrainHeightSamplerTest::terrain, step);
|
||||
for (double x : new double[]{Integer.MIN_VALUE, -33.5D, -8D, -0.25D, 0D, 8D, 31.75D,
|
||||
Integer.MAX_VALUE}) {
|
||||
for (double z : new double[]{-19.5D, -8D, 0D, 8D, 19.5D}) {
|
||||
assertEquals(linear.applyAsDouble(x, z), sampler.sample(x, z), 0D);
|
||||
assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, step),
|
||||
shaped.sample(x, z), 0D);
|
||||
}
|
||||
}
|
||||
for (int x = -32; x <= 32; x += step) {
|
||||
assertEquals(terrain(x, -8), shaped.sample(x, -8), 0D);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentQueriesRemainDeterministicAcrossCacheReplacement() throws Exception {
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler(
|
||||
ProceduralTerrainHeightSamplerTest::terrain, 4);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(4);
|
||||
List<Future<?>> tasks = new ArrayList<>();
|
||||
try {
|
||||
for (int worker = 0; worker < 4; worker++) {
|
||||
int offset = worker * 65_537;
|
||||
tasks.add(executor.submit(() -> {
|
||||
for (int index = 0; index < 100_000; index++) {
|
||||
int x = index * 4 - offset;
|
||||
int z = -index * 8 + offset;
|
||||
assertEquals(interpolated(ProceduralTerrainHeightSamplerTest::terrain, x, z, 4),
|
||||
sampler.sample(x, z), 0D);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (Future<?> task : tasks) {
|
||||
task.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
assertTrue(sampler.cachedNodeCount() > 200_000);
|
||||
assertTrue(sampler.cachedNodeCount() <= 262_144);
|
||||
ProceduralTerrainHeightSampler other = new ProceduralTerrainHeightSampler((x, z) -> 42D, 4);
|
||||
assertEquals(42D, other.sample(0, 0), 0D);
|
||||
assertEquals(terrain(0, 0), sampler.sample(0, 0), 0D);
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedNodesCanBeRetriedAndInvalidStepsAreRejected() {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler((x, z) -> {
|
||||
if (calls.incrementAndGet() == 1) {
|
||||
throw new IllegalStateException("height failure");
|
||||
}
|
||||
return 72D;
|
||||
}, 4);
|
||||
assertThrows(IllegalStateException.class, () -> sampler.sample(-4D, 8D));
|
||||
assertEquals(72D, sampler.sample(-4D, 8D), 0D);
|
||||
assertEquals(2, calls.get());
|
||||
for (int step : new int[]{-1, 0, 3, 16}) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new ProceduralTerrainHeightSampler((x, z) -> 0D, step));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sampledBaseHeightsRetainThreeDimensionalLedgesAndAirGaps() {
|
||||
ProceduralTerrainHeightSampler sampler = new ProceduralTerrainHeightSampler(
|
||||
(x, z) -> 96D + (Math.floorMod((int) x, 4) == 2 ? 2D : 0D), 4);
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(new IrisTerrain3D()
|
||||
.setAmplitude(32).setHorizontalScale(64).setVerticalScale(64)
|
||||
.setMinimumSlope(0).setFluidClearance(0).setFluidFade(1));
|
||||
Terrain3DRuntime.Options options = new Terrain3DRuntime.Options(12, 256, 0, null, true, 4096);
|
||||
Terrain3DRuntime.NoiseFactory noise = (style, seed) ->
|
||||
(x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D);
|
||||
Terrain3DRuntime sampled = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> sampler.sample(x, z), (x, z) -> biome), options, noise);
|
||||
Terrain3DRuntime reference = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome), options, noise);
|
||||
for (int x = -9; x <= 9; x++) {
|
||||
Terrain3DColumn column = sampled.column(x, -2);
|
||||
assertEquals(reference.column(x, -2), column);
|
||||
assertTrue(column.shaped());
|
||||
assertTrue(column.spanCount() > 1);
|
||||
assertTrue(column.isSolid(104));
|
||||
assertFalse(column.isSolid(84));
|
||||
assertTrue(column.isSolid(60));
|
||||
}
|
||||
}
|
||||
|
||||
private static double terrain(double x, double z) {
|
||||
return 96D + StrictMath.sin(x * 0.13D) * 18D + StrictMath.cos(z * 0.17D) * 12D;
|
||||
}
|
||||
|
||||
private static double interpolated(DoubleBinaryOperator source, double x, double z, int step) {
|
||||
double lowerX = Math.floor(x / step) * step;
|
||||
double lowerZ = Math.floor(z / step) * step;
|
||||
double dx = (x - lowerX) / step;
|
||||
double dz = (z - lowerZ) / step;
|
||||
double northWest = source.applyAsDouble(lowerX, lowerZ);
|
||||
double north = dx == 0D ? northWest
|
||||
: northWest + (source.applyAsDouble(lowerX + step, lowerZ) - northWest) * dx;
|
||||
if (dz == 0D) {
|
||||
return north;
|
||||
}
|
||||
double southWest = source.applyAsDouble(lowerX, lowerZ + step);
|
||||
double south = dx == 0D ? southWest
|
||||
: southWest + (source.applyAsDouble(lowerX + step, lowerZ + step) - southWest) * dx;
|
||||
return north + (south - north) * dz;
|
||||
}
|
||||
}
|
||||
+131
@@ -3,15 +3,146 @@ package art.arcane.iris.generation.terrain;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class Terrain3DFragmentFilterTest {
|
||||
@Test
|
||||
public void heightLeavesIrrelevantLowerFragmentsUnclassified() {
|
||||
Terrain3DColumn center = new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20, 100, 612});
|
||||
BiFunction<Integer, Integer, Terrain3DColumn> source = (x, z) -> x == 0 && z == 0
|
||||
? center : new Terrain3DColumn(10D, 11, true, new int[]{0, 10});
|
||||
Fixture reference = new Fixture(source);
|
||||
Fixture selective = new Fixture(source);
|
||||
Terrain3DColumn expected = reference.filter.column(0, 0);
|
||||
|
||||
assertEquals(expected.topY(), selective.filter.height(0, 0), 0D);
|
||||
assertEquals(1, selective.reads.get());
|
||||
assertTrue(reference.reads.get() > selective.reads.get());
|
||||
assertEquals(expected, selective.filter.column(0, 0));
|
||||
assertEquals(2, expected.spanCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heightRejectsSmallTopFragmentBeforeKeepingGroundConnectedLedge() {
|
||||
Fixture fixture = new Fixture((x, z) -> x == 0 && z == 0
|
||||
? new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 50, 70, 110, 112})
|
||||
: new Terrain3DColumn(100D, 101, true, new int[]{0, 100}));
|
||||
|
||||
assertEquals(70D, fixture.filter.height(0, 0), 0D);
|
||||
assertEquals(70, fixture.filter.column(0, 0).topY());
|
||||
assertEquals(2, fixture.filter.column(0, 0).spanCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void heightUsesExactConnectedVolumeThresholdAndSignedCoordinateEdges() {
|
||||
for (int volume : new int[]{512, 513}) {
|
||||
BiFunction<Integer, Integer, Terrain3DColumn> source = (x, z) ->
|
||||
x >= -volume && x < 0 && z == -17
|
||||
? new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20})
|
||||
: new Terrain3DColumn(10D, 11, true, new int[]{0, 10});
|
||||
Fixture reference = new Fixture(source);
|
||||
Fixture selective = new Fixture(source);
|
||||
assertEquals(volume == 512 ? 10D : 20D, selective.filter.height(-volume, -17), 0D);
|
||||
assertEquals(reference.filter.column(-volume, -17), selective.filter.column(-volume, -17));
|
||||
}
|
||||
for (int x : new int[]{Integer.MIN_VALUE, Integer.MAX_VALUE}) {
|
||||
Fixture fixture = new Fixture((ignoredX, ignoredZ) ->
|
||||
new Terrain3DColumn(10D, 11, true, new int[]{0, 10, 20, 20}));
|
||||
assertEquals(20D, fixture.filter.height(x, 7), 0D);
|
||||
assertEquals(20, fixture.filter.column(x, 7).topY());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentHeightAndFullColumnQueriesMatchIndependentFullClassification() throws Exception {
|
||||
Random random = new Random(883719L);
|
||||
Map<Long, Terrain3DColumn> raw = new HashMap<>();
|
||||
List<Long> positions = new ArrayList<>();
|
||||
for (int x = -8; x <= 8; x++) {
|
||||
for (int z = -8; z <= 8; z++) {
|
||||
int ground = random.nextInt(5) == 0 ? 36 : 5 + random.nextInt(6);
|
||||
int[] boundaries = new int[10];
|
||||
boundaries[0] = 0;
|
||||
boundaries[1] = ground;
|
||||
int count = 2;
|
||||
for (int y = 16; y <= 40; y += 8) {
|
||||
if (y > ground + 1 && random.nextBoolean()) {
|
||||
boundaries[count++] = y;
|
||||
boundaries[count++] = y + random.nextInt(3);
|
||||
}
|
||||
}
|
||||
long key = pack(x, z);
|
||||
raw.put(key, new Terrain3DColumn(ground + 0.25D, ground + 1, true, Arrays.copyOf(boundaries, count)));
|
||||
positions.add(key);
|
||||
}
|
||||
}
|
||||
Terrain3DColumn outside = new Terrain3DColumn(8D, 9, true, new int[]{0, 8});
|
||||
BiFunction<Integer, Integer, Terrain3DColumn> source = (x, z) -> raw.getOrDefault(pack(x, z), outside);
|
||||
Fixture reference = new Fixture(source);
|
||||
Map<Long, Terrain3DColumn> expected = new HashMap<>();
|
||||
for (long key : positions) {
|
||||
expected.put(key, reference.filter.column((int) (key >> 32), (int) key));
|
||||
}
|
||||
Fixture selective = new Fixture(source);
|
||||
try (ExecutorService workers = Executors.newFixedThreadPool(4)) {
|
||||
List<Future<?>> tasks = new ArrayList<>();
|
||||
for (int worker = 0; worker < 4; worker++) {
|
||||
int shift = worker * 59;
|
||||
tasks.add(workers.submit(() -> {
|
||||
for (int index = 0; index < positions.size() * 2; index++) {
|
||||
long key = positions.get((index + shift) % positions.size());
|
||||
int x = (int) (key >> 32);
|
||||
int z = (int) key;
|
||||
assertEquals(expected.get(key).topY(), selective.filter.height(x, z), 0D);
|
||||
if ((index & 1) == 0) {
|
||||
assertEquals(expected.get(key), selective.filter.column(x, z));
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (Future<?> task : tasks) {
|
||||
task.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
for (long key : positions) {
|
||||
assertEquals(expected.get(key), selective.filter.column((int) (key >> 32), (int) key));
|
||||
}
|
||||
}
|
||||
|
||||
private static long pack(int x, int z) {
|
||||
return (long) x << 32 | z & 0xffffffffL;
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final AtomicInteger reads = new AtomicInteger();
|
||||
private final ConcurrentHashMap<Long, Terrain3DFragmentFilter.DensityColumn> columns = new ConcurrentHashMap<>();
|
||||
private final Terrain3DFragmentFilter filter;
|
||||
|
||||
private Fixture(BiFunction<Integer, Integer, Terrain3DColumn> source) {
|
||||
filter = new Terrain3DFragmentFilter((x, z) -> {
|
||||
reads.incrementAndGet();
|
||||
return columns.computeIfAbsent(pack(x, z), ignored -> new Terrain3DFragmentFilter.DensityColumn(source.apply(x, z)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void perThreadTraversalScratchDoesNotPinTheFilterAfterItIsDropped() throws Exception {
|
||||
// A hotload retires the runtime that owns the filter; the worker threads that classified
|
||||
|
||||
@@ -21,6 +21,35 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class Terrain3DRuntimeTest {
|
||||
@Test
|
||||
public void heightQueriesPreserveCompleteDensityColumnsAndFractionalFallback() {
|
||||
Terrain3DRuntime.NoiseSource noise = (x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D)
|
||||
* StrictMath.cos(x * 0.08D) * StrictMath.cos(z * 0.06D);
|
||||
Terrain3DRuntime reference = runtime(profile(), noise);
|
||||
Terrain3DRuntime selective = runtime(profile(), noise);
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
for (int x = -12; x <= 12; x++) {
|
||||
for (int z = -6; z <= 6; z++) {
|
||||
Terrain3DColumn expected = reference.column(x, z);
|
||||
assertEquals(expected.topY(), selective.height(x, z), 0D);
|
||||
assertEquals(expected, selective.column(x, z));
|
||||
assertEquals(expected.topY(), selective.height(x, z), 0D);
|
||||
}
|
||||
}
|
||||
selective.clear();
|
||||
}
|
||||
Terrain3DRuntime unshaped = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 300.25D, (x, z) -> new IrisBiome()),
|
||||
options(true), (style, seed) -> noise);
|
||||
assertEquals(300.25D, unshaped.height(-17, 23), 0D);
|
||||
assertEquals(255, unshaped.column(-17, 23).topY());
|
||||
assertEquals(300.25D, unshaped.height(-17, 23), 0D);
|
||||
Terrain3DRuntime inactive = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> -12.75D, (x, z) -> null),
|
||||
options(false), (style, seed) -> noise);
|
||||
assertEquals(-12.75D, inactive.height(0, 0), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void alignedColumnsOnlySampleContributingAnchors() {
|
||||
for (int x = -8; x <= -7; x++) {
|
||||
@@ -260,6 +289,138 @@ public class Terrain3DRuntimeTest {
|
||||
assertEquals(40, water.column(0, 0).topY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anchorRetentionBoundsFullHeightNoiseStorage() {
|
||||
int shorter = Terrain3DRuntime.anchorCacheCapacity(256, 4096);
|
||||
int taller = Terrain3DRuntime.anchorCacheCapacity(1024, 4096);
|
||||
assertTrue(shorter > taller);
|
||||
assertTrue(taller > 4096 / 4);
|
||||
for (int height : new int[]{16, 256, 640, 1024, 4096, 16_384}) {
|
||||
for (int columns : new int[]{16, 4096, Integer.MAX_VALUE}) {
|
||||
int capacity = Terrain3DRuntime.anchorCacheCapacity(height, columns);
|
||||
long fullHeightStorage = 160L + 48L * (Math.ceilDiv((long) height, 4) + 1);
|
||||
assertEquals(0, capacity % 16);
|
||||
assertTrue(capacity >= 16);
|
||||
assertTrue(capacity <= 32_768);
|
||||
assertTrue(capacity * fullHeightStorage <= 64L * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedColumnWorkingSetReusesTerrainAcrossChunksAndClearsCompletely() {
|
||||
AtomicInteger sourceCalls = new AtomicInteger();
|
||||
AtomicInteger height = new AtomicInteger(96);
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> {
|
||||
sourceCalls.incrementAndGet();
|
||||
return height.get();
|
||||
}, (x, z) -> biome),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 65_536),
|
||||
(style, seed) -> (x, y, z) -> 0D);
|
||||
List<Terrain3DColumn> columns = new ArrayList<>(128 * 128);
|
||||
for (int x = -64; x < 64; x++) {
|
||||
for (int z = -64; z < 64; z++) {
|
||||
Terrain3DColumn column = runtime.column(x, z);
|
||||
assertTrue(column.shaped());
|
||||
assertEquals(96, column.topY());
|
||||
columns.add(column);
|
||||
}
|
||||
}
|
||||
int callsBeforeRevisit = sourceCalls.get();
|
||||
int index = 0;
|
||||
for (int x = -64; x < 64; x++) {
|
||||
for (int z = -64; z < 64; z++) {
|
||||
assertSame(columns.get(index++), runtime.column(x, z));
|
||||
}
|
||||
}
|
||||
assertEquals(callsBeforeRevisit, sourceCalls.get());
|
||||
assertEquals(columns.size(), runtime.cachedColumnCount());
|
||||
height.set(120);
|
||||
runtime.clear();
|
||||
assertEquals(0, runtime.cachedColumnCount());
|
||||
assertEquals(120, runtime.column(-64, -64).topY());
|
||||
assertTrue(sourceCalls.get() > callsBeforeRevisit);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedColumnCacheRemainsBoundedAcrossDistantWorkingSets() {
|
||||
IrisBiome biome = new IrisBiome();
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 65_536),
|
||||
(style, seed) -> (x, y, z) -> 0D);
|
||||
for (int x = -65_536; x < 65_536; x++) {
|
||||
assertEquals(96, runtime.column(x, -1).topY());
|
||||
}
|
||||
assertEquals(65_536, runtime.cachedColumnCount());
|
||||
assertEquals(96, runtime.column(-65_536, -1).topY());
|
||||
assertEquals(65_536, runtime.cachedColumnCount());
|
||||
runtime.clear();
|
||||
assertEquals(0, runtime.cachedColumnCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void evictedAnchorsRecomputeIdenticalColumns() {
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> biome),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 16),
|
||||
(style, seed) -> (x, y, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return 0.2D * StrictMath.sin(x + z);
|
||||
});
|
||||
Terrain3DColumn original = runtime.column(-4, -4);
|
||||
for (int index = 0; index < 512; index++) {
|
||||
runtime.column(index * 4, -16);
|
||||
}
|
||||
int samplesBeforeRevisit = samples.get();
|
||||
|
||||
assertEquals(original, runtime.column(-4, -4));
|
||||
assertTrue(samples.get() > samplesBeforeRevisit);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void anchorNoiseSurvivesColumnCacheTurnover() {
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
AtomicInteger biomes = new AtomicInteger();
|
||||
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
|
||||
Terrain3DRuntime runtime = new Terrain3DRuntime(
|
||||
new Terrain3DRuntime.Sources((x, z) -> 96D, (x, z) -> {
|
||||
biomes.incrementAndGet();
|
||||
return biome;
|
||||
}),
|
||||
new Terrain3DRuntime.Options(12, 256, 0, null, true, 64),
|
||||
(style, seed) -> (x, y, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return 0.2D * StrictMath.sin(x + z);
|
||||
});
|
||||
List<Terrain3DColumn> columns = new ArrayList<>();
|
||||
for (int x = -32; x < 32; x += 4) {
|
||||
for (int z = -32; z < 32; z += 4) {
|
||||
columns.add(runtime.column(x, z));
|
||||
}
|
||||
}
|
||||
int initialSamples = samples.get();
|
||||
int initialBiomes = biomes.get();
|
||||
int index = 0;
|
||||
for (int x = -32; x < 32; x += 4) {
|
||||
for (int z = -32; z < 32; z += 4) {
|
||||
assertEquals(columns.get(index++), runtime.column(x, z));
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(initialSamples > 0);
|
||||
assertEquals(initialSamples, samples.get());
|
||||
assertEquals(initialBiomes, biomes.get());
|
||||
runtime.clear();
|
||||
assertEquals(columns.getFirst(), runtime.column(-32, -32));
|
||||
assertTrue(samples.get() > initialSamples);
|
||||
assertTrue(biomes.get() > initialBiomes);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void columnCacheReusesGeometryAndAnchorNoise() {
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package art.arcane.iris.pack;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PackValidatorTerrainSamplingTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void acceptsOmittedAndSupportedSamplingSteps() throws Exception {
|
||||
assertTrue(validate("terrainSamplingStep", null).isLoadable());
|
||||
for (String field : new String[]{"terrainSamplingStep", "caveDensitySamplingStep"}) {
|
||||
for (String value : new String[]{"1", "2", "4", "8"}) {
|
||||
PackValidationResult result = validate(field, value);
|
||||
assertTrue(result.getBlockingErrors().toString(), result.isLoadable());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidSamplingStepsBeforeWorldCreation() throws Exception {
|
||||
for (String field : new String[]{"terrainSamplingStep", "caveDensitySamplingStep"}) {
|
||||
for (String value : new String[]{"0", "-1", "3", "16", "4.5", "\"4\"", "null", "true"}) {
|
||||
PackValidationResult result = validate(field, value);
|
||||
assertFalse("Accepted " + field + "=" + value, result.isLoadable());
|
||||
assertTrue(result.getBlockingErrors().contains(
|
||||
"Dimension 'main' " + field + " must be one of 1, 2, 4, or 8."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsOnlySupportedBiomeBoundsSamplingSteps() throws Exception {
|
||||
assertTrue(validate("biomeBoundsSamplingStep", null).isLoadable());
|
||||
for (String value : new String[]{"4", "8", "16", "32"}) {
|
||||
PackValidationResult result = validate("biomeBoundsSamplingStep", value);
|
||||
assertTrue(result.getBlockingErrors().toString(), result.isLoadable());
|
||||
}
|
||||
for (String value : new String[]{"0", "-1", "1", "2", "3", "5", "64", "8.5", "\"8\"", "null", "true"}) {
|
||||
PackValidationResult result = validate("biomeBoundsSamplingStep", value);
|
||||
assertFalse("Accepted biomeBoundsSamplingStep=" + value, result.isLoadable());
|
||||
assertTrue(result.getBlockingErrors().contains(
|
||||
"Dimension 'main' biomeBoundsSamplingStep must be one of 4, 8, 16, or 32."));
|
||||
}
|
||||
}
|
||||
|
||||
private PackValidationResult validate(String field, String value) throws Exception {
|
||||
Path pack = temporary.newFolder().toPath();
|
||||
Files.createDirectories(pack.resolve("dimensions"));
|
||||
Files.createDirectories(pack.resolve("regions"));
|
||||
Files.createDirectories(pack.resolve("biomes"));
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), "{\"regions\":[\"main\"]"
|
||||
+ (value == null ? "" : ",\"" + field + "\":" + value) + "}");
|
||||
Files.writeString(pack.resolve("regions/main.json"), "{\"landBiomes\":[\"main\"]}");
|
||||
Files.writeString(pack.resolve("biomes/main.json"), "{\"name\":\"Main\"}");
|
||||
return PackValidator.validate(pack.toFile());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package art.arcane.iris.pack.schema;
|
||||
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import com.google.gson.Gson;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class IrisBiomeBoundsSamplingSchemaTest {
|
||||
@Test
|
||||
public void omittedSettingKeepsFourAndExplicitValuesRoundTrip() {
|
||||
Gson gson = new Gson();
|
||||
assertEquals(4, gson.fromJson("{}", IrisDimension.class).getBiomeBoundsSamplingStep());
|
||||
for (int step : new int[]{4, 8, 16, 32}) {
|
||||
IrisDimension dimension = new IrisDimension().setBiomeBoundsSamplingStep(step);
|
||||
assertEquals(step, gson.fromJson(gson.toJson(dimension), IrisDimension.class).getBiomeBoundsSamplingStep());
|
||||
}
|
||||
for (int step : new int[]{0, 1, 2, 3, 5, 64}) {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new IrisDimension().setBiomeBoundsSamplingStep(step).getBiomeBoundsSamplingStep());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void schemaListsOnlySupportedIntegerSteps() throws Exception {
|
||||
SchemaBuilder builder = new SchemaBuilder(IrisDimension.class, null);
|
||||
for (String name : new String[]{"biomeBoundsSamplingStep", "terrainSamplingStep", "caveDensitySamplingStep"}) {
|
||||
int minimum = name.equals("biomeBoundsSamplingStep") ? 4 : 1;
|
||||
Field field = IrisDimension.class.getDeclaredField(name);
|
||||
JSONObject property = new SchemaPropertyBuilder(builder).buildProperty(field, IrisDimension.class);
|
||||
assertEquals("integer", property.getString("type"));
|
||||
assertEquals(minimum, property.getInt("minimum"));
|
||||
assertEquals(minimum * 8, property.getInt("maximum"));
|
||||
JSONArray options = property.getJSONArray("enum");
|
||||
assertEquals(4, options.length());
|
||||
for (int index = 0; index < options.length(); index++) {
|
||||
assertEquals(minimum << index, options.getInt(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest {
|
||||
@ClassRule
|
||||
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
|
||||
|
||||
private static final String EXPECTED_SCHEMA_DIGEST = "415345947f2c96fc468848aefa064854a085ae5631ea0f780c77c99973bff594";
|
||||
private static final String EXPECTED_SCHEMA_DIGEST = "9c118c18c2f757f08ffb71d52d8549c53e4dae4939e0e030d36e4e9115794497";
|
||||
|
||||
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
|
||||
IrisDimension.class,
|
||||
|
||||
@@ -52,6 +52,59 @@ public class BoundaryColumnGeometryTest {
|
||||
assertEquals(50, geometry.surfaceOffsetNear(70D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nearestSurfaceMatchesDenseColumnsAcrossRunsProtectionAndTies() {
|
||||
List<BoundaryColumnGeometry.Voxel> states = List.of(STONE, AIR,
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:oak_log[axis=y]",
|
||||
BoundaryColumnGeometry.Phase.SOLID, "", true),
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:water[level=0]",
|
||||
BoundaryColumnGeometry.Phase.FLUID, "minecraft:water[level=0]", false));
|
||||
for (int combination = 0; combination < 4096; combination++) {
|
||||
int encoded = combination;
|
||||
ArrayList<BoundaryColumnGeometry.Voxel> voxels = new ArrayList<>(18);
|
||||
for (int run = 0; run < 6; run++) {
|
||||
BoundaryColumnGeometry.Voxel voxel = states.get(encoded & 3);
|
||||
encoded >>= 2;
|
||||
for (int offset = 0; offset <= run % 3; offset++) {
|
||||
voxels.add(voxel);
|
||||
}
|
||||
}
|
||||
BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-64, voxels);
|
||||
for (double expected : new double[]{-10D, 0D, 1.5D, 3D, 5.5D, 8D, 12D, 100D}) {
|
||||
assertEquals(denseSurface(voxels, expected), geometry.surfaceOffsetNear(expected));
|
||||
}
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> BoundaryColumnGeometry.empty().surfaceOffsetNear(0D));
|
||||
BoundaryColumnGeometry solid = BoundaryColumnGeometry.fromVoxels(-64, List.of(STONE));
|
||||
assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.NaN));
|
||||
assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.POSITIVE_INFINITY));
|
||||
}
|
||||
|
||||
private static int denseSurface(List<BoundaryColumnGeometry.Voxel> voxels, double expected) {
|
||||
int selected = -1;
|
||||
double distance = Double.POSITIVE_INFINITY;
|
||||
for (int index = 0; index + 1 < voxels.size(); index++) {
|
||||
BoundaryColumnGeometry.Voxel current = voxels.get(index);
|
||||
BoundaryColumnGeometry.Voxel above = voxels.get(index + 1);
|
||||
if (current.phase() == BoundaryColumnGeometry.Phase.SOLID && !current.protectedContent()
|
||||
&& (above.phase() != BoundaryColumnGeometry.Phase.SOLID || above.protectedContent())
|
||||
&& Math.abs(index - expected) < distance) {
|
||||
selected = index;
|
||||
distance = Math.abs(index - expected);
|
||||
}
|
||||
}
|
||||
if (selected >= 0) {
|
||||
return selected;
|
||||
}
|
||||
for (int index = voxels.size() - 1; index >= 0; index--) {
|
||||
BoundaryColumnGeometry.Voxel voxel = voxels.get(index);
|
||||
if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enclosedOpenQueriesIncludeUpperCavesAndExcludeOpenSky() {
|
||||
BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-4,
|
||||
|
||||
+4
@@ -369,6 +369,10 @@ public final class GenerationHistoryRuntimeRouterRoutingTest extends GenerationH
|
||||
GenerationHistoryRuntimeRouter.RuntimeRoute route = router.openRoute(4, -3);
|
||||
|
||||
assertThrows(IllegalStateException.class, route::claimGeneratedSemantics);
|
||||
try (GenerationHistoryRuntimeRouter.RuntimeRoute other = router.openRoute(5, -3);
|
||||
GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = other.openRuntimeScope()) {
|
||||
assertThrows(IllegalStateException.class, route::claimGeneratedSemantics);
|
||||
}
|
||||
try (GenerationHistoryRuntimeRouter.RuntimeRoute.RuntimeScope ignored = route.openRuntimeScope()) {
|
||||
assertTrue(route.claimGeneratedSemantics());
|
||||
assertFalse(route.claimGeneratedSemantics());
|
||||
|
||||
+234
-3
@@ -3,6 +3,19 @@ package art.arcane.iris.world.history;
|
||||
import art.arcane.iris.generation.runtime.IrisComplex;
|
||||
import art.arcane.iris.generation.runtime.IrisEngine;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.generation.runtime.EnginePlatformHooks;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackContext;
|
||||
import art.arcane.iris.generation.runtime.DimensionStackLayout;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry;
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.pack.loading.ResourceLoader;
|
||||
import art.arcane.iris.pack.value.IrisRange;
|
||||
import art.arcane.iris.world.IrisWorld;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.iris.structure.placement.StructurePlacementMarker;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.generation.mantle.EngineMantle;
|
||||
@@ -23,13 +36,18 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
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.eq;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
@@ -47,6 +65,211 @@ public final class GenerationSemanticCaptureTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void scopedCaptureKeepsExplicitPrecedenceAndScalarFallback() {
|
||||
Fixture fixture = new Fixture();
|
||||
IrisBiome configured = new IrisBiome();
|
||||
configured.setLoadKey("iris:configured");
|
||||
IrisBiome fallback = new IrisBiome();
|
||||
fallback.setLoadKey("iris:fallback");
|
||||
IrisData data = mock(IrisData.class);
|
||||
ResourceLoader<IrisBiome> loader = mock(ResourceLoader.class);
|
||||
when(data.getBiomeLoader()).thenReturn(loader);
|
||||
when(loader.load("iris:configured")).thenReturn(configured);
|
||||
IrisDimensionCarvingEntry root = new IrisDimensionCarvingEntry();
|
||||
root.setBiome("iris:configured");
|
||||
root.setWorldYRange(new IrisRange(-64, -48));
|
||||
root.setChildRecursionDepth(0);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.setCarving(new KList<>(List.of(root)));
|
||||
when(fixture.engine.getDimension()).thenReturn(dimension);
|
||||
when(fixture.engine.getData()).thenReturn(data);
|
||||
when(fixture.engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build());
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class)))
|
||||
.thenAnswer(invocation -> (int) invocation.getArgument(1) <= 16 ? configured : fallback);
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
for (int y = 0; y < 64; y++) {
|
||||
consumer.accept(1, y, 2, new MatterCavern(true, y == 3 ? "iris:explicit" : null, (byte) 0));
|
||||
}
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
HydrologyCaveCell duplicate = mock(HydrologyCaveCell.class);
|
||||
when(duplicate.floodedBiomeKey()).thenReturn("iris:duplicate");
|
||||
when(duplicate.fluidProfileKey()).thenReturn("iris:river");
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, HydrologyCaveCell> consumer = invocation.getArgument(3);
|
||||
consumer.accept(1, 4, 2, duplicate);
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(HydrologyCaveCell.class), any());
|
||||
GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class);
|
||||
GenerationActivation activation = mock(GenerationActivation.class);
|
||||
when(activation.activationId()).thenReturn(5L);
|
||||
when(stage.activation()).thenReturn(activation);
|
||||
when(stage.chunkX()).thenReturn(-2);
|
||||
when(stage.chunkZ()).thenReturn(3);
|
||||
|
||||
ChunkGenerationSemantics expected = GenerationSemanticCapture.capture(fixture.engine, stage, (x, y, z) -> y != 8);
|
||||
verify(fixture.engine, times(62)).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
clearInvocations(fixture.engine);
|
||||
ChunkGenerationSemantics actual = GenerationSemanticCapture.captureScoped(fixture.engine, stage, (x, y, z) -> y != 8);
|
||||
|
||||
assertEquals(expected, actual);
|
||||
assertEquals(Set.of("iris:configured", "iris:fallback", "iris:explicit"), actual.caveBiomeKeys());
|
||||
verify(fixture.engine, times(47)).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedFallbackMatchesScalarDepthAndStackBiomesWithOneSamplePerColumn() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
Fixture fixture = new Fixture(engine);
|
||||
IrisComplex complex = engine.getComplex();
|
||||
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||
when(engine.hasGenerationRuntimeScope()).thenReturn(true);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
IrisData data = mock(IrisData.class);
|
||||
IrisWorld world = IrisWorld.builder().minHeight(-64).maxHeight(320).build();
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getData()).thenReturn(data);
|
||||
when(engine.getWorld()).thenReturn(world);
|
||||
DimensionStackContext stack = mock(DimensionStackContext.class);
|
||||
when(engine.getDimensionStackContext()).thenReturn(stack);
|
||||
IrisBiome[] surfaces = new IrisBiome[256];
|
||||
IrisBiome[] caves = new IrisBiome[256];
|
||||
for (int index = 0; index < 256; index++) {
|
||||
surfaces[index] = biome("surface:" + index, 7);
|
||||
caves[index] = index % 3 == 0 ? null : biome(index % 3 == 1 ? null : "cave:" + index, 5);
|
||||
}
|
||||
when(complex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> surfaces[column(x, z)], Interpolated.of(value -> 0D, value -> null)));
|
||||
when(complex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> caves[column(x, z)], Interpolated.of(value -> 0D, value -> null)));
|
||||
AtomicInteger heightSamples = new AtomicInteger();
|
||||
when(complex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> {
|
||||
heightSamples.incrementAndGet();
|
||||
return 40D + (column(x, z) % 8);
|
||||
}, Interpolated.DOUBLE));
|
||||
when(stack.getLayout(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int index = column((int) invocation.getArgument(0), (int) invocation.getArgument(1));
|
||||
DimensionStackLayout layout = mock(DimensionStackLayout.class);
|
||||
DimensionStackLayout.Layer layer = mock(DimensionStackLayout.Layer.class);
|
||||
when(layer.biome()).thenReturn(index % 2 == 0 ? biome("stack:" + index, 3) : null);
|
||||
when(layout.surfaceLayer()).thenReturn(layer);
|
||||
return layout;
|
||||
});
|
||||
Engine scalar = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
doReturn(complex).when(scalar).getComplex();
|
||||
doReturn(dimension).when(scalar).getDimension();
|
||||
doReturn(stack).when(scalar).getDimensionStackContext();
|
||||
doReturn(data).when(scalar).getData();
|
||||
doReturn(world).when(scalar).getWorld();
|
||||
doReturn(hooks).when(scalar).getPlatformHooks();
|
||||
Set<String> expected = new HashSet<>();
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
int index = (x << 4) | z;
|
||||
int surfaceY = 40 + index % 8;
|
||||
int y = surfaceY - index % 10;
|
||||
expected.add(scalar.getCaveBiome(x - 32, y, z + 48, state).getLoadKey());
|
||||
expected.add(scalar.getCaveBiome(x - 32, y + 1, z + 48, state).getLoadKey());
|
||||
}
|
||||
}
|
||||
heightSamples.set(0);
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
int index = (x << 4) | z;
|
||||
int y = 40 + index % 8 - index % 10;
|
||||
consumer.accept(x, y, z, new MatterCavern(true, null, (byte) 0));
|
||||
consumer.accept(x, y + 1, z, new MatterCavern(true, null, (byte) 0));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
|
||||
ChunkGenerationSemantics captured = GenerationSemanticCapture.captureScoped(engine, stage(), (x, y, z) -> true);
|
||||
|
||||
assertEquals(expected, captured.caveBiomeKeys());
|
||||
assertEquals(256, heightSamples.get());
|
||||
verify(engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scopedFallbackRetainsCapturedRuntimeWhenAmbientInputsChange() {
|
||||
IrisEngine engine = mock(IrisEngine.class);
|
||||
Fixture fixture = new Fixture(engine);
|
||||
IrisComplex capturedComplex = engine.getComplex();
|
||||
EnginePlatformHooks hooks = mock(EnginePlatformHooks.class);
|
||||
when(engine.getPlatformHooks()).thenReturn(hooks);
|
||||
when(engine.hasGenerationRuntimeScope()).thenReturn(true);
|
||||
when(engine.getDimension()).thenReturn(new IrisDimension());
|
||||
when(engine.getData()).thenReturn(mock(IrisData.class));
|
||||
when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build());
|
||||
when(capturedComplex.getTrueBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> biome("captured:surface", 0), Interpolated.of(value -> 0D, value -> null)));
|
||||
when(capturedComplex.getCaveBiomeStream()).thenReturn(ProceduralStream.of((x, z) -> biome("captured:cave", 5), Interpolated.of(value -> 0D, value -> null)));
|
||||
when(capturedComplex.getHeightStream()).thenReturn(ProceduralStream.of((x, z) -> 40D, Interpolated.DOUBLE));
|
||||
IrisComplex changedComplex = mock(IrisComplex.class);
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
consumer.accept(0, 40, 0, new MatterCavern(true, null, (byte) 0));
|
||||
when(engine.getComplex()).thenReturn(changedComplex);
|
||||
when(engine.getDimension()).thenReturn(new IrisDimension());
|
||||
when(engine.getData()).thenReturn(mock(IrisData.class));
|
||||
consumer.accept(0, 35, 0, new MatterCavern(true, null, (byte) 0));
|
||||
consumer.accept(15, 20, 15, new MatterCavern(true, null, (byte) 0));
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
|
||||
ChunkGenerationSemantics captured = GenerationSemanticCapture.captureScoped(engine, stage(), (x, y, z) -> true);
|
||||
|
||||
assertEquals(Set.of("captured:surface", "captured:cave"), captured.caveBiomeKeys());
|
||||
verify(changedComplex, never()).getCaveBiomeStream();
|
||||
verify(changedComplex, never()).getHeightStream();
|
||||
verify(engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
private static IrisBiome biome(String key, int minimumDepth) {
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoadKey(key);
|
||||
biome.setCaveMinDepthBelowSurface(minimumDepth);
|
||||
return biome;
|
||||
}
|
||||
|
||||
private static int column(double x, double z) {
|
||||
return (((int) x & 15) << 4) | ((int) z & 15);
|
||||
}
|
||||
|
||||
private static GenerationHistory.GenerationStage stage() {
|
||||
GenerationHistory.GenerationStage stage = mock(GenerationHistory.GenerationStage.class);
|
||||
GenerationActivation activation = mock(GenerationActivation.class);
|
||||
when(activation.activationId()).thenReturn(5L);
|
||||
when(stage.activation()).thenReturn(activation);
|
||||
when(stage.chunkX()).thenReturn(-2);
|
||||
when(stage.chunkZ()).thenReturn(3);
|
||||
return stage;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unscopedCapturePreservesCustomThreeArgumentCaveBiomeDispatch() {
|
||||
Fixture fixture = new Fixture();
|
||||
IrisBiome custom = biome("custom:cave", 0);
|
||||
doReturn(custom).when(fixture.engine).getCaveBiome(-31, 20, 50);
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
consumer.accept(1, 20, 2, new MatterCavern(true, null, (byte) 0));
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
clearInvocations(fixture.engine);
|
||||
|
||||
ChunkGenerationSemantics captured = GenerationSemanticCapture.capture(fixture.engine, -2, 3, 5L);
|
||||
|
||||
assertEquals(Set.of("custom:cave"), captured.caveBiomeKeys());
|
||||
verify(fixture.engine).getCaveBiome(-31, 20, 50);
|
||||
verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void captureKeepsWorldPoiCoordinatesAndInternalHeight() {
|
||||
Fixture fixture = new Fixture();
|
||||
@@ -103,7 +326,7 @@ public final class GenerationSemanticCaptureTest {
|
||||
new ChunkGenerationSemantics.BlockPosition(3, 11, 4),
|
||||
new ChunkGenerationSemantics.BlockPosition(5, 12, 6)), checked);
|
||||
verify(filled, never()).fluidProfileKey();
|
||||
verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt());
|
||||
verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -112,7 +335,7 @@ public final class GenerationSemanticCaptureTest {
|
||||
List<Integer> heights = List.of(0, 15, 16, 255, 256, 767, 4063);
|
||||
Set<String> expected = new HashSet<>();
|
||||
List<ChunkGenerationSemantics.BlockPosition> resolved = new ArrayList<>();
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class))).thenAnswer(invocation -> {
|
||||
int x = invocation.getArgument(0);
|
||||
int y = invocation.getArgument(1);
|
||||
int z = invocation.getArgument(2);
|
||||
@@ -181,11 +404,19 @@ public final class GenerationSemanticCaptureTest {
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final Engine engine = mock(Engine.class);
|
||||
private final Engine engine;
|
||||
@SuppressWarnings("unchecked")
|
||||
private final Mantle<Matter> mantle = mock(Mantle.class);
|
||||
|
||||
private Fixture() {
|
||||
this(mock(Engine.class));
|
||||
}
|
||||
|
||||
private Fixture(Engine engine) {
|
||||
this.engine = engine;
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
when(engine.getCaveBiome(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> engine.getCaveBiome(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2), state));
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
|
||||
@@ -250,25 +250,25 @@ public class SavedBiomeStoreBatchTest {
|
||||
private static List<Future<ClaimResult>> enqueue(SavedBiomeStore store, Path root, BatchControl control,
|
||||
ExecutorService workers, List<SavedBiomeChunk> chunks) throws Exception {
|
||||
Object stripe = regionLock(store, 0, 0);
|
||||
Field writingField = stripe.getClass().getDeclaredField("writing");
|
||||
Field pendingField = stripe.getClass().getDeclaredField("pending");
|
||||
writingField.setAccessible(true);
|
||||
pendingField.setAccessible(true);
|
||||
Queue<?> pending = (Queue<?>) pendingField.get(stripe);
|
||||
ArrayList<Future<ClaimResult>> results = new ArrayList<>(chunks.size());
|
||||
synchronized (stripe) {
|
||||
writingField.setBoolean(stripe, true);
|
||||
try {
|
||||
for (SavedBiomeChunk chunk : chunks) {
|
||||
results.add(workers.submit(() -> claim(store, root, control, chunk)));
|
||||
}
|
||||
Await.reached("every claim to queue behind the region write", Duration.ofSeconds(5L),
|
||||
() -> pending.size() == chunks.size());
|
||||
assertEquals(chunks.size(), pending.size());
|
||||
} finally {
|
||||
writingField.setBoolean(stripe, false);
|
||||
int uncachedClaims = 0;
|
||||
for (SavedBiomeChunk chunk : chunks) {
|
||||
if (store.cached(chunk.chunkX(), chunk.chunkZ()).isEmpty()) {
|
||||
uncachedClaims++;
|
||||
}
|
||||
}
|
||||
int expectedQueuedClaims = uncachedClaims;
|
||||
synchronized (stripe) {
|
||||
for (SavedBiomeChunk chunk : chunks) {
|
||||
results.add(workers.submit(() -> claim(store, root, control, chunk)));
|
||||
}
|
||||
Await.reached("uncached claims to queue before acquiring the region write lock", Duration.ofSeconds(5L),
|
||||
() -> pending.size() == expectedQueuedClaims);
|
||||
assertEquals(expectedQueuedClaims, pending.size());
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
+26
-3
@@ -16,6 +16,24 @@ import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class AsyncPregenMethodConcurrencyCapTest {
|
||||
@Test
|
||||
public void pregenWorkerTargetExpandsOnlyOptedInHighHeapJobs() {
|
||||
assertEquals(16, AsyncPregenMethod.pregenWorkerThreadTarget(16, (16L << 30) - 1L, true));
|
||||
assertEquals(32, AsyncPregenMethod.pregenWorkerThreadTarget(16, 16L << 30, true));
|
||||
assertEquals(32, AsyncPregenMethod.pregenWorkerThreadTarget(24, 16L << 30, true));
|
||||
assertEquals(64, AsyncPregenMethod.pregenWorkerThreadTarget(64, 16L << 30, true));
|
||||
assertEquals(16, AsyncPregenMethod.pregenWorkerThreadTarget(16, 32L << 30, false));
|
||||
assertEquals(2, AsyncPregenMethod.pregenWorkerThreadTarget(1, 8L << 30, true));
|
||||
assertEquals(Integer.MAX_VALUE, AsyncPregenMethod.pregenWorkerThreadTarget(Integer.MAX_VALUE, Long.MAX_VALUE, true));
|
||||
int target = AsyncPregenMethod.pregenWorkerThreadTarget(16, 16L << 30, true);
|
||||
int workers = AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(4, 16, target);
|
||||
assertEquals(32, workers);
|
||||
assertEquals(256, AsyncPregenMethod.computePaperLikeRecommendedCap(workers));
|
||||
assertEquals(256, AsyncPregenMethod.computeInitialInFlightLimit(256, workers));
|
||||
assertEquals(48, AsyncPregenMethod.resolvePaperLikeConcurrencyWorkerThreads(48, 16, target));
|
||||
assertEquals(1, AsyncPregenMethod.selectConcurrencyCap(256, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void paperLikeRecommendedCapTracksWorkerThreads() {
|
||||
assertEquals(16, AsyncPregenMethod.computePaperLikeRecommendedCap(1));
|
||||
@@ -57,11 +75,16 @@ public class AsyncPregenMethodConcurrencyCapTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coldStartMatchesAvailableWorkersBeforeAdaptiveGrowth() {
|
||||
public void coldStartFillsChunkStatusPipelineWithinHardCap() {
|
||||
assertEquals(1, AsyncPregenMethod.computeInitialInFlightLimit(1, 16));
|
||||
assertEquals(4, AsyncPregenMethod.computeInitialInFlightLimit(128, 1));
|
||||
assertEquals(16, AsyncPregenMethod.computeInitialInFlightLimit(128, 16));
|
||||
assertEquals(8, AsyncPregenMethod.computeInitialInFlightLimit(128, 1));
|
||||
assertEquals(128, AsyncPregenMethod.computeInitialInFlightLimit(128, 16));
|
||||
assertEquals(6, AsyncPregenMethod.computeInitialInFlightLimit(6, 16));
|
||||
assertEquals(64, AsyncPregenMethod.computeInitialInFlightLimit(64, 8));
|
||||
assertEquals(192, AsyncPregenMethod.computeInitialInFlightLimit(192, 64));
|
||||
assertEquals(256, AsyncPregenMethod.computeInitialInFlightLimit(256, Integer.MAX_VALUE));
|
||||
assertEquals(8, AsyncPregenMethod.computeInitialInFlightLimit(128, Integer.MIN_VALUE));
|
||||
assertEquals(1, AsyncPregenMethod.computeInitialInFlightLimit(0, 16));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package art.arcane.iris.world.pregen;
|
||||
|
||||
import art.arcane.iris.world.lifecycle.CapabilitySnapshot;
|
||||
import art.arcane.iris.world.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorkerPool;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
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;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class AsyncPregenWorkerBoostTest {
|
||||
@Test
|
||||
public void nestedTargetsEscalateAndRestoreOnlyAfterLastRelease() throws Exception {
|
||||
try (Fixture fixture = new Fixture(4)) {
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(16);
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(16);
|
||||
assertEquals(32, fixture.threads.get());
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(32, fixture.threads.get());
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(4, fixture.threads.get());
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(List.of(16, 32, 4), fixture.adjustments);
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(List.of(16, 32, 4, 32, 4), fixture.adjustments);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitHigherNativeCountIsNeverReduced() throws Exception {
|
||||
try (Fixture fixture = new Fixture(48)) {
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(16);
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(48, fixture.threads.get());
|
||||
assertTrue(fixture.adjustments.isEmpty());
|
||||
assertThrows(IllegalArgumentException.class, () -> AsyncPregenMethod.acquireWorkerThreadBoost(0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reacquisitionWaitsForPreviousRestorationAndKeepsItsOwnHold() throws Exception {
|
||||
try (Fixture fixture = new Fixture(4); ExecutorService executor = Executors.newFixedThreadPool(2)) {
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(16);
|
||||
CountDownLatch restoring = new CountDownLatch(1);
|
||||
CountDownLatch finishRestore = new CountDownLatch(1);
|
||||
CountDownLatch acquiring = new CountDownLatch(1);
|
||||
AtomicBoolean waitOnce = new AtomicBoolean(true);
|
||||
doAnswer(invocation -> {
|
||||
int count = invocation.getArgument(0);
|
||||
if (count == 4 && waitOnce.compareAndSet(true, false)) {
|
||||
restoring.countDown();
|
||||
assertTrue(finishRestore.await(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
fixture.adjustments.add(count);
|
||||
fixture.threads.set(count);
|
||||
return null;
|
||||
}).when(fixture.pool).adjustThreadCount(anyInt());
|
||||
Future<?> released = executor.submit(() -> fixture.withBinding(AsyncPregenMethod::releaseWorkerThreadBoost));
|
||||
try {
|
||||
assertTrue(restoring.await(5L, TimeUnit.SECONDS));
|
||||
Future<?> acquired = executor.submit(() -> fixture.withBinding(() -> {
|
||||
acquiring.countDown();
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
}));
|
||||
assertTrue(acquiring.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(acquired.isDone());
|
||||
finishRestore.countDown();
|
||||
released.get(5L, TimeUnit.SECONDS);
|
||||
acquired.get(5L, TimeUnit.SECONDS);
|
||||
assertEquals(32, fixture.threads.get());
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
assertEquals(4, fixture.threads.get());
|
||||
} finally {
|
||||
finishRestore.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeRestoresWorkersWhenDrainFailsAndPreservesCancellationInterrupt() throws Exception {
|
||||
try (Fixture fixture = new Fixture(4)) {
|
||||
AsyncPregenMethod.acquireWorkerThreadBoost(32);
|
||||
AsyncPregenMethod method = mock(AsyncPregenMethod.class, CALLS_REAL_METHODS);
|
||||
PregenAdmissionGate admission = mock(PregenAdmissionGate.class);
|
||||
IllegalStateException failure = new IllegalStateException("drain failed");
|
||||
when(admission.awaitDrain(anyLong(), any(TimeUnit.class), any(Runnable.class))).thenThrow(failure);
|
||||
set(method, "closing", new AtomicBoolean());
|
||||
set(method, "holdsWorkerBoost", new AtomicBoolean(true));
|
||||
set(method, "admission", admission);
|
||||
Thread.currentThread().interrupt();
|
||||
try {
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, method::close));
|
||||
assertEquals(4, fixture.threads.get());
|
||||
assertTrue(Thread.currentThread().isInterrupted());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, method::close));
|
||||
assertEquals(List.of(32, 4), fixture.adjustments);
|
||||
}
|
||||
}
|
||||
|
||||
private static void set(AsyncPregenMethod method, String name, Object value) throws Exception {
|
||||
Field field = AsyncPregenMethod.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(method, value);
|
||||
}
|
||||
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
private final WorldLifecycleService service = mock(WorldLifecycleService.class);
|
||||
private final NativeWorkerPool pool = mock(NativeWorkerPool.class);
|
||||
private final AtomicInteger threads;
|
||||
private final List<Integer> adjustments = Collections.synchronizedList(new ArrayList<>());
|
||||
private final MockedStatic<WorldLifecycleService> lifecycle = mockStatic(WorldLifecycleService.class);
|
||||
|
||||
private Fixture(int initialThreads) throws Exception {
|
||||
threads = new AtomicInteger(initialThreads);
|
||||
CapabilitySnapshot capabilities = mock(CapabilitySnapshot.class);
|
||||
NativeWorldRuntime runtime = mock(NativeWorldRuntime.class);
|
||||
when(service.capabilities()).thenReturn(capabilities);
|
||||
when(capabilities.nativeRuntime()).thenReturn(runtime);
|
||||
when(runtime.workers()).thenReturn(pool);
|
||||
when(pool.threadCount()).thenAnswer(invocation -> threads.get());
|
||||
doAnswer(invocation -> {
|
||||
int count = invocation.getArgument(0);
|
||||
adjustments.add(count);
|
||||
threads.set(count);
|
||||
return null;
|
||||
}).when(pool).adjustThreadCount(anyInt());
|
||||
lifecycle.when(WorldLifecycleService::get).thenReturn(service);
|
||||
}
|
||||
|
||||
private void withBinding(Runnable action) {
|
||||
try (MockedStatic<WorldLifecycleService> binding = mockStatic(WorldLifecycleService.class)) {
|
||||
binding.when(WorldLifecycleService::get).thenReturn(service);
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (int index = 0; index < 4; index++) {
|
||||
AsyncPregenMethod.releaseWorkerThreadBoost();
|
||||
}
|
||||
lifecycle.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user