Hydrology Fix, and V+

This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-15 23:01:56 -04:00
1 parent dfb3748059
commit 1e1eaa96aa
137 files changed
+8842 -711

No files matched your search

@@ -44,6 +44,12 @@ public final class IrisBiomeLayerSlopeSourceTest {
this.lockLayers = lockLayers;
}
// This test is about which slope source picks the palette, so it pins the fallback to ROCK to
// keep a rejected layer list empty instead of capping it with the top layer.
private static IrisDimension rockFallbackDimension() {
return new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK);
}
@Test
public void lowerLedgeSlopeControlsSurfacePaletteSelection() {
IrisData data = mock(IrisData.class);
@@ -65,9 +71,9 @@ public final class IrisBiomeLayerSlopeSourceTest {
when(grass.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(block);
KList<PlatformBlockState> ledge = biome.generateLayers(
new IrisDimension(), 12D, -8D, rng, 1, 32, data, complex);
rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, complex);
KList<PlatformBlockState> cap = biome.generateLayers(
new IrisDimension(), 12D, -8D, rng, 1, 96, data, complex);
rockFallbackDimension(), 12D, -8D, rng, 1, 96, data, complex);
assertEquals(1, ledge.size());
assertSame(block, ledge.get(0));
@@ -105,9 +111,9 @@ public final class IrisBiomeLayerSlopeSourceTest {
when(paletteLayer.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(surfaceBlock);
KList<PlatformBlockState> hostLayers = biome.generateLayers(
new IrisDimension(), 12D, -8D, rng, 1, 32, data, hostComplex);
rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, hostComplex);
KList<PlatformBlockState> sourceLayers = biome.generateLayersWithSlope(
new IrisDimension(), 12D, -8D, rng, 1, 32, data, sourceSlope);
rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, sourceSlope);
assertTrue(hostLayers.isEmpty());
assertEquals(1, sourceLayers.size());
@@ -0,0 +1,137 @@
package art.arcane.iris.generation.biome;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisSlopeClip;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.noise.CNG;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public final class IrisSurfaceLayerFallbackTest {
private final IrisData data = mock(IrisData.class);
private final IrisBiomePaletteLayer layer = mock(IrisBiomePaletteLayer.class);
private final CNG heightGenerator = mock(CNG.class);
private final PlatformBlockState surfaceBlock = mock(PlatformBlockState.class);
private final PlatformBlockState secondBlock = mock(PlatformBlockState.class);
private final RNG rng = new RNG(19L);
@SuppressWarnings("unchecked")
private final ProceduralStream<Double> slopeStream = mock(ProceduralStream.class);
private IrisBiome biome() {
when(layer.getZoom()).thenReturn(1D);
when(layer.getMinHeight()).thenReturn(1);
when(layer.getMaxHeight()).thenReturn(2);
when(layer.getHeightGenerator(any(RNG.class), same(data))).thenReturn(heightGenerator);
when(heightGenerator.fit(1, 2, 12D, -8D)).thenReturn(2);
when(layer.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(surfaceBlock);
when(layer.get(rng, 1, 13D, 1D, -9D, data)).thenReturn(secondBlock);
when(slopeStream.getDouble(12D, -8D)).thenReturn(9D);
return new IrisBiome().setLayers(new KList<>(layer));
}
private KList<PlatformBlockState> generate(IrisBiome biome, IrisDimension dim) {
return biome.generateLayersWithSlope(dim, 12D, -8D, rng, 4, 32, data, slopeStream);
}
@Test
public void allGatedLayersEmitTopLayerBlockUnderTopLayerFallback() {
IrisBiome biome = biome();
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
KList<PlatformBlockState> blocks = generate(biome,
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER));
assertEquals(1, blocks.size());
assertSame(surfaceBlock, blocks.get(0));
}
@Test
public void allGatedLayersStayEmptyUnderRockFallback() {
IrisBiome biome = biome();
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
KList<PlatformBlockState> blocks = generate(biome,
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK));
assertTrue(blocks.isEmpty());
}
@Test
public void ungatedLayersAreIdenticalUnderBothFallbacks() {
IrisBiome biome = biome();
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip());
KList<PlatformBlockState> topLayer = generate(biome,
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER));
KList<PlatformBlockState> rock = generate(biome,
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK));
assertEquals(2, topLayer.size());
assertSame(surfaceBlock, topLayer.get(0));
assertSame(secondBlock, topLayer.get(1));
assertEquals(topLayer, rock);
}
@Test
public void biomeFallbackOverridesDimensionFallbackInBothDirections() {
IrisBiome pinnedToTopLayer = biome().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER);
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
KList<PlatformBlockState> overRock = generate(pinnedToTopLayer,
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK));
assertEquals(1, overRock.size());
assertSame(surfaceBlock, overRock.get(0));
IrisBiome pinnedToRock = biome().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK);
KList<PlatformBlockState> overTopLayer = generate(pinnedToRock,
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER));
assertTrue(overTopLayer.isEmpty());
}
@Test
public void lockedLayersHonorInheritedAndOverriddenFallbacks() {
IrisBiome biome = biome().setLockLayers(true);
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
IrisDimension dimension = new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER);
KList<PlatformBlockState> blocks = generate(biome, dimension);
assertEquals(1, blocks.size());
assertSame(surfaceBlock, blocks.get(0));
assertTrue(generate(biome.setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK), dimension).isEmpty());
assertEquals(blocks, generate(biome.setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER),
dimension.setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK)));
}
@Test
public void lockedFallbackRespectsEmptyLayersAndDepthLimits() {
IrisBiome biome = biome().setLockLayers(true).setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER);
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
IrisDimension dimension = new IrisDimension();
assertTrue(biome.generateLayersWithSlope(dimension, 12D, -8D, rng, 0, 32, data, slopeStream).isEmpty());
assertTrue(generate(biome.setLockLayersMax(0), dimension).isEmpty());
assertTrue(generate(biome.setLockLayersMax(7).setLayers(new KList<>()), dimension).isEmpty());
}
@Test
public void dimensionDefaultsToRockFallback() {
assertEquals(IrisSurfaceLayerFallback.ROCK, new IrisDimension().getSurfaceLayerFallback());
}
}
@@ -0,0 +1,112 @@
package art.arcane.iris.generation.concurrent;
import art.arcane.iris.configuration.IrisSettings;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class HydrologyBurstTest {
@Test(timeout = 15000)
public void blockedOwnersLeaveTheFullRunnableTargetAvailableForLeafTasks() throws Exception {
int parallelism = 4;
MultiBurst.HydrologyBurst burst = new MultiBurst.HydrologyBurst();
ForkJoinPool pool = burst.createPool(parallelism, ForkJoinPool.defaultForkJoinWorkerThreadFactory, null);
CountDownLatch ownersReady = new CountDownLatch(parallelism);
CountDownLatch startOwners = new CountDownLatch(1);
CountDownLatch ownersBlocked = new CountDownLatch(parallelism);
CountDownLatch releaseOwners = new CountDownLatch(1);
CountDownLatch leavesStarted = new CountDownLatch(parallelism);
CountDownLatch releaseLeaves = new CountDownLatch(1);
List<Future<?>> owners = new ArrayList<>();
try {
for (int index = 0; index < parallelism; index++) {
owners.add(pool.submit(() -> {
ownersReady.countDown();
await(startOwners);
pool.execute(() -> {
leavesStarted.countDown();
await(releaseLeaves);
});
ForkJoinPool.managedBlock(new LatchBlocker(ownersBlocked, releaseOwners));
return null;
}));
}
assertTrue(ownersReady.await(5, TimeUnit.SECONDS));
startOwners.countDown();
assertTrue(ownersBlocked.await(5, TimeUnit.SECONDS));
assertTrue("Managed waits reduced the runnable leaf capacity", leavesStarted.await(5, TimeUnit.SECONDS));
releaseLeaves.countDown();
releaseOwners.countDown();
for (Future<?> owner : owners) {
owner.get(5, TimeUnit.SECONDS);
}
} finally {
startOwners.countDown();
releaseLeaves.countDown();
releaseOwners.countDown();
MultiBurst.close(pool);
}
assertTrue(pool.isTerminated());
}
@Test
public void closedHydrologyWorkersTerminateAndReopenCreatesANewPool() throws Exception {
IrisSettings previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
MultiBurst.HydrologyBurst burst = new MultiBurst.HydrologyBurst();
Callable<ForkJoinPool> currentPool = ForkJoinTask::getPool;
try {
ForkJoinPool first = burst.submit(currentPool).get(5, TimeUnit.SECONDS);
burst.close();
assertTrue(first.isTerminated());
assertTrue(burst.isTerminated());
assertSame(Thread.currentThread(), burst.submit(Thread::currentThread).get(5, TimeUnit.SECONDS));
burst.reopen();
ForkJoinPool reopened = burst.submit(currentPool).get(5, TimeUnit.SECONDS);
assertNotSame(first, reopened);
assertTrue(reopened.getParallelism() > 0);
burst.close();
assertTrue(reopened.isTerminated());
} finally {
burst.close();
IrisSettings.settings = previousSettings;
}
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(10, TimeUnit.SECONDS)) {
throw new AssertionError("Timed out waiting for pool work");
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError(failure);
}
}
private record LatchBlocker(CountDownLatch blocked, CountDownLatch release) implements ForkJoinPool.ManagedBlocker {
@Override
public boolean block() throws InterruptedException {
blocked.countDown();
release.await();
return true;
}
@Override
public boolean isReleasable() {
return release.getCount() == 0;
}
}
}
@@ -73,6 +73,7 @@ public class HydrologyCaveCourseFilterTest {
filtered.edges(),
filtered.outlets(),
filtered.courses(),
Set.of(),
filtered.cavePlans(),
diagnostics,
RiverFootprint.empty()
@@ -83,9 +84,9 @@ public class HydrologyCaveCourseFilterTest {
assertTrue(tile.features().isEmpty());
assertTrue(tile.renderAt(8, 8).features().isEmpty());
assertTrue(tile.nearestFeature(HydrologyFeatureType.UNDERGROUND_POOL, 8, 8, 16).isEmpty());
assertEquals(1, tile.diagnosticCandidates().size());
assertEquals(1, tile.localDiagnosticCandidates().size());
assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT,
tile.diagnosticCandidates().getFirst().rejection());
tile.localDiagnosticCandidates().getFirst().rejection());
}
@Test
@@ -650,6 +651,7 @@ public class HydrologyCaveCourseFilterTest {
filtered.edges(),
filtered.outlets(),
filtered.courses(),
Set.of(),
filtered.cavePlans(),
diagnostics,
footprint
@@ -0,0 +1,248 @@
package art.arcane.iris.generation.hydrology;
import com.github.benmanes.caffeine.cache.Cache;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.ArrayList;
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.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.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.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class HydrologyDiagnosticCacheTest {
@Test
public void terrainLoadsLeaveDiagnosticsLazyAndQueriesRetainImmutableResults() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyTileKey key = new HydrologyTileKey(0, 0);
HydrologyDiagnosticCandidate candidate = mock(HydrologyDiagnosticCandidate.class);
ArrayList<HydrologyDiagnosticCandidate> supplied = new ArrayList<>(List.of(candidate));
when(planner.plan(key)).thenReturn(tile);
when(planner.diagnosticCandidates(tile)).thenReturn(supplied);
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4)) {
assertSame(tile, cache.get(key));
verify(planner, never()).diagnosticCandidates(tile);
AtomicInteger preparations = new AtomicInteger();
cache.setTerrainPreparation(preparations::incrementAndGet);
List<HydrologyDiagnosticCandidate> result = cache.diagnosticCandidates(key);
supplied.clear();
assertEquals(List.of(candidate), result);
assertThrows(UnsupportedOperationException.class, () -> result.add(candidate));
assertSame(result, cache.diagnosticCandidates(key));
verify(planner, times(1)).plan(key);
verify(planner, times(1)).diagnosticCandidates(tile);
assertEquals(1, preparations.get());
}
}
@Test
public void forbiddenThreadsCanReadOnlyCompleteCachedDiagnostics() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyTileKey key = new HydrologyTileKey(0, 0);
AtomicBoolean forbidden = new AtomicBoolean(true);
when(planner.plan(key)).thenReturn(tile);
when(planner.diagnosticCandidates(tile)).thenReturn(List.of());
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4, null, forbidden::get)) {
assertThrows(IllegalStateException.class, () -> cache.diagnosticCandidates(key));
verify(planner, never()).plan(key);
forbidden.set(false);
List<HydrologyDiagnosticCandidate> complete = cache.diagnosticCandidates(key);
forbidden.set(true);
assertSame(complete, cache.diagnosticCandidates(key));
}
}
@Test
public void interruptedWaiterLeavesTheSharedDiagnosticLoadRunning() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyTileKey key = new HydrologyTileKey(0, 0);
List<HydrologyDiagnosticCandidate> expected = List.of(mock(HydrologyDiagnosticCandidate.class));
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch exited = new CountDownLatch(1);
AtomicReference<Thread> waiterThread = new AtomicReference<>();
AtomicBoolean interrupted = new AtomicBoolean();
when(planner.plan(key)).thenReturn(tile);
when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> {
started.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return expected;
});
ExecutorService workers = Executors.newSingleThreadExecutor();
ExecutorService callers = Executors.newFixedThreadPool(2);
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
try {
Future<?> waiter = callers.submit(() -> {
waiterThread.set(Thread.currentThread());
try {
assertThrows(CancellationException.class, () -> cache.diagnosticCandidates(key));
interrupted.set(Thread.currentThread().isInterrupted());
} finally {
exited.countDown();
}
});
assertTrue(started.await(5L, TimeUnit.SECONDS));
Future<List<HydrologyDiagnosticCandidate>> second = callers.submit(() -> cache.diagnosticCandidates(key));
waiterThread.get().interrupt();
assertTrue(exited.await(2L, TimeUnit.SECONDS));
waiter.get(2L, TimeUnit.SECONDS);
assertTrue(interrupted.get());
assertFalse(second.isDone());
release.countDown();
assertEquals(expected, second.get(5L, TimeUnit.SECONDS));
verify(planner, times(1)).diagnosticCandidates(tile);
} finally {
release.countDown();
workers.shutdownNow();
callers.shutdownNow();
assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS));
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
cache.close();
}
}
@Test
public void clearDoesNotPublishAnOlderDiagnosticResultIntoTheNewEpoch() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyTileKey key = new HydrologyTileKey(0, 0);
List<HydrologyDiagnosticCandidate> old = List.of(mock(HydrologyDiagnosticCandidate.class));
List<HydrologyDiagnosticCandidate> fresh = List.of(mock(HydrologyDiagnosticCandidate.class));
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger attempts = new AtomicInteger();
when(planner.plan(key)).thenReturn(tile);
when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> {
if (attempts.getAndIncrement() == 0) {
started.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return old;
}
return fresh;
});
ExecutorService caller = Executors.newSingleThreadExecutor();
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
try {
Future<List<HydrologyDiagnosticCandidate>> first = caller.submit(() -> cache.diagnosticCandidates(key));
assertTrue(started.await(5L, TimeUnit.SECONDS));
cache.clear();
assertEquals(fresh, cache.diagnosticCandidates(key));
release.countDown();
assertEquals(old, first.get(5L, TimeUnit.SECONDS));
assertEquals(fresh, cache.diagnosticCandidates(key));
assertEquals(2, attempts.get());
} finally {
release.countDown();
caller.shutdownNow();
assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS));
cache.close();
}
}
@Test
public void closeCancelsQueuedDiagnosticsAndDrainsActiveDiagnostics() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyTileKey firstKey = new HydrologyTileKey(0, 0);
HydrologyTileKey secondKey = new HydrologyTileKey(1, 0);
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch queued = new CountDownLatch(1);
AtomicInteger submissions = new AtomicInteger();
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> {
started.countDown();
assertTrue(release.await(5L, TimeUnit.SECONDS));
return List.of();
});
ExecutorService workers = Executors.newSingleThreadExecutor();
ExecutorService callers = Executors.newFixedThreadPool(3);
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, task -> {
workers.execute(task);
if (submissions.incrementAndGet() == 2) {
queued.countDown();
}
});
try {
Future<List<HydrologyDiagnosticCandidate>> first = callers.submit(() -> cache.diagnosticCandidates(firstKey));
assertTrue(started.await(5L, TimeUnit.SECONDS));
Future<?> second = callers.submit(() -> cache.diagnosticCandidates(secondKey));
assertTrue(queued.await(5L, TimeUnit.SECONDS));
Future<?> closing = callers.submit(cache::close);
ExecutionException cancelled = assertThrows(ExecutionException.class, () -> second.get(5L, TimeUnit.SECONDS));
assertTrue(cancelled.getCause() instanceof CancellationException);
assertThrows(TimeoutException.class, () -> closing.get(100L, TimeUnit.MILLISECONDS));
assertThrows(CancellationException.class, () -> cache.diagnosticCandidates(firstKey));
release.countDown();
assertEquals(List.of(), first.get(5L, TimeUnit.SECONDS));
closing.get(5L, TimeUnit.SECONDS);
verify(planner, never()).plan(secondKey);
} finally {
release.countDown();
workers.shutdownNow();
callers.shutdownNow();
assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS));
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test
public void aDiagnosticLoadCannotCloseItsOwnCache() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyTileKey key = new HydrologyTileKey(0, 0);
when(planner.plan(key)).thenReturn(tile);
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> {
assertThrows(IllegalStateException.class, cache::close);
return List.of();
});
assertEquals(List.of(), cache.diagnosticCandidates(key));
assertSame(tile, cache.get(key));
cache.close();
}
@Test
public void diagnosticFailureIsRetriedAndCompletedListsRemainBounded() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
when(planner.diagnosticCandidates(tile)).thenThrow(new IllegalStateException("expected")).thenReturn(List.of());
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 2)) {
HydrologyTileKey key = new HydrologyTileKey(0, 0);
assertThrows(IllegalStateException.class, () -> cache.diagnosticCandidates(key));
assertEquals(List.of(), cache.diagnosticCandidates(key));
for (int index = 1; index < 24; index++) {
assertEquals(List.of(), cache.diagnosticCandidates(new HydrologyTileKey(index, 0)));
}
Field field = HydrologyTileCache.class.getDeclaredField("diagnostics");
field.setAccessible(true);
Cache<?, ?> completed = (Cache<?, ?>) field.get(cache);
completed.cleanUp();
assertTrue(completed.estimatedSize() <= 2L);
}
}
}
@@ -0,0 +1,188 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import java.util.Set;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
public class HydrologyFallbackDiagnosticsTest {
private static final HydrologyTileKey KEY = new HydrologyTileKey(0, 0);
private static final HydrologyCaveCourseFilter.Result EMPTY = new HydrologyCaveCourseFilter.Result(
List.of(), List.of(), List.of(), List.of(), List.of());
private static final long DISCARDED = 200L;
private static final long ADOPTED = 201L;
/**
* A fallback re-routing re-runs source selection over the whole owned lattice, so every discarded trial invents a
* full set of per-node rejections. Only the trial the draft actually keeps may report any.
*/
@Test(timeout = 15000)
public void discardedFallbackTrialsDoNotReportTheirRejections() throws Exception {
Harness harness = harness();
HydrologyOwnerDraft draft = run(harness.resolver());
assertEquals(List.of(diagnosticId(ADOPTED)),
draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
assertEquals(List.of(ADOPTED), draft.result().courses().stream().map(RiverCourse::id).toList());
}
/**
* Only the rejections of a discarded trial are dropped. Its refined edges and source compilations are caches
* keyed by routing and source set, so the owner keeps them and the next trial does not recompile them. This is
* the one part of a discarded trial that can still reach generation output.
*/
@Test(timeout = 15000)
public void discardedFallbackTrialsStillMergeTheirCompilationCaches() throws Exception {
Harness harness = harness();
run(harness.resolver());
assertEquals(Set.of(DISCARDED, ADOPTED), new HashSet<>(harness.refinedEdges().get().keySet()));
assertEquals(Set.of(DISCARDED, ADOPTED), harness.sourceCompilations().get().keySet().stream()
.map(key -> key.routing().outlets().getFirst().outlet().id())
.collect(Collectors.toSet()));
}
private static HydrologyOwnerDraft run(HydrologyCrossTileResolver resolver) throws Exception {
ForkJoinPool pool = new ForkJoinPool(4);
try {
return pool.submit(() -> resolver.compileOwnerDraft(KEY, null, false)).get(10, TimeUnit.SECONDS);
} finally {
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
private static Harness harness() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyPlannerSettings.defaults(), terrain);
HydrologyGridNode source = new HydrologyGridNode(0, 0, 0, 0, 0, 10L, terrain.sample(0, 0));
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 2048, 1, 64, List.of(source));
HydrologyRoutingPlan primary = routing(List.of());
planner.routingContexts.put(KEY, new SourceRoutingContext(grid, primary, primary, List.of()));
HydrologySourcePlanner sourcePlanner = mock(HydrologySourcePlanner.class);
when(sourcePlanner.hasRoutedSourceSearch()).thenReturn(true);
when(sourcePlanner.requireOrganicSurface(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(sourcePlanner.buildRouting(any(), anyList(), anyBoolean()))
.thenAnswer(invocation -> routing(invocation.getArgument(1)));
when(sourcePlanner.selectSources(any(), any(), any(), anyBoolean(), anyBoolean(), anyList(), anyMap()))
.thenAnswer(invocation -> {
if (!invocation.<Boolean>getArgument(3)) {
return SourceSelection.empty(false);
}
HydrologyRoutingPlan selected = invocation.getArgument(2);
if (selected.outlets().isEmpty()) {
return selection(false);
}
long outlet = selected.outlets().getFirst().outlet().id();
invocation.<List<HydrologyDiagnosticCandidate>>getArgument(5).add(diagnostic(diagnosticId(outlet)));
return selection(true);
});
when(sourcePlanner.uniqueDiagnostics(anyList()))
.thenAnswer(invocation -> List.copyOf(invocation.<List<HydrologyDiagnosticCandidate>>getArgument(0)));
replace(planner, "sourcePlanner", sourcePlanner);
HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class);
when(outletPlanner.resolveSurfaceFallbackOutlets(any())).thenReturn(List.of(outlet(0), outlet(1)));
replace(planner, "outletPlanner", outletPlanner);
HydrologyCrossTileResolver resolver = spy(planner.crossTile);
AtomicReference<Map<Long, List<HydrologyPoint>>> ownerEdges = new AtomicReference<>();
AtomicReference<Map<HydrologyCrossTileResolver.SourceCompilationKey,
HydrologyCrossTileResolver.SourceCompilation>> ownerCompilations = new AtomicReference<>();
doAnswer(invocation -> {
Map<Long, List<HydrologyPoint>> refinedEdges = invocation.getArgument(7);
Map<HydrologyCrossTileResolver.SourceCompilationKey,
HydrologyCrossTileResolver.SourceCompilation> sourceCompilations = invocation.getArgument(8);
if (invocation.<Boolean>getArgument(9)) {
// Only the owner draft publishes deep fluids, so these are the maps the trials merge into.
ownerEdges.compareAndSet(null, refinedEdges);
ownerCompilations.compareAndSet(null, sourceCompilations);
}
SourceSelection selected = invocation.getArgument(4);
if (selected.selectedCandidateIndices.isEmpty()) {
return new HydrologyCrossTileResolver.PublicationAttempt(EMPTY, List.of());
}
HydrologyRoutingPlan selectedRouting = invocation.getArgument(2);
long outlet = selectedRouting.outlets().getFirst().outlet().id();
refinedEdges.put(outlet, List.of(new HydrologyPoint(0, 70, 0)));
sourceCompilations.put(
new HydrologyCrossTileResolver.SourceCompilationKey(true, selectedRouting, List.of(0)),
new HydrologyCrossTileResolver.SourceCompilation(
new CompiledGraph(List.of(), List.of(), List.of(), Map.of()), List.of(), List.of()));
return new HydrologyCrossTileResolver.PublicationAttempt(
outlet == ADOPTED ? result(outlet, source) : EMPTY, List.of());
}).when(resolver).compilePublication(any(), any(), any(), any(), any(), any(), any(), anyMap(), anyMap(), anyBoolean());
return new Harness(resolver, ownerEdges, ownerCompilations);
}
private record Harness(
HydrologyCrossTileResolver resolver,
AtomicReference<Map<Long, List<HydrologyPoint>>> refinedEdges,
AtomicReference<Map<HydrologyCrossTileResolver.SourceCompilationKey,
HydrologyCrossTileResolver.SourceCompilation>> sourceCompilations
) {
}
private static long diagnosticId(long outlet) {
return 700L + outlet;
}
private static SourceSelection selection(boolean accepted) {
List<SourceCandidate> candidates = accepted ? List.of(new SourceCandidate(0, 10L, 1D, false)) : List.of();
int size = candidates.size();
SourceAdmissionSelection admission = new SourceAdmissionSelection(1, accepted ? List.of(0) : List.of(),
new boolean[size], new boolean[size], new boolean[size], index -> true,
SourceAdmissionSelection.Quotas.uniform(new int[size], 1, 1));
return new SourceSelection(true, candidates, admission, new int[]{0}, 1);
}
private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) {
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true);
}
private static OutletCandidate outlet(int index) {
HydrologyPoint point = new HydrologyPoint(128, 63, index * 128);
return new OutletCandidate(0, 0, new RiverOutlet(200L + index, HydrologyFeatureType.MOUTH, 10L,
point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true));
}
private static HydrologyCaveCourseFilter.Result result(long id, HydrologyGridNode source) {
HydraulicSegment segment = new HydraulicSegment(id, id, HydrologyFeatureType.SURFACE_POOL,
70, 70, 4, 2, false, false, List.of(source.naturalPoint(), new HydrologyPoint(128, 70, 0)),
new HydraulicChannelProfile(new double[]{4D}, new double[]{2D}));
RiverCourse course = new RiverCourse(id, RiverCourseType.SURFACE, OptionalLong.of(source.id()),
OptionalLong.of(id), "default", 1, List.of(), List.of(segment));
DrainageNode node = new DrainageNode(source.id(), source.x(), source.z(), source.terrain(), 0D, id);
return new HydrologyCaveCourseFilter.Result(List.of(node), List.of(),
List.of(outlet(Math.toIntExact(id - 200L)).outlet()), List.of(course), List.of());
}
private static HydrologyDiagnosticCandidate diagnostic(long id) {
return new HydrologyDiagnosticCandidate(id, HydrologyCandidateKind.SOURCE,
HydrologyFeatureType.SURFACE_POOL, new HydrologyPoint(0, 70, 0),
HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0);
}
private static void replace(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
}
@@ -0,0 +1,225 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.OptionalLong;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicIntegerArray;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
public class HydrologyFallbackParallelismTest {
private static final HydrologyTileKey KEY = new HydrologyTileKey(0, 0);
private static final HydrologyCaveCourseFilter.Result EMPTY = new HydrologyCaveCourseFilter.Result(
List.of(), List.of(), List.of(), List.of(), List.of());
@Test(timeout = 15000)
public void publicationRejectionConsumesTheNextCompletedOutletWithoutRebuildingIt() throws Exception {
AtomicIntegerArray builds = new AtomicIntegerArray(4);
Fixture fixture = fixture(outlet -> {
builds.incrementAndGet(outlet);
if (outlet == 3) {
throw new IllegalStateException("Unused fallback provider failed");
}
});
ArrayList<Long> submitted = new ArrayList<>();
CrossTileDraftAdmission admission = new CrossTileDraftAdmission() {
@Override
public void prepare() {
}
@Override
public CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result) {
if (result.courses().isEmpty()) {
return new CrossTilePublicationAdmission(result, List.of(), false);
}
for (int outlet = 0; outlet < 4; outlet++) {
assertEquals("Every trial provider must finish before publication", 1, builds.get(outlet));
}
long course = result.courses().getFirst().id();
submitted.add(course);
return course == 200L
? new CrossTilePublicationAdmission(EMPTY, List.of(diagnostic(900L)), true)
: new CrossTilePublicationAdmission(result, List.of(), false);
}
};
HydrologyOwnerDraft draft = run(fixture, admission);
assertEquals(List.of(200L, 201L), submitted);
assertEquals(List.of(201L), draft.result().courses().stream().map(RiverCourse::id).toList());
assertEquals(List.of(200L, 900L, 201L), draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
for (int outlet = 0; outlet < 4; outlet++) {
assertEquals(1, builds.get(outlet));
}
}
@Test(timeout = 15000)
public void requiredFallbackFailureDrainsOtherStartedProviders() throws Exception {
CountDownLatch failed = new CountDownLatch(1);
CountDownLatch blocked = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicBoolean completed = new AtomicBoolean();
IllegalStateException expected = new IllegalStateException("Required fallback provider failed");
Fixture fixture = fixture(outlet -> {
if (outlet == 0) {
failed.countDown();
throw expected;
}
if (outlet == 1) {
blocked.countDown();
await(release);
completed.set(true);
}
});
ForkJoinPool pool = new ForkJoinPool(4);
try {
Future<HydrologyOwnerDraft> pending = pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, null, false));
assertTrue(failed.await(5, TimeUnit.SECONDS));
assertTrue(blocked.await(5, TimeUnit.SECONDS));
assertFalse(pending.isDone());
release.countDown();
ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS));
Throwable cause = failure.getCause();
while (cause.getCause() != null) {
cause = cause.getCause();
}
assertSame(expected, cause);
assertTrue(completed.get());
} finally {
release.countDown();
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
private static HydrologyOwnerDraft run(Fixture fixture, CrossTileDraftAdmission admission) throws Exception {
ForkJoinPool pool = new ForkJoinPool(4);
try {
return pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, admission, false)).get(10, TimeUnit.SECONDS);
} finally {
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
private static Fixture fixture(OutletProvider provider) throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyPlannerSettings.defaults(), terrain);
HydrologyGridNode source = new HydrologyGridNode(0, 0, 0, 0, 0, 10L, terrain.sample(0, 0));
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 2048, 1, 64, List.of(source));
HydrologyRoutingPlan primary = routing(List.of());
planner.routingContexts.put(KEY, new SourceRoutingContext(grid, primary, primary, List.of()));
HydrologySourcePlanner sourcePlanner = mock(HydrologySourcePlanner.class);
when(sourcePlanner.hasRoutedSourceSearch()).thenReturn(true);
when(sourcePlanner.requireOrganicSurface(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(sourcePlanner.buildRouting(any(), anyList(), anyBoolean())).thenAnswer(invocation -> {
List<OutletCandidate> outlets = invocation.getArgument(1);
provider.build(Math.toIntExact(outlets.getFirst().outlet().id() - 200L));
return routing(outlets);
});
when(sourcePlanner.selectSources(any(), any(), any(), anyBoolean(), anyBoolean(), anyList(), anyMap()))
.thenAnswer(invocation -> {
if (!invocation.<Boolean>getArgument(3)) {
return SourceSelection.empty(false);
}
HydrologyRoutingPlan selected = invocation.getArgument(2);
return selection(!selected.outlets().isEmpty());
});
when(sourcePlanner.uniqueDiagnostics(anyList())).thenAnswer(invocation -> List.copyOf(invocation.<List<HydrologyDiagnosticCandidate>>getArgument(0)));
replace(planner, "sourcePlanner", sourcePlanner);
HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class);
when(outletPlanner.resolveSurfaceFallbackOutlets(any())).thenReturn(List.of(outlet(0), outlet(1), outlet(2), outlet(3)));
replace(planner, "outletPlanner", outletPlanner);
HydrologyCrossTileResolver resolver = spy(planner.crossTile);
doAnswer(invocation -> {
SourceSelection selected = invocation.getArgument(4);
if (selected.selectedCandidateIndices.isEmpty()) {
return new HydrologyCrossTileResolver.PublicationAttempt(EMPTY, List.of());
}
HydrologyRoutingPlan selectedRouting = invocation.getArgument(2);
long id = selectedRouting.outlets().getFirst().outlet().id();
boolean finalPublication = invocation.getArgument(9);
return new HydrologyCrossTileResolver.PublicationAttempt(result(id, source),
finalPublication ? List.of() : List.of(diagnostic(id)));
}).when(resolver).compilePublication(any(), any(), any(), any(), any(), any(), any(), anyMap(), anyMap(), anyBoolean());
return new Fixture(resolver);
}
private static SourceSelection selection(boolean accepted) {
List<SourceCandidate> candidates = accepted ? List.of(new SourceCandidate(0, 10L, 1D, false)) : List.of();
int size = candidates.size();
SourceAdmissionSelection admission = new SourceAdmissionSelection(1, accepted ? List.of(0) : List.of(),
new boolean[size], new boolean[size], new boolean[size], index -> true,
SourceAdmissionSelection.Quotas.uniform(new int[size], 1, 1));
return new SourceSelection(true, candidates, admission, new int[]{0}, 1);
}
private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) {
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true);
}
private static OutletCandidate outlet(int index) {
HydrologyPoint point = new HydrologyPoint(128, 63, index * 128);
return new OutletCandidate(0, 0, new RiverOutlet(200L + index, HydrologyFeatureType.MOUTH, 10L,
point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true));
}
private static HydrologyCaveCourseFilter.Result result(long id, HydrologyGridNode source) {
HydraulicSegment segment = new HydraulicSegment(id, id, HydrologyFeatureType.SURFACE_POOL,
70, 70, 4, 2, false, false, List.of(source.naturalPoint(), new HydrologyPoint(128, 70, 0)),
new HydraulicChannelProfile(new double[]{4D}, new double[]{2D}));
RiverCourse course = new RiverCourse(id, RiverCourseType.SURFACE, OptionalLong.of(source.id()),
OptionalLong.of(id), "default", 1, List.of(), List.of(segment));
DrainageNode node = new DrainageNode(source.id(), source.x(), source.z(), source.terrain(), 0D, id);
return new HydrologyCaveCourseFilter.Result(List.of(node), List.of(), List.of(outlet(Math.toIntExact(id - 200L)).outlet()),
List.of(course), List.of());
}
private static HydrologyDiagnosticCandidate diagnostic(long id) {
return new HydrologyDiagnosticCandidate(id, HydrologyCandidateKind.SOURCE,
HydrologyFeatureType.SURFACE_POOL, new HydrologyPoint(0, 70, 0), HydrologyCandidateRejection.SOURCE_QUOTA, 0);
}
private static void replace(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
private static void await(CountDownLatch latch) {
try {
assertTrue("Timed out waiting for fallback release", latch.await(5, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError(failure);
}
}
@FunctionalInterface
private interface OutletProvider {
void build(int outlet);
}
private record Fixture(HydrologyCrossTileResolver resolver) {
}
}
@@ -758,6 +758,7 @@ public class HydrologyModelTest {
List.of(),
List.of(),
List.of(course),
Set.of(),
List.of(plan),
List.of(),
new RiverFootprint(Map.of(
@@ -17,7 +17,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class HydrologyPlanHashTest {
private static final String EXPECTED_PLAN_DIGEST = "20e96d6f310786d09e7182ae6f73e41e5b7d07591b5571a62b1299fe1ab58b9a";
private static final String EXPECTED_PLAN_DIGEST = "9ec4c9cf7dbdad16976041a70853b909816655170cd694058bf359092c2d5a10";
private static final List<HydrologyTileKey> TILES = List.of(
new HydrologyTileKey(0, 0),
@@ -66,7 +66,7 @@ public class HydrologyPlanHashTest {
for (HydrologyCavePlan plan : tile.cavePlans()) {
out.append("cave ").append(plan).append('\n');
}
for (HydrologyDiagnosticCandidate diagnostic : tile.diagnosticCandidates()) {
for (HydrologyDiagnosticCandidate diagnostic : tile.localDiagnosticCandidates()) {
out.append("diagnostic ").append(diagnostic).append('\n');
}
for (HydrologyFeatureRef feature : tile.features()) {
@@ -26,7 +26,7 @@ public class HydrologyPlannerConfinementTest {
HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(false, true)).plan(TILE);
List<RiverCourse> westCourses = westCourses(tile);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), westCourses.isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), westCourses.isEmpty());
assertTrue("no western course crossed into the east: " + describe(westCourses),
westCourses.stream().anyMatch(course -> maximumX(course) >= BOUNDARY));
}
@@ -36,7 +36,7 @@ public class HydrologyPlannerConfinementTest {
HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(true, true)).plan(TILE);
List<RiverCourse> westCourses = westCourses(tile);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), westCourses.isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), westCourses.isEmpty());
for (RiverCourse course : westCourses) {
assertTrue("western course left its region: " + describe(List.of(course)), maximumX(course) < BOUNDARY);
}
@@ -47,7 +47,7 @@ public class HydrologyPlannerConfinementTest {
HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(true, false)).plan(TILE);
assertTrue(describe(westCourses(tile)), westCourses(tile).isEmpty());
assertTrue("diagnostics=" + tile.diagnosticCandidates(), tile.diagnosticCandidates().stream()
assertTrue("diagnostics=" + tile.localDiagnosticCandidates(), tile.localDiagnosticCandidates().stream()
.anyMatch(candidate -> candidate.rejection() == HydrologyCandidateRejection.CONFINED_NO_OUTLET));
}
@@ -73,8 +73,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
assertEquals(forward, reverse);
List<HydrologyPoint> firstSources = sourcePoints(forward.get(pair.first()), RiverCourseType.SURFACE);
List<HydrologyPoint> secondSources = sourcePoints(forward.get(pair.second()), RiverCourseType.SURFACE);
assertEquals(forward.get(pair.first()).diagnosticCandidates().toString(), 1, firstSources.size());
assertEquals(forward.get(pair.second()).diagnosticCandidates().toString(), 1, secondSources.size());
assertEquals(forward.get(pair.first()).localDiagnosticCandidates().toString(), 1, firstSources.size());
assertEquals(forward.get(pair.second()).localDiagnosticCandidates().toString(), 1, secondSources.size());
assertPointNear(new HydrologyPoint(32, 121, -272), firstSources.getFirst(), 8);
// The second tile keeps the mouth on its own coast, so its heaviest required source no longer
// competes with the first tile's network and is accepted ahead of the lighter fallback source.
@@ -443,7 +443,7 @@ public class HydrologyPlannerGlobalInvariantTest {
).plan(new HydrologyTileKey(0, 0));
assertTrue(courses(tile, RiverCourseType.SURFACE).isEmpty());
assertTrue(tile.diagnosticCandidates().stream().anyMatch(
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch(
(HydrologyDiagnosticCandidate candidate) -> candidate.rejection()
== HydrologyCandidateRejection.NO_DRAINAGE_PATH
));
@@ -28,7 +28,7 @@ public class HydrologyPlannerSeaCaveTest {
HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast).plan(TILE);
List<RiverCourse> caves = courses(tile, RiverCourseType.SEA_CAVE);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), caves.isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), caves.isEmpty());
for (RiverCourse cave : caves) {
assertEquals(1, cave.segments().size());
HydraulicSegment chamber = cave.segments().getFirst();
@@ -42,7 +42,7 @@ public class HydrologyPlannerSeaCaveTest {
assertTrue("inner=" + inner + " connection=" + connection,
inner.distanceSquared2D(connection) >= (long) seaCaves.depth() * seaCaves.depth());
HydrologyCavePlan plan = tile.cavePlan(cave.id()).orElse(null);
assertTrue("cave plan missing or rejected for " + cave.id() + " diagnostics=" + tile.diagnosticCandidates(),
assertTrue("cave plan missing or rejected for " + cave.id() + " diagnostics=" + tile.localDiagnosticCandidates(),
plan != null && plan.accepted());
int chamberColumns = 0;
@@ -148,10 +148,10 @@ public class HydrologyPlannerSeaCaveTest {
HydrologyTile tile = new HydrologyPlanner(17L, settings, coast).plan(TILE);
List<RiverCourse> caves = courses(tile, RiverCourseType.SEA_CAVE);
assertEquals("diagnostics=" + tile.diagnosticCandidates(), 2, caves.size());
assertEquals("diagnostics=" + tile.localDiagnosticCandidates(), 2, caves.size());
for (RiverCourse cave : caves) {
HydrologyPoint connection = cave.segments().getFirst().end();
assertTrue("connection=" + connection + " diagnostics=" + tile.diagnosticCandidates(),
assertTrue("connection=" + connection + " diagnostics=" + tile.localDiagnosticCandidates(),
connection.z() >= 128 && connection.z() < 256);
}
HydrologyPoint first = caves.get(0).segments().getFirst().end();
@@ -159,7 +159,7 @@ public class HydrologyPlannerSeaCaveTest {
long minimum = seaCaves.minimumSpacing() - 2L;
assertTrue(first + " vs " + second, first.distanceSquared2D(second) >= minimum * minimum);
int lowCoastRejections = 0;
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) {
if (candidate.kind() != HydrologyCandidateKind.OUTLET
|| candidate.projectedType() != HydrologyFeatureType.COASTAL_GROTTO
|| candidate.point().z() < 0 || candidate.point().z() >= 128) {
@@ -195,8 +195,8 @@ public class HydrologyPlannerSeaCaveTest {
}
List<RiverCourse> caves = courses(tile, RiverCourseType.SEA_CAVE);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), outletPoints.isEmpty());
assertFalse("diagnostics=" + tile.diagnosticCandidates(), caves.isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), outletPoints.isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), caves.isEmpty());
long clearance = 2L * CHAMBER.horizontalRadius() + 12L;
for (RiverCourse cave : caves) {
for (HydrologyPoint point : cave.segments().getFirst().centerline()) {
@@ -213,7 +213,7 @@ public class HydrologyPlannerSeaCaveTest {
cliffCoast(112, 66)
).plan(TILE);
assertTrue(courses(lowCoast, RiverCourseType.SEA_CAVE).isEmpty());
assertTrue(lowCoast.diagnosticCandidates().toString(),
assertTrue(lowCoast.localDiagnosticCandidates().toString(),
hasDiagnostic(lowCoast, HydrologyCandidateRejection.SURFACE_HEAD_RANGE));
}
@@ -226,12 +226,12 @@ public class HydrologyPlannerSeaCaveTest {
HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast(112, 92)).plan(TILE);
assertTrue(courses(tile, RiverCourseType.SEA_CAVE).isEmpty());
assertTrue(tile.diagnosticCandidates().toString(),
assertTrue(tile.localDiagnosticCandidates().toString(),
hasDiagnostic(tile, HydrologyCandidateRejection.VOLUME_LIMIT));
}
private static boolean hasDiagnostic(HydrologyTile tile, HydrologyCandidateRejection rejection) {
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) {
if (candidate.kind() == HydrologyCandidateKind.OUTLET
&& candidate.projectedType() == HydrologyFeatureType.COASTAL_GROTTO
&& candidate.rejection() == rejection) {
@@ -23,7 +23,7 @@ public class HydrologyPlannerSurfacePoolTest {
assertEquals(tile, replay);
List<RiverCourse> pools = pools(tile);
assertFalse(tile.diagnosticCandidates().toString(), pools.isEmpty());
assertFalse(tile.localDiagnosticCandidates().toString(), pools.isEmpty());
for (RiverCourse pool : pools) {
assertEquals("lava_pool", pool.profileKey());
assertEquals(1, pool.segments().size());
@@ -332,7 +332,7 @@ public class HydrologyPlannerTest {
observedParallelOwner |= parallelThreads.size() > 1;
assertTileContentsEqual(expected, actual);
assertEquals(expected.courses(), actual.courses());
assertEquals(expected.diagnosticCandidates(), actual.diagnosticCandidates());
assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates());
}
} finally {
pool.shutdownNow();
@@ -376,7 +376,7 @@ public class HydrologyPlannerTest {
assertEquals("outlets", expected.outlets(), actual.outlets());
assertEquals("courses", expected.courses(), actual.courses());
assertEquals("cave plans", expected.cavePlans(), actual.cavePlans());
assertEquals("diagnostics", expected.diagnosticCandidates(), actual.diagnosticCandidates());
assertEquals("diagnostics", expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates());
assertEquals("footprint column count", expected.footprint().columns().size(), actual.footprint().columns().size());
for (Map.Entry<Long, HydrologyColumnSample> entry : expected.footprint().columns().entrySet()) {
assertEquals("footprint column " + entry.getKey(), entry.getValue(), actual.footprint().columns().get(entry.getKey()));
@@ -623,7 +623,7 @@ public class HydrologyPlannerTest {
assertEquals(expected, actual);
assertEquals(expected.courses(), actual.courses());
assertEquals(expected.diagnosticCandidates(), actual.diagnosticCandidates());
assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates());
assertTrue(cached.computations <= uncached.computations);
if (organic) {
assertTrue("hits=" + cached.hits, cached.hits > 0);
@@ -645,7 +645,7 @@ public class HydrologyPlannerTest {
HydrologyTile tile = planner.plan(TILE);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty());
assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean));
assertTrue(tile.acyclic());
assertFalse(surfaceCourses(tile).isEmpty());
@@ -674,7 +674,7 @@ public class HydrologyPlannerTest {
assertEquals(first.courses(), replay.courses());
List<RiverCourse> courses = surfaceCourses(first);
assertTrue(first.diagnosticCandidates().toString(), courses.size() <= 4);
assertTrue(first.localDiagnosticCandidates().toString(), courses.size() <= 4);
RiverCourse mainCourse = courses.getFirst();
for (RiverCourse course : courses) {
if (course.drainageEdges().size() > mainCourse.drainageEdges().size()) {
@@ -740,7 +740,7 @@ public class HydrologyPlannerTest {
HydrologyTile tile = planner(78L, settings, rollingCoast(112)).plan(TILE);
assertFalse(tile.diagnosticCandidates().toString(), surfaceCourses(tile).isEmpty());
assertFalse(tile.localDiagnosticCandidates().toString(), surfaceCourses(tile).isEmpty());
assertTrue(surfaceCourses(tile).stream().allMatch(RiverCourse::hydraulicallyNonRising));
}
@@ -835,7 +835,7 @@ public class HydrologyPlannerTest {
DrainageNode replacementSource = backfilled.node(replacement.sourceNodeId().orElseThrow()).orElseThrow();
assertTrue(initialSource.z() < replacementSource.z());
assertFalse(replacement.sourceNodeId().equals(initiallySelected.sourceNodeId()));
assertTrue(backfilled.diagnosticCandidates().stream().anyMatch(
assertTrue(backfilled.localDiagnosticCandidates().stream().anyMatch(
(HydrologyDiagnosticCandidate candidate) ->
candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT
&& candidate.point().x() == initialSource.x()
@@ -973,7 +973,7 @@ public class HydrologyPlannerTest {
HydrologyTile tile = new HydrologyPlanner(82L, settings, terrain).plan(TILE);
assertTrue(surfaceCourses(tile).isEmpty());
assertTrue(tile.diagnosticCandidates().toString(), tile.diagnosticCandidates().stream().anyMatch(
assertTrue(tile.localDiagnosticCandidates().toString(), tile.localDiagnosticCandidates().stream().anyMatch(
candidate -> candidate.rejection() == HydrologyCandidateRejection.NO_DRAINAGE_PATH
));
}
@@ -996,8 +996,8 @@ public class HydrologyPlannerTest {
assertTrue(tile.courses().isEmpty());
assertTrue(tile.footprint().isEmpty());
assertTrue(tile.features().isEmpty());
assertFalse(tile.diagnosticCandidates().isEmpty());
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
assertFalse(tile.localDiagnosticCandidates().isEmpty());
for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) {
assertEquals(HydrologyCandidateKind.SOURCE, candidate.kind());
assertEquals(HydrologyFeatureType.SURFACE_POOL, candidate.projectedType());
assertEquals(HydrologyCandidateRejection.NO_LEGAL_OUTLET, candidate.rejection());
@@ -1009,7 +1009,7 @@ public class HydrologyPlannerTest {
candidate.point().z(),
0
).isEmpty());
HydrologyDiagnosticRenderSample diagnostic = tile.diagnosticRenderAt(
HydrologyDiagnosticRenderSample diagnostic = tile.localDiagnosticRenderAt(
candidate.point().x(),
candidate.point().z(),
0
@@ -1034,7 +1034,7 @@ public class HydrologyPlannerTest {
HydrologyTile cliff = new HydrologyPlanner(19L, settings, cliffTerrain).plan(TILE);
assertTrue(
"diagnostics=" + rolling.diagnosticCandidates() + " segments=" + rolling.courses().stream()
"diagnostics=" + rolling.localDiagnosticCandidates() + " segments=" + rolling.courses().stream()
.flatMap((RiverCourse course) -> course.segments().stream())
.map((HydraulicSegment segment) -> segment.type() + ":" + segment.drop())
.toList(),
@@ -1058,7 +1058,7 @@ public class HydrologyPlannerTest {
}
assertTrue(
"features=" + cliff.features().stream().map(HydrologyFeatureRef::type).toList()
+ " diagnostics=" + cliff.diagnosticCandidates().stream()
+ " diagnostics=" + cliff.localDiagnosticCandidates().stream()
.map((HydrologyDiagnosticCandidate candidate) ->
candidate.projectedType() + ":" + candidate.rejection())
.toList(),
@@ -1134,7 +1134,7 @@ public class HydrologyPlannerTest {
};
HydrologyTile tile = new HydrologyPlanner(812L, settings, terrain, solidCaveView()).plan(TILE);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), surfaceCourses(tile).isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty());
boolean aggregateTransition = false;
for (RiverCourse course : surfaceCourses(tile)) {
for (HydraulicSegment segment : course.segments()) {
@@ -1352,7 +1352,7 @@ public class HydrologyPlannerTest {
HydrologyTile tile = new HydrologyPlanner(333L, settings, coast, solidCaveView()).plan(TILE);
assertTrue(courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
assertTrue(tile.diagnosticCandidates().stream().anyMatch(
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch(
(HydrologyDiagnosticCandidate candidate) ->
candidate.rejection() == HydrologyCandidateRejection.OUTLET_LEVEL
));
@@ -1393,7 +1393,7 @@ public class HydrologyPlannerTest {
footprint -> solidCaveView()
).plan(TILE);
assertFalse(tile.diagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
assertFalse(tile.localDiagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
for (RiverCourse course : courses(tile, RiverCourseType.UNDERGROUND)) {
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
assertEquals(HydrologyFeatureType.INLAND_GROTTO, outlet.type());
@@ -1428,11 +1428,11 @@ public class HydrologyPlannerTest {
assertTrue(courses(narrowTile, RiverCourseType.UNDERGROUND).isEmpty());
assertTrue(courses(wideTile, RiverCourseType.UNDERGROUND).isEmpty());
assertTrue(narrowTile.diagnosticCandidates().stream().anyMatch(
assertTrue(narrowTile.localDiagnosticCandidates().stream().anyMatch(
(HydrologyDiagnosticCandidate candidate) ->
candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT
));
assertTrue(wideTile.diagnosticCandidates().stream().anyMatch(
assertTrue(wideTile.localDiagnosticCandidates().stream().anyMatch(
(HydrologyDiagnosticCandidate candidate) ->
candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT
));
@@ -1458,12 +1458,12 @@ public class HydrologyPlannerTest {
footprint -> solidCaveView()
).plan(TILE);
assertFalse(tile.diagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
assertFalse(tile.localDiagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
RiverCourse course = courses(tile, RiverCourseType.UNDERGROUND).getFirst();
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
assertFalse(
"outlet=" + outlet.type() + " source=" + course.sourceNodeId()
+ " diagnostics=" + tile.diagnosticCandidates(),
+ " diagnostics=" + tile.localDiagnosticCandidates(),
outlet.directOcean()
);
assertEquals(71, outlet.connectionPoint().y());
@@ -1597,7 +1597,7 @@ public class HydrologyPlannerTest {
HydrologyTile tile = planner.plan(TILE);
assertFalse(
"courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(),
"courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(),
tile.outlets().isEmpty()
);
assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean));
@@ -1625,7 +1625,7 @@ public class HydrologyPlannerTest {
).plan(TILE);
assertFalse(
"courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(),
"courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(),
tile.footprint().isEmpty()
);
for (HydrologyColumnSample column : tile.footprint().columns().values()) {
@@ -1650,7 +1650,7 @@ public class HydrologyPlannerTest {
rollingCoast(112)
).plan(TILE);
assertFalse("courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(), tile.footprint().isEmpty());
assertFalse("courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(), tile.footprint().isEmpty());
boolean sawGrading = false;
for (HydrologyColumnSample column : tile.footprint().columns().values()) {
assertEquals(column.ocean() ? "ocean_parent" : "parent", column.parentBiomeKey());
@@ -1870,9 +1870,9 @@ public class HydrologyPlannerTest {
};
HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast).plan(TILE);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty());
for (RiverOutlet outlet : tile.outlets()) {
assertEquals("outlet=" + outlet + " diagnostics=" + tile.diagnosticCandidates(),
assertEquals("outlet=" + outlet + " diagnostics=" + tile.localDiagnosticCandidates(),
HydrologyFeatureType.COASTAL_GROTTO, outlet.type());
assertTrue(outlet.directOcean());
assertEquals(settings.seaLevel(), outlet.connectionPoint().y());
@@ -1937,12 +1937,12 @@ public class HydrologyPlannerTest {
HydrologyTile tile = new HydrologyPlanner(70143L, settings, terrain, solidCaveView()).plan(TILE);
assertEquals(
"outlets=" + tile.outlets() + " diagnostics=" + tile.diagnosticCandidates(),
"outlets=" + tile.outlets() + " diagnostics=" + tile.localDiagnosticCandidates(),
1,
tile.outlets().stream().filter(RiverOutlet::directOcean).count()
);
assertEquals(
"outlets=" + tile.outlets() + " diagnostics=" + tile.diagnosticCandidates(),
"outlets=" + tile.outlets() + " diagnostics=" + tile.localDiagnosticCandidates(),
1,
tile.outlets().stream().filter((RiverOutlet outlet) -> !outlet.directOcean()).count()
);
@@ -1985,7 +1985,7 @@ public class HydrologyPlannerTest {
HydrologyTile tile = new HydrologyPlanner(883L, settings, twoCoasts).plan(TILE);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty());
assertFalse(surfaceCourses(tile).isEmpty());
for (RiverOutlet outlet : tile.outlets()) {
assertTrue(
@@ -2034,12 +2034,12 @@ public class HydrologyPlannerTest {
HydrologyTile tile = new HydrologyPlanner(884L, settings, coast, solidCaveView()).plan(TILE);
List<HydrologyDiagnosticCandidate> level = tile.diagnosticCandidates().stream()
List<HydrologyDiagnosticCandidate> level = tile.localDiagnosticCandidates().stream()
.filter((HydrologyDiagnosticCandidate candidate) ->
candidate.kind() == HydrologyCandidateKind.OUTLET
&& candidate.rejection() == HydrologyCandidateRejection.OUTLET_LEVEL)
.toList();
assertFalse("diagnostics=" + tile.diagnosticCandidates(), level.isEmpty());
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), level.isEmpty());
for (HydrologyDiagnosticCandidate candidate : level) {
assertTrue(
candidate.toString(),
@@ -2071,8 +2071,8 @@ public class HydrologyPlannerTest {
HydrologyTile tile = new HydrologyPlanner(885L, settings, island, solidCaveView()).plan(TILE);
assertFalse("diagnostics=" + tile.diagnosticCandidates(), surfaceCourses(tile).isEmpty());
assertTrue(tile.diagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) ->
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty());
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) ->
candidate.rejection() == HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED));
for (RiverCourse course : surfaceCourses(tile)) {
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
@@ -2107,7 +2107,7 @@ public class HydrologyPlannerTest {
HydrologyTile repeated = planner.plan(TILE);
assertEquals(first, repeated);
assertEquals(first.diagnosticCandidates().toString(), 1, surfaceCourses(first).size());
assertEquals(first.localDiagnosticCandidates().toString(), 1, surfaceCourses(first).size());
RiverCourse course = surfaceCourses(first).getFirst();
assertTrue(course.surfaceSinkholeContinuation());
RiverOutlet outlet = first.outlet(course.outletId().orElseThrow()).orElseThrow();
@@ -2179,7 +2179,7 @@ public class HydrologyPlannerTest {
assertTrue(surfaceCourses(tile).isEmpty());
assertFalse(courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
assertTrue(tile.diagnosticCandidates().stream().anyMatch(
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch(
(HydrologyDiagnosticCandidate candidate) -> candidate.projectedType() == HydrologyFeatureType.SURFACE_POOL
&& candidate.rejection() == HydrologyCandidateRejection.NO_LEGAL_OUTLET
));
@@ -2239,7 +2239,7 @@ public class HydrologyPlannerTest {
HydrologyTile tile = new HydrologyPlanner(70142L, settings, terrain, solidCaveView()).plan(TILE);
assertEquals(tile.diagnosticCandidates().toString(), 1, surfaceCourses(tile).size());
assertEquals(tile.localDiagnosticCandidates().toString(), 1, surfaceCourses(tile).size());
RiverCourse course = surfaceCourses(tile).getFirst();
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
assertFalse(outlet.directOcean());
@@ -2264,7 +2264,7 @@ public class HydrologyPlannerTest {
HydrologyTile tile = new HydrologyPlanner(70140L, settings, terrain, solidCaveView()).plan(TILE);
assertTrue(
tile.diagnosticCandidates().toString(),
tile.localDiagnosticCandidates().toString(),
tile.outlets().stream().anyMatch((RiverOutlet outlet) -> !outlet.directOcean())
);
RiverCourse course = surfaceCourses(tile).stream()
@@ -2300,7 +2300,7 @@ public class HydrologyPlannerTest {
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
assertFalse(
"outlet=" + outlet.type() + " source=" + course.sourceNodeId()
+ " diagnostics=" + tile.diagnosticCandidates(),
+ " diagnostics=" + tile.localDiagnosticCandidates(),
outlet.directOcean()
);
assertTrue(hasAny(tile, HydrologyFeatureType.SINKHOLE, HydrologyFeatureType.INLAND_GROTTO));
@@ -2331,7 +2331,7 @@ public class HydrologyPlannerTest {
assertTrue(rejected.cavePlans().stream().noneMatch(
(HydrologyCavePlan plan) -> plan.actions().containsKey(hazard)
));
assertTrue(rejected.diagnosticCandidates().stream().anyMatch(
assertTrue(rejected.localDiagnosticCandidates().stream().anyMatch(
(HydrologyDiagnosticCandidate candidate) ->
candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT
));
@@ -61,8 +61,8 @@ public class HydrologyPlannerTributaryTest {
List<RiverCourse> singleCourses = surface(single);
List<RiverCourse> splitCourses = surface(split);
assertEquals(single.diagnosticCandidates().toString(), 1, singleCourses.size());
assertEquals(split.diagnosticCandidates().toString(), 2, splitCourses.size());
assertEquals(single.localDiagnosticCandidates().toString(), 1, singleCourses.size());
assertEquals(split.localDiagnosticCandidates().toString(), 2, splitCourses.size());
RiverCourse stem = null;
RiverCourse tributary = null;
for (RiverCourse course : splitCourses) {
@@ -390,7 +390,7 @@ public class HydrologyPlannerTributaryTest {
HydrologyTile split = SPLIT_TILE;
List<RiverCourse> courses = surface(split);
assertEquals(split.diagnosticCandidates().toString(), 2, courses.size());
assertEquals(split.localDiagnosticCandidates().toString(), 2, courses.size());
RiverCourse stem = stations(courses.get(0)) >= stations(courses.get(1)) ? courses.get(0) : courses.get(1);
RiverCourse tributary = stem == courses.get(0) ? courses.get(1) : courses.get(0);
java.util.Set<Long> stemEdges = new java.util.HashSet<>();
@@ -414,7 +414,7 @@ public class HydrologyPlannerTributaryTest {
}
assertNotNull("tributary ends on the stem", stemHead);
assertEquals(stemHead.intValue(), tributary.segments().getLast().downstreamHeadY());
for (HydrologyDiagnosticCandidate candidate : split.diagnosticCandidates()) {
for (HydrologyDiagnosticCandidate candidate : split.localDiagnosticCandidates()) {
assertTrue(candidate.toString(), candidate.kind() != HydrologyCandidateKind.TRIBUTARY);
}
}
@@ -426,8 +426,8 @@ public class HydrologyPlannerTributaryTest {
HydrologyTile single = new HydrologyPlanner(19L, settings(0, 1D, 1), terrain).plan(TILE);
HydrologyTile joined = new HydrologyPlanner(19L, settings(1, 1D, 1), terrain).plan(TILE);
assertEquals(single.diagnosticCandidates().toString(), 1, surface(single).size());
assertEquals(joined.diagnosticCandidates().toString(), 2, surface(joined).size());
assertEquals(single.localDiagnosticCandidates().toString(), 1, surface(single).size());
assertEquals(joined.localDiagnosticCandidates().toString(), 2, surface(joined).size());
}
@Test
@@ -437,9 +437,9 @@ public class HydrologyPlannerTributaryTest {
HydrologyTile single = new HydrologyPlanner(23L, undergroundSettings(0), terrain).plan(TILE);
HydrologyTile joined = new HydrologyPlanner(23L, undergroundSettings(1), terrain).plan(TILE);
assertEquals(single.diagnosticCandidates().toString(), 1, underground(single).size());
assertEquals(single.localDiagnosticCandidates().toString(), 1, underground(single).size());
List<RiverCourse> courses = underground(joined);
assertEquals(joined.diagnosticCandidates().toString(), 2, courses.size());
assertEquals(joined.localDiagnosticCandidates().toString(), 2, courses.size());
RiverCourse stem = stations(courses.get(0)) >= stations(courses.get(1)) ? courses.get(0) : courses.get(1);
RiverCourse tributary = stem == courses.get(0) ? courses.get(1) : courses.get(0);
assertEquals(stem.outletId(), tributary.outletId());
@@ -41,7 +41,7 @@ public class HydrologyPlannerUndergroundKnobsTest {
).plan(TILE);
List<RiverCourse> planned = courses(fromFactory, RiverCourseType.UNDERGROUND);
assertFalse("diagnostics=" + fromFactory.diagnosticCandidates(), planned.isEmpty());
assertFalse("diagnostics=" + fromFactory.localDiagnosticCandidates(), planned.isEmpty());
assertEquals(fromFactory.courses(), explicit.courses());
assertCoveredByRock(coast, planned, base.underground().minimumHeadroom(), 1);
@@ -94,7 +94,7 @@ public class HydrologyPlannerUndergroundKnobsTest {
).plan(TILE);
List<RiverCourse> shallowCourses = courses(shallow, RiverCourseType.UNDERGROUND);
assertFalse("diagnostics=" + shallow.diagnosticCandidates(), shallowCourses.isEmpty());
assertFalse("diagnostics=" + shallow.localDiagnosticCandidates(), shallowCourses.isEmpty());
assertTrue(
"heads=" + heads(shallowCourses) + " bound=" + bound,
heads(shallowCourses).stream().anyMatch((Integer head) -> head < bound)
@@ -166,17 +166,17 @@ public class HydrologyPlannerUndergroundKnobsTest {
SOLID_CAVE_VIEW
).plan(TILE);
assertFalse("diagnostics=" + atTheDefault.diagnosticCandidates(), atTheDefault.outlets().isEmpty());
assertFalse("diagnostics=" + atTheDefault.localDiagnosticCandidates(), atTheDefault.outlets().isEmpty());
for (RiverOutlet outlet : atTheDefault.outlets()) {
assertNotEquals(HydrologyFeatureType.COASTAL_GROTTO, outlet.type());
}
assertFalse("diagnostics=" + sensitive.diagnosticCandidates(), sensitive.outlets().isEmpty());
assertFalse("diagnostics=" + sensitive.localDiagnosticCandidates(), sensitive.outlets().isEmpty());
boolean grotto = false;
for (RiverOutlet outlet : sensitive.outlets()) {
grotto |= outlet.type() == HydrologyFeatureType.COASTAL_GROTTO;
}
assertTrue("outlets=" + sensitive.outlets(), grotto);
assertFalse("diagnostics=" + slopeRuleOff.diagnosticCandidates(), slopeRuleOff.outlets().isEmpty());
assertFalse("diagnostics=" + slopeRuleOff.localDiagnosticCandidates(), slopeRuleOff.outlets().isEmpty());
for (RiverOutlet outlet : slopeRuleOff.outlets()) {
assertNotEquals(HydrologyFeatureType.COASTAL_GROTTO, outlet.type());
}
@@ -205,8 +205,8 @@ public class HydrologyPlannerUndergroundKnobsTest {
List<RiverCourse> turnedCaves = courses(turned, RiverCourseType.SEA_CAVE);
List<RiverCourse> straightCaves = courses(straight, RiverCourseType.SEA_CAVE);
assertFalse("diagnostics=" + turned.diagnosticCandidates(), turnedCaves.isEmpty());
assertFalse("diagnostics=" + straight.diagnosticCandidates(), straightCaves.isEmpty());
assertFalse("diagnostics=" + turned.localDiagnosticCandidates(), turnedCaves.isEmpty());
assertFalse("diagnostics=" + straight.localDiagnosticCandidates(), straightCaves.isEmpty());
boolean offNormal = false;
for (RiverCourse cave : turnedCaves) {
HydraulicSegment chamber = cave.segments().getFirst();
@@ -9,9 +9,7 @@ import art.arcane.iris.generation.hydrology.surface.SurfaceTerminal;
import org.junit.Test;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import java.util.concurrent.atomic.AtomicInteger;
@@ -108,23 +106,14 @@ public class HydrologyRegionalBankSupportTest {
return ground;
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain));
Map<Long, HydrologyTerrainSample> sampled = new HashMap<>();
HydrologyRegionalTerrainRefiner.Samples sampled = refiner.new Samples();
for (int index = 0; index < 65536; index++) {
sampled.put(RiverFootprint.pack(index, 100000), ground);
sampled.sample(index, 100000);
}
Method sample = HydrologyRegionalTerrainRefiner.class.getDeclaredMethod("sample", int.class, int.class, Map.class);
sample.setAccessible(true);
HydrologyTerrainSampler limited = (x, z) -> {
try {
return (HydrologyTerrainSample) sample.invoke(refiner, x, z, sampled);
} catch (ReflectiveOperationException failure) {
throw new AssertionError(failure);
}
};
requests.set(0);
assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), limited));
assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), sampled));
assertEquals(0, requests.get());
assertEquals(65536, sampled.size());
}
private static HydrologyRegionalHydraulics.HeadStation station(int incomingHead) {
@@ -14,6 +14,34 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalCacheTest {
@Test
public void sharedTerrainRetentionIsIndependentOfEachSearchBudget() {
AtomicInteger reads = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
reads.incrementAndGet();
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
HydrologyRegionalTerrainRefiner.Samples first = refiner.new Samples();
for (int x = 0; x < 65536; x++) {
assertNotNull(first.sample(x, 0));
}
assertNull(first.sample(65536, 0));
HydrologyRegionalTerrainRefiner.Samples second = refiner.new Samples();
for (int x = 65536; x < 100000; x++) {
assertNotNull(second.sample(x, 0));
}
assertEquals(100000, reads.get());
for (int x = 0; x < 100000; x++) {
assertEquals(70, refiner.sample(x, 0).naturalHeight());
}
assertEquals(100000, reads.get());
refiner.clear();
assertEquals(70, refiner.sample(0, 0).naturalHeight());
assertEquals(100001, reads.get());
}
@Test
public void clearingTilesRefreshesChangedRegionalTerrain() throws Exception {
AtomicInteger height = new AtomicInteger(70);
@@ -0,0 +1,208 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
import org.junit.Test;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
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 HydrologyRegionalConcurrencyTest {
@Test(timeout = 15000)
public void collidingBasinKeysCompileWithoutHoldingCacheLocks() throws Exception {
HydrologyTileKey first = new HydrologyTileKey(0, 0);
HydrologyTileKey second = new HydrologyTileKey(1, -31);
assertEquals(first.hashCode(), second.hashCode());
CountDownLatch started = new CountDownLatch(2);
CountDownLatch release = new CountDownLatch(1);
HydrologyPlanner planner = planner(request -> {
started.countDown();
await(release);
return land(70);
});
ExecutorService workers = Executors.newFixedThreadPool(2);
try {
Future<HydrologyRegionalNetwork> firstResult = workers.submit(() -> planner.regional.draft(first));
Future<HydrologyRegionalNetwork> secondResult = workers.submit(() -> planner.regional.draft(second));
assertTrue("Independent basin compilation must overlap", started.await(5, TimeUnit.SECONDS));
release.countDown();
assertTrue(firstResult.get(5, TimeUnit.SECONDS).courses().isEmpty());
assertTrue(secondResult.get(5, TimeUnit.SECONDS).courses().isEmpty());
} finally {
release.countDown();
workers.shutdownNow();
}
}
@Test(timeout = 15000)
public void concurrentRequestsShareOneBasinCompilation() throws Exception {
AtomicInteger compilations = new AtomicInteger();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch callers = new CountDownLatch(2);
CountDownLatch release = new CountDownLatch(1);
HydrologyPlanner planner = planner(request -> {
compilations.incrementAndGet();
started.countDown();
await(release);
return land(70);
});
HydrologyTileKey key = new HydrologyTileKey(0, 0);
ExecutorService workers = Executors.newFixedThreadPool(2);
try {
Future<HydrologyRegionalNetwork> first = workers.submit(() -> {
callers.countDown();
return planner.regional.draft(key);
});
Future<HydrologyRegionalNetwork> second = workers.submit(() -> {
callers.countDown();
return planner.regional.draft(key);
});
assertTrue(callers.await(5, TimeUnit.SECONDS));
assertTrue(started.await(5, TimeUnit.SECONDS));
release.countDown();
assertSame(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS));
assertEquals(1, compilations.get());
} finally {
release.countDown();
workers.shutdownNow();
}
}
@Test(timeout = 15000)
public void clearDuringCompilationDoesNotPublishTheOldDraft() throws Exception {
AtomicInteger compilations = new AtomicInteger();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
HydrologyPlanner planner = planner(request -> {
if (compilations.incrementAndGet() == 1) {
started.countDown();
await(release);
return land(70);
}
return land(85);
});
HydrologyTileKey key = new HydrologyTileKey(0, 0);
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
Future<HydrologyRegionalNetwork> old = worker.submit(() -> planner.regional.draft(key));
assertTrue(started.await(5, TimeUnit.SECONDS));
planner.regional.clear();
HydrologyRegionalNetwork current = planner.regional.draft(key);
release.countDown();
assertEquals(70, old.get(5, TimeUnit.SECONDS).diagnostics().getFirst().point().y());
assertEquals(85, current.diagnostics().getFirst().point().y());
assertSame(current, planner.regional.draft(key));
assertEquals(2, compilations.get());
} finally {
release.countDown();
worker.shutdownNow();
}
}
@Test
public void failedCompilationCanBeRetried() {
AtomicInteger compilations = new AtomicInteger();
IllegalStateException expected = new IllegalStateException("Terrain is unavailable");
HydrologyPlanner planner = planner(request -> {
if (compilations.incrementAndGet() == 1) {
throw expected;
}
return land(70);
});
HydrologyTileKey key = new HydrologyTileKey(0, 0);
assertSame(expected, assertThrows(IllegalStateException.class, () -> planner.regional.draft(key)));
HydrologyRegionalNetwork current = planner.regional.draft(key);
assertSame(current, planner.regional.draft(key));
assertEquals(2, compilations.get());
}
@Test(timeout = 15000)
public void aBasinWindowDispatchesIndependentDraftsBeforeWaiting() throws Exception {
AtomicInteger compilations = new AtomicInteger();
CountDownLatch started = new CountDownLatch(2);
CountDownLatch release = new CountDownLatch(1);
HydrologyPlanner planner = planner(request -> {
compilations.incrementAndGet();
started.countDown();
await(release);
return land(70);
});
SurfaceBounds bounds = new SurfaceBounds(0, 0, 15, 15);
ForkJoinPool pool = new ForkJoinPool(2);
try {
Future<HydrologyRegionalNetwork> result = pool.submit(() -> planner.regional.coursesIn(bounds));
assertTrue("A window must start more than one cold basin", started.await(5, TimeUnit.SECONDS));
release.countDown();
assertEquals(planner(request -> land(70)).regional.coursesIn(bounds), result.get(5, TimeUnit.SECONDS));
assertEquals(4, compilations.get());
} finally {
release.countDown();
pool.shutdownNow();
}
}
@Test(timeout = 15000)
public void aBasinWindowCompletesOnASaturatedSingleWorkerPool() throws Exception {
HydrologyPlanner planner = planner(request -> land(70));
SurfaceBounds bounds = new SurfaceBounds(0, 0, 15, 15);
HydrologyRegionalNetwork expected = planner(request -> land(70)).regional.coursesIn(bounds);
ForkJoinPool pool = new ForkJoinPool(1, ForkJoinPool.defaultForkJoinWorkerThreadFactory,
null, false, 1, 1, 1, null, 60, TimeUnit.SECONDS);
try {
assertEquals(expected, pool.submit(() -> planner.regional.coursesIn(bounds)).get(5, TimeUnit.SECONDS));
} finally {
pool.shutdownNow();
}
}
private static HydrologyPlanner planner(
Function<HydrologyRoutingTerrainSampler.GridRequest, HydrologyTerrainSample> grids
) {
HydrologyTerrainSampler terrain = (x, z) -> land(70);
return new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 8), terrain,
new ControlledRoutingSampler(grids), HydrologyGeometrySampler.deterministic(terrain), 0,
footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128));
}
private static HydrologyTerrainSample land(int height) {
return HydrologyTerrainSample.openLand(height, 0D, "land");
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(5, TimeUnit.SECONDS)) {
throw new AssertionError("Timed out waiting for basin compilation");
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError(failure);
}
}
private record ControlledRoutingSampler(
Function<GridRequest, HydrologyTerrainSample> grids
) implements HydrologyRoutingTerrainSampler {
@Override
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()];
Arrays.fill(samples, grids.apply(request));
return samples;
}
@Override
public NaturalClassification classifyNatural(int blockX, int blockZ) {
return NaturalClassification.LAND;
}
}
}
@@ -0,0 +1,135 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.concurrent.CountDownLatch;
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.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalCorridorSamplingTest {
@Test
public void parallelSamplesPreserveCorridorOrderAcrossSegmentsAndBatches() throws Exception {
List<HydrologyPoint> points = List.of(new HydrologyPoint(-137, 70, -67),
new HydrologyPoint(-7, 70, 19), new HydrologyPoint(-7, 70, 19),
new HydrologyPoint(113, 70, 39), new HydrologyPoint(237, 70, 97));
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70 + Math.floorMod(x + z, 9), 0D, "land");
HydrologyRegionalRoute.Refinement serial = validate(points, terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
HydrologyRegionalRoute.Refinement parallel = pool.submit(() -> validate(points, terrain)).get(10, TimeUnit.SECONDS);
assertNull(serial.rejection());
assertEquals(points, serial.points());
assertEquals(serial, parallel);
}
}
@Test
public void independentColumnsRunTogetherWithinOneBoundedBatch() throws Exception {
CountDownLatch overlapping = new CountDownLatch(2);
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
samples.incrementAndGet();
overlapping.countDown();
try {
assertTrue(overlapping.await(5, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
return x == 0 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
};
try (ForkJoinPool pool = new ForkJoinPool(4)) {
HydrologyRegionalRoute.Refinement result = pool.submit(() -> validate(straight(), terrain)).get(10, TimeUnit.SECONDS);
assertEquals(HydrologyCandidateRejection.POLICY_EXCLUDED, result.rejection());
assertEquals(new HydrologyPoint(0, 70, 0), result.failure());
assertEquals(128, samples.get());
}
}
@Test
public void rejectionKeepsPrecedenceOverLaterSpeculativeFailure() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> {
if (x == 64) {
throw new IllegalStateException("Unavailable later sample");
}
return x == 31 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyRegionalRoute.Refinement serial = validate(straight(), terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
HydrologyRegionalRoute.Refinement parallel = pool.submit(() -> validate(straight(), terrain)).get(10, TimeUnit.SECONDS);
assertEquals(serial, parallel);
assertEquals(new HydrologyPoint(31, 70, 0), parallel.failure());
}
}
@Test
public void consumedSampleFailurePropagatesInEncounterOrder() throws Exception {
IllegalStateException first = new IllegalStateException("First failed sample");
IllegalStateException second = new IllegalStateException("Second failed sample");
HydrologyTerrainSampler terrain = (x, z) -> {
if (x == 7) {
throw first;
}
if (x == 64) {
throw second;
}
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
try (ForkJoinPool pool = new ForkJoinPool(4)) {
IllegalStateException actual = pool.submit(() -> assertThrows(IllegalStateException.class,
() -> validate(straight(), terrain))).get(10, TimeUnit.SECONDS);
assertSame(first, actual);
}
}
@Test
public void oceanEntryAndRetracingRejectionsMatchSerialValidation() throws Exception {
HydrologyTerrainSampler ocean = (x, z) -> x >= 701 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(70, 0D, "land");
List<HydrologyPoint> retracing = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(193, 70, 0),
new HydrologyPoint(0, 70, 0), new HydrologyPoint(900, 70, 0));
try (ForkJoinPool pool = new ForkJoinPool(4)) {
HydrologyRegionalRoute.Refinement oceanSerial = validate(straight(), ocean);
HydrologyRegionalRoute.Refinement oceanParallel = pool.submit(() -> validate(straight(), ocean)).get(10, TimeUnit.SECONDS);
assertNull(oceanSerial.rejection());
assertEquals(new HydrologyPoint(700, 63, 0), oceanSerial.oceanEntry().landward());
assertEquals(oceanSerial, oceanParallel);
HydrologyRegionalRoute.Refinement retracingSerial = validate(retracing, ocean);
HydrologyRegionalRoute.Refinement retracingParallel = pool.submit(() -> validate(retracing, ocean)).get(10, TimeUnit.SECONDS);
assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, retracingSerial.rejection());
assertEquals(retracingSerial, retracingParallel);
}
}
private static List<HydrologyPoint> straight() {
return List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(900, 70, 0));
}
private static HydrologyRegionalRoute.Refinement validate(List<HydrologyPoint> points,
HydrologyTerrainSampler terrain) throws Exception {
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner);
Method method = HydrologyRegionalRoute.class.getDeclaredMethod("validateCorridor", List.class, String.class, boolean.class);
method.setAccessible(true);
try {
return (HydrologyRegionalRoute.Refinement) method.invoke(route, points, "default", false);
} catch (InvocationTargetException failure) {
if (failure.getCause() instanceof RuntimeException runtime) {
throw runtime;
}
if (failure.getCause() instanceof Error error) {
throw error;
}
throw failure;
}
}
}
@@ -0,0 +1,242 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.OptionalLong;
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.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
public class HydrologyRegionalDemandTest {
private static final HydrologyTileKey ORIGIN = new HydrologyTileKey(0, 0);
private static final SurfaceBounds EXCLUDED = new SurfaceBounds(1792, 1792, 1807, 1807);
@Test(timeout = 120000)
public void excludedWindowDefersGeometryUntilDiagnosticsAndPreservesLaterRiver() {
HydrologyPlannerSettings settings = localizedSettings();
HydrologyTerrainSampler terrain = localizedCoast();
AtomicInteger eagerGeometry = new AtomicInteger();
HydrologyPlanner eager = planner(settings, terrain, eagerGeometry);
HydrologyRegionalNetwork expected = eager.regional.draft(ORIGIN);
assertFalse("The excluded basin must contain a successful river", expected.courses().isEmpty());
assertTrue(eagerGeometry.get() > 0);
AtomicInteger geometry = new AtomicInteger();
HydrologyPlanner demand = planner(settings, terrain, geometry);
assertEquals(HydrologyRegionalNetwork.EMPTY, demand.regional.coursesIn(EXCLUDED));
assertEquals("A distant query must not build fine source geometry", 0, geometry.get());
assertEquals(expected.diagnostics(), demand.regional.diagnosticsIn(EXCLUDED));
assertTrue("Diagnostics must finish the complete source/outlet competition", geometry.get() > 0);
assertEquals(expected, demand.regional.draft(ORIGIN));
SurfaceBounds nearby = window(expected.courses().getFirst().segments().getFirst().start());
HydrologyRegionalNetwork expectedWindow = eager.regional.coursesIn(nearby);
assertFalse(expectedWindow.courses().isEmpty());
HydrologyRegionalNetwork actual = demand.regional.coursesIn(nearby);
assertEquals(expectedWindow, actual);
assertEquals(eager.regional.materialize(expectedWindow, nearby).columns(),
demand.regional.materialize(actual, nearby).columns());
demand.regional.clear();
geometry.set(0);
assertEquals(HydrologyRegionalNetwork.EMPTY, demand.regional.coursesIn(EXCLUDED));
assertEquals(0, geometry.get());
assertEquals(expectedWindow, demand.regional.coursesIn(nearby));
}
@Test(timeout = 120000)
public void coldQueriesRetainCurvedRiverExtremesAndTheirFootprints() {
HydrologyPlannerSettings settings = localizedSettings();
HydrologyTerrainSampler terrain = localizedCoast();
HydrologyPlanner eager = planner(settings, terrain, new AtomicInteger());
HydrologyRegionalNetwork full = eager.regional.draft(ORIGIN);
assertFalse(full.courses().isEmpty());
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (HydraulicSegment segment : full.courses().getFirst().segments()) {
points.addAll(segment.centerline());
}
HydrologyPoint minimumX = points.getFirst();
HydrologyPoint maximumX = minimumX;
HydrologyPoint minimumZ = minimumX;
HydrologyPoint maximumZ = minimumX;
for (HydrologyPoint point : points) {
if (point.x() < minimumX.x()) {
minimumX = point;
}
if (point.x() > maximumX.x()) {
maximumX = point;
}
if (point.z() < minimumZ.z()) {
minimumZ = point;
}
if (point.z() > maximumZ.z()) {
maximumZ = point;
}
}
assertTrue("The route must contain a lateral bend", maximumZ.z() - minimumZ.z() > 4);
for (HydrologyPoint point : List.of(minimumX, maximumX, minimumZ, maximumZ)) {
SurfaceBounds bounds = window(point);
HydrologyRegionalNetwork expected = eager.regional.coursesIn(bounds);
assertFalse(expected.courses().isEmpty());
HydrologyPlanner demand = planner(settings, terrain, new AtomicInteger());
HydrologyRegionalNetwork actual = demand.regional.coursesIn(bounds);
assertEquals(expected, actual);
assertEquals(eager.regional.materialize(expected, bounds).columns(),
demand.regional.materialize(actual, bounds).columns());
}
}
@Test(timeout = 120000)
public void aLowerRankedNeighborStillRejectsAnOverlappingAcceptedCourse() {
HydrologyPlanner planner = planner(localizedSettings(), localizedCoast(), new AtomicInteger());
HydrologyRegionalNetwork full = planner.regional.draft(ORIGIN);
assertFalse(full.courses().isEmpty());
RiverCourse course = full.courses().getFirst();
HydrologyTileKey neighbor = new HydrologyTileKey(0, -1);
assertTrue(Long.compareUnsigned(HydrologyHash.mix(71L, 0x524547424153494eL, 0, -1),
HydrologyHash.mix(71L, 0x524547424153494eL, 0, 0)) < 0);
HydrologyRegionalPlanner regional = spy(planner.regional);
RiverCourse blocker = copyIdentity(course);
assertTrue(regional.conflicts(course, List.of(blocker)));
doReturn(new HydrologyRegionalNetwork(List.of(), List.of(), List.of(), List.of(blocker), List.of(), List.of()))
.when(regional).draft(neighbor);
HydrologyRegionalNetwork actual = regional.coursesIn(window(course.segments().getFirst().start()));
verify(regional).draft(neighbor);
assertFalse(actual.courses().stream().anyMatch(candidate -> candidate.id() == course.id()));
}
@Test(timeout = 120000)
public void coastalBasinsKeepBothReceivingSeasWhenQueriedAwayFromTheChannel() {
HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(true, 8);
HydrologyTerrainSampler terrain = (x, z) -> x < 128 || x >= 1664
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(65, 0D, "land");
HydrologyPlanner eager = new HydrologyPlanner(19L, settings, terrain);
HydrologyRegionalNetwork expected = eager.regional.draft(ORIGIN);
assertEquals(HydrologyFeatureType.MOUTH, expected.courses().getFirst().segments().getFirst().type());
assertEquals(2, expected.outlets().size());
AtomicInteger geometry = new AtomicInteger();
HydrologyGeometrySampler delegate = HydrologyGeometrySampler.deterministic(terrain);
HydrologyPlanner demand = new HydrologyPlanner(19L, settings, terrain, request -> {
geometry.incrementAndGet();
return delegate.sample(request);
}, 0, footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128));
demand.regional.coursesIn(new SurfaceBounds(1536, 1536, 1551, 1551));
assertTrue("A seeded coastal channel must remain unpruned", geometry.get() > 0);
assertEquals(expected, demand.regional.draft(ORIGIN));
assertEquals(expected.diagnostics(), demand.regional.diagnosticsIn(EXCLUDED));
}
@Test(timeout = 15000)
public void clearSeparatesConcurrentCoarseAndCompleteDraftPublications() throws Exception {
AtomicInteger grids = new AtomicInteger();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(85, 0D, "land");
HydrologyRoutingTerrainSampler routing = new HydrologyRoutingTerrainSampler() {
@Override
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
int height = grids.incrementAndGet() == 1 ? 70 : 85;
if (height == 70) {
started.countDown();
await(release);
}
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()];
Arrays.fill(samples, HydrologyTerrainSample.openLand(height, 0D, "land"));
return samples;
}
@Override
public NaturalClassification classifyNatural(int blockX, int blockZ) {
return NaturalClassification.LAND;
}
};
HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 8), terrain,
routing, HydrologyGeometrySampler.deterministic(terrain), 0,
footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128));
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
Future<HydrologyRegionalNetwork> old = worker.submit(() -> planner.regional.coursesIn(new SurfaceBounds(1024, 1024, 1039, 1039)));
assertTrue(started.await(5, TimeUnit.SECONDS));
planner.regional.clear();
HydrologyRegionalNetwork current = planner.regional.draft(ORIGIN);
assertEquals(85, current.diagnostics().getFirst().point().y());
release.countDown();
assertEquals(HydrologyRegionalNetwork.EMPTY, old.get(5, TimeUnit.SECONDS));
assertSame(current, planner.regional.draft(ORIGIN));
assertEquals(current.diagnostics(), planner.regional.diagnosticsIn(new SurfaceBounds(1024, 1024, 1039, 1039)));
assertEquals(2, grids.get());
} finally {
release.countDown();
worker.shutdownNow();
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
}
}
private static RiverCourse copyIdentity(RiverCourse course) {
long id = course.id() + 1L;
ArrayList<HydraulicSegment> segments = new ArrayList<>();
for (HydraulicSegment segment : course.segments()) {
segments.add(new HydraulicSegment(segment.id(), id, segment.type(), segment.upstreamHeadY(),
segment.downstreamHeadY(), segment.width(), segment.depth(), segment.fallingFluid(),
segment.receivingPool(), segment.centerline(), segment.channelProfile()));
}
return new RiverCourse(id, course.type(), course.sourceNodeId(), OptionalLong.of(course.outletId().orElseThrow() + 1L),
course.profileKey(), course.discharge(), course.drainageEdges(), segments);
}
private static SurfaceBounds window(HydrologyPoint point) {
return new SurfaceBounds(point.x() - 32, point.z() - 32, point.x() + 32, point.z() + 32);
}
private static HydrologyPlanner planner(HydrologyPlannerSettings settings, HydrologyTerrainSampler terrain, AtomicInteger geometry) {
HydrologyGeometrySampler delegate = HydrologyGeometrySampler.deterministic(terrain);
return new HydrologyPlanner(71L, settings, terrain, request -> {
geometry.incrementAndGet();
return delegate.sample(request);
}, 0, footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128));
}
private static HydrologyTerrainSampler localizedCoast() {
return (x, z) -> x < 128 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 768 && x <= 1024 && z >= 0 && z <= 256 ? 70 : 66, 0D, "land");
}
private static HydrologyPlannerSettings localizedSettings() {
HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 8);
HydrologyPlannerSettings.Surface surface = base.surface();
HydrologyPlannerSettings.Source source = new HydrologyPlannerSettings.Source(true, 0D, 70, 0, 0, 128);
return new HydrologyPlannerSettings(base.seaLevel(), base.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), source, surface.minimumWidth(), surface.maximumWidth(),
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()),
base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(),
base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
}
private static void await(CountDownLatch latch) {
try {
assertTrue(latch.await(5, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError(failure);
}
}
}
@@ -6,10 +6,13 @@ import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.OptionalLong;
import java.util.concurrent.atomic.AtomicInteger;
@@ -23,6 +26,52 @@ public final class HydrologyRegionalFallsTest {
private static final HydrologyPlannerSettings SETTINGS = HydrologyPlannerSettings.defaults();
private static final HydrologyTerrainSampler TERRAIN = (x, z) -> HydrologyTerrainSample.openLand(89, 0D, "parent");
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void persistedMixedSurfaceOwnershipRestoresWithoutTerrainPlanning() throws Exception {
HydrologyPlanner planner = new HydrologyPlanner(1L, SETTINGS, TERRAIN, solid(null));
RiverCourse regional = course();
HydrologyRegionalNetwork network = metadata(regional, List.of());
List<HydrologyCavePlan> plans = new HydrologyRegionalFalls(planner, regional)
.validate(network, new ArrayList<>());
network = metadata(regional, plans);
SurfaceBounds bounds = new SurfaceBounds(-2, -2, 1, 2);
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN, planner.geometrySampler);
compiler.seedRegionalSurface(network, bounds);
HydrologyCrossTileResolver.MaterializedHydrology materialized = new HydrologyCrossTileResolver(planner)
.materializeFinalHydrology(new HydrologyCaveCourseFilter.Result(network.nodes(), network.edges(),
network.outlets(), network.courses(), plans), new ArrayList<>(), compiler, true);
HydrologyCaveCourseFilter.Result clipped = HydrologyCrossTileResolver.clipRegionalPlans(materialized.result(), network, bounds);
RiverCourse local = new RiverCourse(101L, RiverCourseType.SURFACE, regional.sourceNodeId(), regional.outletId(),
"water", 1, List.of(), List.of(new HydraulicSegment(102L, 101L, HydrologyFeatureType.SURFACE_POOL,
72, 72, 4, 2, false, false, List.of(new HydrologyPoint(0, 72, 0), new HydrologyPoint(1, 72, 0)),
HydraulicChannelProfile.uniform(4, 2))));
HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(),
SETTINGS.routing().tileSize(), network.nodes(), network.edges(), network.outlets(),
List.of(regional, local), Set.of(regional.id()), clipped.cavePlans(), List.of(), materialized.footprint());
StudioHydrologyTileStore store = new StudioHydrologyTileStore(temporaryFolder.newFolder().toPath(),
new HydrologyTileCache.SharedCacheScope("regional-ownership", 1L, 128, "overworld", SETTINGS.fingerprint()),
SETTINGS.routing().tileSize());
store.save(original);
HydrologyTile restored = store.load(original.key()).orElseThrow();
assertEquals(original, restored);
assertFalse(restored.cavePlans().isEmpty());
HydrologyPlanner fresh = new HydrologyPlanner(1L, SETTINGS, (x, z) -> {
throw new AssertionError("Restoring final ownership must not sample terrain");
});
fresh.reuseResolvedTile(restored);
HydrologyCaveCourseFilter.Result owned = fresh.resolvedOwners.getIfPresent(restored.key()).draft().result();
assertEquals(List.of(local), owned.courses());
assertTrue(owned.cavePlans().isEmpty());
assertEquals(restored.nodes(), owned.nodes());
assertEquals(restored.edges(), owned.edges());
assertEquals(restored.outlets(), owned.outlets());
}
@Test
public void fullContainmentProofIsCachedAndOnlyFinalTileActionsAreClipped() {
AtomicInteger validations = new AtomicInteger();
@@ -55,7 +104,7 @@ public final class HydrologyRegionalFallsTest {
assertEquals(full.actions().get(entry.getKey()), entry.getValue());
}
HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(), 512,
clipped.nodes(), clipped.edges(), clipped.outlets(), clipped.courses(), clipped.cavePlans(), List.of(), materialized.footprint());
clipped.nodes(), clipped.edges(), clipped.outlets(), clipped.courses(), Set.of(clipped.courses().getFirst().id()), clipped.cavePlans(), List.of(), materialized.footprint());
assertEquals(1, tile.courses().size());
assertEquals(full, regional.cavePlans().getFirst());
}
@@ -4,19 +4,46 @@ import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.HashSet;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalPlannerTest {
@Test(timeout = 120000)
public void reusedRegionalTilePreservesAdjacentPlanningWithoutSamplingDuringRestore() {
HydrologyPlannerSettings settings = settings(false, 8);
HydrologyTerrainSampler terrain = coast();
HydrologyPlanner baseline = new HydrologyPlanner(71L, settings, terrain);
RiverCourse regional = baseline.regional.draft(new HydrologyTileKey(0, 0)).courses().getFirst();
HydrologyTileKey key = crossedTiles(regional, settings.routing().tileSize()).stream()
.min(Comparator.comparingInt(HydrologyTileKey::tileX).thenComparingInt(HydrologyTileKey::tileZ))
.orElseThrow();
HydrologyTile original = baseline.plan(key);
assertTrue(original.regionalCourseIds().contains(regional.id()));
HydrologyTileKey adjacent = new HydrologyTileKey(key.tileX(), key.tileZ() + 1);
HydrologyTile expected = baseline.plan(adjacent);
AtomicBoolean planning = new AtomicBoolean();
HydrologyPlanner restored = new HydrologyPlanner(71L, settings, (x, z) -> {
assertTrue("Restoring final ownership must not sample terrain", planning.get());
return terrain.sample(x, z);
});
restored.reuseResolvedTile(original);
planning.set(true);
assertEquals(expected, restored.plan(adjacent));
}
@Test(timeout = 120000)
public void oneRegionalCourseCrossesSeveralGenerationTilesWithStableHydraulics() {
HydrologyPlanner planner = new HydrologyPlanner(71L, settings(false, 8), coast());
@@ -87,29 +114,48 @@ public class HydrologyRegionalPlannerTest {
planner.regional.clear();
assertEquals(first.diagnostics(), planner.regional.draft(key).diagnostics());
HydrologyRegionalNetwork window = planner.regional.coursesIn(new SurfaceBounds(0, 0, 511, 511));
assertEquals(first.diagnostics(), window.diagnostics());
assertTrue(window.diagnostics().isEmpty());
assertEquals(first.diagnostics(), planner.regional.diagnosticsIn(new SurfaceBounds(0, 0, 511, 511)));
}
@Test(timeout = 120000)
public void regionalCacheEvictionAndConcurrentRequestsKeepTheSameCourse() throws Exception {
HydrologyPlanner planner = new HydrologyPlanner(71L, settings(false, 1), coast());
HydrologyPlannerSettings settings = settings(false, 1);
HydrologyTerrainSampler terrain = coast();
Set<Thread> samplingThreads = ConcurrentHashMap.newKeySet();
HydrologyPlanner planner = new HydrologyPlanner(71L, settings, (x, z) -> {
samplingThreads.add(Thread.currentThread());
return terrain.sample(x, z);
});
HydrologyTileKey key = new HydrologyTileKey(0, 0);
HydrologyRegionalNetwork first = planner.regional.draft(key);
assertFalse(first.courses().isEmpty());
assertTrue(first.courses().stream().allMatch(course -> course.type() == RiverCourseType.SURFACE));
SurfaceBounds bounds = networkBounds(first).expand(settings.publicationRadius());
RiverFootprint footprint = planner.regional.materialize(first, bounds);
assertFalse(footprint.columns().isEmpty());
assertTrue(footprint.columns().values().stream().anyMatch(column -> column.layers().stream().anyMatch(HydrologyColumnLayer::fluidOwned)));
planner.regional.draft(new HydrologyTileKey(0, 1));
assertEquals(first, planner.regional.draft(key));
planner.regional.clear();
ExecutorService workers = Executors.newFixedThreadPool(3);
samplingThreads.clear();
ForkJoinPool workers = new ForkJoinPool(4);
try {
ArrayList<CompletableFuture<HydrologyRegionalNetwork>> futures = new ArrayList<>();
for (int index = 0; index < 3; index++) {
futures.add(CompletableFuture.supplyAsync(() -> planner.regional.draft(key), workers));
}
HydrologyRegionalNetwork parallel = futures.getFirst().get(90, TimeUnit.SECONDS);
for (CompletableFuture<HydrologyRegionalNetwork> future : futures) {
assertEquals(first, future.get(90, TimeUnit.SECONDS));
}
assertTrue("A cold successful basin must sample on multiple workers: " + samplingThreads, samplingThreads.size() > 1);
assertNetworkRawBits(first, parallel);
RiverFootprint actual = workers.submit(() -> planner.regional.materialize(parallel, bounds)).get(30, TimeUnit.SECONDS);
assertEquals(footprint.columns(), actual.columns());
} finally {
workers.shutdownNow();
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
}
}
@@ -126,6 +172,48 @@ public class HydrologyRegionalPlannerTest {
}
}
private static SurfaceBounds networkBounds(HydrologyRegionalNetwork network) {
int minimumX = Integer.MAX_VALUE;
int minimumZ = Integer.MAX_VALUE;
int maximumX = Integer.MIN_VALUE;
int maximumZ = Integer.MIN_VALUE;
for (RiverCourse course : network.courses()) {
for (HydraulicSegment segment : course.segments()) {
for (HydrologyPoint point : segment.centerline()) {
minimumX = Math.min(minimumX, point.x());
minimumZ = Math.min(minimumZ, point.z());
maximumX = Math.max(maximumX, point.x());
maximumZ = Math.max(maximumZ, point.z());
}
}
}
return new SurfaceBounds(minimumX, minimumZ, maximumX, maximumZ);
}
private static void assertNetworkRawBits(HydrologyRegionalNetwork expected, HydrologyRegionalNetwork actual) {
assertEquals(expected, actual);
for (int index = 0; index < expected.nodes().size(); index++) {
assertEquals(Double.doubleToRawLongBits(expected.nodes().get(index).potential()),
Double.doubleToRawLongBits(actual.nodes().get(index).potential()));
}
for (int index = 0; index < expected.edges().size(); index++) {
assertEquals(Double.doubleToRawLongBits(expected.edges().get(index).cost()),
Double.doubleToRawLongBits(actual.edges().get(index).cost()));
}
for (int courseIndex = 0; courseIndex < expected.courses().size(); courseIndex++) {
List<HydraulicSegment> expectedSegments = expected.courses().get(courseIndex).segments();
List<HydraulicSegment> actualSegments = actual.courses().get(courseIndex).segments();
for (int segmentIndex = 0; segmentIndex < expectedSegments.size(); segmentIndex++) {
HydraulicChannelProfile first = expectedSegments.get(segmentIndex).channelProfile();
HydraulicChannelProfile second = actualSegments.get(segmentIndex).channelProfile();
for (int station = 0; station < first.size(); station++) {
assertEquals(Double.doubleToRawLongBits(first.widthAt(station)), Double.doubleToRawLongBits(second.widthAt(station)));
assertEquals(Double.doubleToRawLongBits(first.depthAt(station)), Double.doubleToRawLongBits(second.depthAt(station)));
}
}
}
}
private static void assertGraphEndpoints(HydrologyRegionalNetwork network, RiverCourse course) {
DrainageNode source = network.nodes().stream()
.filter(node -> node.id() == course.sourceNodeId().orElseThrow()).findFirst().orElseThrow();
@@ -0,0 +1,272 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceBankSupport;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
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.ForkJoinPool;
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.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalSamplingTest {
private static final HydrologyPlannerSettings SETTINGS = HydrologyRegionalPlannerTest.settings(false, 1);
@Test
public void bankSamplesPreserveSamplingOrderAndResultsForRotatedWidths() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> x == 17 ? null
: HydrologyTerrainSample.openLand(70 + Math.floorMod(x + z, 13), 0D, "land");
SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel());
try (ForkJoinPool pool = new ForkJoinPool(4)) {
for (double angle : new double[]{0D, 0.37D, 0.7853981633974483D}) {
for (double width : new double[]{2D, 5D, 16D, 48D}) {
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(11, -7,
StrictMath.cos(angle), StrictMath.sin(angle), width);
RecordingSampler serial = new RecordingSampler(terrain);
SurfaceBankSupport.CrossSection cross = support.crossSection(serial, station, true);
SurfaceBankSupport.Perimeter perimeter = support.perimeter(serial, station, 80);
RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples());
pool.submit(() -> {
assertEquals(cross, support.crossSection(parallel, station, true));
assertEquals(perimeter, support.perimeter(parallel, station, 80));
}).get(10, TimeUnit.SECONDS);
assertEquals(serial.requests, parallel.requests);
}
}
}
}
@Test
public void searchLimitCountsNullsAndRetainsAlreadyAdmittedCoordinates() {
AtomicInteger reads = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
reads.incrementAndGet();
return x == 0 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = refiner(terrain);
refiner.sample(100000, 0);
HydrologyRegionalTerrainRefiner.Samples samples = refiner.new Samples();
assertNull(samples.sample(0, 0));
for (int x = 1; x < 65536; x++) {
assertEquals(70, samples.sample(x, 0).naturalHeight());
}
assertNull(samples.sample(0, 0));
assertEquals(70, samples.sample(1, 0).naturalHeight());
assertNull(samples.sample(65536, 0));
assertNull(samples.sample(100000, 0));
assertEquals(65537, reads.get());
}
@Test
public void failedRequiredSampleRetainsItsExceptionAndDoesNotConsumeAdmission() {
AtomicInteger failedReads = new AtomicInteger();
IllegalStateException failure = new IllegalStateException("Required terrain unavailable");
HydrologyTerrainSampler terrain = (x, z) -> {
if (x == -1 && failedReads.incrementAndGet() == 1) {
throw failure;
}
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples();
for (int x = 0; x < 65535; x++) {
samples.sample(x, 0);
}
assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(-1, 0)));
assertEquals(70, samples.sample(-1, 0).naturalHeight());
assertEquals(70, samples.sample(0, 0).naturalHeight());
assertNull(samples.sample(-2, 0));
assertEquals(2, failedReads.get());
}
@Test
public void unavailableBankStopsBeforeALaterProviderFailure() throws Exception {
IllegalStateException failure = new IllegalStateException("Later bank sample");
HydrologyTerrainSampler terrain = (x, z) -> {
if (z == 0) {
throw failure;
}
return z == -5 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
};
SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel());
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(0, 0, 1D, 0D, 4D);
SurfaceBankSupport.CrossSection expected = support.crossSection(terrain, station, true);
HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples();
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertEquals(expected, pool.submit(() -> support.crossSection(samples, station, true)).get(10, TimeUnit.SECONDS));
}
assertTrue(expected.blocked());
assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(0, 0)));
}
@Test
public void hydraulicBudgetAndLazyWidthsPreserveConsumptionOrder() throws Exception {
HydrologyPlannerSettings.Surface surface = SETTINGS.surface();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(SETTINGS.seaLevel(), SETTINGS.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), 256,
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()),
SETTINGS.hydraulics(), SETTINGS.underground(), SETTINGS.outlets(), SETTINGS.geometry(), SETTINGS.deepFluids(),
SETTINGS.surfacePools(), SETTINGS.widestShoreBiomeWidth(), SETTINGS.seaCaves(), SETTINGS.surfacePolicyBounds());
HydrologyTerrainSample ground = new HydrologyTerrainSample(65, 0D, false, false, 0, 0,
true, true, true, false, true, false, 0D, 1D, 1D, 0.75D, 1D, 1D, 1D, 1D,
"land", "land", "land", "land", "land", "land", List.of("default"), List.of(),
Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
HydrologyTerrainSampler terrain = (x, z) -> x == 133 ? null : x == 132 && z == 0 ? ground
: HydrologyTerrainSample.openLand(80, 0D, "land");
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(settings);
HydrologyRegionalHydraulics.HeadStation station = new HydrologyRegionalHydraulics.HeadStation(
132, 0, 1D, 0D, terrain.sample(132, 0), 80);
RecordingSampler serial = new RecordingSampler(terrain);
int expected = hydraulics.supportedHead(station, serial);
RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples());
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertEquals(expected, pool.submit(() -> hydraulics.supportedHead(station, parallel)).get(10, TimeUnit.SECONDS).intValue());
}
assertEquals(serial.requests, parallel.requests);
assertTrue(serial.requests.size() > 2048);
}
@Test
public void routeRefinementMatchesSerialTerrainAndHydraulics() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 95 : 70, 0D, "land");
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", false, terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
List<HydrologyPoint> actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS);
assertFalse(expected.isEmpty());
assertEquals(expected, actual);
}
}
@Test
public void coastalCenterlineRetainsNegativeAndFractionalTraversalOrder() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(64, 0D, "land");
List<HydrologyPoint> guide = List.of(new HydrologyPoint(-173, 64, -117), new HydrologyPoint(174, 64, 138));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", true, terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertFalse(expected.isEmpty());
assertEquals(expected, pool.submit(() -> refiner(terrain).refine(guide, "default", true, terrain))
.get(15, TimeUnit.SECONDS));
}
}
@Test
public void rejectedTraversalDoesNotReadALaterProviderFailure() throws Exception {
IllegalStateException failure = new IllegalStateException("Later centerline sample");
AtomicInteger failures = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
if (x == 32 && z == 0) {
failures.incrementAndGet();
throw failure;
}
return z == 0 && (x == 0 || x == 128)
? HydrologyTerrainSample.openLand(70, 0D, "land") : null;
};
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0));
assertTrue(refiner(terrain).refine(guide, "default", false, terrain).isEmpty());
assertEquals(0, failures.get());
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertTrue(pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS).isEmpty());
assertEquals(0, failures.get());
}
}
@Test
public void clearingAnActiveReachCannotPublishIntoTheNewCache() throws Exception {
AtomicBoolean changed = new AtomicBoolean();
AtomicBoolean blocked = new AtomicBoolean();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
HydrologyTerrainSampler terrain = (x, z) -> {
boolean elevated = changed.get();
if (x == 64 && z == 0 && blocked.compareAndSet(false, true)) {
started.countDown();
await(release);
}
return HydrologyTerrainSample.openLand(elevated && x == 64 && z == 0 ? 100 : 70, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = refiner(terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
Future<List<HydrologyPoint>> previous = executor.submit(() -> refiner.refine(guide, "default", false, terrain));
try {
assertTrue(started.await(5, TimeUnit.SECONDS));
changed.set(true);
refiner.clear();
assertEquals(100, refiner.sample(64, 0).naturalHeight());
} finally {
release.countDown();
}
previous.get(15, TimeUnit.SECONDS);
assertEquals(refiner(terrain).refine(guide, "default", false, terrain),
refiner.refine(guide, "default", false, terrain));
assertEquals(100, refiner.sample(64, 0).naturalHeight());
}
}
@Test
public void signedCoordinateTiesRemainStableInEveryDirection() throws Exception {
try (ForkJoinPool pool = new ForkJoinPool(8)) {
for (int[] direction : new int[][]{{1, 0}, {0, 1}, {-1, 0}, {0, -1}}) {
HydrologyTerrainSampler terrain = (x, z) -> {
int along = x * direction[0] + z * direction[1];
int across = -x * direction[1] + z * direction[0];
return HydrologyTerrainSample.openLand(
along >= 96 && along <= 160 && Math.abs(across) < 32 ? 95 : 70, 0D, "land");
};
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0),
new HydrologyPoint(256 * direction[0], 70, 256 * direction[1]));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", false, terrain);
List<HydrologyPoint> actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS);
assertFalse(expected.isEmpty());
assertEquals(expected, actual);
}
}
}
private static HydrologyRegionalTerrainRefiner refiner(HydrologyTerrainSampler terrain) {
return new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain));
}
private static void await(CountDownLatch latch) {
try {
assertTrue(latch.await(5, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
}
private static final class RecordingSampler implements HydrologyTerrainSampler {
private final HydrologyTerrainSampler sampler;
private final List<Long> requests = new ArrayList<>();
private RecordingSampler(HydrologyTerrainSampler sampler) {
this.sampler = sampler;
}
@Override
public HydrologyTerrainSample sample(int x, int z) {
requests.add(RiverFootprint.pack(x, z));
return sampler.sample(x, z);
}
}
}
@@ -0,0 +1,163 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
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;
public class HydrologyRegionalShorelineTest {
@Test(timeout = 15000)
public void successfulBoundariesMatchSerialCandidatesInGridOrderAcrossWindows() throws Exception {
HydrologySampledGrid grid = grid();
HydrologyPlanner serial = planner(HydrologyRegionalShorelineTest::terrain);
List<OutletCandidate> expected = serial.outletPlanner.oceanOutletCandidates(grid, true);
CountDownLatch overlapping = new CountDownLatch(4);
Set<Long> threads = ConcurrentHashMap.newKeySet();
HydrologyPlanner parallel = planner((x, z) -> {
if (x == 1 && z < 64) {
threads.add(Thread.currentThread().threadId());
overlapping.countDown();
await(overlapping);
}
return terrain(x, z);
});
ForkJoinPool pool = new ForkJoinPool(4);
try {
List<OutletCandidate> actual = pool.submit(() -> parallel.outletPlanner.regionalOceanOutletCandidates(grid)).get(10, TimeUnit.SECONDS);
assertEquals(32, expected.size());
assertEquals(expected, actual);
assertEquals(4, threads.size());
for (int index = 0; index < actual.size(); index++) {
assertEquals(index * 32, actual.get(index).landIndex());
assertEquals(new HydrologyPoint(9, 63, index * 16), actual.get(index).outlet().landwardPoint());
assertEquals(new HydrologyPoint(10, 63, index * 16), actual.get(index).outlet().connectionPoint());
}
} finally {
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test(timeout = 15000)
public void unavailableShorelinesKeepTheSameAcceptedOrder() throws Exception {
HydrologyTerrainSampler sampler = (x, z) -> x == 5 && z % 48 == 0 ? null : terrain(x, z);
HydrologySampledGrid grid = grid();
List<OutletCandidate> expected = planner(sampler).outletPlanner.oceanOutletCandidates(grid, true);
HydrologyPlanner parallel = planner(sampler);
ForkJoinPool pool = new ForkJoinPool(4);
try {
List<OutletCandidate> actual = pool.submit(() -> parallel.outletPlanner.regionalOceanOutletCandidates(grid)).get(10, TimeUnit.SECONDS);
assertEquals(21, expected.size());
assertEquals(expected, actual);
} finally {
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test(timeout = 15000)
public void firstFailureDrainsStartedWorkAndDoesNotStartTheNextWindow() throws Exception {
CountDownLatch blocked = new CountDownLatch(1);
CountDownLatch failed = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicBoolean finished = new AtomicBoolean();
AtomicBoolean restored = new AtomicBoolean();
AtomicInteger laterWindow = new AtomicInteger();
IllegalStateException expected = new IllegalStateException("First shoreline provider failure");
IllegalArgumentException laterFailure = new IllegalArgumentException("Later shoreline provider failure");
HydrologyPlanner planner = planner((x, z) -> {
if (x == 1 && z == 0) {
await(blocked);
failed.countDown();
throw expected;
}
if (x == 1 && z == 16) {
blocked.countDown();
await(release);
finished.set(true);
}
if (x == 1 && z == 32) {
throw laterFailure;
}
if (x == 1 && z >= 64) {
laterWindow.incrementAndGet();
}
return terrain(x, z);
});
HydrologySampledGrid grid = grid();
ForkJoinPool pool = new ForkJoinPool(4);
try {
Future<List<OutletCandidate>> pending = pool.submit(() -> {
HydrologyPlanner.PlanningSamples previous = new HydrologyPlanner.PlanningSamples();
planner.planningSamples.set(previous);
try {
return planner.outletPlanner.regionalOceanOutletCandidates(grid);
} finally {
restored.set(planner.planningSamples.get() == previous);
planner.planningSamples.remove();
}
});
assertTrue(blocked.await(5, TimeUnit.SECONDS));
assertTrue(failed.await(5, TimeUnit.SECONDS));
assertFalse(pending.isDone());
release.countDown();
ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS));
Throwable cause = failure.getCause();
while (cause.getCause() != null) {
cause = cause.getCause();
}
assertSame(expected, cause);
assertEquals(List.of(laterFailure), List.of(expected.getSuppressed()));
assertTrue(finished.get());
assertTrue(restored.get());
assertEquals(0, laterWindow.get());
} finally {
release.countDown();
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
private static HydrologyPlanner planner(HydrologyTerrainSampler sampler) {
return new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 1), sampler);
}
private static HydrologyTerrainSample terrain(int x, int z) {
return x >= 10 ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(66, 0D, "land");
}
private static HydrologySampledGrid grid() {
ArrayList<HydrologyGridNode> nodes = new ArrayList<>(1024);
for (int z = 0; z < 32; z++) {
for (int x = 0; x < 32; x++) {
int index = z * 32 + x;
nodes.add(new HydrologyGridNode(index, x, z, x * 16, z * 16, index + 1L, terrain(x * 16, z * 16)));
}
}
return new HydrologySampledGrid(0, 0, 0, 0, 512, 32, 16, nodes);
}
private static void await(CountDownLatch latch) {
try {
assertTrue(latch.await(5, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
}
}
@@ -0,0 +1,246 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.OptionalLong;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ForkJoinPool;
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.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class HydrologyRegionalSourceParallelismTest {
@Test(timeout = 15000)
public void acceptsOutletsInSourceOrderWhenTheMiddleOfAWindowSucceeds() throws Exception {
Fixture fixture = fixture(4, source -> source == 4 || source == 1 ? accepted(source) : rejected(source));
HydrologyRegionalNetwork network = run(fixture);
assertEquals(List.of(104L, 101L), network.courses().stream().map(RiverCourse::id).toList());
assertEquals(List.of(0L), network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
}
@Test(timeout = 15000)
public void skipsFailureFromAnOutletAlreadyAcceptedAndDrainsItsStartedWork() throws Exception {
CountDownLatch blocked = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicBoolean completed = new AtomicBoolean();
Fixture fixture = fixture(4, source -> {
if (source == 5) {
blocked.countDown();
await(release);
completed.set(true);
throw new IllegalStateException("Unused outlet provider failed");
}
return source == 4 ? accepted(source) : rejected(source);
});
ForkJoinPool pool = new ForkJoinPool(4);
try {
Future<HydrologyRegionalNetwork> pending = pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0)));
assertTrue(blocked.await(5, TimeUnit.SECONDS));
assertFalse(pending.isDone());
release.countDown();
HydrologyRegionalNetwork network = pending.get(5, TimeUnit.SECONDS);
assertTrue(completed.get());
assertEquals(List.of(104L), network.courses().stream().map(RiverCourse::id).toList());
assertEquals(List.of(0L, 1L, 2L, 3L), network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
} finally {
release.countDown();
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test(timeout = 15000)
public void acceptedOutletSkipsLeaveExactlyTwentyFourActualSourceTrials() throws Exception {
AtomicInteger evaluations = new AtomicInteger();
Fixture fixture = fixture(32, source -> {
evaluations.incrementAndGet();
return source == 0 ? accepted(source) : rejected(source);
});
HydrologyRegionalNetwork network = run(fixture);
assertEquals(List.of(100L), network.courses().stream().map(RiverCourse::id).toList());
assertEquals(23, network.diagnostics().size());
assertEquals(23L, network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).distinct().count());
assertTrue(network.diagnostics().stream().allMatch(diagnostic -> diagnostic.id() >= 4L));
assertTrue("The fixture must include work skipped after outlet acceptance", evaluations.get() > 24);
}
@Test(timeout = 15000)
public void requiredSourceFailureWaitsForAllStartedTrialProviders() throws Exception {
CountDownLatch failed = new CountDownLatch(1);
CountDownLatch blocked = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicBoolean completed = new AtomicBoolean();
IllegalStateException expected = new IllegalStateException("Required source provider failed");
Fixture fixture = fixture(4, source -> {
if (source == 1) {
failed.countDown();
throw expected;
}
if (source == 5) {
blocked.countDown();
await(release);
completed.set(true);
}
return rejected(source);
});
ForkJoinPool pool = new ForkJoinPool(4);
try {
Future<HydrologyRegionalNetwork> pending = pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0)));
assertTrue(failed.await(5, TimeUnit.SECONDS));
assertTrue(blocked.await(5, TimeUnit.SECONDS));
assertFalse(pending.isDone());
release.countDown();
ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS));
Throwable cause = failure.getCause();
while (cause.getCause() != null) {
cause = cause.getCause();
}
assertSame(expected, cause);
assertTrue(completed.get());
} finally {
release.countDown();
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
private static HydrologyRegionalNetwork run(Fixture fixture) throws Exception {
ForkJoinPool pool = new ForkJoinPool(4);
try {
return pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0))).get(10, TimeUnit.SECONDS);
} finally {
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
private static Fixture fixture(int secondCatchmentSize, SourceProvider provider) throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyPlannerSettings.Routing routing = base.routing();
HydrologyPlannerSettings.Regional regional = routing.regional();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(),
new HydrologyPlannerSettings.Routing(routing.tileSize(), routing.sampleSpacing(), routing.maximumRouteNodes(),
routing.maximumRouteLength(), routing.minimumSurfaceCourseLength(), routing.minimumUndergroundCourseLength(),
routing.valleyPreference(), routing.uphillPenalty(), routing.slopePenalty(), routing.confluenceAttraction(),
routing.lengthPreference(), routing.tributaries(), new HydrologyPlannerSettings.Regional(true,
regional.sampleSpacing(), regional.minimumLength(), 2, regional.maximumCachedBasins(),
regional.maximumCachedStations(), false, regional.coastalChannelChance(), regional.maximumCoastalIncision())),
base.surface(), base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(),
base.surfacePools(), base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
HydrologyPlanner planner = new HydrologyPlanner(71L, settings, terrain);
ArrayList<OutletCandidate> outlets = new ArrayList<>(List.of(outlet(0), outlet(1)));
HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class);
when(outletPlanner.regionalOceanOutletCandidates(any())).thenAnswer(invocation -> new ArrayList<>(outlets));
replace(planner, "outletPlanner", outletPlanner);
Map<Long, Integer> sources = new HashMap<>();
HydrologyRegionalGraph graph = mock(HydrologyRegionalGraph.class);
when(graph.route(any(), anyList(), anyBoolean())).thenAnswer(invocation -> {
HydrologySampledGrid grid = invocation.getArgument(0);
HydrologyRegionalGraph.Label[] labels = new HydrologyRegionalGraph.Label[grid.nodes().size()];
int[] contributions = new int[grid.nodes().size()];
Arrays.fill(contributions, 1);
int ordinal = 0;
for (HydrologyGridNode node : grid.nodes()) {
if (!grid.owns(node.x(), node.z()) || ordinal >= secondCatchmentSize + 4) {
continue;
}
int root = ordinal < 4 ? 0 : 1;
int local = ordinal < 4 ? ordinal : ordinal - 4;
double length = ordinal < 4 || secondCatchmentSize == 4 ? 10000D - local * 2000D : 10000D - local;
HydrologyRegionalGraph.Label terminal = new HydrologyRegionalGraph.Label(
new HydrologyRegionalGraph.LabelState(0, 0, 63, 0D, 0D, root), null);
labels[node.index()] = new HydrologyRegionalGraph.Label(new HydrologyRegionalGraph.LabelState(
node.index(), node.index(), 63, 0D, length, root), terminal);
sources.put(RiverFootprint.pack(node.x(), node.z()), ordinal++);
}
assertEquals(secondCatchmentSize + 4, ordinal);
return new HydrologyRegionalGraph.Tree(labels, contributions, outlets);
});
when(graph.path(any(), anyInt())).thenAnswer(invocation -> List.of(invocation.<Integer>getArgument(1), 0));
replace(planner.regional, "graph", graph);
HydrologyRegionalRoute routes = mock(HydrologyRegionalRoute.class);
when(routes.select(anyList(), anyString(), anyBoolean(), any(), any())).thenAnswer(invocation -> {
List<HydrologyPoint> guide = invocation.getArgument(0);
HydrologyPoint first = guide.getFirst();
return provider.build(sources.get(RiverFootprint.pack(first.x(), first.z())));
});
replace(planner.regional, "routes", routes);
return new Fixture(planner);
}
private static OutletCandidate outlet(int root) {
HydrologyPoint point = new HydrologyPoint(2048, 63, root * 1024);
return new OutletCandidate(0, 0, new RiverOutlet(1000L + root, HydrologyFeatureType.MOUTH,
root, point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true));
}
private static HydrologyRegionalRoute.Attempt accepted(int source) {
int z = source * 4096;
HydrologyPoint first = new HydrologyPoint(0, 70, z);
HydrologyPoint last = new HydrologyPoint(512, 70, z);
HydraulicSegment segment = new HydraulicSegment(100L + source, 100L + source, HydrologyFeatureType.SURFACE_POOL,
70, 70, 4, 2, false, false, List.of(first, last),
new HydraulicChannelProfile(new double[]{4D}, new double[]{2D}));
RiverCourse course = new RiverCourse(100L + source, RiverCourseType.SURFACE, OptionalLong.of(10000L + source),
OptionalLong.of(1000L + (source < 4 ? 0 : 1)), "default", 1, List.of(), List.of(segment));
HydrologyRegionalNetwork network = new HydrologyRegionalNetwork(List.of(), List.of(), List.of(),
List.of(course), List.of(), List.of());
return new HydrologyRegionalRoute.Attempt(new HydrologyRegionalRoute.Refinement(List.of(first, last), null, null, 0, null), network, null);
}
private static HydrologyRegionalRoute.Attempt rejected(int source) {
HydrologyPoint point = new HydrologyPoint(source, 70, 0);
HydrologyDiagnosticCandidate diagnostic = new HydrologyDiagnosticCandidate(source, HydrologyCandidateKind.REGIONAL_SOURCE,
HydrologyFeatureType.SURFACE_POOL, point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, source);
return new HydrologyRegionalRoute.Attempt(new HydrologyRegionalRoute.Refinement(List.of(point), null, null, 0, null),
HydrologyRegionalNetwork.EMPTY, diagnostic);
}
private static void replace(Object target, String name, Object value) throws Exception {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
}
private static void await(CountDownLatch latch) {
try {
assertTrue("Timed out waiting for trial release", latch.await(5, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError(failure);
}
}
@FunctionalInterface
private interface SourceProvider {
HydrologyRegionalRoute.Attempt build(int source);
}
private record Fixture(HydrologyPlanner planner) {
}
}
@@ -8,6 +8,11 @@ import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.lang.reflect.Method;
@@ -17,6 +22,69 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalTerrainRefinerTest {
@Test(timeout = 10000)
public void clearingDuringSamplingCannotPublishOldTerrain() throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
int call = calls.incrementAndGet();
if (call == 1) {
started.countDown();
try {
assertTrue(release.await(5, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
}
return HydrologyTerrainSample.openLand(call == 1 ? 70 : 80, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newSingleThreadExecutor()) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
try {
assertTrue(started.await(3, TimeUnit.SECONDS));
refiner.clear();
assertEquals(80, refiner.sample(0, 0).naturalHeight());
} finally {
release.countDown();
}
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(80, refiner.sample(0, 0).naturalHeight());
assertEquals(2, calls.get());
}
}
@Test(timeout = 10000)
public void independentDiagonalSamplesDoNotWaitForEachOthersTerrain() throws Exception {
CountDownLatch sampling = new CountDownLatch(2);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
sampling.countDown();
try {
assertTrue("Independent terrain samples were serialized", sampling.await(3, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
return HydrologyTerrainSample.openLand(70 + x, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newFixedThreadPool(2)) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
Future<HydrologyTerrainSample> second = workers.submit(() -> refiner.sample(1, 1));
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(71, second.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(first.get(), refiner.sample(0, 0));
assertEquals(second.get(), refiner.sample(1, 1));
assertEquals(2, calls.get());
}
}
@Test
public void aHigherHeadDetourSurvivesACheaperInfeasibleArrival() {
int[][] heights = {
@@ -5,6 +5,7 @@ import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.OptionalLong;
@@ -43,7 +44,7 @@ public class HydrologyRegionalTributariesTest {
HydrologyCaveCourseFilter.Result combined = planner.regional.include(new HydrologyCaveCourseFilter.Result(
connected.nodes(), connected.edges(), connected.outlets(), courses, List.of()), fixture.regional());
HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 19L, 0L, 512,
combined.nodes(), combined.edges(), combined.outlets(), combined.courses(), List.of(), diagnostics,
combined.nodes(), combined.edges(), combined.outlets(), combined.courses(), Set.of(fixture.regional().courses().getFirst().id()), List.of(), diagnostics,
RiverFootprint.empty());
assertTrue(tile.acyclic());
DrainageNode source = tile.node(tributary.sourceNodeId().orElseThrow()).orElseThrow();
@@ -0,0 +1,225 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class HydrologySourceSpacingTest {
private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0);
private static final int WIDTH = 11;
private static final int SPACING = 64;
/** One lattice step (64, or 91 diagonally) is inside the spacing; two steps (128) are outside it. */
private static final int MINIMUM_SPACING = 100;
private static final int HIGH = 200;
private static final int ROUTE = 512;
private static final int LONG_ROUTE = 4096;
@Test
public void aRejectedSourceNoLongerCostsItsLowerRankedNeighbourTheSlot() {
// A > B > C by weight, A one step from B, B one step from C, A two steps from C.
Field withMiddle = field(Map.of(pack(3, 4), 3D, pack(4, 4), 2D, pack(5, 4), 1D));
assertTrue(withMiddle.admits(3, 4));
assertFalse(withMiddle.admits(4, 4));
assertTrue(withMiddle.admits(5, 4));
Field withoutMiddle = field(Map.of(pack(3, 4), 3D, pack(5, 4), 1D));
assertEquals(withoutMiddle.accepted(), withMiddle.accepted());
}
/**
* Route length and potential belong to the routing plan of the tile that owns a node, so a contest scored with
* them ranks neighbours either side of a tile seam against different outlet sets and hands the slot to whoever
* owns the node. The contest is terrain only, and must not move when the plan under it does.
*/
@Test
public void theSpacingContestIgnoresTheOwnerTileRoutingPlan() {
int[] pair = hashInvertedPair(planner());
long left = pack(pair[0], pair[1]);
long right = pack(pair[0] + 1, pair[1]);
Map<Long, Double> weights = Map.of(left, 1D, right, 1D);
Set<Long> terrainOnly = field(weights).accepted();
assertEquals(Set.of(left), terrainOnly);
assertEquals(terrainOnly, field(weights, Map.of(right, LONG_ROUTE)).accepted());
assertEquals(terrainOnly, field(weights, Map.of(left, LONG_ROUTE)).accepted());
}
@Test
public void greedyAdmissionIsDeterministicSeparatedAndMaximal() {
Set<Long> first = denseField().accepted();
Set<Long> second = denseField().accepted();
assertEquals(first, second);
assertFalse(first.isEmpty());
for (long accepted : first) {
for (long other : first) {
if (accepted != other) {
assertTrue("accepted sources " + accepted + " and " + other + " sit inside the spacing",
separation(accepted, other) >= MINIMUM_SPACING);
}
}
}
for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) {
for (int gridX = 1; gridX < WIDTH - 1; gridX++) {
long node = pack(gridX, gridZ);
if (first.contains(node)) {
continue;
}
boolean blocked = false;
for (long accepted : first) {
blocked |= separation(node, accepted) < MINIMUM_SPACING;
}
assertTrue("rejected source " + node + " is separated from every accepted source", blocked);
}
}
}
private static Field denseField() {
HashMap<Long, Double> weights = new HashMap<>();
for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) {
for (int gridX = 1; gridX < WIDTH - 1; gridX++) {
weights.put(pack(gridX, gridZ), 1D + (gridX * 7 + gridZ * 13) % 5);
}
}
return field(weights);
}
/** The first adjacent pair whose raw source hashes rank the left node above the right one. */
private static int[] hashInvertedPair(HydrologyPlanner planner) {
for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) {
for (int gridX = 1; gridX < WIDTH - 2; gridX++) {
long left = planner.sourcePlanner.sourceStableId(HydrologySourcePlanner.SURFACE_SOURCE_SALT,
gridX * SPACING, gridZ * SPACING);
long right = planner.sourcePlanner.sourceStableId(HydrologySourcePlanner.SURFACE_SOURCE_SALT,
(gridX + 1) * SPACING, gridZ * SPACING);
if (Long.compareUnsigned(left, right) > 0) {
return new int[]{gridX, gridZ};
}
}
}
fail("No hash-inverted adjacent source pair exists on this lattice");
return null;
}
private static double separation(long first, long second) {
return StrictMath.hypot((double) (unpackX(first) - unpackX(second)) * SPACING,
(double) (unpackZ(first) - unpackZ(second)) * SPACING);
}
private static Field field(Map<Long, Double> weights) {
return field(weights, Map.of());
}
private static Field field(Map<Long, Double> weights, Map<Long, Integer> routeLengths) {
HydrologyPlanner planner = planner();
HydrologySampledGrid grid = grid(weights);
HydrologyRoutingPlan routing = routing(grid, routeLengths);
HashMap<HydrologyTileKey, SourceRoutingContext> contexts = new HashMap<>();
contexts.put(TILE, new SourceRoutingContext(grid, routing, routing, List.of()));
return new Field(planner, grid, weights.keySet(), contexts, new HashMap<>());
}
private static HydrologyPlanner planner() {
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(true,
new HydrologyPlannerSettings.Source(true, 0.5D, 88, 0, 1, MINIMUM_SPACING),
4, 8, 2, 4, 10, 1.5D, HydrologyPlannerSettings.Banks.defaults());
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(), surface,
base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(),
base.surfacePools(), base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
return new HydrologyPlanner(1337L, settings, (x, z) -> land(HIGH, 0D));
}
private static HydrologySampledGrid grid(Map<Long, Double> weights) {
ArrayList<HydrologyGridNode> nodes = new ArrayList<>(WIDTH * WIDTH);
for (int gridZ = 0; gridZ < WIDTH; gridZ++) {
for (int gridX = 0; gridX < WIDTH; gridX++) {
int index = gridZ * WIDTH + gridX;
long node = pack(gridX, gridZ);
nodes.add(new HydrologyGridNode(index, gridX, gridZ, gridX * SPACING, gridZ * SPACING, index + 1L,
land(HIGH, weights.getOrDefault(node, 0D))));
}
}
return new HydrologySampledGrid(0, 0, 0, 0, WIDTH * SPACING, WIDTH, SPACING, List.copyOf(nodes));
}
private static HydrologyRoutingPlan routing(HydrologySampledGrid grid, Map<Long, Integer> routeLengths) {
int count = grid.nodes().size();
double[] potential = new double[count];
int[] parent = new int[count];
int[] outlets = new int[count];
int[] lengths = new int[count];
Arrays.fill(potential, 100D);
Arrays.fill(parent, 0);
ArrayList<OutletCandidate> candidates = new ArrayList<>(count);
for (HydrologyGridNode node : grid.nodes()) {
outlets[node.index()] = node.index();
lengths[node.index()] = routeLengths.getOrDefault(pack(node.gridX(), node.gridZ()), ROUTE);
HydrologyPoint point = new HydrologyPoint(node.x(), 63, node.z());
candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L,
HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true)));
}
return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false);
}
private static HydrologyTerrainSample land(int naturalHeight, double weight) {
return new HydrologyTerrainSample(naturalHeight, 0D, false, false, naturalHeight - 32, naturalHeight - 30,
true, true, true, false, false, false,
0D, weight, 0D, 1D, 1D, 1D, 1D, 1D, "land", "land", "land", "land", "land", "land",
List.of("default"), List.of(), Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
}
private static long pack(int gridX, int gridZ) {
return RiverFootprint.pack(gridX, gridZ);
}
private static int unpackX(long packed) {
return RiverFootprint.unpackX(packed);
}
private static int unpackZ(long packed) {
return RiverFootprint.unpackZ(packed);
}
private record Field(
HydrologyPlanner planner,
HydrologySampledGrid grid,
Set<Long> eligible,
Map<HydrologyTileKey, SourceRoutingContext> contexts,
Map<Long, Boolean> admissions
) {
boolean admits(int gridX, int gridZ) {
return planner.sourcePlanner.globallyAdmittedSource(
grid.nodeAt(gridX, gridZ),
planner.settings.surface().sources(),
HydrologySourcePlanner.SURFACE_SOURCE_SALT,
true,
contexts,
admissions
);
}
Set<Long> accepted() {
LinkedHashSet<Long> selected = new LinkedHashSet<>();
for (long node : new TreeSet<>(eligible)) {
if (admits(unpackX(node), unpackZ(node))) {
selected.add(node);
}
}
return selected;
}
}
}
@@ -0,0 +1,85 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.util.NavigableSet;
import java.util.TreeSet;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class HydrologySurfaceBankProbeTest {
@Test
public void raisingShoreWidthWidensTheBankProbeBandInsteadOfSlidingIt() {
NavigableSet<Integer> narrow = probedBankOffsets(1.5D);
NavigableSet<Integer> wide = probedBankOffsets(3D);
int innerRing = innerRing();
assertTrue("narrow band " + narrow, narrow.contains(innerRing));
assertTrue("wide band " + wide, wide.contains(innerRing));
assertTrue("band " + wide + " must still cover " + narrow, wide.containsAll(narrow));
assertTrue("band " + wide + " must reach past " + narrow, wide.size() > narrow.size());
}
@Test
public void theInnerProbeRingDoesNotMoveWithShoreWidth() {
int innerRing = innerRing();
for (double shoreWidth : new double[]{0D, 1.5D, 3D, 16D}) {
NavigableSet<Integer> band = probedBankOffsets(shoreWidth);
assertEquals("band " + band + " at shoreWidth " + shoreWidth,
innerRing, band.first().intValue());
}
}
/** One block outside half the nominal maximum width - see HydrologySurfaceCoursePlanner. */
private int innerRing() {
return (int) StrictMath.ceil(
HydrologyPlannerSettings.defaults().surface().maximumWidth() / 2D) + 1;
}
private NavigableSet<Integer> probedBankOffsets(double shoreWidth) {
HydrologyPoint center = new HydrologyPoint(0, 84, 0);
NavigableSet<Integer> probed = new TreeSet<>();
HydrologyTerrainSampler sampler = (x, z) -> {
if (x == center.x()) {
probed.add(Math.abs(z - center.z()));
}
return HydrologyTerrainSample.openLand(84, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(91L, settings(shoreWidth), sampler);
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
planner.surfaceCourses.surfaceRouteCandidateBankPenalty(new RouteCandidate(
center, center.x(), center.z(), 0D, 0D, 0D, new RouteDirection(1D, 0D), true));
probed.remove(0);
return probed;
}
private HydrologyPlannerSettings settings(double shoreWidth) {
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Surface surface = base.surface();
return new HydrologyPlannerSettings(
base.seaLevel(),
base.routing(),
new HydrologyPlannerSettings.Surface(
surface.enabled(),
surface.sources(),
surface.minimumWidth(),
surface.maximumWidth(),
surface.minimumDepth(),
surface.maximumDepth(),
surface.maximumIncision(),
shoreWidth,
surface.banks()),
base.hydraulics(),
base.underground(),
base.outlets(),
base.geometry(),
base.deepFluids(),
base.surfacePools(),
base.widestShoreBiomeWidth(),
base.seaCaves(),
base.surfacePolicyBounds());
}
}
@@ -19,6 +19,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.OptionalLong;
import java.util.concurrent.atomic.AtomicInteger;
@@ -345,7 +346,7 @@ public final class HydrologySurfaceDropRasterTest {
columns.put(RiverFootprint.pack(sample.x(), sample.z()), sample);
}
HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, 1L, 128,
accepted.nodes(), accepted.edges(), accepted.outlets(), accepted.courses(), accepted.cavePlans(), List.of(), new RiverFootprint(columns));
accepted.nodes(), accepted.edges(), accepted.outlets(), accepted.courses(), Set.of(), accepted.cavePlans(), List.of(), new RiverFootprint(columns));
assertEquals(1, tile.courses().size());
for (boolean connectToExistingCaves : new boolean[] {false, true}) {
diagnostics.clear();
@@ -388,6 +388,13 @@ public class HydrologyTileCacheTest {
when(tile.tileSize()).thenReturn(64);
HydrologyTileCache first = new HydrologyTileCache(firstPlanner, 4, null, null, scope);
HydrologyTileCache second = new HydrologyTileCache(secondPlanner, 4, null, null, scope);
AtomicInteger terrainPreparations = new AtomicInteger();
first.setTerrainPreparation(terrainPreparations::incrementAndGet);
second.setTerrainPreparation(() -> {
throw new AssertionError("Shared tile reuse must not prepare terrain caches");
});
assertEquals(0, terrainPreparations.get());
assertSame(tile, first.get(key));
assertSame(tile, second.get(key));
@@ -395,6 +402,7 @@ public class HydrologyTileCacheTest {
verify(firstPlanner, times(1)).plan(key);
verify(secondPlanner, never()).plan(key);
verify(secondPlanner, times(1)).reuseResolvedTile(tile);
assertEquals(1, terrainPreparations.get());
}
@Test
@@ -801,7 +809,7 @@ public class HydrologyTileCacheTest {
public void planningFailureFallsBackToTheEmptyTileAndIsCached() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTileKey key = new HydrologyTileKey(3, -2);
HydrologyTile empty = new HydrologyTile(key, 7L, 11L, 1024, List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), RiverFootprint.empty());
HydrologyTile empty = new HydrologyTile(key, 7L, 11L, 1024, List.of(), List.of(), List.of(), List.of(), Set.of(), List.of(), List.of(), RiverFootprint.empty());
when(planner.settings()).thenReturn(emptySettings());
when(planner.plan(key)).thenThrow(new IllegalStateException("Hydrology natural height was not finite at -66,-641"));
when(planner.emptyTile(key)).thenReturn(empty);
@@ -870,6 +878,33 @@ public class HydrologyTileCacheTest {
assertEquals(width * height, cache.size());
}
@Test
public void deferredNeighboursAllowFurtherDemandAndResumeAfterEntry() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = emptySettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty());
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, Runnable::run);
cache.setNeighbourPrefetchEnabled(false);
int tileSize = settings.routing().tileSize();
cache.prepareChunkColumns(0, 0);
int requiredTiles = cache.size();
HydrologyTileKey distant = new HydrologyTileKey(20, 20);
assertSame(tile, cache.get(distant));
assertEquals(requiredTiles + 1, cache.size());
verify(planner, never()).plan(new HydrologyTileKey(-2, -2));
cache.setNeighbourPrefetchEnabled(true);
cache.prepareChunkColumns(tileSize * 20 + 32, tileSize * 20 + 48);
verify(planner).plan(new HydrologyTileKey(19, 19));
assertTrue(cache.size() > requiredTiles + 1);
cache.close();
}
@Test
public void pregenPreparationStartsAtTheNearestUncachedForwardTile() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
@@ -1,19 +1,27 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.OptionalLong;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class StudioHydrologyTileStoreTest {
@@ -35,9 +43,29 @@ public class StudioHydrologyTileStoreTest {
assertEquals(original.edges(), restored.edges());
assertEquals(original.outlets(), restored.outlets());
assertEquals(original.courses(), restored.courses());
assertEquals(original.regionalCourseIds(), restored.regionalCourseIds());
assertEquals(original.cavePlans(), restored.cavePlans());
assertEquals(original.diagnosticCandidates(), restored.diagnosticCandidates());
assertEquals(original.localDiagnosticCandidates(), restored.localDiagnosticCandidates());
assertEquals(original.footprint(), restored.footprint());
assertThrows(UnsupportedOperationException.class, () -> restored.regionalCourseIds().clear());
}
@Test
public void entryRequiresRegionalOwnershipMetadata() throws Exception {
HydrologyTile original = tile();
StudioHydrologyTileStore store = store();
store.save(original);
Path file = store.file(original.key());
JsonObject persisted;
try (InputStream input = new GZIPInputStream(Files.newInputStream(file))) {
persisted = JsonParser.parseString(new String(input.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject();
}
persisted.remove("regionalCourseIds");
try (OutputStream output = new GZIPOutputStream(Files.newOutputStream(file))) {
output.write(persisted.toString().getBytes(StandardCharsets.UTF_8));
}
assertTrue(store.load(original.key()).isEmpty());
}
@Test
@@ -168,6 +196,7 @@ public class StudioHydrologyTileStoreTest {
List.of(),
List.of(outlet),
List.of(course),
Set.of(course.id()),
List.of(),
List.of(),
new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column))
@@ -159,7 +159,7 @@ public class IrisHydrologyPhysicalOceanTest {
shore.setLoadKey("shore");
return new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(17L, 768, dimension, data,
(x, z, height) -> new IrisHydrologyNaturalSample(height, oceanIntent.isOcean(x, z), shore, region),
heights, (x, z) -> "coast", oceanIntent, footprint -> null, () -> false));
heights, (x, z) -> "coast", oceanIntent, footprint -> null, () -> false, () -> {}));
}
private static IrisHydrologyRoutingTerrainSampler sampler(IrisHydrologyRuntime runtime) throws Exception {
@@ -229,7 +229,7 @@ public class IrisHydrologyPolicyCacheTest {
private IrisHydrologyRuntime runtime() {
return new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(17L, 768, dimension, data,
(x, z, height) -> new IrisHydrologyNaturalSample(height, false, biome.get(), region.get()),
(x, z) -> 180D, (x, z) -> "constant", (x, z) -> false, footprint -> null, () -> false));
(x, z) -> 180D, (x, z) -> "constant", (x, z) -> false, footprint -> null, () -> false, () -> {}));
}
}
@@ -0,0 +1,122 @@
package art.arcane.iris.generation.hydrology.runtime;
import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler.NaturalClassification;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
import org.junit.Test;
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.assertSame;
import static org.junit.Assert.assertTrue;
public class IrisHydrologyRoutingCacheConcurrencyTest {
@Test(timeout = 15000)
public void publishedBasisSupersedesAnEarlierClassification() throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
new IrisHydrologyRoutingTerrainSampler.Sources(
(x, z, height) -> new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height,
HydrologyTerrainSample.ocean(60, "ocean")),
(x, z) -> 60D,
(x, z) -> {
started.countDown();
await(release);
return false;
}, 63),
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(65536));
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
Future<NaturalClassification> classification = worker.submit(() -> sampler.classifyNatural(-8193, 257));
assertTrue(started.await(5, TimeUnit.SECONDS));
assertTrue(sampler.sampleBasisWithoutSlope(-8193, 257).ocean());
release.countDown();
assertEquals(NaturalClassification.OCEAN, classification.get(5, TimeUnit.SECONDS));
assertEquals(NaturalClassification.OCEAN, sampler.classifyNatural(-8193, 257));
assertEquals(0, sampler.oceanClassificationCacheSize());
} finally {
release.countDown();
worker.shutdownNow();
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test(timeout = 15000)
public void concurrentSameCoordinateReturnsTheFirstPublishedBasis() throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
new IrisHydrologyRoutingTerrainSampler.Sources(
(x, z, height) -> {
if (calls.incrementAndGet() == 1) {
started.countDown();
await(release);
}
return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height,
HydrologyTerrainSample.openLand(70, 0D, "land"));
}, (x, z) -> 70D, (x, z) -> false, 63),
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(65536));
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
Future<IrisHydrologyRoutingTerrainSampler.TerrainBasis> first = worker.submit(() -> sampler.basis(4096, -37));
assertTrue(started.await(5, TimeUnit.SECONDS));
IrisHydrologyRoutingTerrainSampler.TerrainBasis published = sampler.basis(4096, -37);
release.countDown();
assertSame(published, first.get(5, TimeUnit.SECONDS));
assertSame(published, sampler.basis(4096, -37));
assertEquals(2, calls.get());
} finally {
release.countDown();
worker.shutdownNow();
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test
public void unevenStripeCapacityStaysBoundedAndCloseClearsEveryCache() {
int capacity = 4097;
AtomicInteger heightCalls = new AtomicInteger();
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
new IrisHydrologyRoutingTerrainSampler.Sources(
(x, z, height) -> new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height,
HydrologyTerrainSample.openLand((int) height, 0D, "land")),
(x, z) -> {
heightCalls.incrementAndGet();
return 70D + Math.floorMod(x + z, 11);
}, (x, z) -> false, 63),
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(capacity));
for (int coordinate = -10000; coordinate < 10000; coordinate++) {
sampler.sampleBasisWithoutSlope(coordinate, coordinate * 3);
sampler.classifyNatural(coordinate + 100000, -coordinate);
}
assertTrue(sampler.basisCacheSize() <= capacity);
assertTrue(sampler.naturalHeightCacheSize() <= capacity);
assertTrue(sampler.oceanClassificationCacheSize() <= capacity);
assertTrue(sampler.basisCacheSize() > capacity / 2);
assertTrue(sampler.oceanClassificationCacheSize() > capacity / 2);
HydrologyTerrainSample before = sampler.sampleBasisWithoutSlope(7, 21);
int previousCalls = heightCalls.get();
sampler.close();
assertEquals(0, sampler.basisCacheSize());
assertEquals(0, sampler.naturalHeightCacheSize());
assertEquals(0, sampler.oceanClassificationCacheSize());
assertEquals(before, sampler.sampleBasisWithoutSlope(7, 21));
assertEquals(previousCalls + 1, heightCalls.get());
}
private static void await(CountDownLatch latch) {
try {
assertTrue("Timed out waiting for provider release", latch.await(5, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError(failure);
}
}
}
@@ -268,6 +268,43 @@ public class IrisHydrologyRoutingTerrainSamplerTest {
assertEquals(1, calls.get(pack(x + 3, z)).intValue());
}
@Test
public void slopeReusesBasisHeightAfterNaturalHeightEvictionUntilClose() {
Map<Long, Integer> heightCalls = new HashMap<>();
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
new IrisHydrologyRoutingTerrainSampler.Sources(
(int x, int z, double naturalHeight) -> basis(x, z, naturalHeight),
(int x, int z) -> {
heightCalls.merge(pack(x, z), 1, Integer::sum);
return height(x, z);
},
(int x, int z) -> false,
128
),
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(2)
);
sampler.sampleBasisWithoutSlope(0, 0);
sampler.localSlope(16, 16, height(16, 16));
assertEquals(3, heightCalls.size());
assertEquals(2, sampler.naturalHeightCacheSize());
assertEquals(1, sampler.basisCacheSize());
double expected = IrisHydrologyRoutingTerrainSampler.localSlope(
height(-3, 0), height(0, 0), height(-3, 3));
double actual = sampler.localSlope(-3, 0, height(-3, 0));
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual));
assertEquals(1, heightCalls.get(pack(0, 0)).intValue());
assertEquals(4, heightCalls.size());
sampler.close();
double afterClose = sampler.localSlope(-3, 0, height(-3, 0));
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(afterClose));
assertEquals(2, heightCalls.get(pack(0, 0)).intValue());
}
@Test
public void denseDetailedStencilsShareRawNaturalHeights() {
AtomicInteger basisCalls = new AtomicInteger();
@@ -305,7 +305,7 @@ public class MantleHydrologyComponentTest {
surfaceCourses.add(course);
}
}
assertEquals(tile.diagnosticCandidates().toString(), 1, surfaceCourses.size());
assertEquals(tile.localDiagnosticCandidates().toString(), 1, surfaceCourses.size());
RiverCourse course = surfaceCourses.getFirst();
LinkedHashMap<Long, HydrologyColumnLayer> fluidLayers = new LinkedHashMap<>();
@@ -1202,6 +1202,34 @@ public class MantleHydrologyComponentTest {
);
}
@Test
public void partiallyOverlappingSurfaceLayerPublishesOnlyAcceptedCaveCells() {
HydrologyColumnLayer surface = layerForCourse(
HydrologyFeatureType.SURFACE_POOL, 71L, 1L, 14L, 18, 25, 25, "river"
);
HydrologyColumnLayer cave = layerForCourse(
HydrologyFeatureType.UNDERGROUND_POOL, 72L, 1L, 15L, 20, 23, 24, "river"
);
HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(surface, cave));
MantleHydrologyComponent.Publication publication = compile(
samples(sample), List.of(acceptedPlan(sample, cave)), new TestCaveVoxelView()
);
for (int y = 19; y <= 25; y++) {
CavePosition position = new CavePosition(8, y, 8);
if (y >= 21 && y <= 24) {
assertEquals(HydrologyCaveAction.WET_SOURCE, publication.caveCells().get(position).action());
assertFalse(publication.surfaceWrites().containsKey(position));
} else {
assertEquals(HydrologyCaveAction.WET_SOURCE, publication.surfaceWrites().get(position).action());
assertFalse(publication.caveCells().containsKey(position));
}
}
assertEquals(HydrologyCaveAction.SEAL_GUARD,
publication.caveCells().get(new CavePosition(9, 22, 8)).action());
}
@Test
public void matchingSurfaceFluidComposesOverAcceptedCaveClearance() {
HydrologyColumnLayer surface = layer(
@@ -5,6 +5,11 @@ import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.ToDoubleFunction;
@@ -91,6 +96,104 @@ public class IrisGeneratorSurfaceDetailTest {
assertEquals(91, samples.get());
}
@Test
public void repeatedCellSamplingMatchesColdCacheRawBits() {
Sampler source = (seed, x, z) -> Math.sin(x * 0.017D) + Math.cos(z * 0.031D);
IrisGenerator warm = generator(source).setSurfaceDetail(0.5D);
IrisGenerator cold = generator(source).setSurfaceDetail(0.5D);
double[][] points = {
{-6D, -6D}, {-5.75D, -5.5D}, {-4.25D, -4.5D}, {-3D, -3D},
{-0.25D, -0.125D}, {0D, 0D}, {1.25D, 1.75D}, {5.5D, 5.75D},
{6D, 6D}, {6.25D, 6.5D}, {8191.25D, -4096.75D}
};
for (double[] point : points) {
cold.rescale(1D);
long expected = Double.doubleToRawLongBits(cold.getHeight(point[0], point[1], 27L));
for (int repeat = 0; repeat < 4; repeat++) {
assertEquals(expected, Double.doubleToRawLongBits(warm.getHeight(point[0], point[1], 27L)));
}
}
}
@Test
public void repeatedLatticeSamplesRefreshSeedChanges() {
AtomicInteger samples = new AtomicInteger();
IrisGenerator generator = generator((seed, x, z) -> {
samples.incrementAndGet();
return seed * 0.000001D;
}).setSurfaceDetail(0.5D);
double first = generator.getHeight(0, 0, 27L);
assertEquals(Double.doubleToRawLongBits(first), Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
assertEquals(1, samples.get());
double second = generator.getHeight(0, 0, 28L);
assertEquals(0.000001D, second - first, 1e-12D);
assertEquals(Double.doubleToRawLongBits(second), Double.doubleToRawLongBits(generator.getHeight(0, 0, 28L)));
assertEquals(2, samples.get());
assertEquals(Double.doubleToRawLongBits(first), Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
assertEquals(3, samples.get());
}
@Test
public void collidingLatticeSamplesRecomputeWithoutReturningAnotherCoordinate() {
AtomicInteger originSamples = new AtomicInteger();
IrisGenerator generator = generator((seed, x, z) -> {
if (x == 0D && z == 0D) {
originSamples.incrementAndGet();
}
return x * 0.00001D;
}).setSurfaceDetail(0.5D);
assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
assertEquals(1, originSamples.get());
assertEquals(Double.doubleToRawLongBits(71754D * 0.00001D),
Double.doubleToRawLongBits(generator.getHeight(71754, 0, 27L)));
assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
assertEquals(2, originSamples.get());
assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
assertEquals(2, originSamples.get());
}
@Test(timeout = 15000)
public void concurrentCollisionsAndSeedChangesPreserveRawHeightBits() throws Exception {
Sampler source = (seed, x, z) -> seed * 0.000001D + x * 0.00001D - z * 0.00003D;
IrisGenerator generator = generator(source).setSurfaceDetail(0.5D);
IrisGenerator reference = generator(source);
double[] xs = {0D, 71754D};
long[] seeds = {27L, 28L};
long[][] expected = new long[xs.length][seeds.length];
for (int coordinate = 0; coordinate < xs.length; coordinate++) {
for (int seed = 0; seed < seeds.length; seed++) {
expected[coordinate][seed] = Double.doubleToRawLongBits(reference.getHeight(xs[coordinate], 0D, seeds[seed]));
}
}
ForkJoinPool pool = new ForkJoinPool(4);
try {
List<Future<?>> tasks = new ArrayList<>();
for (int task = 0; task < 16; task++) {
int offset = task;
tasks.add(pool.submit(() -> {
for (int sample = 0; sample < 64; sample++) {
int coordinate = (sample + offset) & 1;
int seed = (sample / 2 + offset) & 1;
assertEquals(expected[coordinate][seed],
Double.doubleToRawLongBits(generator.getHeight(xs[coordinate], 0D, seeds[seed])));
}
}));
}
for (Future<?> task : tasks) {
task.get(5, TimeUnit.SECONDS);
}
} finally {
pool.shutdownNow();
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test
public void cachedLatticeHeightsRemainIsolatedBySeed() {
IrisGenerator generator = generator((seed, x, z) -> seed * 0.000001D);
@@ -69,6 +69,9 @@ public class EngineShutdownDrainTest {
@ClassRule
public static final DurabilityMode DURABILITY = DurabilityMode.relaxed();
@ClassRule
public static final TemporaryFolder PLATFORM_FILES = new TemporaryFolder();
private static IrisPlatform previousPlatform;
@Rule
@@ -79,6 +82,7 @@ public class EngineShutdownDrainTest {
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatforms.unbind();
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.dataFile("iris.json")).thenReturn(PLATFORM_FILES.getRoot().toPath().resolve("iris.json").toFile());
PlatformRegistries registries = mock(PlatformRegistries.class);
when(platform.registries()).thenReturn(registries);
when(registries.block(anyString())).thenReturn(mock(PlatformBlockState.class));
@@ -0,0 +1,96 @@
package art.arcane.iris.generation.runtime;
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.assertThrows;
import static org.junit.Assert.assertTrue;
public class HydrologyNoiseCacheBudgetTest {
@Test
public void reservationsClampToTheRemainingSharedCapacity() {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(4_096L * 15L + 4_095L);
HydrologyNoiseCacheBudget.Reservation first = budget.reserve(4, 10);
HydrologyNoiseCacheBudget.Reservation second = budget.reserve(2, 10);
assertEquals(3, first.additionalChunks());
assertEquals(1, second.additionalChunks());
assertEquals(14L * 4_096L, budget.reservedBytes());
first.close();
first.close();
assertEquals(2L * 4_096L, budget.reservedBytes());
second.close();
assertEquals(0L, budget.reservedBytes());
}
@Test
public void emptyBudgetAndZeroDemandReserveNothing() {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(0L);
try (HydrologyNoiseCacheBudget.Reservation reservation = budget.reserve(5, 32_768)) {
assertEquals(0, reservation.additionalChunks());
}
HydrologyNoiseCacheBudget available = new HydrologyNoiseCacheBudget(1_000_000L);
try (HydrologyNoiseCacheBudget.Reservation reservation = available.reserve(5, 0)) {
assertEquals(0, reservation.additionalChunks());
}
assertEquals(0L, budget.reservedBytes());
assertEquals(0L, available.reservedBytes());
}
@Test
public void requestsCannotExceedTheCapacityLimit() {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
try (HydrologyNoiseCacheBudget.Reservation reservation = budget.reserve(5, 32_768)) {
assertEquals(32_768, reservation.additionalChunks());
assertEquals(5L * 32_768L * 4_096L, budget.reservedBytes());
}
assertThrows(IllegalArgumentException.class, () -> budget.reserve(0, 10));
assertThrows(IllegalArgumentException.class, () -> budget.reserve(5, -1));
assertThrows(IllegalArgumentException.class, () -> budget.reserve(5, 32_769));
assertThrows(IllegalArgumentException.class, () -> new HydrologyNoiseCacheBudget(-1L));
}
@Test(timeout = 10_000L)
public void concurrentOwnersNeverOversubscribeAndReleaseIndependently() throws Exception {
long maximumBytes = 4_096L * 503L;
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(maximumBytes);
CountDownLatch start = new CountDownLatch(1);
List<Future<HydrologyNoiseCacheBudget.Reservation>> pending = new ArrayList<>();
try (ExecutorService workers = Executors.newFixedThreadPool(16)) {
for (int index = 0; index < 32; index++) {
pending.add(workers.submit(() -> {
assertTrue(start.await(5L, TimeUnit.SECONDS));
return budget.reserve(5, 50);
}));
}
start.countDown();
List<HydrologyNoiseCacheBudget.Reservation> reservations = new ArrayList<>();
long expectedBytes = 0L;
for (Future<HydrologyNoiseCacheBudget.Reservation> future : pending) {
HydrologyNoiseCacheBudget.Reservation reservation = future.get(5L, TimeUnit.SECONDS);
reservations.add(reservation);
expectedBytes += reservation.additionalChunks() * 5L * 4_096L;
}
assertEquals(expectedBytes, budget.reservedBytes());
assertTrue(expectedBytes <= maximumBytes);
List<Future<?>> releases = new ArrayList<>();
for (HydrologyNoiseCacheBudget.Reservation reservation : reservations) {
releases.add(workers.submit(() -> {
reservation.close();
reservation.close();
}));
}
for (Future<?> release : releases) {
release.get(5L, TimeUnit.SECONDS);
}
assertEquals(0L, budget.reservedBytes());
}
}
}
@@ -0,0 +1,356 @@
package art.arcane.iris.generation.runtime;
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.HydrologyTile;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.iris.generation.hydrology.HydrologyTileKey;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.stream.CachedDoubleStream2D;
import art.arcane.iris.generation.stream.CachedStream2D;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.spi.IrisServices;
import art.arcane.volmlib.util.stream.ProceduralStream;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class IrisComplexHydrologyNoiseCacheTest {
@Test
public void pregenGrowsTheBackingHeightCacheWithoutReplacingItsThreadAwareStream() throws Exception {
IrisSettings previousSettings = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
IrisSettings.get().getPerformance().setNoiseCacheSize(1_024);
try (Fixture fixture = new Fixture(1_024)) {
ProceduralStream<Double> wrapper = fixture.complex.getRawHeightStream();
assertEquals(13D, fixture.height.getDouble(18, 5), 0D);
assertEquals(13D, fixture.rawHeight.getDouble(18, 5), 0D);
PregenPerformanceProfile.apply(fixture.engine);
assertEquals(4_096L * 256L, fixture.height.getMaxSize());
assertEquals(4_096L * 256L, fixture.rawHeight.getMaxSize());
assertEquals(1_024L * 256L, fixture.biomes.getMaxSize());
assertSame(wrapper, fixture.complex.getRawHeightStream());
assertEquals(47D, wrapper.getDouble(18, 5), 0D);
assertEquals(13D, fixture.height.getDouble(18, 5), 0D);
assertEquals(13D, fixture.rawHeight.getDouble(18, 5), 0D);
} finally {
PregenPerformanceProfile.restore();
IrisSettings.settings = previousSettings;
}
}
@Test
public void pregenPreservesActiveHydrologyGrants() throws Exception {
try (Fixture fixture = new Fixture(1_024)) {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
fixture.complex.expandHydrologyNoiseCaches(budget);
long reservedBytes = budget.reservedBytes();
fixture.complex.ensureTerrainNoiseCacheSize(4_096);
fixture.assertCapacities(32_768);
assertEquals(4_096L * 256L, fixture.rawHeight.getMaxSize());
assertEquals(reservedBytes, budget.reservedBytes());
fixture.complex.close();
assertEquals(0L, budget.reservedBytes());
}
}
@Test
public void pregenPreservesLargerRuntimeCacheCapacities() throws Exception {
try (Fixture fixture = new Fixture(65_536)) {
fixture.complex.ensureTerrainNoiseCacheSize(4_096);
fixture.assertCapacities(65_536);
assertEquals(65_536L * 256L, fixture.rawHeight.getMaxSize());
}
}
@Test
public void idleRuntimeReservesOnlyOnDemandAndRestoresAfterHydrologyDrains() throws Exception {
try (Fixture fixture = new Fixture(1_024)) {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
HydrologyTileCache cache = fixture.planningCache(budget);
fixture.assertCapacities(1_024);
assertEquals(0L, budget.reservedBytes());
cache.get(new HydrologyTileKey(0, 0));
cache.get(new HydrologyTileKey(0, 0));
fixture.assertCapacities(32_768);
assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
doAnswer(invocation -> {
fixture.assertCapacities(32_768);
assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
cache.close();
return null;
}).when(fixture.hydrology).close();
fixture.complex.close();
fixture.assertCapacities(1_024);
assertEquals(0L, budget.reservedBytes());
doNothing().when(fixture.hydrology).close();
fixture.complex.close();
fixture.complex.expandHydrologyNoiseCaches(budget);
fixture.assertCapacities(1_024);
assertEquals(0L, budget.reservedBytes());
}
}
@Test(timeout = 10_000L)
public void concurrentFirstDemandsShareOneReservation() throws Exception {
try (Fixture fixture = new Fixture(1_024);
ExecutorService callers = Executors.newFixedThreadPool(2)) {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
HydrologyTileCache cache = fixture.planningCache(budget);
CountDownLatch entered = new CountDownLatch(2);
CountDownLatch release = new CountDownLatch(1);
cache.setTerrainPreparation(() -> {
entered.countDown();
await(release);
fixture.complex.expandHydrologyNoiseCaches(budget);
});
Future<?> first = callers.submit(() -> cache.get(new HydrologyTileKey(0, 0)));
Future<?> second = callers.submit(() -> cache.get(new HydrologyTileKey(1, 0)));
assertTrue(entered.await(5L, TimeUnit.SECONDS));
assertEquals(0L, budget.reservedBytes());
release.countDown();
first.get(5L, TimeUnit.SECONDS);
second.get(5L, TimeUnit.SECONDS);
fixture.assertCapacities(32_768);
assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
fixture.complex.close();
assertEquals(0L, budget.reservedBytes());
}
}
@Test(timeout = 10_000L)
public void closeDrainsAnAdmittedFirstDemandBeforeReleasingItsGrant() throws Exception {
try (Fixture fixture = new Fixture(1_024);
ExecutorService callers = Executors.newFixedThreadPool(2)) {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
HydrologyTileCache cache = fixture.planningCache(budget);
CountDownLatch preparing = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch closing = new CountDownLatch(1);
cache.setTerrainPreparation(() -> {
preparing.countDown();
await(release);
fixture.complex.expandHydrologyNoiseCaches(budget);
});
doAnswer(invocation -> {
closing.countDown();
cache.close();
return null;
}).when(fixture.hydrology).close();
Future<?> demand = callers.submit(() -> cache.get(new HydrologyTileKey(0, 0)));
assertTrue(preparing.await(5L, TimeUnit.SECONDS));
Future<?> close = callers.submit(fixture.complex::close);
assertTrue(closing.await(5L, TimeUnit.SECONDS));
try {
assertThrows(TimeoutException.class, () -> close.get(100L, TimeUnit.MILLISECONDS));
assertEquals(0L, budget.reservedBytes());
} finally {
release.countDown();
}
demand.get(5L, TimeUnit.SECONDS);
close.get(5L, TimeUnit.SECONDS);
fixture.assertCapacities(1_024);
assertEquals(0L, budget.reservedBytes());
}
}
@Test
public void volumetricNaturalHeightReservesItsDistinctFifthCache() throws Exception {
try (Fixture fixture = new Fixture(1_024)) {
CachedDoubleStream2D natural = fixture.heightCache(1_024);
fixture.complex.setUnblendedNaturalHeightStream(natural);
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
fixture.complex.expandHydrologyNoiseCaches(budget);
fixture.assertCapacities(32_768);
assertEquals(32_768L * 256L, natural.getMaxSize());
assertEquals(5L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
fixture.complex.close();
assertEquals(1_024L * 256L, natural.getMaxSize());
assertEquals(0L, budget.reservedBytes());
}
}
@Test
public void partialGrantPreservesTheConfiguredFloorForEveryCache() throws Exception {
try (Fixture fixture = new Fixture(1_024)) {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(4L * 80L * 4_096L + 4_095L);
fixture.complex.expandHydrologyNoiseCaches(budget);
fixture.assertCapacities(1_104);
assertEquals(4L * 80L * 4_096L, budget.reservedBytes());
fixture.complex.close();
fixture.assertCapacities(1_024);
assertEquals(0L, budget.reservedBytes());
}
}
@Test
public void configuredCapacityAboveTargetRemainsUnchanged() throws Exception {
try (Fixture fixture = new Fixture(65_536)) {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
fixture.complex.expandHydrologyNoiseCaches(budget);
fixture.assertCapacities(65_536);
assertEquals(0L, budget.reservedBytes());
fixture.complex.close();
fixture.assertCapacities(65_536);
}
}
@Test
public void detachedAndNonHydrologyRuntimesDoNotReserve() throws Exception {
try (Fixture fixture = new Fixture(1_024)) {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
setField(fixture.complex, "detached", true);
fixture.complex.expandHydrologyNoiseCaches(budget);
fixture.assertCapacities(1_024);
setField(fixture.complex, "detached", false);
fixture.complex.setHydrologyRuntime(null);
fixture.complex.expandHydrologyNoiseCaches(budget);
fixture.assertCapacities(1_024);
assertEquals(0L, budget.reservedBytes());
}
}
@Test
public void failedHydrologyDrainRetainsTheReservationUntilRetry() throws Exception {
try (Fixture fixture = new Fixture(1_024)) {
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
HydrologyTileCache cache = fixture.planningCache(budget);
cache.get(new HydrologyTileKey(0, 0));
doThrow(new IllegalStateException("Hydrology is still draining")).when(fixture.hydrology).close();
assertThrows(IllegalStateException.class, fixture.complex::close);
fixture.assertCapacities(32_768);
assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
doAnswer(invocation -> {
cache.close();
return null;
}).when(fixture.hydrology).close();
fixture.complex.close();
fixture.assertCapacities(1_024);
assertEquals(0L, budget.reservedBytes());
}
}
@Test
public void mappedStreamRetainsOnlyTheFinalCacheAtTheExpandedCapacity() throws Exception {
try (Fixture fixture = new Fixture(1_024)) {
CachedStream2D<IrisBiome> inner = fixture.biomes;
CachedStream2D<IrisBiome> mapped = new CachedStream2D<>("mapped", fixture.engine, inner, 1_024);
fixture.complex.setBaseBiomeStream(mapped);
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
fixture.complex.expandHydrologyNoiseCaches(budget);
assertEquals(1_024L * 256L, inner.getMaxSize());
assertEquals(32_768L * 256L, mapped.getMaxSize());
fixture.complex.close();
assertEquals(1_024L * 256L, mapped.getMaxSize());
}
}
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);
}
private static void await(CountDownLatch latch) {
try {
assertTrue(latch.await(5L, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
}
private static final class Fixture implements AutoCloseable {
private final MockedStatic<IrisServices> services = mockStatic(IrisServices.class);
private final Engine engine = mock(Engine.class);
private final IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
private final IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class);
private final CachedStream2D<IrisBiome> biomes;
private final CachedStream2D<IrisRegion> regions;
private final CachedStream2D<InferredType> bridge;
private final CachedDoubleStream2D height;
private final CachedDoubleStream2D rawHeight;
private Fixture(int capacity) throws Exception {
services.when(() -> IrisServices.get(PreservationRegistry.class)).thenReturn(mock(PreservationRegistry.class));
biomes = objectCache(capacity);
regions = objectCache(capacity);
bridge = objectCache(capacity);
height = heightCache(capacity);
rawHeight = heightCache(capacity);
complex.setNaturalHeightStream(height);
complex.setHeightStream(ProceduralStream.ofDouble((x, z) -> 47D));
setField(complex, "cachedHeightStream", rawHeight);
when(engine.getComplex()).thenReturn(complex);
complex.setBaseBiomeStream(biomes);
complex.setRegionStream(regions);
complex.setBridgeStream(bridge);
complex.setBaseTerrainHeightStream(height);
complex.setUnblendedNaturalHeightStream(height);
complex.setHydrologyRuntime(hydrology);
setField(complex, "resolvedTerrain", mock(ResolvedTerrainProvider.class));
}
@Override
public void close() {
services.close();
}
private <T> CachedStream2D<T> objectCache(int capacity) {
@SuppressWarnings("unchecked")
ProceduralStream<T> source = mock(ProceduralStream.class);
return new CachedStream2D<>("cache", engine, source, capacity);
}
private CachedDoubleStream2D heightCache(int capacity) {
return new CachedDoubleStream2D("height", engine, ProceduralStream.ofDouble((x, z) -> x - z), capacity);
}
private HydrologyTileCache planningCache(HydrologyNoiseCacheBudget budget) {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(mock(HydrologyTile.class));
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
cache.setTerrainPreparation(() -> complex.expandHydrologyNoiseCaches(budget));
doAnswer(invocation -> {
cache.close();
return null;
}).when(hydrology).close();
return cache;
}
private void assertCapacities(int chunks) {
assertEquals(chunks * 256L, biomes.getMaxSize());
assertEquals(chunks * 256L, regions.getMaxSize());
assertEquals(chunks * 256L, bridge.getMaxSize());
assertEquals(chunks * 256L, height.getMaxSize());
}
}
}
@@ -129,7 +129,8 @@ public class IrisComplexSurfaceBiomeTest {
70D,
constant(base),
constant(sea),
constant(base)
constant(base),
constantSlope(0D)
);
assertSame(sea, resolved);
@@ -152,7 +153,8 @@ public class IrisComplexSurfaceBiomeTest {
63D,
constant(base),
constant(base),
constant(shore)
constant(shore),
constantSlope(0D)
);
assertSame(shore, resolved);
@@ -176,12 +178,151 @@ public class IrisComplexSurfaceBiomeTest {
63D,
constant(land),
constant(aquatic),
constant(aquatic)
constant(aquatic),
constantSlope(0D)
);
assertSame(land, resolved);
}
@Test
public void zeroShoreMinimumWidthLeavesTheVerticalBandAlone() {
IrisBiome base = mock(IrisBiome.class);
IrisBiome land = mock(IrisBiome.class);
IrisRegion region = mock(IrisRegion.class);
doReturn(false).when(base).isShore();
doReturn(false).when(base).isLand();
doReturn(3D).when(region).getShoreHeight(12D, 18D);
doReturn(0D).when(region).getShoreMinimumWidth();
IrisBiome resolved = IrisComplex.resolveSurfaceBiome(
72D,
base,
region,
12D,
18D,
63D,
constant(land),
constant(base),
constant(base),
unsampledSlopes()
);
assertSame(land, resolved);
}
@Test
public void shoreMinimumWidthWidensTheBandOnSteepGround() {
IrisBiome base = mock(IrisBiome.class);
IrisBiome shore = mock(IrisBiome.class);
IrisRegion region = mock(IrisRegion.class);
doReturn(false).when(base).isShore();
doReturn(3D).when(region).getShoreHeight(12D, 18D);
doReturn(3D).when(region).getShoreMinimumWidth();
IrisBiome resolved = IrisComplex.resolveSurfaceBiome(
74D,
base,
region,
12D,
18D,
63D,
constant(base),
constant(base),
constant(shore),
constantSlope(9D)
);
assertSame(shore, resolved);
}
@Test
public void shoreMinimumWidthDoesNothingOnFlatGround() {
IrisBiome base = mock(IrisBiome.class);
IrisBiome land = mock(IrisBiome.class);
IrisRegion region = mock(IrisRegion.class);
doReturn(false).when(base).isShore();
doReturn(false).when(base).isLand();
doReturn(3D).when(region).getShoreHeight(12D, 18D);
doReturn(3D).when(region).getShoreMinimumWidth();
IrisBiome resolved = IrisComplex.resolveSurfaceBiome(
67D,
base,
region,
12D,
18D,
63D,
constant(land),
constant(base),
constant(base),
constantSlope(0D)
);
assertSame(land, resolved);
}
@Test
public void widenedBandStillLeavesDeepWaterAndHighGroundAlone() {
IrisBiome base = mock(IrisBiome.class);
IrisBiome sea = mock(IrisBiome.class);
IrisBiome land = mock(IrisBiome.class);
IrisRegion region = mock(IrisRegion.class);
doReturn(false).when(base).isShore();
doReturn(false).when(base).isLand();
doReturn(false).when(base).isAquatic();
doReturn(3D).when(region).getShoreHeight(12D, 18D);
doReturn(3D).when(region).getShoreMinimumWidth();
assertSame(sea, IrisComplex.resolveSurfaceBiome(
60D,
base,
region,
12D,
18D,
63D,
constant(land),
constant(sea),
constant(base),
unsampledSlopes()
));
assertSame(land, IrisComplex.resolveSurfaceBiome(
200D,
base,
region,
12D,
18D,
63D,
constant(land),
constant(sea),
constant(base),
unsampledSlopes()
));
}
@Test
public void shoreBandTopTradesHorizontalWidthForHeightOnSlopes() {
IrisRegion region = mock(IrisRegion.class);
doReturn(3D).when(region).getShoreHeight(12D, 18D);
doReturn(3D).when(region).getShoreMinimumWidth();
assertEquals(66D, IrisComplex.shoreBandTop(67D, region, 12D, 18D, 63D, constantSlope(0D)), 0D);
assertEquals(75D, IrisComplex.shoreBandTop(74D, region, 12D, 18D, 63D, constantSlope(9D)), 0D);
assertEquals(75D, IrisComplex.shoreBandTop(74D, region, 12D, 18D, 63D, constantSlope(120D)), 0D);
assertEquals(66D, IrisComplex.shoreBandTop(80D, region, 12D, 18D, 63D, constantSlope(120D)), 0D);
assertEquals(66D, IrisComplex.shoreBandTop(64D, region, 12D, 18D, 63D, unsampledSlopes()), 0D);
}
private static ProceduralStream<Double> constantSlope(double slope) {
return ProceduralStream.ofDouble((x, z) -> slope);
}
private static ProceduralStream<Double> unsampledSlopes() {
return ProceduralStream.ofDouble((x, z) -> {
throw new AssertionError("The shore band sampled the slope for a column that cannot be widened");
});
}
private static ProceduralStream<IrisBiome> constant(IrisBiome biome) {
@SuppressWarnings("unchecked")
ProceduralStream<IrisBiome> stream = mock(ProceduralStream.class);
@@ -189,6 +330,11 @@ public class IrisComplexSurfaceBiomeTest {
return stream;
}
@Test
public void regionShoreMinimumWidthDefaultsToZeroSoExistingPacksAreUnchanged() {
assertEquals(0D, new IrisRegion().getShoreMinimumWidth(), 0D);
}
private static ProceduralStream<InferredType> constantType(InferredType type) {
return ProceduralStream.of(
(x, z) -> type,
@@ -46,7 +46,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class IrisEngineStudioEntryHydrologyTest {
public class IrisEngineEntryHydrologyTest {
@Test
@SuppressWarnings("unchecked")
public void queuesScopedEntryDemandWithoutBlockingInitialization() throws Exception {
@@ -57,7 +57,7 @@ public class IrisEngineStudioEntryHydrologyTest {
GenerationSessionLease lease = mock(GenerationSessionLease.class);
IrisHydrologyRuntime hydrology = hydrology(engine);
when(engine.getActiveGenerationRuntimeBinding()).thenReturn(binding);
when(engine.acquireGenerationLease("studio_entry_hydrology")).thenReturn(lease);
when(engine.acquireGenerationLease("initial_entry_hydrology")).thenReturn(lease);
when(lease.sessionId()).thenReturn(93L);
doAnswer(invocation -> {
assertSame(engine, IrisContext.require().getEngine());
@@ -67,7 +67,7 @@ public class IrisEngineStudioEntryHydrologyTest {
}).when(hydrology).prepareChunkColumns(0, 0);
AtomicReference<Callable<Void>> queued = new AtomicReference<>();
try (MockedStatic<J> scheduler = scheduler(queued)) {
engine.startStudioEntryHydrology(0, 0);
engine.startEntryHydrology(0, 0);
assertNotNull(queued.get());
verifyNoInteractions(hydrology, lease);
verify(engine, never()).acquireGenerationLease(any());
@@ -86,7 +86,7 @@ public class IrisEngineStudioEntryHydrologyTest {
tasks.openBackgroundTaskAdmission();
IrisEngine engine = engine(tasks);
GenerationSessionLease lease = mock(GenerationSessionLease.class);
when(engine.acquireGenerationLease("studio_entry_hydrology")).thenReturn(lease);
when(engine.acquireGenerationLease("initial_entry_hydrology")).thenReturn(lease);
IrisHydrologyRuntime hydrology = hydrology(engine);
IllegalStateException cause = new IllegalStateException("planning failed");
doAnswer(invocation -> {
@@ -96,10 +96,10 @@ public class IrisEngineStudioEntryHydrologyTest {
AtomicReference<Callable<Void>> queued = new AtomicReference<>();
try (MockedStatic<J> scheduler = scheduler(queued);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
engine.startStudioEntryHydrology(0, 0);
engine.startEntryHydrology(0, 0);
IllegalStateException failure = assertThrows(IllegalStateException.class, () -> queued.get().call());
assertSame(cause, failure.getCause());
assertTrue(failure.getMessage().contains("Studio entry hydrology preparation failed at 0,0"));
assertTrue(failure.getMessage().contains("World entry hydrology preparation failed at 0,0"));
logging.verify(() -> IrisLogging.reportError(failure));
verify(lease).close();
}
@@ -110,14 +110,14 @@ public class IrisEngineStudioEntryHydrologyTest {
EngineBackgroundTasks tasks = new EngineBackgroundTasks();
IrisEngine engine = engine(tasks);
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> engine.startStudioEntryHydrology(0, 0));
() -> engine.startEntryHydrology(0, 0));
assertTrue(failure.getMessage().contains("admission closed"));
tasks.openBackgroundTaskAdmission();
RejectedExecutionException rejected = new RejectedExecutionException("scheduler closed");
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(() -> J.a(any(Callable.class))).thenThrow(rejected);
assertSame(rejected, assertThrows(RejectedExecutionException.class,
() -> engine.startStudioEntryHydrology(0, 0)));
() -> engine.startEntryHydrology(0, 0)));
}
verify(engine, never()).acquireGenerationLease(any());
}
@@ -133,7 +133,7 @@ public class IrisEngineStudioEntryHydrologyTest {
AtomicReference<Callable<Void>> queued = new AtomicReference<>();
try (MockedStatic<J> scheduler = scheduler(queued);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
engine.startStudioEntryHydrology(0, 0);
engine.startEntryHydrology(0, 0);
assertEquals(0, sessions.activeLeases());
closing(engine).set(true);
tasks.closeBackgroundTaskAdmission();
@@ -161,7 +161,7 @@ public class IrisEngineStudioEntryHydrologyTest {
AtomicReference<Callable<Void>> queued = new AtomicReference<>();
try (MockedStatic<J> scheduler = scheduler(queued);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
engine.startStudioEntryHydrology(0, 0);
engine.startEntryHydrology(0, 0);
if (transition) {
queued.get().call();
logging.verifyNoInteractions();
@@ -189,7 +189,7 @@ public class IrisEngineStudioEntryHydrologyTest {
queued.set(future);
return future;
});
engine.startStudioEntryHydrology(0, 0);
engine.startEntryHydrology(0, 0);
assertTrue(queued.get().cancel(false));
queued.get().run();
sessions.sealAndAwait("close", 0L, true);
@@ -216,7 +216,7 @@ public class IrisEngineStudioEntryHydrologyTest {
AtomicReference<Callable<Void>> queued = new AtomicReference<>();
try (MockedStatic<J> scheduler = scheduler(queued);
ExecutorService executor = Executors.newFixedThreadPool(2)) {
engine.startStudioEntryHydrology(0, 0);
engine.startEntryHydrology(0, 0);
Future<Void> worker = executor.submit(queued.get());
assertTrue(admitted.await(5, TimeUnit.SECONDS));
Future<?> drain = executor.submit(() -> {
@@ -328,7 +328,7 @@ public class IrisEngineStudioEntryHydrologyTest {
Field lifecycle = IrisEngine.class.getDeclaredField("lifecycleLock");
lifecycle.setAccessible(true);
lifecycle.set(engine, new Object());
doCallRealMethod().when(engine).startStudioEntryHydrology(anyInt(), anyInt());
doCallRealMethod().when(engine).startEntryHydrology(anyInt(), anyInt());
return engine;
}
}
@@ -0,0 +1,178 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.generation.runtime.EngineBackgroundTasks.BackgroundTaskDrain;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.decoration.IrisOreGenerator;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
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.anyLong;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
public class IrisEngineGenerationCacheWarmTest {
@Test
public void newWorldAndStudioReturnBeforeWarmButReadinessWaits() throws Exception {
for (IrisEngine.InitializationMode mode : new IrisEngine.InitializationMode[]{
IrisEngine.InitializationMode.WORLD_CREATION, IrisEngine.InitializationMode.STUDIO}) {
Fixture fixture = fixture();
AtomicReference<Callable<Void>> queued = new AtomicReference<>();
try (MockedStatic<J> scheduling = scheduler(queued);
MockedStatic<GenerationCacheWarmer> warmer = mockStatic(GenerationCacheWarmer.class)) {
fixture.engine().prepareGenerationCaches(mode, System.nanoTime());
assertNotNull(queued.get());
assertFalse(fixture.ready().isDone());
warmer.verifyNoInteractions();
queued.get().call();
fixture.engine().awaitGenerationCacheWarm();
warmer.verify(() -> GenerationCacheWarmer.warm(fixture.engine()));
fixture.tasks().drainBackgroundTasks("test").requireComplete("test");
}
}
}
@Test
public void restoredRuntimeStillWarmsSynchronously() throws Exception {
Fixture fixture = fixture();
try (MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<GenerationCacheWarmer> warmer = mockStatic(GenerationCacheWarmer.class)) {
fixture.engine().prepareGenerationCaches(IrisEngine.InitializationMode.RUNTIME, System.nanoTime());
assertTrue(fixture.ready().isDone());
scheduling.verifyNoInteractions();
warmer.verify(() -> GenerationCacheWarmer.warm(fixture.engine()));
}
}
@Test
public void failedWarmIsLoggedAndBothGenerationPathsRejectItsCause() throws Exception {
Fixture fixture = fixture();
AtomicReference<Callable<Void>> queued = new AtomicReference<>();
IllegalStateException expected = new IllegalStateException("cache warm failed");
IrisData data = mock(IrisData.class);
IrisOreGenerator ore = mock(IrisOreGenerator.class);
IrisBiome biome = new IrisBiome();
biome.setLoadKey("failing-ore");
biome.setOres(new KList<>(ore));
when(fixture.engine().getData()).thenReturn(data);
when(fixture.engine().getSeedManager()).thenReturn(mock(SeedManager.class));
when(fixture.engine().getAllBiomes()).thenReturn(new KList<>(biome));
doThrow(expected).when(ore).warm(any(RNG.class), same(data));
try (MockedStatic<J> scheduling = scheduler(queued);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
fixture.engine().prepareGenerationCaches(IrisEngine.InitializationMode.WORLD_CREATION, System.nanoTime());
assertSame(expected, assertThrows(IllegalStateException.class, () -> queued.get().call()));
assertTrue(fixture.ready().isCompletedExceptionally());
logging.verify(() -> IrisLogging.reportError(expected));
assertSame(expected, assertThrows(IllegalStateException.class,
() -> fixture.engine().generate(0, 0, null, null, false)).getCause());
assertSame(expected, assertThrows(IllegalStateException.class,
() -> fixture.engine().generateMatter(0, 0, false, null)).getCause());
fixture.tasks().drainBackgroundTasks("test").requireComplete("test");
}
}
@Test
public void runningWarmRemainsOwnedUntilShutdownDrainCompletes() throws Exception {
Fixture fixture = fixture();
AtomicReference<Callable<Void>> queued = new AtomicReference<>();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
ExecutorService workers = Executors.newFixedThreadPool(2);
try (MockedStatic<J> scheduling = scheduler(queued)) {
fixture.engine().prepareGenerationCaches(IrisEngine.InitializationMode.WORLD_CREATION, System.nanoTime());
Future<Void> warming = workers.submit(() -> {
try (MockedStatic<GenerationCacheWarmer> warmer = mockStatic(GenerationCacheWarmer.class)) {
warmer.when(() -> GenerationCacheWarmer.warm(fixture.engine())).thenAnswer(invocation -> {
started.countDown();
assertTrue(release.await(5, TimeUnit.SECONDS));
return null;
});
return queued.get().call();
}
});
assertTrue(started.await(5, TimeUnit.SECONDS));
fixture.tasks().closeBackgroundTaskAdmission();
Future<BackgroundTaskDrain> drain = workers.submit(() -> fixture.tasks().drainBackgroundTasks("close"));
assertThrows(TimeoutException.class, () -> drain.get(100, TimeUnit.MILLISECONDS));
assertFalse(fixture.ready().isDone());
release.countDown();
warming.get(5, TimeUnit.SECONDS);
drain.get(5, TimeUnit.SECONDS).requireComplete("close");
fixture.engine().awaitGenerationCacheWarm();
} finally {
release.countDown();
workers.shutdownNow();
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test
public void closedTaskAdmissionRejectsNewWorldWarmSynchronously() throws Exception {
Fixture fixture = fixture();
fixture.tasks().closeBackgroundTaskAdmission();
assertThrows(IllegalStateException.class,
() -> fixture.engine().prepareGenerationCaches(IrisEngine.InitializationMode.WORLD_CREATION, System.nanoTime()));
}
private static Fixture fixture() throws Exception {
IrisEngine engine = mock(IrisEngine.class);
EngineBackgroundTasks tasks = new EngineBackgroundTasks();
tasks.openBackgroundTaskAdmission();
CompletableFuture<Void> ready = new CompletableFuture<>();
set(engine, "backgroundTasks", tasks);
set(engine, "generationCacheWarm", ready);
set(engine, "diagnostics", mock(EngineDiagnostics.class));
doCallRealMethod().when(engine).prepareGenerationCaches(any(), anyLong());
doCallRealMethod().when(engine).awaitGenerationCacheWarm();
doCallRealMethod().when(engine).generate(0, 0, null, null, false);
doCallRealMethod().when(engine).generateMatter(0, 0, false, null);
return new Fixture(engine, tasks, ready);
}
@SuppressWarnings("unchecked")
private static MockedStatic<J> scheduler(AtomicReference<Callable<Void>> queued) {
MockedStatic<J> scheduling = mockStatic(J.class);
scheduling.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> {
Callable<Void> task = invocation.getArgument(0);
queued.set(task);
return new FutureTask<>(task);
});
return scheduling;
}
private static void set(IrisEngine engine, String name, Object value) throws Exception {
Field field = IrisEngine.class.getDeclaredField(name);
field.setAccessible(true);
field.set(engine, value);
}
private record Fixture(IrisEngine engine, EngineBackgroundTasks tasks, CompletableFuture<Void> ready) {
}
}
@@ -6,10 +6,19 @@ import org.mockito.MockedStatic;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
@@ -57,6 +66,98 @@ public class PreservationSVCTest {
assertFalse(new PreservationSVC().hasActiveResources());
}
@Test(timeout = 10_000L)
public void postShutdownWaitsForInterruptedThreadsAndExecutorsToFinish() throws Exception {
PreservationSVC preservation = spy(new PreservationSVC());
AtomicReference<Runnable> postShutdown = new AtomicReference<>();
doAnswer(invocation -> {
postShutdown.set(invocation.getArgument(0));
return null;
}).when(preservation).postShutdown(any(Runnable.class));
CountDownLatch started = new CountDownLatch(2);
CountDownLatch interrupted = new CountDownLatch(2);
CountDownLatch finish = new CountDownLatch(1);
Thread thread = new Thread(() -> drainAfterInterrupt(started, interrupted, finish), "preserved-worker");
ExecutorService executor = Executors.newSingleThreadExecutor();
preservation.register(thread);
preservation.register(executor);
thread.start();
executor.submit(() -> drainAfterInterrupt(started, interrupted, finish));
try (ExecutorService closer = Executors.newSingleThreadExecutor();
MockedStatic<IrisData> data = mockStatic(IrisData.class)) {
assertTrue(started.await(5L, TimeUnit.SECONDS));
preservation.onDisable();
assertNotNull(postShutdown.get());
Future<?> stopped = closer.submit(postShutdown.get());
try {
assertTrue(interrupted.await(5L, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> stopped.get(100L, TimeUnit.MILLISECONDS));
assertTrue(preservation.hasActiveResources());
} finally {
finish.countDown();
}
stopped.get(5L, TimeUnit.SECONDS);
assertFalse(preservation.hasActiveResources());
} finally {
finish.countDown();
thread.interrupt();
thread.join(5_000L);
executor.shutdownNow();
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test(timeout = 10_000L)
public void timedOutWorkersRemainTrackedWithTheirIdentityInTheFailure() throws Exception {
PreservationSVC preservation = new PreservationSVC();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch interrupted = new CountDownLatch(1);
CountDownLatch finish = new CountDownLatch(1);
Thread worker = new Thread(() -> drainAfterInterrupt(started, interrupted, finish), "stalled-preserved-worker");
preservation.register(worker);
worker.start();
try {
assertTrue(started.await(5L, TimeUnit.SECONDS));
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> preservation.shutdownResources(25L));
assertTrue(failure.getMessage().contains("stalled-preserved-worker"));
assertTrue(preservation.hasActiveResources());
} finally {
finish.countDown();
worker.interrupt();
worker.join(5_000L);
}
assertFalse(preservation.hasActiveResources());
}
@Test(timeout = 5_000L)
public void shutdownNeverInterruptsOrWaitsForItsCallingThread() {
PreservationSVC preservation = new PreservationSVC();
preservation.register(Thread.currentThread());
preservation.shutdownResources(1_000L);
assertFalse(Thread.currentThread().isInterrupted());
assertTrue(preservation.hasActiveResources());
}
private static void drainAfterInterrupt(CountDownLatch started, CountDownLatch interrupted, CountDownLatch finish) {
started.countDown();
try {
new CountDownLatch(1).await();
} catch (InterruptedException expected) {
interrupted.countDown();
}
boolean released = false;
while (!released) {
try {
finish.await();
released = true;
} catch (InterruptedException ignored) {
}
}
}
private static void awaitStop(CountDownLatch started, CountDownLatch stop) {
started.countDown();
try {
@@ -0,0 +1,138 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.generation.stage;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomePaletteLayer;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.iris.generation.context.ChunkedDataCache;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.stream.ProceduralStream;
import org.junit.Test;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
/**
* The wall patcher fires on any neighbour more than two blocks lower, and neighbour heights come from
* trueHeight, which excludes fluid. Every coastal column therefore sees a seaward drop, so without a
* shore check the beach surface is replaced with the wall palette.
*/
public class IrisPostModifierWallShoreTest {
private static final int PLANE_WIDTH = 3;
private static final int SURFACE = 64;
private static final int HUNK_HEIGHT = 80;
private static final PlatformBlockState GROUND = state("minecraft:sand");
private static final PlatformBlockState WALL = state("minecraft:stone");
private static final PlatformBlockState AIR = state("minecraft:air");
@Test
public void shoreColumnsKeepTheirSurfaceBlockBesideASeawardDrop() throws ReflectiveOperationException {
assertSame(GROUND, surfaceAfterPost(true, true, false));
}
@Test
public void inlandColumnsStillGetWallPatchedBesideADrop() throws ReflectiveOperationException {
assertSame(WALL, surfaceAfterPost(false, true, false));
}
@Test
public void disabledWallsAndRiverColumnsAreUntouched() throws ReflectiveOperationException {
assertSame(GROUND, surfaceAfterPost(false, false, false));
assertSame(GROUND, surfaceAfterPost(false, true, true));
}
/**
* Drives the private per-column pass directly. The heights plane is the only input that decides
* whether the wall patcher fires, and building it through onModify would need a live mantle.
*/
@SuppressWarnings("unchecked")
private PlatformBlockState surfaceAfterPost(boolean shore, boolean walls, boolean river) throws ReflectiveOperationException {
Hunk<PlatformBlockState> data = Hunk.newHunk(1, HUNK_HEIGHT, 1);
for (int y = 0; y < HUNK_HEIGHT; y++) {
data.set(0, y, 0, y <= SURFACE ? GROUND : AIR);
}
IrisBiomePaletteLayer wallLayer = mock(IrisBiomePaletteLayer.class);
doReturn(new KList<IrisBlockData>().qadd(new IrisBlockData())).when(wallLayer).getPalette();
doReturn(WALL).when(wallLayer).get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any());
IrisBiome biome = mock(IrisBiome.class);
doReturn(wallLayer).when(biome).getWall();
doReturn(shore).when(biome).isShore();
ChunkedDataCache<IrisBiome> biomes = mock(ChunkedDataCache.class);
doReturn(biome).when(biomes).get(0, 0);
ChunkContext context = mock(ChunkContext.class);
doReturn(biomes).when(context).getBiome();
ProceduralStream<Double> carveWeights = mock(ProceduralStream.class);
doReturn(river ? 1D : 0D).when(carveWeights).get(anyDouble(), anyDouble());
ProceduralStream<Double> waterSurface = mock(ProceduralStream.class);
doReturn(0D).when(waterSurface).get(anyDouble(), anyDouble());
IrisComplex complex = mock(IrisComplex.class);
doReturn(carveWeights).when(complex).getRiverCarveWeightStream();
doReturn(waterSurface).when(complex).getRiverWaterSurfaceStream();
Engine engine = mock(Engine.class);
doReturn(complex).when(engine).getComplex();
doReturn(mock(IrisData.class)).when(engine).getData();
IrisPostModifier modifier = mock(IrisPostModifier.class, CALLS_REAL_METHODS);
doReturn(engine).when(modifier).getEngine();
Field rng = IrisPostModifier.class.getDeclaredField("rng");
rng.setAccessible(true);
rng.set(modifier, new RNG(88L));
// Only the +x neighbour is low, so neither nib pass can fire and the wall patcher is the sole writer.
int[] heights = new int[PLANE_WIDTH * PLANE_WIDTH];
Arrays.fill(heights, SURFACE);
heights[5] = SURFACE - 4;
Method post = IrisPostModifier.class.getDeclaredMethod("post", int.class, int.class, Hunk.class,
int.class, int.class, ChunkContext.class, int[].class, int.class, boolean.class, boolean.class);
post.setAccessible(true);
post.invoke(modifier, 0, 0, data, 0, 0, context, heights, PLANE_WIDTH, walls, false);
return data.get(0, SURFACE, 0);
}
private static PlatformBlockState state(String key) {
PlatformBlockState state = mock(PlatformBlockState.class);
doReturn(key).when(state).key();
doReturn(key).when(state).materialKey();
return state;
}
}
@@ -270,7 +270,7 @@ public class IrisTerrainNormalActuatorHydrologyTest {
assertSame(rock, output.get(0, 58, 0));
material.setEnabled(false);
new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context);
assertSame(rock, output.get(0, 60, 0));
assertSame(grass, output.get(0, 60, 0));
Terrain3DColumn carved = mock(Terrain3DColumn.class);
when(carved.spanCount()).thenReturn(2);
@@ -287,7 +287,7 @@ public class IrisTerrainNormalActuatorHydrologyTest {
anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>());
new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context);
assertSame(grass, output.get(0, 60, 0));
assertSame(rock, output.get(0, 40, 0));
assertSame(grass, output.get(0, 40, 0));
material.setEnabled(true);
new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context);
assertSame(painted, output.get(0, 60, 0));
@@ -0,0 +1,194 @@
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.ChunkedDataCache;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.IrisHydrology;
import art.arcane.iris.generation.image.IrisImageMapRuntime;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.generation.terrain.Terrain3DColumn;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.testsupport.PlatformBinding;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Rule;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisTerrainNormalActuatorStrataCutTest {
@Rule
public final PlatformBinding platform = PlatformBinding.mockPlatform();
@Test
public void exposedBankCutDeeperThanThePaletteShowsTheDeepestAuthoredLayer() {
Fixture fixture = new Fixture();
Hunk<PlatformBlockState> output = fixture.actuate();
assertBlock(fixture.dirt, output.get(0, 40, 0));
assertBlock(fixture.dirt, output.get(0, 39, 0));
}
@Test
public void exposedBankBelowTheAuthoredSoilStillFallsThroughToRock() {
Fixture fixture = new Fixture();
Hunk<PlatformBlockState> output = fixture.actuate();
assertBlock(fixture.rock, output.get(0, 38, 0));
}
@Test
public void uncutColumnsKeepTheirExactPaletteDepths() {
Fixture fixture = new Fixture();
Hunk<PlatformBlockState> output = fixture.actuate();
assertBlock(fixture.grass, output.get(0, 60, 0));
assertBlock(fixture.dirt, output.get(0, 59, 0));
assertBlock(fixture.rock, output.get(0, 58, 0));
}
@Test
public void aCutClampsToTheDeepestAuthoredLayerAndNoFurther() {
assertEquals(3, IrisTerrainNormalActuator.strataIndex(0, 6, 4));
assertEquals(3, IrisTerrainNormalActuator.strataIndex(1, 6, 4));
assertEquals(3, IrisTerrainNormalActuator.strataIndex(2, 6, 4));
assertEquals(3, IrisTerrainNormalActuator.strataIndex(3, 6, 4));
assertEquals(4, IrisTerrainNormalActuator.strataIndex(4, 6, 4));
}
@Test
public void aModestCutStillShiftsTheStrataInsteadOfClamping() {
assertEquals(1, IrisTerrainNormalActuator.strataIndex(0, 1, 4));
assertEquals(2, IrisTerrainNormalActuator.strataIndex(1, 1, 4));
assertEquals(3, IrisTerrainNormalActuator.strataIndex(2, 1, 4));
assertEquals(3, IrisTerrainNormalActuator.strataIndex(3, 1, 4));
assertEquals(4, IrisTerrainNormalActuator.strataIndex(4, 1, 4));
}
@Test
public void aZeroCutIndexesTheBiomePaletteByDepthAlone() {
for (int paletteSize = 0; paletteSize <= 16; paletteSize++) {
for (int depth = 0; depth <= 32; depth++) {
assertEquals(depth, IrisTerrainNormalActuator.strataIndex(depth, 0, paletteSize));
}
}
}
private static void assertBlock(PlatformBlockState expected, PlatformBlockState actual) {
assertEquals(expected.key(), actual == null ? null : actual.key());
assertSame(expected, actual);
}
/**
* A bank column whose upper span is untouched (cut 0) and whose lower span lost four blocks to
* erosion, over a two block soil palette.
*/
private static final class Fixture {
private final PlatformBlockState rock = mock(PlatformBlockState.class);
private final PlatformBlockState grass = mock(PlatformBlockState.class);
private final PlatformBlockState dirt = mock(PlatformBlockState.class);
private final Engine engine = mock(Engine.class);
private final ChunkContext context = mock(ChunkContext.class);
private Fixture() {
when(rock.key()).thenReturn("minecraft:stone");
when(grass.key()).thenReturn("minecraft:grass_block");
when(dirt.key()).thenReturn("minecraft:dirt");
IrisComplex complex = mock(IrisComplex.class);
IrisData data = mock(IrisData.class);
IrisBiome biome = mock(IrisBiome.class);
IrisRegion region = new IrisRegion();
IrisDimension dimension = new IrisDimension()
.setHydrology(new IrisHydrology())
.setBedrock(false)
.setHideOresForHiddenOre(true);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex);
when(engine.getData()).thenReturn(data);
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
when(complex.getImageMapRuntime()).thenReturn(mock(IrisImageMapRuntime.class));
when(complex.getRiverWaterSurfaceStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 0D));
when(complex.sampleHydrologyColumn(11, -4)).thenReturn(new HydrologyColumnSample(
11, -4, 80, 0, false, "parent", List.of(bankLayer())));
Terrain3DColumn carved = mock(Terrain3DColumn.class);
when(carved.spanCount()).thenReturn(2);
when(carved.floor(1)).thenReturn(60);
when(carved.ceiling(1)).thenReturn(50);
when(carved.floor(0)).thenReturn(40);
when(carved.ceiling(0)).thenReturn(0);
Terrain3DColumn natural = mock(Terrain3DColumn.class);
when(natural.surfaceY(60)).thenReturn(60);
when(natural.surfaceY(40)).thenReturn(44);
when(complex.terrainColumn(eq(11), eq(-4), any(HydrologyColumnSample.class))).thenReturn(carved);
when(complex.naturalTerrainColumn(11, -4)).thenReturn(natural);
when(context.getRoundedHeight(0, 0)).thenReturn(60);
when(context.getBiome()).thenReturn(new ChunkedDataCache<>(
ProceduralStream.of((x, z) -> biome, Interpolated.of(value -> 0D, value -> biome)), 11, -4, false));
when(context.getRegion()).thenReturn(new ChunkedDataCache<>(
ProceduralStream.of((x, z) -> region, Interpolated.of(value -> 0D, value -> region)), 11, -4, false));
when(context.getRock()).thenReturn(new ChunkedDataCache<>(
ProceduralStream.of((x, z) -> rock, Interpolated.of(value -> 0D, value -> rock)), 11, -4, false));
when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class),
anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>(List.of(grass, dirt)));
when(biome.generateCeilingLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class),
anyInt(), anyInt(), eq(data), eq(complex))).thenReturn(new KList<>());
}
private Hunk<PlatformBlockState> actuate() {
Hunk<PlatformBlockState> output = Hunk.newArrayHunk(1, 64, 1);
new IrisTerrainNormalActuator(engine).terrainSliver(11, -4, 0, output, context);
return output;
}
}
private static HydrologyColumnLayer bankLayer() {
return new HydrologyColumnLayer(
new HydrologyFeatureRef(21L, HydrologyFeatureType.RIFFLE, 22L, 23L, 11, 63, -4, 1, 0, false),
60,
63,
63,
false,
false,
true,
false,
false,
false,
true,
false,
false,
"default",
"river",
"mouth",
"shore",
"bank",
"cave"
);
}
}
@@ -198,6 +198,49 @@ public class ServerConfiguratorDatapackFingerprintTest {
packsDir.toPath().resolve("overworld").toFile()));
}
@Test
public void fingerprintsIgnoreNestedFinderMetadata() throws Exception {
File packsDir = tmp.newFolder("finder-metadata-packs");
Path pack = packsDir.toPath().resolve("overworld");
Path dimension = pack.resolve("dimensions/overworld.json");
Path metadata = pack.resolve("objects/nested/.DS_Store");
Files.createDirectories(dimension.getParent());
Files.createDirectories(metadata.getParent());
Files.writeString(dimension, "authored", StandardCharsets.UTF_8);
ServerConfigurator.PackContentSnapshot before = ServerConfigurator.computePackContentSnapshot(packsDir);
String metadataBefore = ServerConfigurator.computePackMetadataDigest(packsDir);
for (String content : new String[]{"initial Finder layout", "updated Finder layout with different length"}) {
Files.writeString(metadata, content, StandardCharsets.UTF_8);
ServerConfigurator.PackContentSnapshot after = ServerConfigurator.computePackContentSnapshot(packsDir);
assertEquals(before, after);
assertEquals(before.packContents().get("overworld"),
ServerConfigurator.computePackTreeFingerprint(pack.toFile()));
assertEquals(metadataBefore, ServerConfigurator.computePackMetadataDigest(packsDir));
}
}
@Test
public void fingerprintsStillDetectNestedResourceChangesAlongsideFinderMetadata() throws Exception {
File packsDir = tmp.newFolder("finder-resource-packs");
Path pack = packsDir.toPath().resolve("overworld");
Path resource = pack.resolve("objects/nested/tree.iob");
Files.createDirectories(resource.getParent());
Files.writeString(resource, "aaaa", StandardCharsets.UTF_8);
Files.writeString(resource.resolveSibling(".DS_Store"), "Finder layout", StandardCharsets.UTF_8);
FileTime originalMtime = Files.getLastModifiedTime(resource);
ServerConfigurator.PackContentSnapshot before = ServerConfigurator.computePackContentSnapshot(packsDir);
Files.writeString(resource, "bbbb", StandardCharsets.UTF_8);
Files.setLastModifiedTime(resource, originalMtime);
ServerConfigurator.PackContentSnapshot after = ServerConfigurator.computePackContentSnapshot(packsDir);
assertNotEquals(before.content(), after.content());
assertNotEquals(before.packContents().get("overworld"), after.packContents().get("overworld"));
assertEquals(after.packContents().get("overworld"),
ServerConfigurator.computePackTreeFingerprint(pack.toFile()));
}
@Test
public void resolvePackFingerprintReusesCachedContentWhileMetadataIsUnchanged() throws Exception {
File packsDir = tmp.newFolder("two-tier-packs");
@@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "545cbfcdaa4b4c8dd03fca96f6b2617c37bbe9bf73d2011679d2fabcf3f2a6be";
private static final String EXPECTED_SCHEMA_DIGEST = "5f041feb24213ee5b7e2e06c84b113ec0242b8f8e04c73dde6ea2356944d93fd";
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class,
@@ -0,0 +1,120 @@
/*
* Iris is a World Generator for Minecraft Bukkit Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.platform.bukkit;
import org.junit.Test;
import org.junit.runner.JUnitCore;
import org.junit.runner.Result;
import java.io.IOException;
import java.io.InputStream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* org.bukkit.Registry resolves its static fields through a live RegistryAccess, which no unit test binds.
* A failed static initialiser is permanent and JVM-wide, so a probe that touches Registry without one
* breaks every later test in the same Gradle fork - a try/catch around the probe cannot undo it.
* Replaying the conformance suite against fork-local copies of the Bukkit classes makes that leak
* observable without poisoning the fork this test runs in.
*/
public class BukkitSpiConformanceForkHygieneTest {
private static final String CONFORMANCE = "art.arcane.iris.platform.bukkit.BukkitSpiConformanceTest";
private static final String REGISTRY = "org.bukkit.Registry";
@Test
public void conformanceSuiteLeavesBukkitRegistryInitialisable() throws Exception {
ForkLocalClassLoader isolated = new ForkLocalClassLoader(getClass().getClassLoader());
Result result = JUnitCore.runClasses(Class.forName(CONFORMANCE, true, isolated));
assertTrue("the conformance suite did not run in isolation", result.getRunCount() > 0);
assertEquals(result.getFailures().toString(), 0, result.getFailureCount());
Throwable initialisation = null;
try {
Class.forName(REGISTRY, true, isolated);
} catch (Throwable thrown) {
initialisation = thrown;
}
assertFalse(REGISTRY + " was left permanently un-initialisable by the conformance suite: " + initialisation,
initialisation instanceof NoClassDefFoundError);
}
/** Child-first for the Bukkit and Iris classes under test, parent for JUnit, Mockito and the JDK. */
private static final class ForkLocalClassLoader extends ClassLoader {
private static final String[] FORK_LOCAL = {
"org.bukkit.",
"io.papermc.",
"com.destroystokyo.",
"art.arcane.iris.",
"art.arcane.volmlib."
};
static {
registerAsParallelCapable();
}
ForkLocalClassLoader(ClassLoader parent) {
super(parent);
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> loaded = findLoadedClass(name);
if (loaded == null) {
loaded = forkLocal(name) ? define(name) : getParent().loadClass(name);
}
if (resolve) {
resolveClass(loaded);
}
return loaded;
}
}
private Class<?> define(String name) throws ClassNotFoundException {
try (InputStream source = getParent().getResourceAsStream(name.replace('.', '/') + ".class")) {
if (source == null) {
throw new ClassNotFoundException(name);
}
byte[] bytes = source.readAllBytes();
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException unreadable) {
throw new ClassNotFoundException(name, unreadable);
}
}
private static boolean forkLocal(String name) {
for (String prefix : FORK_LOCAL) {
if (name.startsWith(prefix)) {
return true;
}
}
return false;
}
}
}
@@ -253,6 +253,9 @@ public class BukkitSpiConformanceTest {
@Test
public void blockStatePropertiesAreKeyedByNamespacedBlock() {
// Pre-flight before touching the registry: org.bukkit.Registry fail-initialises without a live
// RegistryAccess, and that is permanent and JVM-wide, so the catch below is already too late.
Assume.assumeTrue("live Bukkit registry unavailable in this environment", liveRegistriesAvailable());
Map<String, List<PlatformBlockProperty>> states;
try {
states = registries().blockStateProperties();
@@ -1,6 +1,8 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisEngine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.ReactiveFolder;
import art.arcane.volmlib.util.scheduling.Looper;
@@ -25,8 +27,39 @@ import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BukkitChunkGeneratorCloseTest {
@Test
public void failedCloseReleasesInitialEntryDeferral() throws Exception {
CloseFixture fixture = new CloseFixture();
fixture.beginInitialEntry();
doThrow(new IllegalStateException("Planner remains active")).when(fixture.engine).close();
CompletableFuture<Void> operation = fixture.queueOperation();
CompletableFuture<Void> closed = fixture.generator.closeAsync();
operation.completeExceptionally(assertThrows(IllegalStateException.class, fixture.operation.get()::run));
assertTrue(closed.isCompletedExceptionally());
verify(fixture.hydrology).setNeighbourPrefetchEnabled(false);
verify(fixture.hydrology).setNeighbourPrefetchEnabled(true);
fixture.generator.completeInitialEntry();
verify(fixture.hydrology, times(1)).setNeighbourPrefetchEnabled(true);
}
@Test
public void rejectedCloseDispatchReleasesInitialEntryDeferral() throws Exception {
CloseFixture fixture = new CloseFixture();
fixture.beginInitialEntry();
doThrow(new IllegalStateException("Scheduler unavailable")).when(fixture.generator)
.withExclusiveControlFuture(any(Runnable.class));
assertTrue(fixture.generator.closeAsync().isCompletedExceptionally());
assertFalse(fixture.generator.isClosing());
verify(fixture.hydrology).setNeighbourPrefetchEnabled(true);
}
@Test
public void asynchronousCloseFailureCanRetryWithoutReopeningGeneration() throws Exception {
CloseFixture fixture = new CloseFixture();
@@ -121,12 +154,16 @@ public class BukkitChunkGeneratorCloseTest {
private static final class CloseFixture {
private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.class);
private final IrisEngine engine = mock(IrisEngine.class);
private final IrisComplex complex = mock(IrisComplex.class);
private final IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class);
private final AtomicReference<Runnable> operation = new AtomicReference<>();
private final AtomicReference<CompletableFuture<Void>> watcherHotload = new AtomicReference<>();
private final ConcurrentLinkedQueue<CompletableFuture<Void>> operations = new ConcurrentLinkedQueue<>();
private CloseFixture() throws Exception {
when(engine.getComplex()).thenReturn(complex);
when(complex.getHydrologyRuntime()).thenReturn(hydrology);
setField(generator, "closeFuture", new AtomicReference<CompletableFuture<Void>>());
setField(generator, "watcherHotload", watcherHotload);
setField(generator, "startupReady", CompletableFuture.completedFuture(null));
@@ -144,5 +181,11 @@ public class BukkitChunkGeneratorCloseTest {
operations.add(future);
return future;
}
private void beginInitialEntry() {
generator.setEngine(null);
generator.beginInitialEntry(false);
generator.publishInitializedEngine(engine);
}
}
}
@@ -0,0 +1,152 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.generation.cache.AtomicCache;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.EngineTarget;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.world.history.GenerationHistory;
import org.junit.Test;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.file.Path;
import java.util.List;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BukkitChunkGeneratorHistoryTargetTest {
@Test
public void unchangedVerifiedPackRetainsThePreviouslyLoadedTarget() throws Exception {
Fixture fixture = new Fixture();
fixture.generator.prepareGenerationHistoryTarget(128);
assertSame(fixture.target, fixture.generator.getTarget());
verify(fixture.history).prepareCurrentGenerator(128);
verify(fixture.history).activePackRoot();
verify(fixture.data, never()).close();
verify(fixture.data, never()).dump();
verify(fixture.data, never()).clearLists();
}
@Test
public void changedActivePackInvalidatesAndClosesTheDetachedTarget() throws Exception {
Fixture fixture = new Fixture();
when(fixture.history.activePackRoot()).thenReturn(Path.of("generation", "packs", "next"));
fixture.generator.prepareGenerationHistoryTarget(128);
assertNull(fixture.targets.getIfPresent());
verify(fixture.data).close();
}
@Test
public void closedTargetCannotBeReusedEvenWhenItsPackIsUnchanged() throws Exception {
Fixture fixture = new Fixture();
when(fixture.data.isClosed()).thenReturn(true);
fixture.generator.prepareGenerationHistoryTarget(128);
assertNull(fixture.targets.getIfPresent());
verify(fixture.history).activePackRoot();
verify(fixture.data, never()).close();
}
@Test
public void finalPackVerificationStillRejectsTamperingAfterPreparation() throws Exception {
Fixture fixture = new Fixture();
IOException failure = new IOException("Active pack fingerprint changed");
when(fixture.history.activePackRoot()).thenThrow(failure);
assertSame(failure, assertThrows(IOException.class,
() -> fixture.generator.prepareGenerationHistoryTarget(128)));
verify(fixture.history).prepareCurrentGenerator(128);
verify(fixture.data, never()).close();
}
@Test
public void preparationFailureCannotBeHiddenByAnExistingTarget() throws Exception {
Fixture fixture = new Fixture();
IOException failure = new IOException("Pending activation is invalid");
doThrow(failure).when(fixture.history).prepareCurrentGenerator(128);
assertSame(failure, assertThrows(IOException.class,
() -> fixture.generator.prepareGenerationHistoryTarget(128)));
verify(fixture.history, never()).activePackRoot();
verify(fixture.data, never()).close();
}
@Test
public void discardedTargetDoesNotCloseDataHeldByAnotherEngine() throws Exception {
Fixture fixture = new Fixture();
when(fixture.history.activePackRoot()).thenReturn(Path.of("generation", "packs", "next"));
when(fixture.data.getEngines()).thenReturn(List.of(mock(Engine.class)));
fixture.generator.prepareGenerationHistoryTarget(128);
assertNull(fixture.targets.getIfPresent());
verify(fixture.data, never()).close();
}
@Test
public void discardedTargetDoesNotCloseDeferredStartupData() throws Exception {
Fixture fixture = new Fixture();
when(fixture.history.activePackRoot()).thenReturn(Path.of("generation", "packs", "next"));
setField(fixture.generator, "startupTarget", fixture.target);
fixture.generator.prepareGenerationHistoryTarget(128);
assertNull(fixture.targets.getIfPresent());
assertSame(fixture.target, fixture.generator.getTarget());
verify(fixture.data, never()).close();
}
@Test
public void discardedTargetDoesNotClosePublishedEngineData() throws Exception {
Fixture fixture = new Fixture();
when(fixture.history.activePackRoot()).thenReturn(Path.of("generation", "packs", "next"));
Engine engine = mock(Engine.class);
when(engine.getData()).thenReturn(fixture.data);
fixture.generator.setEngine(engine);
fixture.generator.prepareGenerationHistoryTarget(128);
assertNull(fixture.targets.getIfPresent());
verify(fixture.data, never()).close();
}
private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception {
Field field = BukkitChunkGenerator.class.getDeclaredField(name);
field.setAccessible(true);
field.set(generator, value);
}
private static final class Fixture {
private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
private final GenerationHistory history = mock(GenerationHistory.class);
private final AtomicCache<EngineTarget> targets = new AtomicCache<>();
private final EngineTarget target = mock(EngineTarget.class);
private final IrisData data = mock(IrisData.class);
private Fixture() throws Exception {
Path packRoot = Path.of("generation", "packs", "current");
when(history.activePackRoot()).thenReturn(packRoot);
when(target.getData()).thenReturn(data);
when(data.getDataFolder()).thenReturn(packRoot.toFile());
when(data.getEngines()).thenReturn(List.of());
targets.aquireOrThrow(() -> target);
setField(generator, "generationHistory", history);
setField(generator, "targetCache", targets);
}
}
}
@@ -1,6 +1,7 @@
package art.arcane.iris.platform.generation;
import art.arcane.iris.world.task.J;
import art.arcane.iris.world.runtime.WorldRuntimeControlService;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
@@ -9,21 +10,28 @@ import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BukkitChunkGeneratorInitialSpawnTest {
@@ -58,9 +66,182 @@ public class BukkitChunkGeneratorInitialSpawnTest {
assertFalse(readiness.isDone());
regionTask.get().run();
readiness.get(5L, TimeUnit.SECONDS);
verify(world).getChunkAtAsync(0, 0, true);
verify(world, never()).getChunkAtAsync(0, 0, true, true);
}
}
@Test
public void freshConsoleWorldRequestsOnlyCanonicalChunkAndWaitsForRegionPlacement() throws Exception {
CompletableFuture<Void> readiness = new CompletableFuture<>();
BukkitChunkGenerator generator = generatorWithReadiness(readiness);
generator.beginInitialEntry(false);
World world = world("fresh-spawn-success");
Chunk chunk = chunk(world);
CompletableFuture<Chunk> chunkFuture = new CompletableFuture<>();
AtomicReference<Runnable> regionTask = new AtomicReference<>();
WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class);
when(runtime.requestChunkAsync(world, 0, 0, true, true)).thenReturn(chunkFuture);
when(world.getSpawnLocation()).thenReturn(new Location(world, 0.5D, 64D, 0.5D));
when(world.getHighestBlockYAt(any(Location.class))).thenReturn(70);
try (MockedStatic<WorldRuntimeControlService> control = mockStatic(WorldRuntimeControlService.class);
MockedStatic<J> scheduling = mockStatic(J.class)) {
control.when(WorldRuntimeControlService::get).thenReturn(runtime);
scheduling.when(() -> J.runRegionFuture(
any(World.class),
anyInt(),
anyInt(),
any(Runnable.class)))
.thenAnswer(invocation -> {
regionTask.set(invocation.getArgument(3, Runnable.class));
return CompletableFuture.completedFuture(null);
});
invokeUpdateSpawnLocation(generator, world);
verify(runtime).requestChunkAsync(world, 0, 0, true, true);
verify(runtime, times(1)).requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true));
verify(world, never()).getChunkAtAsync(0, 0, true);
assertFalse(readiness.isDone());
chunkFuture.complete(chunk);
assertNotNull(regionTask.get());
assertFalse(readiness.isDone());
regionTask.get().run();
readiness.get(5L, TimeUnit.SECONDS);
}
}
@Test
public void playerEntryRequestsNativeFootprintBeforeCanonicalChunkCompletes() throws Exception {
CompletableFuture<Void> readiness = new CompletableFuture<>();
BukkitChunkGenerator generator = generatorWithReadiness(readiness);
generator.beginInitialEntry(true);
World world = world("player-entry-footprint");
Chunk chunk = chunk(world);
CompletableFuture<Chunk> canonical = new CompletableFuture<>();
CompletableFuture<Chunk> neighbours = new CompletableFuture<>();
AtomicReference<Runnable> regionTask = new AtomicReference<>();
WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class);
when(runtime.requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true))).thenReturn(neighbours);
when(runtime.requestChunkAsync(world, 0, 0, true, true)).thenReturn(canonical);
when(world.getSpawnLocation()).thenReturn(new Location(world, 0.5D, 64D, 0.5D));
when(world.getHighestBlockYAt(any(Location.class))).thenReturn(52);
try (MockedStatic<WorldRuntimeControlService> control = mockStatic(WorldRuntimeControlService.class);
MockedStatic<J> scheduling = mockStatic(J.class)) {
control.when(WorldRuntimeControlService::get).thenReturn(runtime);
scheduling.when(() -> J.runRegionFuture(any(World.class), anyInt(), anyInt(), any(Runnable.class)))
.thenAnswer(invocation -> {
regionTask.set(invocation.getArgument(3, Runnable.class));
return CompletableFuture.completedFuture(null);
});
invokeUpdateSpawnLocation(generator, world);
verify(runtime).requestChunkAsync(world, 0, 0, true, true);
verify(runtime).requestChunkAsync(world, -1, -1, true, true);
verify(runtime).requestChunkAsync(world, -1, 0, true, true);
verify(runtime).requestChunkAsync(world, 0, -1, true, true);
verify(runtime, times(4)).requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true));
assertFalse(canonical.isDone());
neighbours.complete(chunk);
assertNull(regionTask.get());
assertFalse(readiness.isDone());
verify(world, never()).getHighestBlockYAt(any(Location.class));
canonical.complete(chunk);
assertNotNull(regionTask.get());
assertFalse(readiness.isDone());
regionTask.get().run();
readiness.get(5L, TimeUnit.SECONDS);
verify(world).setSpawnLocation(new Location(world, 0.5D, 53D, 0.5D));
}
}
@Test
public void failedEntryNeighbourWaitsForOtherRequiredChunksAndFailsReadiness() throws Exception {
CompletableFuture<Void> readiness = new CompletableFuture<>();
BukkitChunkGenerator generator = generatorWithReadiness(readiness);
generator.beginInitialEntry(true);
World world = world("player-entry-footprint-failure");
CompletableFuture<Chunk> pending = new CompletableFuture<>();
IllegalStateException failure = new IllegalStateException("entry neighbour request rejected");
WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class);
when(runtime.requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true))).thenReturn(pending);
when(runtime.requestChunkAsync(world, -1, -1, true, true)).thenThrow(failure);
try (MockedStatic<WorldRuntimeControlService> control = mockStatic(WorldRuntimeControlService.class);
MockedStatic<J> scheduling = mockStatic(J.class)) {
control.when(WorldRuntimeControlService::get).thenReturn(runtime);
invokeUpdateSpawnLocation(generator, world);
verify(runtime, times(4)).requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true));
assertFalse(readiness.isDone());
pending.complete(chunk(world));
assertSame(failure, awaitFailure(readiness));
scheduling.verifyNoInteractions();
}
}
@Test
public void canonicalEntryFailureRetainsItsCauseAfterNeighboursDrain() throws Exception {
CompletableFuture<Void> readiness = new CompletableFuture<>();
BukkitChunkGenerator generator = generatorWithReadiness(readiness);
generator.beginInitialEntry(true);
World world = world("player-entry-canonical-failure");
CompletableFuture<Chunk> neighbours = new CompletableFuture<>();
IllegalStateException failure = new IllegalStateException("canonical entry failed");
IllegalArgumentException neighbourFailure = new IllegalArgumentException("entry neighbour failed");
WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class);
when(runtime.requestChunkAsync(eq(world), anyInt(), anyInt(), eq(true), eq(true))).thenReturn(neighbours);
when(runtime.requestChunkAsync(world, 0, 0, true, true)).thenReturn(CompletableFuture.failedFuture(failure));
try (MockedStatic<WorldRuntimeControlService> control = mockStatic(WorldRuntimeControlService.class)) {
control.when(WorldRuntimeControlService::get).thenReturn(runtime);
invokeUpdateSpawnLocation(generator, world);
assertFalse(readiness.isDone());
neighbours.completeExceptionally(neighbourFailure);
assertSame(failure, awaitFailure(readiness));
assertEquals(List.of(neighbourFailure), List.of(failure.getSuppressed()));
}
}
@Test
public void failedUrgentChunkRequestFailsSpawnReadiness() throws Exception {
CompletableFuture<Void> readiness = new CompletableFuture<>();
BukkitChunkGenerator generator = generatorWithReadiness(readiness);
generator.beginInitialEntry(false);
World world = world("fresh-spawn-chunk-failure");
IllegalStateException failure = new IllegalStateException("urgent chunk failed");
WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class);
when(runtime.requestChunkAsync(world, 0, 0, true, true))
.thenReturn(CompletableFuture.failedFuture(failure));
try (MockedStatic<WorldRuntimeControlService> control = mockStatic(WorldRuntimeControlService.class)) {
control.when(WorldRuntimeControlService::get).thenReturn(runtime);
invokeUpdateSpawnLocation(generator, world);
}
assertSame(failure, awaitFailure(readiness));
}
@Test
public void rejectedUrgentChunkRequestFailsSpawnReadiness() throws Exception {
CompletableFuture<Void> readiness = new CompletableFuture<>();
BukkitChunkGenerator generator = generatorWithReadiness(readiness);
generator.beginInitialEntry(false);
World world = world("fresh-spawn-request-failure");
IllegalStateException failure = new IllegalStateException("urgent request rejected");
WorldRuntimeControlService runtime = mock(WorldRuntimeControlService.class);
when(runtime.requestChunkAsync(world, 0, 0, true, true)).thenThrow(failure);
try (MockedStatic<WorldRuntimeControlService> control = mockStatic(WorldRuntimeControlService.class)) {
control.when(WorldRuntimeControlService::get).thenReturn(runtime);
invokeUpdateSpawnLocation(generator, world);
}
assertSame(failure, awaitFailure(readiness));
}
@Test
public void failedChunkRequestFailsSpawnReadiness() throws Exception {
CompletableFuture<Void> readiness = new CompletableFuture<>();
@@ -63,7 +63,7 @@ public class BukkitChunkGeneratorInitializationModeTest {
((CompletableFuture<?>) prefetch.invoke(generator, engine, world)).get(5L, TimeUnit.SECONDS);
verify(engine).startStudioEntryHydrology(0, 0);
verify(engine).startEntryHydrology(0, 0);
verifyNoInteractions(hydrology);
assertEquals(0.5D, generator.getInitialSpawnLocation(world).getX(), 0D);
assertEquals(96D, generator.getInitialSpawnLocation(world).getY(), 0D);
@@ -71,7 +71,7 @@ public class BukkitChunkGeneratorInitializationModeTest {
when(dimension.getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_1x1);
((CompletableFuture<?>) prefetch.invoke(generator, engine, world)).get(5L, TimeUnit.SECONDS);
verify(engine, times(1)).startStudioEntryHydrology(0, 0);
verify(engine, times(1)).startEntryHydrology(0, 0);
verify(hydrology).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL);
@@ -85,7 +85,7 @@ public class BukkitChunkGeneratorInitializationModeTest {
doReturn(false).when(generator).usesFlatStudioTerrain();
when(world.getChunkAtAsync(0, 0, false)).thenReturn(CompletableFuture.completedFuture(mock(Chunk.class)));
((CompletableFuture<?>) prefetch.invoke(generator, generated, world)).get(5L, TimeUnit.SECONDS);
verify(generated, never()).startStudioEntryHydrology(anyInt(), anyInt());
verify(generated, never()).startEntryHydrology(anyInt(), anyInt());
}
@Test
@@ -108,9 +108,9 @@ public class BukkitChunkGeneratorInitializationModeTest {
@Test
public void runtimeAndOrdinaryStudioWarmGenerationCaches() {
IrisEngine.InitializationMode runtime =
BukkitChunkGenerator.selectInitializationMode(false, false, false);
BukkitChunkGenerator.selectInitializationMode(false, false, false, false);
IrisEngine.InitializationMode studio =
BukkitChunkGenerator.selectInitializationMode(true, false, false);
BukkitChunkGenerator.selectInitializationMode(true, false, false, false);
assertEquals(IrisEngine.InitializationMode.RUNTIME, runtime);
assertFalse(runtime.studio());
@@ -120,10 +120,22 @@ public class BukkitChunkGeneratorInitializationModeTest {
assertTrue(studio.warmGenerationCaches());
}
@Test
public void onlyFreshNormalWorldsUseCreationCacheWarmMode() {
IrisEngine.InitializationMode mode =
BukkitChunkGenerator.selectInitializationMode(false, false, false, true);
assertEquals(IrisEngine.InitializationMode.WORLD_CREATION, mode);
assertFalse(mode.studio());
assertTrue(mode.warmGenerationCaches());
assertEquals(IrisEngine.InitializationMode.STUDIO,
BukkitChunkGenerator.selectInitializationMode(true, false, false, true));
}
@Test
public void activeJigsawStudioSkipsGenerationCacheWarm() {
IrisEngine.InitializationMode mode =
BukkitChunkGenerator.selectInitializationMode(true, true, false);
BukkitChunkGenerator.selectInitializationMode(true, true, false, false);
assertEquals(IrisEngine.InitializationMode.JIGSAW_STUDIO, mode);
assertTrue(mode.studio());
@@ -133,13 +145,13 @@ public class BukkitChunkGeneratorInitializationModeTest {
@Test
public void activeObjectStudioSkipsGenerationCacheWarm() {
IrisEngine.InitializationMode mode =
BukkitChunkGenerator.selectInitializationMode(true, false, true);
BukkitChunkGenerator.selectInitializationMode(true, false, true, false);
assertEquals(IrisEngine.InitializationMode.OBJECT_STUDIO, mode);
assertTrue(mode.studio());
assertFalse(mode.warmGenerationCaches());
assertEquals(IrisEngine.InitializationMode.RUNTIME,
BukkitChunkGenerator.selectInitializationMode(false, false, true));
BukkitChunkGenerator.selectInitializationMode(false, false, true, false));
}
@Test
@@ -17,6 +17,7 @@ import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -35,8 +36,56 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
public class BukkitChunkGeneratorSpawnHydrologyTest {
@Test
public void creationDefersNeighboursBeforePublishingAndKeepsEntryDemandTracked() throws Exception {
Fixture fixture = new Fixture();
fixture.generator.setEngine(null);
setField(fixture.generator, "studioEntryBootstrapActive", new AtomicBoolean(false));
fixture.generator.beginInitialEntry(false);
fixture.generator.publishInitializedEngine(fixture.engine);
verify(fixture.hydrology).setNeighbourPrefetchEnabled(false);
assertTrue(fixture.generator.shouldGenerateStructures());
CompletableFuture<Void> prefetch = fixture.prefetch();
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verify(fixture.engine).startEntryHydrology(0, 0);
verify(fixture.hydrology, never()).prefetchArea(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
verify(fixture.hydrology, never()).setNeighbourPrefetchEnabled(true);
fixture.generator.completeInitialEntry();
fixture.generator.completeInitialEntry();
verify(fixture.hydrology, times(1)).setNeighbourPrefetchEnabled(true);
}
@Test
public void completionBeforePublicationDoesNotLeaveNormalStartupDisabled() throws Exception {
Fixture fixture = new Fixture();
fixture.generator.setEngine(null);
fixture.generator.beginInitialEntry(false);
fixture.generator.completeInitialEntry();
fixture.generator.publishInitializedEngine(fixture.engine);
verifyNoInteractions(fixture.hydrology);
CompletableFuture<Void> prefetch = fixture.prefetch();
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verify(fixture.hydrology).prefetchArea(-256, -256, 255, 255, 0, 0);
verify(fixture.engine, never()).startEntryHydrology(anyInt(), anyInt());
}
@Test
public void initializedGeneratorCannotBeginLateEntryDeferral() throws Exception {
Fixture fixture = new Fixture();
assertThrows(IllegalStateException.class, () -> fixture.generator.beginInitialEntry(false));
verifyNoInteractions(fixture.hydrology);
}
@Test
public void startupWaitsAsynchronouslyWithoutHoldingGenerationAdmission() throws Exception {
Fixture fixture = new Fixture();
@@ -144,7 +193,7 @@ public class BukkitChunkGeneratorSpawnHydrologyTest {
fixture.lookup.complete(null);
prefetch.get(5L, TimeUnit.SECONDS);
verify(fixture.engine).startStudioEntryHydrology(0, 0);
verify(fixture.engine).startEntryHydrology(0, 0);
verifyNoInteractions(fixture.hydrology);
}
@@ -16,6 +16,7 @@ import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
@@ -31,7 +32,9 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class StudioSVCWorldPackPublishTest {
@ClassRule
@@ -264,6 +267,42 @@ public class StudioSVCWorldPackPublishTest {
assertSame(sourceValidation, PackValidationRegistry.requireLoadable(target));
}
@Test
public void immutableSnapshotDimensionLoadingReusesExactSourceValidation() throws Exception {
Path source = temporaryFolder.newFolder("snapshot-source").toPath();
Path target = temporaryFolder.getRoot().toPath().resolve("snapshot-target");
writeValidPack(source);
StudioSVC.copyPackTree(source, target);
PackValidationResult sourceValidation = new PackValidationResult(
"snapshot-source", List.of(), List.of("source warning"), 23L);
PackValidationRegistry.publish(source, sourceValidation,
ServerConfigurator.computePackTreeFingerprint(source.toFile()));
IrisData installed = IrisData.openDatapackCompiler(target.toFile());
try (MockedStatic<IrisData> data = mockStatic(IrisData.class, CALLS_REAL_METHODS)) {
data.when(() -> IrisData.get(target.toFile())).thenReturn(installed);
assertEquals("main", StudioSVC.loadInstalledDimension(target, source, "main").getLoadKey());
assertSame(sourceValidation, PackValidationRegistry.requireLoadable(target));
} finally {
installed.close();
}
}
@Test
public void immutableSnapshotDimensionLoadingRejectsChangedContent() throws Exception {
Path source = temporaryFolder.newFolder("changed-snapshot-source").toPath();
Path target = temporaryFolder.getRoot().toPath().resolve("changed-snapshot-target");
writeValidPack(source);
StudioSVC.copyPackTree(source, target);
PackValidationRegistry.publish(source, new PackValidationResult("snapshot-source", List.of(), List.of(), 29L),
ServerConfigurator.computePackTreeFingerprint(source.toFile()));
Files.writeString(target.resolve("dimensions/main.json"), "{");
assertThrows(BrokenPackException.class, () -> StudioSVC.loadInstalledDimension(target, source, "main"));
assertTrue(PackValidationRegistry.isBroken(target));
}
@Test
public void copiedFingerprintMismatchFallsBackToTargetValidation() throws Exception {
Path root = temporaryFolder.newFolder("mismatched-validation-copy").toPath();
@@ -1,19 +1,31 @@
package art.arcane.iris.world;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.pack.datapack.DatapackInstallResult;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
public class IrisCreatorDatapackPreparationTest {
@Test
public void nonStudioCreationAlwaysInstallsDatapacks() {
public void explicitInstallModeAlwaysChecksDatapacks() {
assertTrue(IrisCreator.DatapackPreparation.INSTALL_IF_CHANGED.requiresInstall(false));
assertTrue(IrisCreator.DatapackPreparation.INSTALL_IF_CHANGED.requiresInstall(true));
}
@Test
public void studioCreationReusesOnlyAReadyLoadedRuntimeWithMatchingInputs() {
public void normalAndStudioCreationReuseOnlyAReadyLoadedRuntime() {
assertTrue(IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY.requiresInstall(false));
assertFalse(IrisCreator.DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY.requiresInstall(true));
}
@@ -24,4 +36,72 @@ public class IrisCreatorDatapackPreparationTest {
assertFalse(IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME.requiresInstall(true));
assertTrue(IrisCreator.DatapackPreparation.FORCE_REUSE_LOADED_RUNTIME.forcesLoadedRuntime());
}
@Test
public void defaultCreationSkipsInstallationWhenRequiredRegistriesAreLoaded() throws Exception {
IrisCreator creator = creator();
IrisDimension dimension = mock(IrisDimension.class);
try (MockedStatic<ServerConfigurator> configuration = mockStatic(ServerConfigurator.class)) {
configuration.when(() -> ServerConfigurator.isLoadedDatapackRuntimeReady(dimension)).thenReturn(true);
assertEquals(DatapackInstallResult.unchangedResult(), prepare(creator, dimension));
configuration.verify(() -> ServerConfigurator.isLoadedDatapackRuntimeReady(dimension));
configuration.verify(() -> ServerConfigurator.installDataPacksIfChanged(true, null), never());
}
}
@Test
public void unavailableRequiredRegistriesRunInstallationAndPreserveRestartRequirement() throws Exception {
IrisCreator creator = creator();
IrisDimension dimension = mock(IrisDimension.class);
DatapackInstallResult result = DatapackInstallResult.restartRequiredResult();
try (MockedStatic<ServerConfigurator> configuration = mockStatic(ServerConfigurator.class)) {
configuration.when(() -> ServerConfigurator.isLoadedDatapackRuntimeReady(dimension)).thenReturn(false);
configuration.when(() -> ServerConfigurator.installDataPacksIfChanged(true, null)).thenReturn(result);
assertSame(result, prepare(creator, dimension));
configuration.verify(() -> ServerConfigurator.installDataPacksIfChanged(true, null));
}
}
@Test
public void failedInstallationRemainsFailed() throws Exception {
IrisCreator creator = creator();
IrisDimension dimension = mock(IrisDimension.class);
DatapackInstallResult result = DatapackInstallResult.failedResult();
try (MockedStatic<ServerConfigurator> configuration = mockStatic(ServerConfigurator.class)) {
configuration.when(() -> ServerConfigurator.installDataPacksIfChanged(true, null)).thenReturn(result);
assertSame(result, prepare(creator, dimension));
}
}
@Test
public void explicitInstallRequestDoesNotReuseLoadedRegistries() throws Exception {
IrisCreator creator = creator().datapackPreparation(IrisCreator.DatapackPreparation.INSTALL_IF_CHANGED);
IrisDimension dimension = mock(IrisDimension.class);
DatapackInstallResult result = DatapackInstallResult.readyResult();
try (MockedStatic<ServerConfigurator> configuration = mockStatic(ServerConfigurator.class)) {
configuration.when(() -> ServerConfigurator.installDataPacksIfChanged(true, null)).thenReturn(result);
assertSame(result, prepare(creator, dimension));
configuration.verify(() -> ServerConfigurator.isLoadedDatapackRuntimeReady(dimension), never());
}
}
private static IrisCreator creator() {
try (MockedStatic<IrisSettings> settings = mockStatic(IrisSettings.class)) {
settings.when(IrisSettings::get).thenReturn(new IrisSettings());
return new IrisCreator();
}
}
private static DatapackInstallResult prepare(IrisCreator creator, IrisDimension dimension) throws Exception {
Method method = IrisCreator.class.getDeclaredMethod("prepareDatapacks", IrisDimension.class);
method.setAccessible(true);
return (DatapackInstallResult) method.invoke(creator, dimension);
}
}
@@ -0,0 +1,159 @@
package art.arcane.iris.world;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.platform.generation.BukkitChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.iris.world.history.GenerationPackFingerprint;
import art.arcane.iris.world.history.GenerationRegistryContract;
import org.bukkit.NamespacedKey;
import org.bukkit.WorldCreator;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.RETURNS_SELF;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class IrisWorldCreatorHistoryTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void normalCreationHandsThePublishedHistoryToItsGeneratorWithoutReopening() throws Exception {
assertGeneratorHistory(false, true);
}
@Test
public void studioCreationHandsThePublishedHistoryToItsGeneratorWithoutReopening() throws Exception {
assertGeneratorHistory(true, true);
}
@Test
public void creationWithoutAHandoffOpensAndValidatesThePackOnce() throws Exception {
assertGeneratorHistory(false, false);
}
@Test
public void suppliedHistoryMustMatchTheDimensionRootAndSeed() throws Exception {
GenerationHistory history = createHistory();
IrisWorldCreator creator = new IrisWorldCreator().persistent(true).seed(42L).generationHistory(history);
assertThrows(IllegalArgumentException.class, () -> creator.resolveGenerationHistory(
temporaryFolder.newFolder("different-world")));
assertThrows(IllegalStateException.class, () -> creator.seed(43L).resolveGenerationHistory(
history.paths().dimensionRoot().toFile()));
}
@Test
public void transientWorldsDoNotAcquireGenerationHistory() throws Exception {
GenerationHistory history = createHistory();
IrisWorldCreator creator = new IrisWorldCreator();
assertNull(creator.resolveGenerationHistory(history.paths().dimensionRoot().toFile()));
assertThrows(IllegalStateException.class, () -> creator.generationHistory(history)
.resolveGenerationHistory(history.paths().dimensionRoot().toFile()));
}
@Test
public void finalGeneratorPreparationRejectsTamperingAfterTheHandoff() throws Exception {
GenerationHistory history = createHistory();
Path pack = history.paths().packRoot(history.activeEpoch().epochId());
IrisWorldCreator creator = new IrisWorldCreator().persistent(true).seed(42L).generationHistory(history);
GenerationHistory resolved = creator.resolveGenerationHistory(history.paths().dimensionRoot().toFile());
Files.writeString(pack.resolve("dimensions/overworld.json"), "changed after publication");
assertSame(history, resolved);
assertThrows(IOException.class, () -> resolved.prepareCurrentGenerator(128));
assertThrows(IllegalStateException.class, () -> new IrisWorldCreator().persistent(true).seed(42L)
.resolveGenerationHistory(history.paths().dimensionRoot().toFile()));
}
private void assertGeneratorHistory(boolean studio, boolean handoff) throws Exception {
GenerationHistory history = createHistory();
File root = history.paths().dimensionRoot().toFile();
File pack = history.paths().packRoot(history.activeEpoch().epochId()).toFile();
File authoring = temporaryFolder.newFolder("authoring");
IrisDimension dimension = mock(IrisDimension.class);
when(dimension.getLoadKey()).thenReturn("overworld");
when(dimension.getMinHeight()).thenReturn(-64);
when(dimension.getMaxHeight()).thenReturn(320);
StudioSVC.GenerationPublication publication = new StudioSVC.GenerationPublication(dimension, history);
IrisWorldCreator creator = new IrisWorldCreator().name("created").dimension(publication.dimension())
.studio(studio).persistent(!studio).studioPackSource(authoring).seed(42L);
if (handoff) {
creator.generationHistory(publication.history());
}
NamespacedKey key = new NamespacedKey("iris", "created");
WorldCreator bukkitCreator = mock(WorldCreator.class, RETURNS_SELF);
when(bukkitCreator.name()).thenReturn("world_iris_created");
List<GenerationHistory> histories = new ArrayList<>();
List<File> packs = new ArrayList<>();
AtomicInteger hashes = new AtomicInteger();
try (MockedStatic<IrisWorldStorage> storage = mockStatic(IrisWorldStorage.class);
MockedStatic<WorldCreatorCompat> compatibility = mockStatic(WorldCreatorCompat.class);
MockedStatic<GenerationPackFingerprint> fingerprints = mockStatic(GenerationPackFingerprint.class, invocation -> {
if (invocation.getMethod().getName().equals("compute")) {
hashes.incrementAndGet();
}
return invocation.callRealMethod();
});
MockedConstruction<BukkitChunkGenerator> generators = mockConstruction(BukkitChunkGenerator.class,
(generator, context) -> {
packs.add((File) context.arguments().get(2));
histories.add((GenerationHistory) context.arguments().get(4));
})) {
storage.when(() -> IrisWorldStorage.keyFromName("created")).thenReturn(key);
storage.when(() -> IrisWorldStorage.dimensionRoot(key)).thenReturn(root);
compatibility.when(() -> WorldCreatorCompat.ofKey(key)).thenReturn(bukkitCreator);
compatibility.when(() -> WorldCreatorCompat.ofPersistentKey(key)).thenReturn(bukkitCreator);
compatibility.when(() -> WorldCreatorCompat.persistentDimensionRoot(key)).thenReturn(root);
assertSame(bukkitCreator, creator.create());
assertEquals(1, generators.constructed().size());
assertEquals(List.of(studio ? authoring : pack), packs);
assertEquals(handoff ? 0 : 1, hashes.get());
if (handoff) {
assertSame(history, histories.getFirst());
} else {
assertNotSame(history, histories.getFirst());
assertEquals(history.activeEpoch(), histories.getFirst().activeEpoch());
}
}
}
private GenerationHistory createHistory() throws IOException {
Path world = temporaryFolder.newFolder("world").toPath();
Path source = temporaryFolder.newFolder("source").toPath();
Files.createDirectories(source.resolve("dimensions"));
Files.writeString(source.resolve("dimensions/overworld.json"), "{}");
GenerationEpoch.DimensionContract contract = new GenerationEpoch.DimensionContract(
"overworld", "iris:overworld_type", "NORMAL", "OVERWORLD", 127,
-64, 384, 384, 1D, false, "none", 0, "0".repeat(64),
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, "c".repeat(64));
return GenerationHistory.create(world, source,
GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION),
42L, contract, GenerationRegistryContract.empty());
}
}
@@ -9,6 +9,7 @@ import org.bukkit.NamespacedKey;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.io.IOException;
@@ -16,11 +17,13 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mockStatic;
public class IrisWorldStorageTest {
@Rule
@@ -442,6 +445,36 @@ public class IrisWorldStorageTest {
);
}
@Test
public void activePackResolutionHashesOnceAndRechecksLaterAccesses() throws Exception {
Path world = temporaryFolder.newFolder("generation-pack-resolution").toPath();
Path source = createGenerationPack("resolution-source");
GenerationHistory history = createGenerationHistory(world, source, 42L);
Path expectedPack = history.paths().packRoot(history.activeEpoch().epochId());
AtomicInteger hashes = new AtomicInteger();
try (MockedStatic<GenerationPackFingerprint> ignored = mockStatic(GenerationPackFingerprint.class, invocation -> {
if (invocation.getMethod().getName().equals("compute")) {
hashes.incrementAndGet();
}
return invocation.callRealMethod();
})) {
assertEquals(expectedPack.toFile(), IrisWorldStorage.requireActiveGenerationPackRoot(world.toFile()));
assertEquals(1, hashes.get());
assertEquals(expectedPack.toFile(), IrisWorldStorage.requireActiveGenerationPackRoot(world.toFile(), 42L));
assertEquals(2, hashes.get());
Files.writeString(expectedPack.resolve("dimensions/overworld.json"), "changed");
assertThrows(IllegalStateException.class,
() -> IrisWorldStorage.requireActiveGenerationPackRoot(world.toFile()));
assertEquals(3, hashes.get());
assertThrows(IllegalStateException.class,
() -> IrisWorldStorage.requireActiveGenerationPackRoot(world.toFile(), 42L));
assertEquals(4, hashes.get());
}
}
@Test
public void managedWorldStorageIsOnlyReportedForRealIrisWorldDirectories() throws Exception {
File levelRoot = temporaryFolder.newFolder("managed-storage-level-root");
@@ -1,10 +1,17 @@
package art.arcane.iris.world;
import art.arcane.iris.world.lifecycle.MissingWorldStorageLog;
import art.arcane.iris.world.history.GenerationHistory;
import art.arcane.volmlib.util.collection.KMap;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.UncheckedIOException;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
@@ -14,7 +21,11 @@ import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class IrisWorldsTest {
@Rule
@@ -110,4 +121,98 @@ public class IrisWorldsTest {
assertEquals(Map.of("world_iris_moon", "overworld"), selected);
}
@Test
public void registryHousekeepingDoesNotOpenFrozenPacks() throws Exception {
Path levelRoot = temporaryFolder.newFolder("housekeeping").toPath();
Path first = Files.createDirectories(levelRoot.resolve("dimensions/iris/first"));
Files.createDirectories(first.resolve("iris/generation"));
Files.writeString(first.resolve("iris/generation/manifest.json"), "invalid history");
Files.createDirectories(levelRoot.resolve("dimensions/iris/second"));
KMap<String, String> entries = new KMap<>();
entries.put("iris:first", "overworld");
IrisWorlds registry = registry(levelRoot, entries);
try (MockedStatic<GenerationHistory> history = mockStatic(GenerationHistory.class)) {
registry.clean();
registry.save();
registry.put("iris:second", "missing_dimension");
registry.save();
history.verifyNoInteractions();
}
assertEquals(Map.of("iris:first", "overworld", "iris:second", "missing_dimension"), entries);
JsonObject saved = savedRegistry(levelRoot);
assertEquals("overworld", saved.get("iris:first").getAsString());
assertEquals("missing_dimension", saved.get("iris:second").getAsString());
}
@Test
public void savingPersistsEntriesRemovedByStorageCleanup() throws Exception {
Path levelRoot = temporaryFolder.newFolder("cleanup").toPath();
Files.createDirectories(levelRoot.resolve("dimensions/iris/retained"));
KMap<String, String> entries = new KMap<>();
entries.put("iris:retained", "overworld");
entries.put("iris:missing", "overworld");
IrisWorlds registry = registry(levelRoot, entries);
registry.save();
assertEquals(Map.of("iris:retained", "overworld"), entries);
assertEquals(Set.of("iris:retained"), savedRegistry(levelRoot).keySet());
}
@Test
public void failedPutRestoresPreviousValueAndCanBeRetried() throws Exception {
Path levelRoot = temporaryFolder.newFolder("put-rollback").toPath();
Files.createDirectories(levelRoot.resolve("dimensions/iris/retained"));
Path blockedParent = levelRoot.resolve("iris");
Files.writeString(blockedParent, "not a directory");
KMap<String, String> entries = new KMap<>();
entries.put("iris:retained", "overworld");
IrisWorlds registry = registry(levelRoot, entries);
assertThrows(UncheckedIOException.class, () -> registry.put("iris:retained", "replacement"));
assertEquals("overworld", entries.get("iris:retained"));
Files.delete(blockedParent);
registry.save();
assertEquals("overworld", savedRegistry(levelRoot).get("iris:retained").getAsString());
}
@Test
public void failedRemovalRestoresEntryAndCanBeRetried() throws Exception {
Path levelRoot = temporaryFolder.newFolder("remove-rollback").toPath();
Files.createDirectories(levelRoot.resolve("dimensions/iris/retained"));
Path blockedParent = levelRoot.resolve("iris");
Files.writeString(blockedParent, "not a directory");
KMap<String, String> entries = new KMap<>();
entries.put("iris:retained", "overworld");
IrisWorlds registry = registry(levelRoot, entries);
assertThrows(UncheckedIOException.class, () -> registry.remove("iris:retained"));
assertEquals("overworld", entries.get("iris:retained"));
Files.delete(blockedParent);
assertTrue(registry.remove("iris:retained"));
assertTrue(savedRegistry(levelRoot).isEmpty());
}
private static IrisWorlds registry(Path levelRoot, KMap<String, String> entries) throws Exception {
IrisWorlds registry = mock(IrisWorlds.class, CALLS_REAL_METHODS);
setField(registry, "levelRoot", levelRoot.toAbsolutePath().normalize());
setField(registry, "registryFile", IrisWorlds.registryFile(levelRoot));
setField(registry, "worlds", entries);
return registry;
}
private static void setField(IrisWorlds registry, String name, Object value) throws Exception {
Field field = IrisWorlds.class.getDeclaredField(name);
field.setAccessible(true);
field.set(registry, value);
}
private static JsonObject savedRegistry(Path levelRoot) throws Exception {
return JsonParser.parseString(Files.readString(IrisWorlds.registryFile(levelRoot))).getAsJsonObject();
}
}
@@ -47,6 +47,7 @@ public class GenerationHistoryAdmissionTest {
@Test
public void stageMetadataProceedsWhileUnrelatedSemanticPersistenceWaits() throws Exception {
GenerationHistory history = history();
GenerationManifest manifest = history.manifest();
GenerationActivation active = history.activeActivation();
GenerationEpoch epoch = history.activeEpoch();
GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
@@ -59,6 +60,9 @@ public class GenerationHistoryAdmissionTest {
Future<Boolean> persisted = executor.submit(() -> persistPaused(history, writing, claim, entered, release));
assertTrue(entered.await(5, TimeUnit.SECONDS));
Future<?> admitted = executor.submit(() -> {
assertSame(manifest, history.manifest());
assertEquals(active, history.activeActivation());
assertEquals(epoch, history.activeEpoch());
try (GenerationHistory.GenerationStage stage = history.openStage(32, 64)) {
assertEquals(active, stage.activation());
assertEquals(epoch, stage.epoch());
@@ -109,6 +113,11 @@ public class GenerationHistoryAdmissionTest {
writing.close();
assertTrue(cutoverEntered.await(5, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> promoted.get(100, TimeUnit.MILLISECONDS));
executor.submit(() -> {
assertEquals(writing.activation(), history.activeActivation());
assertEquals(writing.epoch(), history.activeEpoch());
assertEquals(writing.activation(), history.manifest().activeActivation());
}).get(1, TimeUnit.SECONDS);
assertThrows(IllegalStateException.class, runtime::close);
Future<?> admitted = executor.submit(() -> {
try (GenerationHistory.GenerationStage stage = history.openStage(32, 64)) {
@@ -122,6 +131,9 @@ public class GenerationHistoryAdmissionTest {
release.countDown();
assertTrue(persisted.get(5, TimeUnit.SECONDS));
assertEquals(pending.activationId(), promoted.get(5, TimeUnit.SECONDS).activationId());
assertEquals(pending.activationId(), history.activeActivation().activationId());
assertEquals(pending.epochId(), history.activeEpoch().epochId());
assertEquals(pending.activationId(), history.manifest().activeActivation().activationId());
admitted.get(5, TimeUnit.SECONDS);
} finally {
release.countDown();
@@ -143,6 +155,9 @@ public class GenerationHistoryAdmissionTest {
IOException failure = new IOException("Manifest publication failed");
failureField.set(store, failure);
try {
assertSame(failure, assertThrows(IllegalStateException.class, history::manifest).getCause());
assertSame(failure, assertThrows(IllegalStateException.class, history::activeActivation).getCause());
assertSame(failure, assertThrows(IllegalStateException.class, history::activeEpoch).getCause());
IllegalStateException rejected = assertThrows(IllegalStateException.class, () -> history.openStage(1, 2));
assertSame(failure, rejected.getCause());
} finally {
@@ -4,14 +4,52 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.Test;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mockStatic;
public class GenerationHistoryTest extends GenerationHistorySupport {
@Test
public void freshPublicationReusesOnlyItsInternalVerification() throws Exception {
Path world = temporaryFolder.newFolder("verified-publication-world").toPath();
Path pack = createPack("verified-publication-pack", "alpha");
String expected = fingerprint(pack);
AtomicInteger hashes = new AtomicInteger();
try (MockedStatic<GenerationPackFingerprint> ignored = mockStatic(GenerationPackFingerprint.class, invocation -> {
if (invocation.getMethod().getName().equals("compute")) {
hashes.incrementAndGet();
}
return invocation.callRealMethod();
})) {
GenerationHistory history = GenerationHistory.create(
world, pack, expected, 42L, contract(), GenerationRegistryContract.empty());
assertEquals(2, hashes.get());
assertEquals(history.paths().packRoot(history.activeEpoch().epochId()), history.activePackRoot());
assertEquals(3, hashes.get());
GenerationHistory.open(world);
assertEquals(4, hashes.get());
}
}
@Test
public void firstPackAccessRejectsTamperingAfterCreation() throws Exception {
Path world = temporaryFolder.newFolder("tampered-publication-world").toPath();
Path pack = createPack("tampered-publication-pack", "alpha");
GenerationHistory history = createHistory(world, pack);
Path installed = history.paths().packRoot(history.activeEpoch().epochId());
Files.writeString(installed.resolve("dimensions/main.json"), "changed");
assertThrows(IOException.class, history::activePackRoot);
assertThrows(IOException.class, () -> history.packRoot(1L));
assertThrows(IOException.class, () -> GenerationHistory.open(world));
}
@Test
public void upperContentUpdatesPreserveLayoutAndDistinctActivationsAcrossRestarts() throws Exception {
Path world = temporaryFolder.newFolder("upper-update-world").toPath();
@@ -0,0 +1,319 @@
package art.arcane.iris.world.history;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.AdditionalAnswers.delegatesTo;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class GenerationPackCopyConcurrencyTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test(timeout = 15000)
public void forkJoinShutdownSettlesCancelledQueuedCopyWorkers() throws Exception {
Path source = createPack("cancelled-queue-source", 2);
Path target = temporaryFolder.getRoot().toPath().resolve("cancelled-queue-target");
CountDownLatch blocked = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
CountDownLatch queued = new CountDownLatch(2);
ForkJoinPool workers = new ForkJoinPool(1);
ExecutorService caller = Executors.newSingleThreadExecutor();
workers.execute(() -> {
blocked.countDown();
awaitUninterruptibly(release);
});
Executor execution = command -> {
workers.execute(command);
queued.countDown();
};
try {
assertTrue(blocked.await(5, TimeUnit.SECONDS));
Future<?> copy = caller.submit(() -> {
GenerationPackRepository.copyPackTree(source, target, execution, 2);
return null;
});
assertTrue(queued.await(5, TimeUnit.SECONDS));
workers.shutdownNow();
ExecutionException failure = assertThrows(ExecutionException.class, () -> copy.get(5, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof CancellationException);
assertFalse(Files.exists(target.resolve("dimensions/0.json")));
assertFalse(Files.exists(target.resolve("dimensions/1.json")));
} finally {
release.countDown();
caller.shutdownNow();
workers.shutdownNow();
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test(timeout = 15000)
public void cancelledRunningFlushDrainsFileWorkBeforeReturning() throws Exception {
Path source = createPack("cancelled-flush-source", 1);
Path target = temporaryFolder.getRoot().toPath().resolve("cancelled-flush-target");
CountDownLatch flushing = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicReference<Future<?>> submitted = new AtomicReference<>();
AtomicReference<FileChannel> opened = new AtomicReference<>();
AtomicBoolean flushed = new AtomicBoolean();
ExecutorService workers = Executors.newSingleThreadExecutor();
ExecutorService caller = Executors.newSingleThreadExecutor();
Executor execution = command -> {
submitted.set((Future<?>) command);
workers.execute(() -> {
try (MockedStatic<FileChannel> ignored = mockStatic(FileChannel.class, invocation -> {
FileChannel channel = (FileChannel) invocation.callRealMethod();
opened.set(channel);
FileChannel intercepted = mock(FileChannel.class, delegatesTo(channel));
doAnswer(force -> {
flushing.countDown();
awaitUninterruptibly(release);
channel.force(true);
flushed.set(true);
return null;
}).when(intercepted).force(true);
return intercepted;
})) {
command.run();
}
});
};
try {
Future<?> copy = caller.submit(() -> {
GenerationPackRepository.copyPackTree(source, target, execution, 1);
return null;
});
assertTrue(flushing.await(5, TimeUnit.SECONDS));
assertTrue(submitted.get().cancel(true));
assertThrows(TimeoutException.class, () -> copy.get(100, TimeUnit.MILLISECONDS));
assertFalse(flushed.get());
assertTrue(opened.get().isOpen());
assertTrue(Files.exists(target.resolve("dimensions/0.json")));
release.countDown();
ExecutionException failure = assertThrows(ExecutionException.class, () -> copy.get(5, TimeUnit.SECONDS));
assertTrue(failure.getCause() instanceof CancellationException);
assertTrue(flushed.get());
assertFalse(opened.get().isOpen());
} finally {
release.countDown();
caller.shutdownNow();
workers.shutdownNow();
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test(timeout = 15000)
public void boundedWorkersCopyTheExactFilteredTreeAndAttributes() throws Exception {
Path source = createPack("source", 48);
Path target = temporaryFolder.getRoot().toPath().resolve("target");
Files.createDirectories(source.resolve(".iris"));
Files.writeString(source.resolve(".iris/cache"), "excluded");
Files.writeString(source.resolve("source.code-workspace"), "excluded");
Files.writeString(source.resolve("dimensions/.hidden.json"), "{}");
FileTime timestamp = FileTime.fromMillis(1_700_000_000_000L);
Files.setLastModifiedTime(source.resolve("dimensions/0.json"), timestamp);
CountDownLatch started = new CountDownLatch(2);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger submitted = new AtomicInteger();
ExecutorService workers = Executors.newFixedThreadPool(2);
ExecutorService caller = Executors.newSingleThreadExecutor();
Executor execution = command -> {
if (submitted.incrementAndGet() == 3) {
try {
assertEquals(fingerprint(source), fingerprint(target));
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
}
workers.execute(() -> {
started.countDown();
await(release);
command.run();
});
};
try {
Future<?> copy = caller.submit(() -> {
GenerationPackRepository.copyPackTree(source, target, execution, 2);
return null;
});
assertTrue(started.await(5, TimeUnit.SECONDS));
assertFalse(copy.isDone());
release.countDown();
copy.get(5, TimeUnit.SECONDS);
assertEquals(4, submitted.get());
assertEquals(fingerprint(source), fingerprint(target));
assertEquals(timestamp, Files.getLastModifiedTime(target.resolve("dimensions/0.json")));
assertFalse(Files.exists(target.resolve(".iris")));
assertFalse(Files.exists(target.resolve("source.code-workspace")));
assertTrue(Files.isRegularFile(target.resolve("dimensions/.hidden.json")));
} finally {
release.countDown();
caller.shutdownNow();
workers.shutdownNow();
}
}
@Test(timeout = 15000)
public void rejectedSubmissionDrainsAcceptedWorkersBeforeReturning() throws Exception {
Path source = createPack("rejected-source", 2);
Path target = temporaryFolder.getRoot().toPath().resolve("rejected-target");
CountDownLatch started = new CountDownLatch(1);
CountDownLatch rejected = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger submitted = new AtomicInteger();
AtomicInteger completed = new AtomicInteger();
RejectedExecutionException expected = new RejectedExecutionException("IO executor closed");
ExecutorService worker = Executors.newSingleThreadExecutor();
ExecutorService caller = Executors.newSingleThreadExecutor();
Executor execution = command -> {
if (submitted.getAndIncrement() != 0) {
rejected.countDown();
throw expected;
}
worker.execute(() -> {
started.countDown();
await(release);
command.run();
completed.incrementAndGet();
});
};
try {
Future<?> copy = caller.submit(() -> {
GenerationPackRepository.copyPackTree(source, target, execution, 2);
return null;
});
assertTrue(started.await(5, TimeUnit.SECONDS));
assertTrue(rejected.await(5, TimeUnit.SECONDS));
assertFalse(copy.isDone());
release.countDown();
ExecutionException actual = assertThrows(ExecutionException.class, () -> copy.get(5, TimeUnit.SECONDS));
assertSame(expected, actual.getCause());
worker.submit(() -> { }).get(5, TimeUnit.SECONDS);
assertEquals(1, completed.get());
assertFalse(Files.exists(target.resolve("dimensions/0.json")));
} finally {
release.countDown();
caller.shutdownNow();
worker.shutdownNow();
}
}
@Test
public void fileFailurePropagatesAsIOException() throws Exception {
Path source = createPack("missing-source", 1);
Path target = temporaryFolder.getRoot().toPath().resolve("missing-target");
Executor execution = command -> {
try {
Files.delete(source.resolve("dimensions/0.json"));
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
command.run();
};
assertThrows(IOException.class, () -> GenerationPackRepository.copyPackTree(source, target, execution, 1));
}
@Test(timeout = 15000)
public void copyCanJoinWorkSubmittedToItsOwnForkJoinPool() throws Exception {
Path source = createPack("nested-source", 4);
Path target = temporaryFolder.getRoot().toPath().resolve("nested-target");
try (ForkJoinPool pool = new ForkJoinPool(1)) {
pool.submit(() -> {
GenerationPackRepository.copyPackTree(source, target, pool, 2);
return null;
}).get(5, TimeUnit.SECONDS);
assertEquals(fingerprint(source), fingerprint(target));
}
}
@Test
public void emptyPackTreeDoesNotSubmitFileWork() throws Exception {
Path source = temporaryFolder.newFolder("empty-source").toPath();
Path target = temporaryFolder.getRoot().toPath().resolve("empty-target");
Files.createDirectory(source.resolve("empty"));
GenerationPackRepository.copyPackTree(source, target, command -> {
throw new AssertionError("Empty packs do not need copy workers");
}, 2);
assertTrue(Files.isDirectory(target.resolve("empty")));
}
private Path createPack(String name, int files) throws IOException {
Path root = temporaryFolder.newFolder(name).toPath();
Files.createDirectory(root.resolve("dimensions"));
for (int index = 0; index < files; index++) {
Files.writeString(root.resolve("dimensions/" + index + ".json"), "{\"value\":" + index + "}");
}
return root;
}
private static String fingerprint(Path root) throws IOException {
return GenerationPackFingerprint.compute(root, GenerationPackFingerprint.CURRENT_VERSION);
}
private static void await(CountDownLatch latch) {
try {
if (!latch.await(5, TimeUnit.SECONDS)) {
throw new AssertionError("Timed out waiting for copy workers");
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError(failure);
}
}
private static void awaitUninterruptibly(CountDownLatch latch) {
boolean interrupted = false;
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
try {
while (true) {
try {
if (!latch.await(Math.max(0L, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) {
throw new AssertionError("Timed out waiting for copy worker release");
}
return;
} catch (InterruptedException failure) {
interrupted = true;
}
}
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
}
}
@@ -0,0 +1,148 @@
package art.arcane.iris.world.history;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class GenerationPackDirectorySyncTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test(timeout = 15000)
public void syncsEveryChildBeforeItsParentWithBoundedSiblingConcurrency() throws Exception {
Path root = temporaryFolder.newFolder("tree").toPath();
for (int index = 0; index < 12; index++) {
Files.createDirectories(root.resolve("branch-" + (index / 3)).resolve("leaf-" + index));
}
Set<Path> completed = ConcurrentHashMap.newKeySet();
AtomicInteger active = new AtomicInteger();
AtomicInteger maximum = new AtomicInteger();
CountDownLatch siblings = new CountDownLatch(4);
CountDownLatch release = new CountDownLatch(1);
ExecutorService workers = Executors.newFixedThreadPool(8);
ExecutorService caller = Executors.newSingleThreadExecutor();
try {
Future<?> sync = caller.submit(() -> {
GenerationPackRepository.forceDirectoryTree(root, workers, 4, directory -> {
int running = active.incrementAndGet();
maximum.accumulateAndGet(running, Math::max);
try {
if (directory.getFileName().toString().startsWith("leaf-")) {
siblings.countDown();
await(release);
}
try (Stream<Path> children = Files.list(directory)) {
assertTrue(children.filter(Files::isDirectory).allMatch(completed::contains));
}
assertTrue(completed.add(directory));
} finally {
active.decrementAndGet();
}
});
return null;
});
assertTrue(siblings.await(5, TimeUnit.SECONDS));
assertFalse(sync.isDone());
assertFalse(completed.contains(root));
release.countDown();
sync.get(5, TimeUnit.SECONDS);
assertEquals(4, maximum.get());
assertEquals(17, completed.size());
} finally {
release.countDown();
caller.shutdownNow();
workers.shutdownNow();
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test(timeout = 15000)
public void failedSiblingDrainsStartedSyncsAndNeverSyncsTheParent() throws Exception {
Path root = temporaryFolder.newFolder("failed-tree").toPath();
Files.createDirectory(root.resolve("first"));
Files.createDirectory(root.resolve("second"));
AtomicInteger next = new AtomicInteger();
Set<Path> completed = ConcurrentHashMap.newKeySet();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch failed = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
IOException expected = new IOException("directory sync failed");
ExecutorService workers = Executors.newFixedThreadPool(2);
ExecutorService caller = Executors.newSingleThreadExecutor();
try {
Future<?> sync = caller.submit(() -> {
GenerationPackRepository.forceDirectoryTree(root, workers, 2, directory -> {
if (next.getAndIncrement() == 0) {
started.countDown();
await(release);
completed.add(directory);
} else {
await(started);
failed.countDown();
throw expected;
}
});
return null;
});
assertTrue(failed.await(5, TimeUnit.SECONDS));
assertFalse(sync.isDone());
release.countDown();
ExecutionException failure = assertThrows(ExecutionException.class, () -> sync.get(5, TimeUnit.SECONDS));
assertSame(expected, failure.getCause());
assertEquals(1, completed.size());
assertFalse(completed.contains(root));
} finally {
release.countDown();
caller.shutdownNow();
workers.shutdownNow();
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test(timeout = 15000)
public void syncCanJoinItsOwnForkJoinPool() throws Exception {
Path root = temporaryFolder.newFolder("nested-tree").toPath();
Files.createDirectory(root.resolve("first"));
Files.createDirectory(root.resolve("second"));
AtomicInteger completed = new AtomicInteger();
try (ForkJoinPool pool = new ForkJoinPool(1)) {
pool.submit(() -> {
GenerationPackRepository.forceDirectoryTree(root, pool, 2, directory -> completed.incrementAndGet());
return null;
}).get(5, TimeUnit.SECONDS);
}
assertEquals(3, completed.get());
}
private static void await(CountDownLatch latch) throws IOException {
try {
if (!latch.await(5, TimeUnit.SECONDS)) {
throw new IOException("Directory sync wait timed out");
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IOException("Directory sync wait was interrupted", failure);
}
}
}
@@ -0,0 +1,334 @@
package art.arcane.iris.world.history;
import org.junit.Assume;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.ByteArrayInputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class GenerationPackFingerprintPrefetchTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void mixedFilesAndMetadataMatchTheSerialContentAddressForBothVersions() throws Exception {
Path pack = temporaryFolder.newFolder("mixed").toPath();
Map<String, byte[]> included = new TreeMap<>();
for (int index = 0; index < 70; index++) {
included.put("objects/entry-" + index + ".bin", new byte[]{(byte) index, 3, 7});
}
byte[] large = new byte[300 * 1_024 + 1];
new Random(1827L).nextBytes(large);
included.put("objects/large.bin", large);
included.put("objects/zero.bin", new byte[0]);
included.put("objects/.nested/resource.bin", new byte[]{17});
included.put("objects/.DS_Store", new byte[]{42});
included.put("objects/.nested/.DS_Store", new byte[]{43});
for (Map.Entry<String, byte[]> entry : included.entrySet()) {
write(pack.resolve(entry.getKey()), entry.getValue());
}
write(pack.resolve(".git/config"), new byte[]{1});
write(pack.resolve(".hidden"), new byte[]{2});
write(pack.resolve(".DS_Store"), new byte[]{3});
write(pack.resolve("objects/ignored.code-workspace"), new byte[]{4});
write(pack.resolve("workspace.code-workspace"), new byte[]{5});
assertEquals(serialFingerprint(included), GenerationPackFingerprint.compute(pack, 1));
included.remove("objects/.DS_Store");
included.remove("objects/.nested/.DS_Store");
assertEquals(serialFingerprint(included), GenerationPackFingerprint.compute(pack, 2));
}
@Test
public void fewerThanSixtyFourSmallFilesStayOnTheCallingThread() throws Exception {
Path pack = smallPack("small", 63);
Set<Thread> readers = ConcurrentHashMap.newKeySet();
Thread caller = Thread.currentThread();
GenerationPackFingerprint.computeWithInput(pack, 2, path -> {
readers.add(Thread.currentThread());
return GenerationPackFingerprint.openInput(path);
});
assertEquals(Set.of(caller), readers);
}
@Test
public void largeFilesStreamOnTheCallerAndSmallReadsAreBounded() throws Exception {
requireParallelReaders();
Path pack = smallPack("parallel", 64);
Path large = pack.resolve("large.bin");
Files.write(large, new byte[256 * 1_024 + 1]);
AtomicReference<Thread> largeReader = new AtomicReference<>();
Set<Thread> smallReaders = ConcurrentHashMap.newKeySet();
Thread caller = Thread.currentThread();
GenerationPackFingerprint.computeWithInput(pack, 2, path -> {
if (path.equals(large)) {
largeReader.set(Thread.currentThread());
} else {
smallReaders.add(Thread.currentThread());
}
return GenerationPackFingerprint.openInput(path);
});
assertSame(caller, largeReader.get());
assertFalse(smallReaders.contains(caller));
assertFalse(smallReaders.isEmpty());
assertTrue(smallReaders.size() <= 4);
assertStopped(smallReaders);
}
@Test
public void shortAndGrowingFilesFailAndCloseEveryStartedInput() throws Exception {
requireParallelReaders();
for (int changedSize : new int[]{0, 2}) {
Path pack = smallPack("changed-" + changedSize, 64);
AtomicInteger active = new AtomicInteger();
Set<Thread> readers = ConcurrentHashMap.newKeySet();
IOException failure = assertThrows(IOException.class, () -> GenerationPackFingerprint.computeWithInput(pack, 2, path -> {
readers.add(Thread.currentThread());
if (path.getFileName().toString().equals("000.bin")) {
Files.write(path, new byte[changedSize]);
}
return tracked(GenerationPackFingerprint.openInput(path), active);
}));
assertTrue(failure.getMessage().contains("changed while fingerprinting"));
assertEquals(0, active.get());
assertStopped(readers);
}
}
@Test
public void leafReplacedByASymlinkAfterCollectionIsRejected() throws Exception {
requireParallelReaders();
Path pack = smallPack("replaced", 64);
Path outside = temporaryFolder.newFile("outside.bin").toPath();
Files.write(outside, new byte[]{99});
Path probe = temporaryFolder.getRoot().toPath().resolve("symlink-probe");
try {
Files.createSymbolicLink(probe, outside);
} catch (IOException | UnsupportedOperationException failure) {
Assume.assumeNoException(failure);
}
Files.delete(probe);
assertThrows(IOException.class, () -> GenerationPackFingerprint.computeWithInput(pack, 2, path -> {
if (path.getFileName().toString().equals("000.bin")) {
Files.delete(path);
Files.createSymbolicLink(path, outside);
}
return GenerationPackFingerprint.openInput(path);
}));
}
@Test
public void firstOrderedReadFailureWaitsForEveryStartedInputToClose() throws Exception {
requireParallelReaders();
Path pack = smallPack("failure", 64);
CountDownLatch blocked = new CountDownLatch(1);
CountDownLatch failed = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger active = new AtomicInteger();
Set<Thread> readers = ConcurrentHashMap.newKeySet();
IOException expected = new IOException("first ordered failure");
IOException later = new IOException("later ordered failure");
CompletableFuture<Throwable> completion = new CompletableFuture<>();
Thread caller = Thread.ofPlatform().start(() -> {
try {
GenerationPackFingerprint.computeWithInput(pack, 2, path -> {
readers.add(Thread.currentThread());
String name = path.getFileName().toString();
if (name.equals("000.bin")) {
await(blocked);
failed.countDown();
throw expected;
}
if (name.equals("001.bin")) {
return tracked(blockingInput(blocked, release, null), active);
}
if (name.equals("002.bin")) {
throw later;
}
return tracked(GenerationPackFingerprint.openInput(path), active);
});
completion.complete(null);
} catch (Throwable failure) {
completion.complete(failure);
}
});
try {
assertTrue(failed.await(5, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> completion.get(100, TimeUnit.MILLISECONDS));
} finally {
release.countDown();
caller.join(5_000L);
}
assertFalse(caller.isAlive());
assertSame(expected, completion.get(5, TimeUnit.SECONDS));
assertEquals(1, expected.getSuppressed().length);
assertSame(later, expected.getSuppressed()[0]);
assertEquals(0, active.get());
assertStopped(readers);
}
@Test
public void interruptedCallerDrainsStartedReadsAndRetainsItsInterruptFlag() throws Exception {
requireParallelReaders();
Path pack = smallPack("interrupted", 64);
CountDownLatch blocked = new CountDownLatch(1);
CountDownLatch interruptedReader = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger active = new AtomicInteger();
AtomicBoolean interruptPreserved = new AtomicBoolean();
Set<Thread> readers = ConcurrentHashMap.newKeySet();
CompletableFuture<Throwable> completion = new CompletableFuture<>();
Thread caller = Thread.ofPlatform().start(() -> {
try {
GenerationPackFingerprint.computeWithInput(pack, 2, path -> {
readers.add(Thread.currentThread());
if (path.getFileName().toString().equals("000.bin")) {
return tracked(blockingInput(blocked, release, interruptedReader), active);
}
return tracked(GenerationPackFingerprint.openInput(path), active);
});
completion.complete(null);
} catch (Throwable failure) {
interruptPreserved.set(Thread.currentThread().isInterrupted());
completion.complete(failure);
}
});
try {
assertTrue(blocked.await(5, TimeUnit.SECONDS));
caller.interrupt();
assertTrue(interruptedReader.await(5, TimeUnit.SECONDS));
assertThrows(TimeoutException.class, () -> completion.get(100, TimeUnit.MILLISECONDS));
} finally {
release.countDown();
caller.join(5_000L);
}
assertFalse(caller.isAlive());
assertTrue(completion.get(5, TimeUnit.SECONDS) instanceof IOException);
assertTrue(interruptPreserved.get());
assertEquals(0, active.get());
assertStopped(readers);
}
private Path smallPack(String name, int count) throws IOException {
Path pack = temporaryFolder.newFolder(name).toPath();
for (int index = 0; index < count; index++) {
Files.write(pack.resolve(String.format("%03d.bin", index)), new byte[]{1});
}
return pack;
}
private static void write(Path path, byte[] bytes) throws IOException {
Files.createDirectories(path.getParent());
Files.write(path, bytes);
}
private static String serialFingerprint(Map<String, byte[]> entries) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
byte[] name = entry.getKey().getBytes(StandardCharsets.UTF_8);
digest.update(ByteBuffer.allocate(4).putInt(name.length).array());
digest.update(name);
digest.update(ByteBuffer.allocate(8).putLong(entry.getValue().length).array());
digest.update(entry.getValue());
}
return HexFormat.of().formatHex(digest.digest());
}
private static InputStream tracked(InputStream input, AtomicInteger active) {
active.incrementAndGet();
return new FilterInputStream(input) {
@Override
public void close() throws IOException {
try {
super.close();
} finally {
active.decrementAndGet();
}
}
};
}
private static InputStream blockingInput(CountDownLatch blocked, CountDownLatch release,
CountDownLatch interrupted) {
return new ByteArrayInputStream(new byte[]{1}) {
@Override
public synchronized int read(byte[] bytes, int offset, int length) {
blocked.countDown();
boolean wasInterrupted = false;
while (true) {
try {
release.await();
break;
} catch (InterruptedException failure) {
wasInterrupted = true;
if (interrupted != null) {
interrupted.countDown();
}
}
}
if (wasInterrupted) {
Thread.currentThread().interrupt();
}
return super.read(bytes, offset, length);
}
};
}
private static void await(CountDownLatch latch) throws IOException {
try {
if (!latch.await(5, TimeUnit.SECONDS)) {
throw new IOException("Timed out waiting for the concurrent reader");
}
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IOException(failure);
}
}
private static void assertStopped(Set<Thread> readers) throws InterruptedException {
for (Thread reader : readers) {
reader.join(5_000L);
assertFalse("Fingerprint reader did not stop: " + reader.getName(), reader.isAlive());
}
}
private static void requireParallelReaders() {
Assume.assumeTrue(Runtime.getRuntime().availableProcessors() > 1);
}
}
@@ -72,6 +72,28 @@ public class GenerationPackRepositoryTest {
));
}
@Test
public void rejectsAChangedCopyBeforePublishingTheEpoch() throws Exception {
Path dimensionRoot = temporaryFolder.newFolder("changed-copy-world").toPath();
Path source = createPack("changed-copy-source", "{}");
String expected = fingerprint(source);
String epochId = digest('a');
GenerationPackRepository repository = new GenerationPackRepository(dimensionRoot);
try (MockedStatic<GenerationPackFingerprint> ignored = mockStatic(GenerationPackFingerprint.class, invocation -> {
if (invocation.getMethod().getName().equals("compute")) {
Path pack = invocation.getArgument(0);
if (pack.getFileName().toString().startsWith(".pack-")) {
Files.writeString(pack.resolve("dimensions/main.json"), "changed");
}
}
return invocation.callRealMethod();
})) {
assertThrows(IOException.class, () -> repository.publish(
epochId, expected, GenerationPackFingerprint.CURRENT_VERSION, source));
assertFalse(Files.exists(repository.packRoot(epochId)));
}
}
@Test
public void finderMetadataCreatedDuringPublicationDoesNotChangeVersionTwoIdentity() throws Exception {
Path dimensionRoot = temporaryFolder.newFolder("finder-world").toPath();
@@ -9,13 +9,17 @@ import org.mockito.MockedConstruction;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Queue;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -41,6 +45,56 @@ public class SavedBiomeStoreBatchTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void independentOriginRegionsPersistWhileOneRegionRemainsLocked() throws Exception {
Path root = temporaryFolder.newFolder().toPath();
SavedBiomeStore store = SavedBiomeStore.open(root);
Object lockedRegion = regionLock(store, 0, 0);
List<SavedBiomeChunk> independent = List.of(chunk(-1, -1, 3L), chunk(-1, 0, 3L), chunk(0, -1, 3L));
Set<Object> owners = Collections.newSetFromMap(new IdentityHashMap<>());
owners.add(lockedRegion);
for (SavedBiomeChunk chunk : independent) {
Object owner = regionLock(store, chunk.chunkX(), chunk.chunkZ());
assertTrue(owners.add(owner));
assertSame(owner, regionLock(store, chunk.chunkX() & ~31, chunk.chunkZ() & ~31));
assertSame(owner, regionLock(store, chunk.chunkX() | 31, chunk.chunkZ() | 31));
}
CountDownLatch started = new CountDownLatch(4);
ExecutorService workers = Executors.newFixedThreadPool(4);
List<Future<Boolean>> writes = new ArrayList<>();
Future<Boolean> sameRegion;
try {
synchronized (lockedRegion) {
for (SavedBiomeChunk chunk : independent) {
writes.add(workers.submit(() -> {
started.countDown();
return store.claimAndPersist(chunk);
}));
}
sameRegion = workers.submit(() -> {
started.countDown();
return store.claimAndPersist(chunk(31, 31, 3L));
});
assertTrue(started.await(5, TimeUnit.SECONDS));
for (Future<Boolean> write : writes) {
assertTrue(write.get(5, TimeUnit.SECONDS));
}
assertFalse(sameRegion.isDone());
assertTrue(store.cached(31, 31).isEmpty());
assertSame(lockedRegion, regionLock(store, 31, 31));
}
assertTrue(sameRegion.get(5, TimeUnit.SECONDS));
SavedBiomeStore reopened = SavedBiomeStore.open(root);
for (SavedBiomeChunk chunk : independent) {
assertEquals(chunk, reopened.get(chunk.chunkX(), chunk.chunkZ()).orElseThrow());
}
assertEquals(chunk(31, 31, 3L), reopened.get(31, 31).orElseThrow());
} finally {
workers.shutdownNow();
assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS));
}
}
@Test
public void queuedClaimsShareOneForceAndPublishAfterItCompletes() throws Exception {
Path root = temporaryFolder.newFolder().toPath();
@@ -158,8 +212,9 @@ public class SavedBiomeStoreBatchTest {
List<Future<ClaimResult>> results = enqueue(store, root, control, workers,
List.of(chunk(1, 3L), chunk(2, 3L), chunk(3, 3L), chunk(4, 3L)));
assertTrue(control.forcing.await(5, TimeUnit.SECONDS));
Object writingRegion = regionLock(store, 0, 0);
for (int region = 1; region <= 132; region++) {
if ((region & 63) != 0) {
if (regionLock(store, region << 5, 0) != writingRegion) {
assertTrue(store.get(region << 5, 0).isEmpty());
}
}
@@ -194,9 +249,7 @@ public class SavedBiomeStoreBatchTest {
private static List<Future<ClaimResult>> enqueue(SavedBiomeStore store, Path root, BatchControl control,
ExecutorService workers, List<SavedBiomeChunk> chunks) throws Exception {
Field locksField = SavedBiomeStore.class.getDeclaredField("regionLocks");
locksField.setAccessible(true);
Object stripe = ((Object[]) locksField.get(store))[0];
Object stripe = regionLock(store, 0, 0);
Field writingField = stripe.getClass().getDeclaredField("writing");
Field pendingField = stripe.getClass().getDeclaredField("pending");
writingField.setAccessible(true);
@@ -252,18 +305,28 @@ public class SavedBiomeStoreBatchTest {
}
private static SavedBiomeChunk chunk(int x, long activation) {
return chunk(x, 0, activation);
}
private static SavedBiomeChunk chunk(int x, int z, long activation) {
SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(activation, "biome", "region");
SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell,
List.of(new SavedBiomeChunk.Span(-64, 320, cell)));
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(x, 0, activation, -64, 384));
for (int z = 0; z < 16; z++) {
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(x, z, activation, -64, 384));
for (int localZ = 0; localZ < 16; localZ++) {
for (int localX = 0; localX < 16; localX++) {
builder.column(localX, z, column);
builder.column(localX, localZ, column);
}
}
return builder.build();
}
private static Object regionLock(SavedBiomeStore store, int x, int z) throws ReflectiveOperationException {
Method lock = SavedBiomeStore.class.getDeclaredMethod("regionLock", int.class, int.class);
lock.setAccessible(true);
return lock.invoke(store, x, z);
}
private static final class BatchControl {
private final CountDownLatch forcing = new CountDownLatch(1);
private final CountDownLatch release = new CountDownLatch(1);
@@ -120,6 +120,14 @@ public class WorldLifecycleRuntimeLevelStemTest {
return CompletableFuture.completedFuture(0);
}
@Override
public void beginInitialEntry(boolean playerEntry) {
}
@Override
public void completeInitialEntry() {
}
@Override
public void hotload() {
}
@@ -6,7 +6,6 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.iris.generation.stream.CachedDoubleStream2D;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
@@ -16,6 +15,7 @@ import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -26,8 +26,6 @@ public class PregenPerformanceProfileTest {
private IrisSettings previousSettings;
private Engine engine;
private IrisComplex complex;
private CachedDoubleStream2D natural;
private CachedDoubleStream2D raw;
@Before
public void setUp() {
@@ -36,11 +34,7 @@ public class PregenPerformanceProfileTest {
IrisSettings.get().getPerformance().setNoiseCacheSize(1_024);
engine = mock(Engine.class);
complex = mock(IrisComplex.class);
natural = mock(CachedDoubleStream2D.class);
raw = mock(CachedDoubleStream2D.class);
when(engine.getComplex()).thenReturn(complex);
when(complex.getNaturalHeightStream()).thenReturn(natural);
when(complex.getRawHeightStream()).thenReturn(raw);
when(complex.getHeightStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 47D));
doThrow(new IllegalStateException("Immutable history cannot rebuild its runtime"))
.when(engine).hotloadComplex();
@@ -56,8 +50,7 @@ public class PregenPerformanceProfileTest {
PregenPerformanceProfile.apply(engine);
assertEquals(4_096, IrisSettings.get().getPerformance().getNoiseCacheSize());
verify(natural).setMaximumChunks(4_096);
verify(raw).setMaximumChunks(4_096);
verify(complex).ensureTerrainNoiseCacheSize(4_096);
verify(complex, never()).getHeightStream();
verify(engine, never()).hotloadComplex();
}
@@ -69,8 +62,7 @@ public class PregenPerformanceProfileTest {
PregenPerformanceProfile.applyToGenerator(generator);
verify(natural).setMaximumChunks(4_096);
verify(raw).setMaximumChunks(4_096);
verify(complex).ensureTerrainNoiseCacheSize(4_096);
verify(generator, never()).hotloadComplexAsync(30L, TimeUnit.SECONDS);
verify(engine, never()).hotloadComplex();
}
@@ -82,8 +74,21 @@ public class PregenPerformanceProfileTest {
PregenPerformanceProfile.apply(engine);
assertEquals(8_192, IrisSettings.get().getPerformance().getNoiseCacheSize());
verify(natural).setMaximumChunks(8_192);
verify(raw).setMaximumChunks(8_192);
verify(complex).ensureTerrainNoiseCacheSize(8_192);
}
@Test
public void failedCachePreparationDoesNotChangeGlobalTuningOrAcquireAParallelismHold() {
int holders = PregenPerformanceProfile.parallelismHolders();
int parallelism = MultiBurst.burst.parallelism();
doThrow(new IllegalStateException("Runtime closed"))
.when(complex).ensureTerrainNoiseCacheSize(4_096);
assertThrows(IllegalStateException.class, () -> PregenPerformanceProfile.apply(engine));
assertEquals(1_024, IrisSettings.get().getPerformance().getNoiseCacheSize());
assertEquals(parallelism, MultiBurst.burst.parallelism());
assertEquals(holders, PregenPerformanceProfile.parallelismHolders());
}
@Test
@@ -0,0 +1,82 @@
package art.arcane.iris.world.runtime;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.testsupport.BukkitTestServer;
import art.arcane.iris.world.lifecycle.CapabilitySnapshot;
import org.bukkit.World;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.withSettings;
public class PaperLikeRuntimeControlBackendTimeTest {
@Before
public void installServer() {
BukkitTestServer.install();
}
@Test
public void missingWorldHandleSkipsClockWithoutReportingFailure() throws Exception {
assertUnsupportedClock(mock(World.class));
}
@Test
public void missingClockMethodsSkipClockWithoutReportingFailure() throws Exception {
assertUnsupportedClock(worldWithHandle(new Object()));
}
@Test
public void supportedClockStillReadsAndWrites() throws Exception {
ClockHandle handle = new ClockHandle();
World world = worldWithHandle(handle);
PaperLikeRuntimeControlBackend backend = new PaperLikeRuntimeControlBackend(mock(CapabilitySnapshot.class));
assertEquals(OptionalLong.of(12000L), backend.readDayTime(world));
assertTrue(backend.writeDayTime(world, 6000L));
assertEquals(OptionalLong.of(6000L), backend.readDayTime(world));
}
private void assertUnsupportedClock(World world) throws Exception {
PaperLikeRuntimeControlBackend backend = new PaperLikeRuntimeControlBackend(mock(CapabilitySnapshot.class));
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
assertEquals(OptionalLong.empty(), backend.readDayTime(world));
assertEquals(OptionalLong.empty(), backend.readDayTime(world));
assertFalse(backend.writeDayTime(world, 6000L));
logging.verify(() -> IrisLogging.reportError(anyString(), any(Throwable.class)), never());
}
}
private World worldWithHandle(Object handle) {
World world = mock(World.class, withSettings().extraInterfaces(HandleWorld.class));
when(((HandleWorld) world).getHandle()).thenReturn(handle);
return world;
}
public interface HandleWorld {
Object getHandle();
}
public static class ClockHandle {
private long dayTime = 12000L;
public long getDayTime() {
return dayTime;
}
public void setDayTime(long dayTime) {
this.dayTime = dayTime;
}
}
}