mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-26 14:00:26 +00:00
Hydrology Fix, and V+
This commit is contained in:
1 parent
dfb3748059
commit
1e1eaa96aa
137 files changed
+8842
-711
No files matched your search
+10
-4
@@ -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());
|
||||
|
||||
+137
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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
|
||||
|
||||
+248
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -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);
|
||||
}
|
||||
}
|
||||
+225
@@ -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()) {
|
||||
|
||||
+3
-3
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
));
|
||||
|
||||
+10
-10
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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
|
||||
));
|
||||
|
||||
+8
-8
@@ -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());
|
||||
|
||||
+7
-7
@@ -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();
|
||||
|
||||
+4
-15
@@ -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) {
|
||||
|
||||
+28
@@ -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);
|
||||
|
||||
+208
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-1
@@ -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());
|
||||
}
|
||||
|
||||
+93
-5
@@ -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();
|
||||
|
||||
+272
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
+163
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -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) {
|
||||
}
|
||||
}
|
||||
+68
@@ -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 = {
|
||||
|
||||
+2
-1
@@ -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();
|
||||
|
||||
+225
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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();
|
||||
|
||||
+36
-1
@@ -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);
|
||||
|
||||
+30
-1
@@ -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))
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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, () -> {}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+122
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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();
|
||||
|
||||
+29
-1
@@ -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(
|
||||
|
||||
+103
@@ -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));
|
||||
|
||||
+96
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+356
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
-3
@@ -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,
|
||||
|
||||
+13
-13
@@ -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;
|
||||
}
|
||||
}
|
||||
+178
@@ -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 {
|
||||
|
||||
+138
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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));
|
||||
|
||||
+194
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+43
@@ -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,
|
||||
|
||||
+120
@@ -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();
|
||||
|
||||
+45
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
@@ -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<>();
|
||||
|
||||
+20
-8
@@ -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
|
||||
|
||||
+50
-1
@@ -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();
|
||||
|
||||
+319
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+334
@@ -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);
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+82
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user