Refine hydrology and Studio

- Add dimension-wide hydrology density controls.
- Validate pond containment and shorelines at block resolution.
- Prioritize requested water plans and deduplicate prefetch work.
- Keep completed water previews visible while details load.
- Add pregeneration map navigation and Studio theme controls.
- Reject pack mutations during generation-registry capture.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-26 00:08:25 -04:00
1 parent 1d182769d8
commit 494cdb1a5c
61 files changed
+1624 -325

No files matched your search

@@ -53,6 +53,7 @@ import java.util.Optional;
import java.util.Set;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
@@ -79,8 +80,10 @@ import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -296,6 +299,31 @@ public class HydrologyRuntimeTest {
}
}
@Test(timeout = 180_000L)
public void chunksSharePrefetchAndRetryAfterEvictionOrFailure() {
FakePlanners planners = new FakePlanners(settings());
HydrologyPlanCache cache = spy(new HydrologyPlanCache(planners.settings, planners::region, planners::window,
pool(planners, 2), () -> false));
AtomicInteger prefetches = new AtomicInteger();
doAnswer(invocation -> prefetches.incrementAndGet() == 2
? CompletableFuture.failedFuture(new IllegalStateException("prefetch failed")) : CompletableFuture.completedFuture(null))
.when(cache).prepareWindowsAround(anyInt(), anyInt(), anyInt(), anyInt(), anyInt());
AtomicLong evictions = new AtomicLong();
doAnswer(invocation -> evictions.get()).when(cache).windowEvictions();
HydrologyRuntime runtime = new HydrologyRuntime(planners.settings, cache, planners::natural, () -> false, planners.ticker::get);
runtimes.add(runtime);
runtime.raster(0, 0);
runtime.raster(1, 0);
assertEquals(1, prefetches.get());
evictions.incrementAndGet();
runtime.raster(2, 0);
assertEquals(2, prefetches.get());
runtime.raster(3, 0);
assertEquals(3, prefetches.get());
runtime.raster(4, 0);
assertEquals(3, prefetches.get());
}
@Test(timeout = 180_000L)
public void preparePregenerationPlansRegionsInOrder() throws Exception {
FakePlanners planners = new FakePlanners(settings());
@@ -702,6 +730,44 @@ public class HydrologyRuntimeTest {
verify(engine, never()).getComplex();
}
@Test
public void zeroDensitySkipsRuntimePlanningAfterValidatingSettings() {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisDimension dimension = new IrisDimension();
dimension.setHydrology(new IrisHydrology().setEnabled(true).setDensity(0D));
when(engine.getDimension()).thenReturn(dimension);
when(engine.getSeedManager()).thenReturn(new SeedManager(1337L));
when(engine.getHeight()).thenReturn(384);
assertDisabled(HydrologyRuntime.create(engine, complex));
verifyNoInteractions(complex);
verify(engine, never()).getPlatformHooks();
dimension.getHydrology().setDensity(-1D);
assertThrows(IllegalArgumentException.class, () -> HydrologyRuntime.create(engine, complex));
}
@Test(timeout = 15_000L)
public void nestedPreviewScopesStayNonblockingAndRestoreDemandSampling() throws Exception {
try (FakeHydrology water = new FakeHydrology(() -> false, (x, z) -> 60, 40, 63)) {
HydrologyRuntime runtime = spy(water.runtime);
water.hold();
try (HydrologyRuntime.PreviewScope outer = runtime.preview()) {
assertTrue(runtime.raster(0, 0).provisional());
try (HydrologyRuntime.PreviewScope inner = runtime.preview()) {
assertTrue(runtime.raster(1, 0).provisional());
}
assertTrue(runtime.raster(0, 1).provisional());
}
water.release();
clearInvocations(runtime);
assertFalse(runtime.raster(400, 400).provisional());
verify(runtime, never()).rasterPreview(anyInt(), anyInt());
}
}
@Test(timeout = 300_000L)
public void createSavesPlansUnderTheWorldFolder() throws Exception {
Path worldFolder = temporaryFolder.newFolder("world").toPath();
@@ -4,6 +4,7 @@ import org.junit.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.Set;
@@ -32,6 +33,35 @@ public class HeightLatticeTest {
private static final int AREA = 3 * WINDOW;
private static final int DISTINCT_ACROSS_NINE = 25 * WINDOW * WINDOW;
@Test
public void neighbouringCellsSpreadAcrossConcurrentMapBuckets() {
int[] buckets = new int[65_536];
int occupied = 0;
int largest = 0;
Set<Long> identities = new HashSet<>();
for (int z = -96; z < 96; z++) {
for (int x = -96; x < 96; x++) {
long key = HeightLattice.key(x, z);
assertTrue(identities.add(key));
int hash = Long.hashCode(key);
int bucket = (hash ^ (hash >>> 16)) & (buckets.length - 1);
if (buckets[bucket]++ == 0) {
occupied++;
}
largest = Math.max(largest, buckets[bucket]);
}
}
assertTrue("occupied buckets " + occupied, occupied > 25_000);
assertTrue("largest collision chain " + largest, largest < 12);
identities.clear();
int[] extremes = {Integer.MIN_VALUE, Integer.MIN_VALUE + 1, -1, 0, 1, Integer.MAX_VALUE - 1, Integer.MAX_VALUE};
for (int x : extremes) {
for (int z : extremes) {
assertTrue("distinct coordinate " + x + "," + z, identities.add(HeightLattice.key(x, z)));
}
}
}
@Test
public void samplesEachCellOnceAcrossNineOverlappingWindows() {
CountingSource source = new CountingSource();
@@ -317,6 +317,41 @@ public class HydrologyPlanCacheTest {
assertEquals(26, planners.windowCalls.size());
}
@Test
public void speculativePlanningLeavesWorkersForNewAndPromotedDemand() throws Exception {
for (int threads : List.of(2, 4)) {
FakePlanners planners = new FakePlanners(unpersisted());
Gate gate = new Gate();
planners.hook = key -> {
if (key instanceof RegionKey region && Math.abs(region.rx()) < 10 && Math.abs(region.rz()) < 10) {
gate.pass();
}
};
ExecutorService workers = pool(threads);
HydrologyPlanCache cache = cache(planners, workers, () -> false);
CompletableFuture<Void> prepared = cache.prepareWindowsAround(0, 0, 31, 31, 2);
try {
await(() -> gate.arrivals() >= threads / 2, "speculative planning reaches its concurrency limit");
WindowKey demanded = new WindowKey(400, 400);
assertEquals(demanded, cache.demandWindow(demanded).get(5L, TimeUnit.SECONDS).plan().key());
assertEquals("prefetch leaves workers available for demand", threads / 2, gate.arrivals());
WindowKey promoted = new WindowKey(800, 800);
CompletableFuture<IndexedWindow> prefetched = cache.prefetchWindow(promoted);
workers.submit(() -> null).get(5L, TimeUnit.SECONDS);
assertFalse("the prefetch waits for speculative capacity", prefetched.isDone());
IndexedWindow ready = cache.demandWindow(promoted).get(5L, TimeUnit.SECONDS);
assertEquals(promoted, ready.plan().key());
assertSame("demand promotes the queued prefetch", ready, prefetched.get(5L, TimeUnit.SECONDS));
assertEquals(threads / 2, gate.arrivals());
} finally {
gate.release();
}
prepared.get(TIMEOUT_SECONDS, TimeUnit.SECONDS);
assertEquals("all prepared windows finish after demand", 27, planners.windowCalls.size());
}
}
@Test
public void plansWithRealPlannersAndReloadsFromStore() throws Exception {
HydrologySettings settings = settings(new IrisHydrology());
@@ -412,13 +447,14 @@ public class HydrologyPlanCacheTest {
}
@Test
public void leastRecentlyUsedWindowIsEvicted() {
public void leastRecentlyUsedWindowIsEvicted() throws Exception {
FakePlanners planners = new FakePlanners(unpersisted());
HydrologyPlanCache cache = cache(planners, pool(4), () -> false);
WindowKey kept = new WindowKey(0, 0);
WindowKey stale = new WindowKey(1, 0);
IndexedWindow keptWindow = cache.window(kept);
cache.window(stale);
assertEquals(0L, cache.windowEvictions());
List<WindowKey> requested = new ArrayList<>(List.of(kept, stale));
for (int index = 0; index < 300; index++) {
WindowKey key = new WindowKey(index, 7);
@@ -435,8 +471,13 @@ public class HydrologyPlanCacheTest {
}
}
assertTrue("the cache stays bounded: " + ready, ready < requested.size() / 2);
await(() -> cache.windowEvictions() == requested.size() - 128, "all excess windows are evicted");
long evictions = cache.windowEvictions();
assertSame(keptWindow, cache.window(kept));
assertEquals("a resident cache hit does not evict a window", evictions, cache.windowEvictions());
cache.window(stale);
assertEquals("an evicted window is planned again on demand", 2, planners.windowCalls(stale));
await(() -> cache.windowEvictions() == evictions + 1, "replanning an evicted window advances the eviction count");
}
@Test
@@ -30,7 +30,20 @@ public class HydrologySettingsTest {
private static final int SEA_LEVEL = 62;
private static final int MIN_Y = -64;
private static final int MAX_Y = 320;
private static final long DEFAULT_FINGERPRINT = 3973071586549334460L;
private static final long DEFAULT_FINGERPRINT = 8818472145657699942L;
@Test
public void densityIsBoundedAndChangesThePlanIdentity() {
HydrologySettings full = compile(new IrisHydrology());
HydrologySettings half = compile(new IrisHydrology().setDensity(0.5D));
assertEquals(1f, full.density(), 0f);
assertEquals(0.5f, half.density(), 0f);
assertTrue(full.fingerprint() != half.fingerprint());
assertEquals(full.tiers(), half.tiers());
for (double invalid : new double[]{-0.01D, 1.01D, Double.NaN, Double.POSITIVE_INFINITY}) {
assertRejected(new IrisHydrology().setDensity(invalid), "density");
}
}
@Rule
public final TemporaryFolder temporary = new TemporaryFolder();
@@ -34,6 +34,14 @@ public class RegionPlannerTest {
private static final RegionKey ORIGIN = new RegionKey(0, 0);
private static final RegionKey EAST = new RegionKey(1, 0);
@Test
public void zeroDensityRemovesMajorChannelsAndLakes() {
HydrologySettings settings = HydrologySettings.compile(new IrisHydrology().setDensity(0D), SEED, SEA_LEVEL,
SyntheticTerrain.MIN_Y, SyntheticTerrain.MAX_Y);
assertTrue(planner(settings, SyntheticTerrain.coneIsland(2048D, 2048D, 1700D, 240D, 6)).plan(ORIGIN).majorReaches().isEmpty());
assertTrue(planner(settings, SyntheticTerrain.crater(2048D, 2048D, 150, 700D)).plan(ORIGIN).lakes().isEmpty());
}
@Test
public void coneIslandMajorReachesEndInOcean() {
HydrologySettings settings = settings();
@@ -41,6 +41,14 @@ public class WindowPlannerTest {
private static final WindowKey LAND = new WindowKey(4, 4);
private static final int SEAM_DISTANCE = 256;
@Test
public void zeroDensityRemovesLocalChannelsAndPonds() {
HydrologySettings settings = settings(new IrisHydrology().setDensity(0D));
WindowPlan plan = planner(settings, SyntheticTerrain.trunkValley()).plan(LAND, noRegions(LAND));
assertTrue(plan.reaches().isEmpty());
assertTrue(plan.ponds().isEmpty());
}
@Test
public void burnedTrunkCapturesLocalDrainage() {
HydrologySettings settings = settings(new IrisHydrology());
@@ -28,12 +28,51 @@ import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class CavePoolFillerTest {
private static final long SEED = 1337L;
private static final int FLOOR = 50;
@Test
public void globalDensityThinsCandidateColumnsBeforeSamplingCaves() {
IrisHydrology config = new IrisHydrology().setDensity(0.5D);
config.getUnderground().getCavePools().setDensity(8D);
CavePoolFiller filler = new CavePoolFiller(HydrologySettings.compile(config, SEED, 63, 0, 384));
CarvedTerrain carved = mock(CarvedTerrain.class);
MantleWriter writer = mock(MantleWriter.class);
Set<Long> columns = new HashSet<>();
when(carved.surface(anyInt(), anyInt())).thenAnswer(invocation -> {
int x = invocation.getArgument(0);
int z = invocation.getArgument(1);
columns.add(key(x, 0, z));
return 0;
});
for (int chunkX = 0; chunkX < 1024; chunkX++) {
filler.fill(chunkX, 0, carved, writer, SEED);
}
assertTrue("half density samples approximately half the candidate columns", columns.size() > 3600 && columns.size() < 4500);
Set<Long> repeated = new HashSet<>(columns);
columns.clear();
for (int chunkX = 1023; chunkX >= 0; chunkX--) {
filler.fill(chunkX, 0, carved, writer, SEED);
}
assertEquals(repeated, columns);
verifyNoInteractions(writer);
}
@Test
public void zeroGlobalDensityDoesNotReadCaves() {
IrisHydrology config = new IrisHydrology().setDensity(0D);
config.getUnderground().getCavePools().setDensity(8D);
CavePoolFiller filler = new CavePoolFiller(HydrologySettings.compile(config, SEED, 63, 0, 384));
CarvedTerrain carved = mock(CarvedTerrain.class);
MantleWriter writer = mock(MantleWriter.class);
filler.fill(0, 0, carved, writer, SEED);
verifyNoInteractions(carved, writer);
}
@Test
public void poolLevelIsHighestContainedLevelMinusFreeboard() {
VoxelWorld world = roomWithSill();
@@ -21,6 +21,31 @@ import static org.junit.Assert.assertTrue;
public class SeaInletPlannerTest {
private static final int SEA = InletTerrain.SEA;
@Test
public void globalDensityThinsInletSitesWithoutChangingTheirGeometry() {
InletTerrain terrain = InletTerrain.cliff(30D);
IrisHydrology full = new IrisHydrology();
IrisHydrology half = new IrisHydrology().setDensity(0.5D);
full.getUnderground().getSeaInlets().setSpacing(256);
half.getUnderground().getSeaInlets().setSpacing(256);
int baseline = 0;
int retained = 0;
for (int offset = 0; offset < 32; offset++) {
InletTerrain shifted = terrain.shifted(0, offset * 512);
List<SeaInlet> all = shifted.plan(InletTerrain.settings(full));
List<SeaInlet> reduced = shifted.plan(InletTerrain.settings(half));
baseline += all.size();
retained += reduced.size();
for (SeaInlet inlet : reduced) {
assertTrue("retained inlets keep the same full passage", all.contains(inlet));
}
assertEquals(reduced, shifted.plan(InletTerrain.settings(half)));
}
assertTrue("cliff provides enough candidates", baseline >= 32);
assertTrue("half density retains approximately half the sites", retained > baseline * 0.3 && retained < baseline * 0.7);
assertTrue(terrain.plan(InletTerrain.settings(new IrisHydrology().setDensity(0D))).isEmpty());
}
@Test
public void inletStartsAtOceanCell() {
InletTerrain terrain = InletTerrain.cliff(30D);
@@ -10,15 +10,24 @@ import art.arcane.iris.generation.hydrology.grid.HydrologySampleBuffer;
import art.arcane.iris.generation.hydrology.grid.HydrologyTerrainSource;
import art.arcane.iris.generation.hydrology.grid.PriorityFlood;
import art.arcane.iris.generation.hydrology.plan.HydrologySettings;
import art.arcane.iris.generation.hydrology.plan.IndexedWindow;
import art.arcane.iris.generation.hydrology.plan.RegionKey;
import art.arcane.iris.generation.hydrology.plan.RegionPlan;
import art.arcane.iris.generation.hydrology.plan.WindowKey;
import art.arcane.iris.generation.hydrology.plan.WindowPlan;
import art.arcane.iris.generation.hydrology.raster.ChunkRasterizer;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.raster.ReachIndex;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.DoubleBinaryOperator;
@@ -38,6 +47,77 @@ public class PondPlannerTest {
private static final float BOWL_SPILL = 112f;
private static final WindowKey ORIGIN = new WindowKey(0, 0);
@Test
public void smallSeededPondFollowsItsBowlAtBlockResolution() {
HydrologySettings settings = settings(config -> {
seededOnly(config);
config.getPonds().getSeeded().setDensity(1D).setRadius(new KList<>(8, 8)).setDepth(new KList<>(6, 6))
.setAlongChannels(false);
config.getPonds().setFreeboard(new KList<>(1, 1));
config.getPonds().getShore().setRoundness(0D);
}, MIN_Y, MAX_Y);
List<Pond> ponds = plan(settings, new Terrain((x, z) -> 100D, 1f, ALLOW_ALL, null, MIN_Y, MAX_Y),
0, 0, 64, 64, new BitSet(), List.of());
Pond pond = single(ponds);
PondMask mask = pond.mask();
assertEquals(1, mask.step());
int wet = 0;
int minX = Integer.MAX_VALUE;
int minZ = Integer.MAX_VALUE;
int maxX = Integer.MIN_VALUE;
int maxZ = Integer.MIN_VALUE;
for (int j = 0; j < mask.depth(); j++) {
for (int i = 0; i < mask.width(); i++) {
if (mask.node(i, j)) {
wet++;
minX = Math.min(minX, i);
minZ = Math.min(minZ, j);
maxX = Math.max(maxX, i);
maxZ = Math.max(maxZ, j);
}
}
}
int rectangle = (maxX - minX + 1) * (maxZ - minZ + 1);
assertTrue("rounded bowl occupies " + wet + " of " + rectangle + " bounding blocks", wet < rectangle * 0.9D);
assertFalse(mask.node(minX, minZ));
assertFalse(mask.node(maxX, maxZ));
assertTrue(mask.contains(pond.floorX(), pond.floorZ()));
}
@Test
public void globalDensityHalvesSaturatedSeededSiteAcceptance() {
HydrologySettings full = settings(config -> {
seededOnly(config);
config.getPonds().getSeeded().setDensity(16D).setAlongChannels(false);
}, MIN_Y, MAX_Y);
HydrologySettings half = settings(config -> {
seededOnly(config);
config.setDensity(0.5D);
config.getPonds().getSeeded().setDensity(16D).setAlongChannels(false);
}, MIN_Y, MAX_Y);
Terrain terrain = new Terrain((x, z) -> 100D, 1f, ALLOW_ALL, null, MIN_Y, MAX_Y);
Set<Long> all = seededSites(full, terrain);
Set<Long> reduced = seededSites(half, terrain);
assertTrue("full candidates " + all.size(), all.size() >= 450);
assertTrue(all.containsAll(reduced));
double fraction = reduced.size() / (double) all.size();
assertTrue("retained fraction " + fraction, fraction > 0.44D && fraction < 0.56D);
}
private static Set<Long> seededSites(HydrologySettings settings, Terrain terrain) {
Set<Long> sites = new HashSet<>();
for (int window = 0; window < 32; window++) {
int x = window * WindowKey.SIZE;
Sampled sampled = sample(terrain, 8, x, 0, 64, 64);
Arrays.fill(sampled.samples().pondDensity(), 100f);
CupSet cups = new PondPlanner(settings).subtractCups(sampled.grid(), new WindowKey(window, 0), sampled.samples(), new BitSet());
for (CupSet.Cup cup : cups.within(x, 0, x + WindowKey.SIZE, WindowKey.SIZE)) {
sites.add(((long) cup.x() << 32) | (cup.z() & 0xFFFFFFFFL));
}
}
return sites;
}
@Test
public void naturalBasinPondSitsBelowSpill() {
HydrologySettings settings = settings(PondPlannerTest::naturalOnly, MIN_Y, MAX_Y);
@@ -187,6 +267,7 @@ public class PondPlannerTest {
assertTrue(pond.mask().contains(200, 256));
assertTrue(pond.mask().contains(312, 256));
assertContained(pond, terrain);
assertRasterRimContained(settings, pond, terrain);
}
@Test
@@ -527,6 +608,43 @@ public class PondPlannerTest {
return new PondMask(x, 208, 16, 1, 7, new long[]{0b1111111L});
}
private static void assertRasterRimContained(HydrologySettings settings, Pond pond, DoubleBinaryOperator natural) {
Map<Long, ChunkWaterRaster> rasters = new HashMap<>();
ChunkRasterizer rasterizer = new ChunkRasterizer(settings);
int raised = 0;
int[][] sides = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
for (int[] wet : wetNodes(pond.mask())) {
for (int[] side : sides) {
int x = wet[0] + side[0];
int z = wet[1] + side[1];
if (pond.mask().contains(x, z)) {
continue;
}
long key = ((long) (x >> 4) << 32) | ((z >> 4) & 0xFFFFFFFFL);
ChunkWaterRaster raster = rasters.get(key);
if (raster == null) {
WindowPlan window = new WindowPlan(WindowKey.containing(x, z), List.of(), List.of(pond), List.of());
IndexedWindow indexed = new IndexedWindow(window, ReachIndex.build(List.of(), List.of(pond)));
List<RegionPlan> regions = new ArrayList<>();
for (RegionKey region : ChunkRasterizer.lakeRegions(x >> 4, z >> 4)) {
regions.add(new RegionPlan(region, List.of(), List.of()));
}
raster = rasterizer.rasterize(x >> 4, z >> 4, (px, pz) -> (int) Math.round(natural.applyAsDouble(px, pz)),
List.of(indexed), regions);
rasters.put(key, raster);
}
int ground = (int) Math.round(natural.applyAsDouble(x, z));
int actual = raster.terrainHeight(x & 15, z & 15);
assertTrue("dry rim contains the water at " + x + "," + z, actual >= pond.level());
assertTrue("rim fill remains bounded at " + x + "," + z, actual <= ground + settings.deformation().maxFill());
if (actual > ground) {
raised++;
}
}
}
assertTrue("sub-lattice rim needs real raster berms", raised > 0);
}
private static Pond lake(PondMask mask) {
return new Pond(42L, PondKind.LAKE, 110, 100, mask.originX(), mask.originZ(), 111f, mask, 0L, 0, "water", mask.originX(), mask.originZ());
}
@@ -548,6 +666,7 @@ public class PondPlannerTest {
private static void assertContained(Pond pond, DoubleBinaryOperator natural) {
PondMask mask = pond.mask();
int step = mask.step();
int maxFill = new IrisHydrology().getDeformation().getMaxFill();
List<int[]> nodes = wetNodes(mask);
assertFalse(nodes.isEmpty());
assertTrue("floor " + pond.floor() + " level " + pond.level(), pond.floor() < pond.level());
@@ -557,8 +676,8 @@ public class PondPlannerTest {
int x = node[0] + direction[0];
int z = node[1] + direction[1];
if (!mask.contains(x, z)) {
float edge = (float) natural.applyAsDouble(x, z);
assertTrue("edge " + x + "," + z + " natural " + edge + " level " + pond.level(), edge >= pond.level());
long edge = Math.round(natural.applyAsDouble(x, z));
assertTrue("edge " + x + "," + z + " natural " + edge + " level " + pond.level(), edge + maxFill >= pond.level());
}
}
}
@@ -23,6 +23,10 @@ import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.studio.map.components.MapLayer;
import art.arcane.iris.studio.map.components.MapTile;
import art.arcane.iris.studio.map.components.MapTileRenderer;
import art.arcane.iris.studio.map.components.TileType;
import art.arcane.iris.structure.object.IObjectPlacer;
import art.arcane.iris.testsupport.FakeHydrology;
import art.arcane.iris.testsupport.PlatformLeakGuard;
@@ -44,12 +48,14 @@ import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CancellationException;
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.assertNotNull;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
@@ -208,6 +214,35 @@ public class IrisComplexHeightStreamTest {
}
}
@Test(timeout = 15_000L)
public void mapHeightPreviewReturnsWhilePlanningAndRefreshesToFinalTerrain() throws Exception {
try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) {
fixture.hydrology.hold();
MapTileRenderer renderer = new MapTileRenderer(fixture.engine);
MapLayer layer = MapLayer.of(TileType.HEIGHT);
long before = ProvisionalSampling.revision();
MapTile preview = renderer.renderTile(0D, 0D, 1D, 16, layer, true, () -> false);
assertEquals(HydrologyFixture.NATURAL, preview.heightAt(8, 8), 0D);
assertNotEquals(before, ProvisionalSampling.revision());
fixture.hydrology.release();
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10L);
MapTile settled;
do {
assertTrue("Map height preview never settled", System.nanoTime() < deadline);
Thread.sleep(10L);
before = ProvisionalSampling.revision();
settled = renderer.renderTile(0D, 0D, 1D, 16, layer, true, () -> false);
} while (before != ProvisionalSampling.revision());
double terrain = fixture.complex.getHeightStream().getDouble(8D, 8D);
assertNotEquals(HydrologyFixture.NATURAL, terrain, 0D);
assertEquals(terrain, settled.heightAt(8, 8), 0D);
assertEquals(before, ProvisionalSampling.revision());
}
}
@Test(timeout = 120_000L)
public void waterStreamsFollowTheRaster() throws Exception {
try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) {
@@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "dd1ea7f0361dfad24c4800e75f1e26e951b4f775aa1b541328724a8635afeeca";
private static final String EXPECTED_SCHEMA_DIGEST = "6935e8d3914e5cde852522151d87fe898f79de3b394826e544c5c5bfbfceba57";
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class,
@@ -13,6 +13,8 @@ import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BooleanSupplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -102,6 +104,42 @@ public class VisionRenderControllerTest {
}
}
@Test
public void zoomRetainsCompleteWaterTilesWhileDetailedTilesAreProvisional() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
MapTile overview = mock(MapTile.class);
MapTile incomplete = mock(MapTile.class);
when(renderer.renderTile(anyDouble(), anyDouble(), anyDouble(), anyInt(), any(), anyBoolean(), any()))
.thenAnswer(invocation -> {
double scale = invocation.getArgument(2);
if (scale < 4D) {
ProvisionalSampling.mark();
return incomplete;
}
return overview;
});
try (VisionRenderController controller = new VisionRenderController(() -> { })) {
VisionRenderController.Frame coarse = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false));
await(() -> controller.progress(coarse).ready() == 1);
VisionRenderController.Frame fine = controller.request(spec(renderer, 1L));
await(() -> fine.tiles().getFirst().tile() != null);
VisionRenderController.VisibleTile visible = fine.tiles().getFirst();
assertTrue(visible.provisional());
assertEquals(0, controller.progress(fine).ready());
VisionRenderController.Fallback retained = controller.fallback(fine, visible);
assertNotNull(retained);
assertSame(overview, retained.pieces().getFirst().tile());
assertEquals(16, retained.pieces().getFirst().sourceSize());
VisionRenderController.Frame returned = controller.request(new VisionRenderController.RenderSpec(renderer,
MapLayer.of(TileType.RIVER), 1L, 128D, 128D, 4D, 64, 64, false));
assertSame(overview, returned.tiles().getFirst().tile());
assertFalse(returned.tiles().getFirst().provisional());
}
}
@Test
public void closeRejectsLatePublication() throws Exception {
MapTileRenderer renderer = mock(MapTileRenderer.class);
@@ -2,8 +2,10 @@ package art.arcane.iris.studio.render;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.engine.HydrologyPlanPreview;
import art.arcane.iris.generation.hydrology.plan.HydrologySettings;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.water.Pond;
import art.arcane.iris.generation.hydrology.water.Reach;
import art.arcane.iris.generation.runtime.Engine;
@@ -93,6 +95,34 @@ public class IrisRendererRiverTest {
}
}
@Test
public void zoomKeepsResidentWaterPlansUntilDetailedRastersAreReady() {
HydrologyRuntime runtime = mock(HydrologyRuntime.class);
Pond pond = WaterScene.pond(32L, 62, 16, 16, 10);
when(runtime.enabled()).thenReturn(true);
when(runtime.planPreview(anyInt(), anyInt(), anyInt(), anyInt()))
.thenReturn(new HydrologyPlanPreview(List.of(), List.of(pond), List.of(), List.of(), true));
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.PROVISIONAL);
IrisRenderer renderer = new IrisRenderer(engine(runtime));
BufferedImage overview = renderer.renderStudio(0D, 0D, 256D, 64, RenderType.RIVER, () -> false);
int water = IrisRenderer.hydrologyFeatureColor(HydrologyFeatureType.POND);
assertEquals(water, overview.getRGB(4, 4));
for (int step : new int[]{2, 1}) {
long before = ProvisionalSampling.revision();
BufferedImage detailed = renderer.renderStudio(0D, 0D, step * 64D, 64, RenderType.RIVER, () -> false);
assertEquals(water, detailed.getRGB(16 / step, 16 / step));
assertNotEquals(before, ProvisionalSampling.revision());
}
when(runtime.rasterPreview(anyInt(), anyInt())).thenReturn(ChunkWaterRaster.EMPTY);
long before = ProvisionalSampling.revision();
BufferedImage settled = renderer.renderStudio(0D, 0D, 64D, 64, RenderType.RIVER, () -> false);
assertEquals(NO_RIVER, settled.getRGB(16, 16));
assertEquals(before, ProvisionalSampling.revision());
}
@Test
public void channelPixelsShowTheirTier() throws Exception {
HydrologySettings settings = FakeHydrology.settings(SEA_LEVEL);
@@ -6,8 +6,10 @@ import org.junit.Test;
import javax.swing.SwingUtilities;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.image.BufferedImage;
import java.lang.reflect.Field;
@@ -125,6 +127,82 @@ public class PregenMapPanelTest {
SwingUtilities.invokeAndWait(extreme::disposeMap);
}
@Test
public void wheelZoomPreservesTheChunkUnderThePointerAndRetainedPixels() throws Exception {
PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds(-32, -32, 31, 31));
SwingUtilities.invokeAndWait(() -> panel.setSize(802, 602));
panel.submit(-7, -19, Color.GREEN);
flush(panel);
BufferedImage retained = image(panel);
String before = tooltip(panel, 337, 123);
SwingUtilities.invokeAndWait(() -> panel.dispatchEvent(new MouseWheelEvent(panel,
MouseEvent.MOUSE_WHEEL, 1L, 0, 337, 123, 0, false,
MouseWheelEvent.WHEEL_UNIT_SCROLL, 3, -4)));
assertEquals(before, tooltip(panel, 337, 123));
assertTrue(panel.zoom() > 1D);
assertTrue(retained == image(panel));
assertEquals(Color.GREEN.getRGB(), image(panel).getRGB(25, 13));
SwingUtilities.invokeAndWait(panel::disposeMap);
}
@Test
public void draggingPansTheMapAndResetFitsBesideTheSidebar() throws Exception {
PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds(-32, -32, 31, 31));
SwingUtilities.invokeAndWait(() -> {
panel.setSize(1000, 600);
panel.setCoveredLeftWidth(300);
});
String initial = tooltip(panel, 650, 300);
assertNull(tooltip(panel, 250, 300));
SwingUtilities.invokeAndWait(() -> {
panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_PRESSED, 1L,
MouseEvent.BUTTON1_DOWN_MASK, 650, 300, 1, false, MouseEvent.BUTTON1));
panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_DRAGGED, 2L,
MouseEvent.BUTTON1_DOWN_MASK, 710, 350, 0, false, MouseEvent.NOBUTTON));
panel.dispatchEvent(new MouseEvent(panel, MouseEvent.MOUSE_RELEASED, 3L,
0, 710, 350, 1, false, MouseEvent.BUTTON1));
});
assertEquals(initial, tooltip(panel, 710, 350));
assertFalse(initial.equals(tooltip(panel, 650, 300)));
SwingUtilities.invokeAndWait(() -> {
panel.zoomIn();
panel.resetView();
});
assertEquals(1D, panel.zoom(), 0D);
assertEquals(initial, tooltip(panel, 650, 300));
SwingUtilities.invokeAndWait(panel::disposeMap);
}
@Test
public void zoomBoundsAndGridPaintingHandleExtremeMapCoordinates() throws Exception {
PregenMapPanel panel = panel(new PregenRenderSnapshot.Bounds(
Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE));
SwingUtilities.invokeAndWait(() -> {
panel.setSize(820, 520);
panel.toggleGrid();
for (int index = 0; index < 100; index++) {
panel.zoomIn();
}
assertEquals(128D, panel.zoom(), 0D);
BufferedImage target = new BufferedImage(820, 520, BufferedImage.TYPE_INT_RGB);
Graphics2D canvas = target.createGraphics();
try {
panel.paint(canvas);
panel.panBy(1_000_000, -1_000_000);
panel.paint(canvas);
for (int index = 0; index < 100; index++) {
panel.zoomOut();
}
assertEquals(0.25D, panel.zoom(), 0D);
panel.resetView();
panel.paint(canvas);
} finally {
canvas.dispose();
panel.disposeMap();
}
});
}
private static String tooltip(PregenMapPanel panel, int x, int y) throws Exception {
String[] result = new String[1];
SwingUtilities.invokeAndWait(() -> result[0] = panel.getToolTipText(
@@ -11,8 +11,12 @@ import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JTextArea;
import javax.swing.JToggleButton;
import javax.swing.JViewport;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import java.awt.Component;
import java.awt.Container;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.lang.reflect.Constructor;
@@ -27,6 +31,50 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mockStatic;
public class PregenRendererTest {
@Test
public void smallWindowOpensAtTheProgressHeaderWithoutScrollingAway() throws Exception {
PregenRenderer[] result = new PregenRenderer[1];
onEdt(() -> {
result[0] = renderer(new Source(), () -> { });
result[0].setSize(820, 520);
layout(result[0]);
});
try {
onEdt(() -> layout(result[0]));
onEdt(() -> assertScrollAtTop(result[0]));
} finally {
onEdt(result[0]::close);
}
}
@Test
public void mapShortcutsZoomPanResetAndKeepGridControlSynchronized() throws Exception {
onEdt(() -> {
PregenRenderer renderer = renderer(new Source(), () -> { });
try {
PregenMapPanel map = field(renderer, "map", PregenMapPanel.class);
map.setSize(1000, 600);
map.setCoveredLeftWidth(300);
shortcut(renderer, KeyEvent.VK_EQUALS);
assertTrue(map.zoom() > 1D);
shortcut(renderer, KeyEvent.VK_LEFT);
shortcut(renderer, KeyEvent.VK_R);
assertEquals(1D, map.zoom(), 0D);
shortcut(renderer, KeyEvent.VK_G);
assertTrue(map.isGridVisible());
JToggleButton grid = field(renderer, "gridToggle", JToggleButton.class);
assertTrue(grid.isSelected());
grid.doClick(0);
assertFalse(map.isGridVisible());
assertFalse(grid.isSelected());
shortcut(renderer, KeyEvent.VK_SLASH);
shortcut(renderer, KeyEvent.VK_ESCAPE);
} finally {
renderer.close();
}
});
}
@Test
public void keyboardAndButtonActivationToggleExactlyOnceAndTerminalControlsAreDisabled() throws Exception {
onEdt(() -> {
@@ -155,6 +203,32 @@ public class PregenRendererTest {
refresh.invoke(renderer);
}
private static void shortcut(PregenRenderer renderer, int keyCode) {
Object action = renderer.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW)
.get(KeyStroke.getKeyStroke(keyCode, 0));
renderer.getActionMap().get(action).actionPerformed(new ActionEvent(renderer, 0, "shortcut"));
}
private static void layout(Container container) {
container.doLayout();
for (Component component : container.getComponents()) {
if (component instanceof Container child) {
layout(child);
}
}
}
private static void assertScrollAtTop(Container container) {
for (Component component : container.getComponents()) {
if (component instanceof JViewport viewport) {
assertEquals(0, viewport.getViewPosition().y);
}
if (component instanceof Container child) {
assertScrollAtTop(child);
}
}
}
private static <T> T field(PregenRenderer renderer, String name, Class<T> type) throws Exception {
Field field = PregenRenderer.class.getDeclaredField(name);
field.setAccessible(true);
@@ -68,6 +68,11 @@ public class IrisRiverSchemaTest {
JSONObject fluid = referencedProperties(definitions, properties.getJSONObject("fluids").getJSONObject("items"));
assertEquals("boolean", properties.getJSONObject("enabled").getString("type"));
JSONObject density = properties.getJSONObject("density");
assertEquals("number", density.getString("type"));
assertEquals(0D, density.getDouble("minimum"), 0D);
assertEquals(1D, density.getDouble("maximum"), 0D);
assertTrue(density.getString("description").contains("Default Value is 1.0"));
assertEquals("boolean", properties.getJSONObject("persist").getString("type"));
assertEquals(0, properties.getJSONObject("prefetchRings").getInt("minimum"));
assertEquals(4, properties.getJSONObject("prefetchRings").getInt("maximum"));
@@ -34,6 +34,8 @@ import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.FileTime;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
@@ -895,7 +897,7 @@ public class GenerationRegistryContractFactoryTest {
}
@Test
public void rejectsAChangedPackBeforeCapture() throws Exception {
public void rejectsAPackChangedBeforeCapture() throws Exception {
File pack = temporary.newFolder("changed-pack");
String original = GenerationPackFingerprint.compute(
pack.toPath(),
@@ -904,20 +906,58 @@ public class GenerationRegistryContractFactoryTest {
Files.writeString(pack.toPath().resolve("dimension.json"), "{}", StandardCharsets.UTF_8);
IrisData data = mock(IrisData.class);
when(data.getDataFolder()).thenReturn(pack);
IrisDimension dimension = spy(new IrisDimension());
dimension.setLoadKey("overworld");
stubDimensions(data, dimension);
doReturn(new KList<>()).when(dimension).getReachableBiomes(any());
doReturn(new KList<>()).when(dimension).getAllRegions(any());
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.blockTypeKeys()).thenReturn(new KList<>());
when(registries.entityKeys()).thenReturn(new KList<>());
assertThrows(
IOException.class,
() -> GenerationRegistryContractFactory.create(
data,
new IrisDimension(),
dimension,
original,
DataVersion.getLatest().get(),
mock(PlatformRegistries.class),
registries,
new FakeGenerationRegistry("runtime")
)
);
}
@Test
public void rejectsSameSizePackMutationDuringCaptureWithPreservedTimestamp() throws Exception {
File pack = temporary.newFolder("mutating-pack");
Path content = pack.toPath().resolve("content.json");
Files.writeString(content, "{\"version\":1}", StandardCharsets.UTF_8);
FileTime modified = Files.getLastModifiedTime(content);
String original = GenerationPackFingerprint.compute(pack.toPath(), GenerationPackFingerprint.CURRENT_VERSION);
IrisData data = mock(IrisData.class);
when(data.getDataFolder()).thenReturn(pack);
IrisDimension dimension = spy(new IrisDimension());
dimension.setLoadKey("overworld");
stubDimensions(data, dimension);
doReturn(new KList<>()).when(dimension).getReachableBiomes(any());
doReturn(new KList<>()).when(dimension).getAllRegions(any());
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.blockTypeKeys()).thenAnswer(ignored -> {
Files.writeString(content, "{\"version\":2}", StandardCharsets.UTF_8);
Files.setLastModifiedTime(content, modified);
return new KList<>();
});
when(registries.entityKeys()).thenReturn(new KList<>());
IOException failure = assertThrows(IOException.class, () -> GenerationRegistryContractFactory.create(
data, dimension, original, DataVersion.getLatest().get(), registries, new FakeGenerationRegistry("runtime")));
assertEquals("Iris generation registry capture rejected a changed pack.", failure.getMessage());
assertEquals(modified, Files.getLastModifiedTime(content));
assertEquals("{\"version\":2}", Files.readString(content));
}
@Test
public void rejectsAnUnloadableAdditionalDimensionBeforeCapturingItsRegistry() throws Exception {
File pack = temporary.newFolder("missing-secondary-dimension");