Production-readiness suite fixes

Surface hydrology policy split, native terrain height cache, watched-edit
hotload cancellation, world-save requestSave with tracked ownership flush,
background-task drain that follows saves admitted mid-drain, peaceful spawn
guard, cached-completion terminal state, IO drains off the owner thread.

Studio pregen at scale: detached historical runtimes keep the configured
noise cache instead of the Studio-wide cache; retired runtimes are no longer
pinned by the volumetric fragment filter's per-thread scratch; pregen mantle
cleanup and native region flushes run on per-run threads instead of the
server thread and the shared Iris IO pool (the flush on the IO pool starved
tectonic plate loads and wedged every Paper worker in Mantle.getChunks);
background-task cancellation claims tasks atomically.
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-09 16:24:05 -04:00
1 parent 4de1aea8b9
commit 62e61970f8
150 files changed
+7892 -1341

No files matched your search

@@ -0,0 +1,142 @@
package art.arcane.iris.core.gui;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.pregenerator.PregenApiPhase;
import art.arcane.iris.core.pregenerator.PregenApiSink;
import art.arcane.iris.core.pregenerator.PregenListener;
import art.arcane.iris.core.pregenerator.PregenTask;
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
import art.arcane.iris.spi.IrisServices;
import art.arcane.volmlib.util.math.Position2;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class PregeneratorJobTerminalProgressTest {
@Test
public void finalApiEventIncludesEveryChunkCompletedDuringClose() throws Exception {
List<ProgressEvent> events = run(new Completion(false, false, false));
assertCompleted(events);
}
@Test
public void cachedCompletionsDrainedDuringCloseReachTheSameTerminalTotal() throws Exception {
List<ProgressEvent> events = run(new Completion(true, false, false));
assertCompleted(events);
}
@Test
public void failedFinalRequestRemainsIncompleteWithCurrentFailureCount() throws Exception {
List<ProgressEvent> events = run(new Completion(false, true, false));
ProgressEvent terminal = events.getLast();
assertEquals(PregenApiPhase.CANCELLED, terminal.phase());
assertEquals(8L, terminal.progress().generated());
assertEquals(9L, terminal.progress().totalChunks());
assertEquals(1L, terminal.progress().chunksRemaining());
assertEquals(1L, terminal.progress().failed());
assertFalse(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.COMPLETED));
}
@Test
public void explicitCancellationPublishesItsDrainedPartialCount() throws Exception {
List<ProgressEvent> events = run(new Completion(false, false, true));
ProgressEvent terminal = events.getLast();
assertEquals(PregenApiPhase.CANCELLED, terminal.phase());
assertEquals(1L, terminal.progress().generated());
assertEquals(9L, terminal.progress().totalChunks());
assertEquals(8L, terminal.progress().chunksRemaining());
assertEquals(0L, terminal.progress().failed());
}
private static void assertCompleted(List<ProgressEvent> events) {
ProgressEvent terminal = events.getLast();
assertEquals(PregenApiPhase.COMPLETED, terminal.phase());
assertEquals(9L, terminal.progress().generated());
assertEquals(9L, terminal.progress().totalChunks());
assertEquals(0L, terminal.progress().chunksRemaining());
assertEquals(0L, terminal.progress().failed());
assertEquals(100D, terminal.progress().percent(), 0D);
assertTrue(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.SAVING
&& event.progress().generated() == 0L));
assertFalse(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.CANCELLED));
assertEquals(1L, events.stream().filter(event -> event.phase() == PregenApiPhase.COMPLETED).count());
}
private static List<ProgressEvent> run(Completion completion) throws Exception {
IrisSettings previousSettings = IrisSettings.settings;
PregenApiSink previousSink = IrisServices.getOrNull(PregenApiSink.class);
IrisSettings.settings = new IrisSettings();
CopyOnWriteArrayList<ProgressEvent> events = new CopyOnWriteArrayList<>();
IrisServices.register(PregenApiSink.class,
(PregenApiSink) (phase, progress) -> events.add(new ProgressEvent(phase, progress)));
PregeneratorMethod method = mock(PregeneratorMethod.class);
when(method.getMethod(anyInt(), anyInt())).thenReturn("terminal-progress-test");
ArrayList<PregenListener> pending = new ArrayList<>();
doAnswer(invocation -> {
pending.add(invocation.getArgument(2));
if (completion.cancel()) {
PregeneratorJob.shutdownInstance();
}
return null;
}).when(method).generateChunk(anyInt(), anyInt(), any(PregenListener.class));
doAnswer(invocation -> {
for (int index = 0; index < pending.size(); index++) {
PregenListener listener = pending.get(index);
if (completion.failLast() && index == pending.size() - 1) {
listener.onChunkFailed(0, 0);
} else {
listener.onChunkGenerated(0, 0, completion.cached());
}
}
return null;
}).when(method).close();
PregenTask task = PregenTask.builder().center(new Position2(0, 0))
.radiusX(1).radiusZ(1).gui(false).build();
Thread worker = null;
try {
assertNull(PregeneratorJob.getInstance());
PregeneratorJob job = new PregeneratorJob(new PregeneratorJob.Configuration(task, method, null, () -> {}));
Field workerField = PregeneratorJob.class.getDeclaredField("worker");
workerField.setAccessible(true);
worker = (Thread) workerField.get(job);
worker.join(10_000L);
assertFalse(worker.isAlive());
assertNull(PregeneratorJob.getInstance());
return List.copyOf(events);
} finally {
if (worker != null && worker.isAlive()) {
PregeneratorJob.shutdownInstance();
worker.join(5_000L);
}
IrisSettings.settings = previousSettings;
if (previousSink == null) {
IrisServices.remove(PregenApiSink.class);
} else {
IrisServices.register(PregenApiSink.class, previousSink);
}
}
}
private record Completion(boolean cached, boolean failLast, boolean cancel) {
}
private record ProgressEvent(PregenApiPhase phase, PregeneratorJob.PregenProgress progress) {
}
}
@@ -17,6 +17,53 @@ public class PackHydrologyValidatorTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void acceptsNullableRegionalSurfaceControlsAtTheirBounds() throws Exception {
File pack = pack(riversDimension("{}", """
"surfaceSourceDensity": null,
"surfaceSourceSpacing": null,
"surfaceTributaries": null,
"surfaceInlandOutlets": null,
"surfaceCoastalOutlets": null,
"surfaceMinimumCourseLength": null,
"surfaceMaximumIncision": null
"""));
write(pack, "regions/region.json", """
{"landBiomes":["biome"],"riverPolicy":{
"surfaceSourceDensity":64,"surfaceSourceSpacing":8192,"surfaceTributaries":4,
"surfaceInlandOutlets":256,"surfaceCoastalOutlets":64,
"surfaceMinimumCourseLength":4096,"surfaceMaximumIncision":32}}
""");
write(pack, "biomes/biome.json", """
{"name":"Biome","derivative":"minecraft:plains","riverPolicy":{
"surfaceSourceDensity":0,"surfaceSourceSpacing":0,"surfaceTributaries":0,
"surfaceInlandOutlets":0,"surfaceCoastalOutlets":0,
"surfaceMinimumCourseLength":16,"surfaceMaximumIncision":1}}
""");
PackHydrologyValidator.Validation validation = validate(pack);
assertTrue(validation.errors().toString(), validation.errors().isEmpty());
}
@Test
public void rejectsInvalidRegionalSurfaceControlNumbers() throws Exception {
List<String> invalidEntries = List.of(
"\"surfaceSourceDensity\":-1", "\"surfaceSourceDensity\":65", "\"surfaceSourceDensity\":\"NaN\"",
"\"surfaceSourceDensity\":true", "\"surfaceSourceSpacing\":8193", "\"surfaceSourceSpacing\":1.5",
"\"surfaceTributaries\":5", "\"surfaceTributaries\":1.5", "\"surfaceInlandOutlets\":-1",
"\"surfaceInlandOutlets\":257", "\"surfaceCoastalOutlets\":65", "\"surfaceCoastalOutlets\":\"4\"",
"\"surfaceMinimumCourseLength\":15", "\"surfaceMinimumCourseLength\":4097",
"\"surfaceMinimumCourseLength\":128.5", "\"surfaceMinimumCourseLength\":true",
"\"surfaceMaximumIncision\":0", "\"surfaceMaximumIncision\":33",
"\"surfaceMaximumIncision\":2.5", "\"surfaceMaximumIncision\":\"NaN\"");
for (String entry : invalidEntries) {
File pack = pack(riversDimension("{}"));
write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"],\"riverPolicy\":{" + entry + "}}");
PackHydrologyValidator.Validation validation = validate(pack);
assertContains(validation.errors(), entry.substring(1, entry.indexOf('"', 1)));
}
}
@Test
public void acceptsCanonicalHydrologyAndRecursivePolicyClosure() throws Exception {
File pack = pack(canonicalDimension());
@@ -249,4 +249,71 @@ public class PackValidationCacheTest {
}
}
}
@Test
public void sameVersionImplementationChangeInvalidatesSuccessfulAndFailedResults() throws Exception {
bindValidationPlatform();
try {
String original = PackValidationCache.contextFingerprint("a".repeat(64));
String changed = PackValidationCache.contextFingerprint("b".repeat(64));
Path cache = temporaryFolder.newFolder("implementation-change").toPath().resolve("validation.json");
PackValidationCache.save(cache, "content", original, List.of(
new PackValidationResult("valid", List.of(), List.of(), 1L),
new PackValidationResult("invalid", List.of("unsupported enum"), List.of(), 1L)));
assertNotEquals(original, changed);
assertTrue(PackValidationCache.load(cache, "content", original, List.of("valid", "invalid")).isPresent());
assertTrue(PackValidationCache.load(cache, "content", changed, List.of("valid", "invalid")).isEmpty());
} finally {
IrisPlatforms.unbind();
}
}
@Test
public void currentBuildIdentityPreservesCacheHits() throws Exception {
bindValidationPlatform();
try {
String context = PackValidationCache.contextFingerprint();
Path cache = temporaryFolder.newFolder("current-implementation").toPath().resolve("validation.json");
PackValidationCache.save(cache, "content", context, List.of(
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
assertFalse(context.isBlank());
assertEquals(context, PackValidationCache.contextFingerprint());
assertTrue(PackValidationCache.load(
cache, "content", PackValidationCache.contextFingerprint(), List.of("overworld")).isPresent());
} finally {
IrisPlatforms.unbind();
}
}
@Test
public void unavailableImplementationIdentityDisablesCacheReuseAndPersistence() throws Exception {
bindValidationPlatform();
try {
Path cache = temporaryFolder.newFolder("missing-implementation").toPath().resolve("validation.json");
String missing = PackValidationCache.contextFingerprint("");
assertEquals("", missing);
assertEquals("", PackValidationCache.contextFingerprint(null));
PackValidationCache.save(cache, "content", missing, List.of(
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
assertFalse(Files.exists(cache));
PackValidationCache.save(cache, "content", "previous", List.of(
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
assertTrue(PackValidationCache.load(cache, "content", missing, List.of("overworld")).isEmpty());
} finally {
IrisPlatforms.unbind();
}
}
private static void bindValidationPlatform() {
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.platformName()).thenReturn("Paper");
when(platform.minecraftVersion()).thenReturn("26.2");
when(platform.irisVersionNumber()).thenReturn(40100);
when(platform.registries()).thenReturn(mock(PlatformRegistries.class));
when(platform.structureHooks()).thenReturn(mock(PlatformStructureHooks.class));
IrisPlatforms.bind(platform);
}
}
@@ -112,7 +112,7 @@ public class PackValidatorStructureTerrainBackendTest {
@Test
public void rejectsNativeOnlyTerrainModesForEditablePlacementsAcrossEveryHost() throws Exception {
for (String mode : List.of("VACUUM", "ENCASE")) {
for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) {
String folderName = "editable-" + mode.toLowerCase(Locale.ROOT);
File pack = temporaryFolder.newFolder(folderName);
writePlacement(pack, "dimensions/main.json", "dimension-" + folderName,
@@ -138,7 +138,7 @@ public class PackValidatorStructureTerrainBackendTest {
@Test
public void acceptsNativeOnlyTerrainModesForNativeStructurePlacements() throws Exception {
for (String mode : List.of("VACUUM", "ENCASE")) {
for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) {
File pack = temporaryFolder.newFolder("native-" + mode.toLowerCase(Locale.ROOT));
writePlacement(pack, "dimensions/main.json", "native-" + mode,
"minecraft:village_plains", true, mode);
@@ -167,7 +167,7 @@ public class PackValidatorStructureTerrainBackendTest {
@Test
public void acceptsNativeOnlyTerrainModesForImportedStructureAdjustments() {
for (String mode : List.of("VACUUM", "ENCASE")) {
for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) {
JSONObject policy = new JSONObject().put("adjustments", new JSONArray().put(
new JSONObject()
.put("match", new JSONArray().put("towns_and_towers:"))
@@ -182,6 +182,26 @@ public class PackValidatorStructureTerrainBackendTest {
}
}
@Test
public void flattenRequiresIntegralBoundedRangeAndBlendSettings() {
for (String field : List.of("flattenRange", "horizontalPadding")) {
for (Object value : List.of(0, 128, -1, 129, 1.5D, "64")) {
List<String> errors = new ArrayList<>();
JSONObject terrain = new JSONObject().put("mode", "FLATTEN").put(field, value);
PackStructurePlacementValidator.validateNativeTerrain("placement",
new JSONObject().put("terrain", terrain), errors);
if (value.equals(0) || value.equals(128)) {
assertTrue(value + ": " + errors, errors.isEmpty());
} else {
assertEquals(value.toString(), 1, errors.size());
assertTrue(errors.getFirst(), errors.getFirst().contains("terrain." + field));
}
}
}
}
private void writePlacement(File pack, String relativePath, String placementId,
String structureKey, boolean nativeStructure,
String terrainMode) throws Exception {
@@ -4,6 +4,7 @@ import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KSet;
import org.bukkit.Chunk;
@@ -16,24 +17,34 @@ import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Queue;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executor;
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.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
@@ -42,6 +53,104 @@ import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class AsyncPregenServerSaveOwnershipTest {
@Test
public void ownerReturnsAfterUnloadWhileEvictionTracksTheOffOwnerIoDrain() throws Exception {
Fixture fixture = new Fixture();
ExecutorService io = Executors.newSingleThreadExecutor();
fixture.set("chunkIoExecutor", io);
Thread ownerThread = Thread.currentThread();
CountDownLatch draining = new CountDownLatch(1);
CountDownLatch finishDrain = new CountDownLatch(1);
AtomicReference<Thread> ioThread = new AtomicReference<>();
doAnswer(invocation -> {
assertSame(ownerThread, Thread.currentThread());
return true;
}).when(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
doAnswer(invocation -> {
ioThread.set(Thread.currentThread());
draining.countDown();
assertTrue(finishDrain.await(5L, TimeUnit.SECONDS));
return null;
}).when(fixture.binding).flushChunkIO(fixture.world);
try (SchedulingContext context = new SchedulingContext(fixture)) {
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
assertTrue(draining.await(5L, TimeUnit.SECONDS));
assertNotSame(ownerThread, ioThread.get());
assertFalse(eviction.isDone());
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
finishDrain.countDown();
eviction.get(5L, TimeUnit.SECONDS);
} finally {
finishDrain.countDown();
io.shutdownNow();
assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test
public void foliaFlushWaitsForEveryOwningRegionAndRunsOffOwner() throws Exception {
Fixture fixture = new Fixture();
fixture.set("foliaRuntime", true);
ExecutorService io = Executors.newSingleThreadExecutor();
fixture.set("chunkIoExecutor", io);
Chunk second = mock(Chunk.class);
when(second.getX()).thenReturn(17);
when(second.getZ()).thenReturn(3);
fixture.chunks.get(0L).add(second);
when(fixture.binding.saveAndUnloadChunk(fixture.world, 17, 3)).thenReturn(true);
List<OwnedTask> owners = new ArrayList<>();
Thread ownerThread = Thread.currentThread();
CountDownLatch draining = new CountDownLatch(1);
CountDownLatch finishDrain = new CountDownLatch(1);
doAnswer(invocation -> {
assertNotSame(ownerThread, Thread.currentThread());
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 17, 3);
draining.countDown();
assertTrue(finishDrain.await(5L, TimeUnit.SECONDS));
return null;
}).when(fixture.binding).flushChunkIO(fixture.world);
try (SchedulingContext context = new SchedulingContext(fixture)) {
context.scheduling.when(() -> J.runRegionFuture(eq(fixture.world), anyInt(), anyInt(), any(Runnable.class)))
.thenAnswer(invocation -> {
CompletableFuture<Void> completion = new CompletableFuture<>();
owners.add(new OwnedTask(invocation.getArgument(1), invocation.getArgument(2),
invocation.getArgument(3), completion));
return completion;
});
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
assertEquals(2, owners.size());
assertEquals(2, owners.get(0).chunkX());
assertEquals(17, owners.get(1).chunkX());
owners.get(0).run();
assertEquals(1L, draining.getCount());
assertFalse(eviction.isDone());
owners.get(1).run();
assertTrue(draining.await(5L, TimeUnit.SECONDS));
assertFalse(eviction.isDone());
finishDrain.countDown();
eviction.get(5L, TimeUnit.SECONDS);
} finally {
finishDrain.countDown();
io.shutdownNow();
assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS));
}
}
@Test
public void failedIoDrainFailsTheEvictionReceiptAndReportsTheFailure() throws Exception {
Fixture fixture = new Fixture();
IllegalStateException failure = new IllegalStateException("native IO failure");
doThrow(failure).when(fixture.binding).flushChunkIO(fixture.world);
try (SchedulingContext context = new SchedulingContext(fixture);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
assertTrue(eviction.isCompletedExceptionally());
assertSame(failure, assertThrows(CompletionException.class, eviction::join).getCause());
logging.verify(() -> IrisLogging.reportError(any(Throwable.class)));
}
}
@Test
public void serverStopRetainsChunksForNativeSaveWithoutSchedulingEviction() throws Exception {
Fixture fixture = new Fixture();
@@ -151,6 +260,26 @@ public class AsyncPregenServerSaveOwnershipTest {
}
}
@Test
public void nativeFlushRunsOnThePregenFlushWorkerNotTheSharedIoPool() throws Exception {
Fixture fixture = new Fixture();
PregenSerialWorker flush = new PregenSerialWorker("Iris Pregen Chunk Flush", "world");
fixture.set("chunkIoExecutor", flush.executor());
AtomicReference<Thread> ioThread = new AtomicReference<>();
doAnswer(invocation -> {
ioThread.set(Thread.currentThread());
return null;
}).when(fixture.binding).flushChunkIO(fixture.world);
try (SchedulingContext context = new SchedulingContext(fixture)) {
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
eviction.get(5L, TimeUnit.SECONDS);
assertTrue(ioThread.get().getName().startsWith("Iris Pregen Chunk Flush world"));
assertFalse(ioThread.get().getName().startsWith("Iris IO"));
} finally {
assertTrue(flush.close(5L, TimeUnit.SECONDS));
}
}
private static Object invoke(AsyncPregenMethod target, String name, Object... arguments) throws Exception {
Method method = arguments.length == 0
? AsyncPregenMethod.class.getDeclaredMethod(name)
@@ -182,6 +311,7 @@ public class AsyncPregenServerSaveOwnershipTest {
set("regionPending", new ConcurrentHashMap<>());
set("pendingEvictions", evictions);
set("evictedRegions", new KSet<>());
set("chunkIoExecutor", (Executor) Runnable::run);
when(chunk.getX()).thenReturn(2);
when(chunk.getZ()).thenReturn(3);
when(binding.saveAndUnloadChunk(world, 2, 3)).thenReturn(true);
@@ -218,4 +348,11 @@ public class AsyncPregenServerSaveOwnershipTest {
toolbelt.close();
}
}
private record OwnedTask(int chunkX, int chunkZ, Runnable action, CompletableFuture<Void> completion) {
private void run() {
action.run();
completion.complete(null);
}
}
}
@@ -0,0 +1,106 @@
package art.arcane.iris.core.pregenerator.methods;
import art.arcane.iris.core.nms.INMS;
import art.arcane.iris.core.nms.INMSBinding;
import art.arcane.iris.util.common.scheduling.J;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
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.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
public class MedievalPregenFlushOwnershipTest {
@Test
public void closeWaitsForOffOwnerIoAfterTheOwnedUnloadReturns() throws Exception {
MedievalPregenMethod method = mock(MedievalPregenMethod.class, CALLS_REAL_METHODS);
World world = mock(World.class);
Chunk chunk = mock(Chunk.class);
INMSBinding binding = mock(INMSBinding.class);
Map<Chunk, Long> lastUse = new ConcurrentHashMap<>();
lastUse.put(chunk, 0L);
ExecutorService io = Executors.newSingleThreadExecutor();
ExecutorService closing = Executors.newSingleThreadExecutor();
set(method, "world", world);
set(method, "lastUse", lastUse);
set(method, "chunkIoExecutor", io);
CountDownLatch scheduled = new CountDownLatch(1);
CountDownLatch draining = new CountDownLatch(1);
CountDownLatch finishDrain = new CountDownLatch(1);
AtomicReference<Runnable> ownerAction = new AtomicReference<>();
CompletableFuture<Void> unloaded = new CompletableFuture<>();
Thread owner = Thread.currentThread();
doAnswer(invocation -> {
assertSame(owner, Thread.currentThread());
return true;
}).when(chunk).unload(true);
doAnswer(invocation -> {
assertNotSame(owner, Thread.currentThread());
verify(chunk).unload(true);
draining.countDown();
assertTrue(finishDrain.await(5L, TimeUnit.SECONDS));
return null;
}).when(binding).flushChunkIO(world);
try {
Future<?> closed = closing.submit(() -> {
try (MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<INMS> nativeAccess = mockStatic(INMS.class)) {
nativeAccess.when(INMS::get).thenReturn(binding);
scheduling.when(() -> J.sfut(any(Runnable.class))).thenAnswer(invocation -> {
ownerAction.set(invocation.getArgument(0));
scheduled.countDown();
return unloaded;
});
Method flush = MedievalPregenMethod.class.getDeclaredMethod("unloadAndSaveAllChunks", boolean.class);
flush.setAccessible(true);
flush.invoke(method, true);
}
return null;
});
assertTrue(scheduled.await(5L, TimeUnit.SECONDS));
verifyNoInteractions(binding);
ownerAction.get().run();
unloaded.complete(null);
assertTrue(draining.await(5L, TimeUnit.SECONDS));
assertFalse(closed.isDone());
assertTrue(lastUse.isEmpty());
finishDrain.countDown();
closed.get(5L, TimeUnit.SECONDS);
} finally {
unloaded.complete(null);
finishDrain.countDown();
io.shutdownNow();
closing.shutdownNow();
assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS));
assertTrue(closing.awaitTermination(5L, TimeUnit.SECONDS));
}
}
private static void set(MedievalPregenMethod method, String name, Object value) throws Exception {
Field field = MedievalPregenMethod.class.getDeclaredField(name);
field.setAccessible(true);
field.set(method, value);
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.core.pregenerator.methods;
import org.junit.Test;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class PregenSerialWorkerTest {
@Test
public void submitRunsOffTheCallerThreadAndCloseDrainsTheBacklog() throws Exception {
Reports reports = new Reports();
PregenSerialWorker queue = reports.queue();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicReference<Thread> runner = new AtomicReference<>();
AtomicBoolean second = new AtomicBoolean();
try {
assertTrue(queue.submit(() -> {
runner.set(Thread.currentThread());
entered.countDown();
await(release);
}));
assertTrue(queue.submit(() -> second.set(true)));
assertTrue(entered.await(5, TimeUnit.SECONDS));
assertNotSame(Thread.currentThread(), runner.get());
assertTrue(runner.get().getName().startsWith("Iris Pregen Mantle Cleanup world"));
assertEquals(2, queue.pending());
assertFalse(second.get());
release.countDown();
assertTrue(queue.close(5, TimeUnit.SECONDS));
assertTrue(second.get());
assertEquals(0, queue.pending());
assertTrue(reports.failures.isEmpty());
assertTrue(reports.warnings.isEmpty());
} finally {
release.countDown();
queue.close(1, TimeUnit.SECONDS);
}
}
@Test
public void closeAbandonsABlockedBacklogAfterItsTimeout() throws Exception {
Reports reports = new Reports();
PregenSerialWorker queue = reports.queue();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicBoolean second = new AtomicBoolean();
try {
assertTrue(queue.submit(() -> {
entered.countDown();
await(release);
}));
assertTrue(queue.submit(() -> second.set(true)));
assertTrue(entered.await(5, TimeUnit.SECONDS));
assertFalse(queue.close(50, TimeUnit.MILLISECONDS));
assertFalse(queue.submit(() -> {}));
assertEquals(1, reports.warnings.size());
assertTrue(reports.warnings.get(0).contains("1 queued task(s)"));
} finally {
release.countDown();
}
assertFalse(second.get());
}
@Test
public void aFailingCleanupIsReportedAndDoesNotStopLaterCleanups() throws Exception {
Reports reports = new Reports();
PregenSerialWorker queue = reports.queue();
AtomicBoolean second = new AtomicBoolean();
IllegalStateException failure = new IllegalStateException("plate read failed");
assertTrue(queue.submit(() -> {
throw failure;
}));
assertTrue(queue.submit(() -> second.set(true)));
assertTrue(queue.close(5, TimeUnit.SECONDS));
assertTrue(second.get());
assertEquals(1, reports.failures.size());
assertSame(failure, reports.failures.get(0));
assertEquals(0, queue.pending());
}
private static void await(CountDownLatch latch) {
try {
assertTrue(latch.await(30, TimeUnit.SECONDS));
} catch (InterruptedException interruption) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interruption);
}
}
private static final class Reports {
private final List<Throwable> failures = new CopyOnWriteArrayList<>();
private final List<String> warnings = new CopyOnWriteArrayList<>();
private PregenSerialWorker queue() {
return new PregenSerialWorker("Iris Pregen Mantle Cleanup", "world",
(message, failure) -> failures.add(failure),
warnings::add);
}
}
}
@@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest {
@ClassRule
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
private static final String EXPECTED_SCHEMA_DIGEST = "ddfc51ba19c02dd50737a0d1ba30ee24cdb6cbbe5fed40c875adac69960ad81d";
private static final String EXPECTED_SCHEMA_DIGEST = "b205ce33025fa36115f9ddb38c704803875043950967e31449e2b08ae364a7fe";
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
IrisDimension.class,
@@ -28,6 +28,8 @@ import art.arcane.iris.engine.object.IrisJigsawPiece;
import art.arcane.iris.engine.object.IrisObjectPlacement;
import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.object.IrisStructureTerrain;
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
import art.arcane.iris.engine.object.IrisVanillaStructureAdjustment;
import art.arcane.iris.engine.object.annotations.ArrayType;
import art.arcane.iris.engine.object.annotations.Desc;
@@ -162,6 +164,8 @@ public class SchemaBuilderParityTest {
.getJSONObject(terrain.getString("$ref").substring("#/definitions/".length()));
JSONObject terrainProperties = terrainDefinition.getJSONObject("properties");
JSONObject terrainMode = terrainProperties.getJSONObject("mode");
JSONObject flattenRange = terrainProperties.getJSONObject("flattenRange");
JSONObject horizontalPadding = terrainProperties.getJSONObject("horizontalPadding");
JSONObject carveShape = terrainProperties.getJSONObject("shape");
JSONObject erosionStrength = terrainProperties.getJSONObject("erosionStrength");
JSONObject erosionFrequency = terrainProperties.getJSONObject("erosionFrequency");
@@ -178,8 +182,18 @@ public class SchemaBuilderParityTest {
assertTrue(terrain.getString("description").contains(
"The editable structures backend supports SOURCE, PRESERVE, BORE, and FORCE_CARVE. "
+ "The nativeStructures backend supports every terrain mode."));
assertEquals(List.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"),
assertEquals(List.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "FLATTEN", "ENCASE"),
oneOfValues(schema.getJSONObject("definitions"), terrainModeDefinition));
assertEquals("integer", flattenRange.getString("type"));
assertEquals(0, flattenRange.getInt("minimum"));
assertEquals(128, flattenRange.getInt("maximum"));
assertEquals("integer", horizontalPadding.getString("type"));
assertEquals(0, horizontalPadding.getInt("minimum"));
assertEquals(128, horizontalPadding.getInt("maximum"));
IrisStructureTerrain terrainDefaults = new IrisStructureTerrain();
assertEquals(IrisStructureTerrainMode.SOURCE, terrainDefaults.resolvedMode());
assertEquals(64, terrainDefaults.getFlattenRange());
assertEquals(0, terrainDefaults.getHorizontalPadding());
assertEquals(List.of("BOX", "ROUNDED", "ERODED"), oneOfValues(
schema.getJSONObject("definitions"), carveShapeDefinition));
assertEquals(0D, erosionStrength.getDouble("minimum"), 0D);
@@ -226,6 +240,10 @@ public class SchemaBuilderParityTest {
assertTrue(terrainProperties.has("lobeFrequency"));
assertTrue(terrainProperties.has("lobeStrength"));
assertTrue(terrainProperties.has("encasePalette"));
assertTrue(oneOfValues(definitions, definitionKey(terrainProperties.getJSONObject("mode")))
.contains("FLATTEN"));
assertEquals(0, terrainProperties.getJSONObject("flattenRange").getInt("minimum"));
assertEquals(128, terrainProperties.getJSONObject("flattenRange").getInt("maximum"));
}
@Test
@@ -0,0 +1,129 @@
package art.arcane.iris.core.service;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.object.TileData;
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.ChunkTiles;
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.PlacedTile;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.event.world.ChunkLoadEvent;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.UUID;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class ObjectStudioTileRestorationTest {
@Test
public void restoresGeneratedTileOnlyAfterChunkLoadAndKeepsLaterEdits() {
Fixture fixture = fixture();
queue(fixture);
verifyNoInteractions(fixture.tile());
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true));
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
verify(fixture.tile(), times(1)).toBukkitTry(fixture.block());
}
@Test
public void failedRestorationRemainsPendingForTheNextOwnedLoad() {
Fixture fixture = fixture();
when(fixture.tile().toBukkitTry(fixture.block())).thenReturn(false, true);
queue(fixture);
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true));
}
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
verify(fixture.tile(), times(2)).toBukkitTry(fixture.block());
}
@Test
public void retriesOnlyUnrestoredTilesAfterPartialFailure() {
Fixture fixture = fixture();
TileData second = mock(TileData.class);
Block secondBlock = mock(Block.class);
BlockData secondState = mock(BlockData.class);
when(fixture.chunk().getBlock(15, 161, 1)).thenReturn(secondBlock);
when(secondBlock.getBlockData()).thenReturn(secondState);
when(second.isApplicable(secondState)).thenReturn(true);
when(second.toBukkitTry(secondBlock)).thenReturn(false, true);
try (MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class)) {
binding.when(() -> BukkitWorldBinding.world(fixture.irisWorld())).thenReturn(fixture.world());
fixture.service().queueTiles(fixture.engine(), new ChunkTiles(-2, 3, List.of(
new PlacedTile(14, 161, 1, fixture.tile()), new PlacedTile(15, 161, 1, second))));
}
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true));
}
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
verify(fixture.tile(), times(1)).toBukkitTry(fixture.block());
verify(second, times(2)).toBukkitTry(secondBlock);
}
@Test
public void worldUnregistrationDiscardsPendingTiles() {
Fixture fixture = fixture();
queue(fixture);
fixture.service().unregister(fixture.world());
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true));
verifyNoInteractions(fixture.tile());
}
private static void queue(Fixture fixture) {
try (MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class)) {
binding.when(() -> BukkitWorldBinding.world(fixture.irisWorld())).thenReturn(fixture.world());
fixture.service().queueTiles(fixture.engine(), new ChunkTiles(-2, 3,
List.of(new PlacedTile(14, 161, 1, fixture.tile()))));
}
}
private static Fixture fixture() {
ObjectStudioSaveService service = new ObjectStudioSaveService();
Engine engine = mock(Engine.class);
EngineTarget target = mock(EngineTarget.class);
IrisWorld irisWorld = mock(IrisWorld.class);
when(engine.getTarget()).thenReturn(target);
when(target.getWorld()).thenReturn(irisWorld);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("object-studio");
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
when(chunk.getX()).thenReturn(-2);
when(chunk.getZ()).thenReturn(3);
Block block = mock(Block.class);
BlockData state = mock(BlockData.class);
when(chunk.getBlock(14, 161, 1)).thenReturn(block);
when(block.getBlockData()).thenReturn(state);
TileData tile = mock(TileData.class);
when(tile.isApplicable(state)).thenReturn(true);
when(tile.toBukkitTry(block)).thenReturn(true);
return new Fixture(service, engine, irisWorld, world, chunk, block, tile);
}
private record Fixture(ObjectStudioSaveService service, Engine engine, IrisWorld irisWorld, World world,
Chunk chunk, Block block, TileData tile) {
}
}
@@ -0,0 +1,146 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.tools.IrisToolbelt;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.framework.GenerationSessionManager;
import art.arcane.iris.engine.framework.GenerationTransitionGate;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.data.Cuboid;
import org.bukkit.Location;
import org.bukkit.TreeType;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.event.world.StructureGrowEvent;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.concurrent.CompletableFuture;
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.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
public class TreeSVCLifecycleTest {
@Test
public void growthDuringCutoverLeavesOwnerFreeForCheckpointAndRetriesAfterward() throws Exception {
ExecutorService owner = Executors.newSingleThreadExecutor();
ExecutorService reloader = Executors.newSingleThreadExecutor();
Fixture fixture = new Fixture();
AtomicInteger checkpoints = new AtomicInteger();
try {
StructureGrowEvent first = fixture.event();
owner.submit(() -> fixture.grow(first)).get(2L, TimeUnit.SECONDS);
assertFalse(first.isCancelled());
assertEquals(1, fixture.biomeReads.get());
StructureGrowEvent during = fixture.event();
CompletableFuture<Void> cutover = CompletableFuture.runAsync(() -> {
try (GenerationTransitionGate.Transition ignored = fixture.sessions.transitionGate().beginTransition(2_000L)) {
fixture.sessions.sealAndAwait("Studio generation cutover", 2_000L);
Future<?> growth = owner.submit(() -> fixture.grow(during));
owner.submit(checkpoints::incrementAndGet).get(2L, TimeUnit.SECONDS);
growth.get(2L, TimeUnit.SECONDS);
assertTrue(during.isCancelled());
assertEquals(1, fixture.biomeReads.get());
fixture.sessions.activateNextSession();
} catch (Exception failure) {
throw new IllegalStateException(failure);
}
}, reloader);
cutover.get(4L, TimeUnit.SECONDS);
StructureGrowEvent after = fixture.event();
owner.submit(() -> fixture.grow(after)).get(2L, TimeUnit.SECONDS);
assertFalse(after.isCancelled());
assertEquals(2, fixture.biomeReads.get());
assertEquals(1, checkpoints.get());
assertEquals(0, fixture.sessions.activeLeases());
} finally {
owner.shutdownNow();
reloader.shutdownNow();
assertTrue(owner.awaitTermination(3L, TimeUnit.SECONDS));
assertTrue(reloader.awaitTermination(3L, TimeUnit.SECONDS));
}
}
@Test
public void disabledTreeOverridesLeaveNormalGrowthUntouched() {
Fixture fixture = new Fixture();
fixture.dimension.getTreeSettings().setEnabled(false);
StructureGrowEvent event = fixture.event();
fixture.grow(event);
assertFalse(event.isCancelled());
assertEquals(0, fixture.biomeReads.get());
assertEquals(0, fixture.sessions.activeLeases());
}
private static final class Fixture {
private final GenerationSessionManager sessions = new GenerationSessionManager(true);
private final Engine engine = mock(Engine.class);
private final World world = mock(World.class);
private final IrisDimension dimension = new IrisDimension();
private final PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
private final TreeSVC service = spy(new TreeSVC());
private final AtomicInteger biomeReads = new AtomicInteger();
private Fixture() {
dimension.getTreeSettings().setEnabled(true);
EngineTarget target = mock(EngineTarget.class);
IrisWorld irisWorld = mock(IrisWorld.class);
Block block = mock(Block.class);
BlockData data = mock(BlockData.class);
when(engine.getGenerationSessions()).thenReturn(sessions);
when(engine.getDimension()).thenReturn(dimension);
when(generator.getEngine()).thenReturn(engine);
when(generator.getTarget()).thenReturn(target);
when(target.getWorld()).thenReturn(irisWorld);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
when(world.getBlockAt(any(Location.class))).thenReturn(block);
when(block.getBlockData()).thenReturn(data);
when(data.clone()).thenReturn(data);
doReturn(mock(Cuboid.class)).when(service).getSaplings(any(), any(), any());
when(engine.getBiome(8, 80, 8)).thenAnswer(invocation -> {
try (GenerationTransitionGate.Participation ignored = sessions.transitionGate().enter()) {
biomeReads.incrementAndGet();
return new IrisBiome();
}
});
when(engine.getRegion(8, 80, 8)).thenReturn(new IrisRegion());
}
private StructureGrowEvent event() {
return new StructureGrowEvent(new Location(world, 8, 80, 8), TreeType.TREE, false, null, List.of());
}
private void grow(StructureGrowEvent event) {
try (MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
service.on(event);
}
}
}
}
@@ -0,0 +1,304 @@
package art.arcane.iris.engine;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
import art.arcane.iris.engine.framework.GenerationSessionManager;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.junit.Test;
import org.mockito.MockedStatic;
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.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
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.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class EngineBackgroundTasksAdmissionTest {
@Test
public void preliminaryDrainWaitsForSaveAdmittedAfterItsInitialSnapshot() throws Exception {
Fixture fixture = fixture();
CountDownLatch firstEntered = new CountDownLatch(1);
CountDownLatch firstRelease = new CountDownLatch(1);
CountDownLatch saveEntered = new CountDownLatch(1);
CountDownLatch saveRelease = new CountDownLatch(1);
try (ExecutorService workers = Executors.newFixedThreadPool(2);
ExecutorService closer = Executors.newSingleThreadExecutor();
MockedStatic<J> scheduler = scheduler(workers, new AtomicReference<>())) {
try {
fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease));
assertTrue(firstEntered.await(5, TimeUnit.SECONDS));
fixture.tasks().closeBackgroundTaskAdmission();
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = startDrain(closer, fixture.tasks());
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () ->
fixture.tasks().scheduleAdmittedTask(fixture.engine(),
() -> waitForRelease(saveEntered, saveRelease))));
assertTrue(saveEntered.await(5, TimeUnit.SECONDS));
firstRelease.countDown();
assertThrows(TimeoutException.class, () -> drain.get(100, TimeUnit.MILLISECONDS));
saveRelease.countDown();
drain.get(5, TimeUnit.SECONDS).requireComplete("Studio cutover");
} finally {
firstRelease.countDown();
saveRelease.countDown();
}
}
}
@Test
public void newlyAdmittedFailureSurvivesCompletionAndLaterTaskSubmission() throws Exception {
Fixture fixture = fixture();
CountDownLatch firstEntered = new CountDownLatch(1);
CountDownLatch firstRelease = new CountDownLatch(1);
CountDownLatch failed = new CountDownLatch(1);
IllegalStateException failure = new IllegalStateException("ownership write failed");
AtomicReference<Throwable> logged = new AtomicReference<>();
try (ExecutorService workers = Executors.newFixedThreadPool(2);
ExecutorService closer = Executors.newSingleThreadExecutor();
MockedStatic<J> scheduler = scheduler(workers, logged)) {
try {
fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease));
assertTrue(firstEntered.await(5, TimeUnit.SECONDS));
fixture.tasks().closeBackgroundTaskAdmission();
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = startDrain(closer, fixture.tasks());
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () ->
fixture.tasks().scheduleAdmittedTask(fixture.engine(), () -> {
failed.countDown();
throw failure;
})));
assertTrue(failed.await(5, TimeUnit.SECONDS));
awaitLogged(logged, failure);
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () ->
fixture.tasks().scheduleAdmittedTask(fixture.engine(), () -> {})));
firstRelease.countDown();
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(5, TimeUnit.SECONDS);
assertTrue(result.complete());
assertNotNull(result.failure());
assertSame(failure, result.failure().getCause());
assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover"));
} finally {
firstRelease.countDown();
}
}
}
@Test
public void failureSettledBeforeDrainDoesNotPoisonTheNextTransition() throws Exception {
Fixture fixture = fixture();
IllegalStateException failure = new IllegalStateException("previous storage failure");
AtomicReference<Throwable> logged = new AtomicReference<>();
try (ExecutorService workers = Executors.newSingleThreadExecutor();
MockedStatic<J> scheduler = scheduler(workers, logged)) {
fixture.tasks().scheduleTrackedTask(() -> {
throw failure;
});
workers.submit(() -> {}).get(5, TimeUnit.SECONDS);
assertSame(failure, logged.get());
fixture.tasks().closeBackgroundTaskAdmission();
fixture.tasks().drainBackgroundTasks("next transition").requireComplete("next transition");
assertFalse(fixture.tasks().scheduleTrackedTask(() -> {}));
}
}
@Test
public void continuationsShareTheOriginalDrainDeadline() throws Exception {
Fixture fixture = fixture();
CountDownLatch firstEntered = new CountDownLatch(1);
CountDownLatch firstRelease = new CountDownLatch(1);
CountDownLatch saveEntered = new CountDownLatch(1);
CountDownLatch saveRelease = new CountDownLatch(1);
try (ExecutorService workers = Executors.newFixedThreadPool(2);
ExecutorService closer = Executors.newSingleThreadExecutor();
MockedStatic<J> scheduler = scheduler(workers, new AtomicReference<>())) {
try {
fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease));
assertTrue(firstEntered.await(5, TimeUnit.SECONDS));
fixture.tasks().closeBackgroundTaskAdmission();
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = startDrain(closer, fixture.tasks());
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () ->
fixture.tasks().scheduleAdmittedTask(fixture.engine(),
() -> waitForRelease(saveEntered, saveRelease))));
assertTrue(saveEntered.await(5, TimeUnit.SECONDS));
TimeUnit.SECONDS.sleep(7);
firstRelease.countDown();
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(10, TimeUnit.SECONDS);
assertTrue(result.failure() instanceof TimeoutException);
assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover"));
} finally {
firstRelease.countDown();
saveRelease.countDown();
}
}
}
@Test
public void cancellationClaimsAQueuedTaskBeforeItCanStart() throws Exception {
Fixture fixture = fixture();
CountDownLatch gate = new CountDownLatch(1);
AtomicBoolean ran = new AtomicBoolean();
ExecutorService worker = Executors.newSingleThreadExecutor();
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
// Mirrors the ForkJoinPool behind J.a: cancel(true) reports success yet cannot stop a
// callable the pool has already dequeued, so the claim must be decided by the task itself.
scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> {
Callable<Void> task = invocation.getArgument(0);
CompletableFuture<Void> handle = new CompletableFuture<>();
worker.execute(() -> {
try {
assertTrue(gate.await(5, TimeUnit.SECONDS));
task.call();
handle.complete(null);
} catch (Throwable failure) {
handle.completeExceptionally(failure);
}
});
return handle;
});
assertTrue(fixture.tasks().scheduleTrackedTask(() -> ran.set(true)));
fixture.tasks().cancelBackgroundTasks("close");
gate.countDown();
worker.submit(() -> {}).get(5, TimeUnit.SECONDS);
assertFalse(ran.get());
fixture.tasks().closeBackgroundTaskAdmission();
EngineBackgroundTasks.BackgroundTaskDrain drain = fixture.tasks().drainBackgroundTasks("close");
assertTrue(drain.complete());
} finally {
worker.shutdownNow();
}
}
@Test
public void cancellationOfARunningTaskLeavesItsCompletionToTheTask() throws Exception {
Fixture fixture = fixture();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
try (ExecutorService workers = Executors.newSingleThreadExecutor();
MockedStatic<J> scheduler = scheduler(workers, new AtomicReference<>())) {
assertTrue(fixture.tasks().scheduleTrackedTask(() -> waitForReleaseIgnoringInterrupts(entered, release)));
assertTrue(entered.await(5, TimeUnit.SECONDS));
fixture.tasks().cancelBackgroundTasks("close");
fixture.tasks().closeBackgroundTaskAdmission();
ExecutorService closer = Executors.newSingleThreadExecutor();
try {
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = closer.submit(
() -> fixture.tasks().drainBackgroundTasks("close"));
assertThrows(TimeoutException.class, () -> drain.get(200, TimeUnit.MILLISECONDS));
release.countDown();
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(5, TimeUnit.SECONDS);
assertTrue(result.complete());
result.requireComplete("close");
} finally {
release.countDown();
closer.shutdownNow();
}
}
}
private static Future<EngineBackgroundTasks.BackgroundTaskDrain> startDrain(
ExecutorService closer, EngineBackgroundTasks tasks) {
AtomicReference<Thread> drainingThread = new AtomicReference<>();
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = closer.submit(() -> {
drainingThread.set(Thread.currentThread());
return tasks.drainBackgroundTasks("Studio cutover");
});
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (System.nanoTime() < deadline) {
Thread thread = drainingThread.get();
if (thread != null && thread.getState() == Thread.State.TIMED_WAITING) {
for (StackTraceElement frame : thread.getStackTrace()) {
if (frame.getClassName().equals(EngineBackgroundTasks.class.getName())
&& frame.getMethodName().equals("drainBackgroundTasks")) {
return drain;
}
}
}
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
}
throw new AssertionError("Background drain did not await the initial task");
}
private static void awaitLogged(AtomicReference<Throwable> logged, Throwable failure) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (System.nanoTime() < deadline) {
if (logged.get() == failure) {
return;
}
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
}
throw new AssertionError("Background task did not log its failure");
}
private static void waitForReleaseIgnoringInterrupts(CountDownLatch entered, CountDownLatch release) {
entered.countDown();
boolean interrupted = false;
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
while (true) {
try {
assertTrue(release.await(Math.max(1L, deadline - System.nanoTime()), TimeUnit.NANOSECONDS));
break;
} catch (InterruptedException interruption) {
interrupted = true;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
}
private static void waitForRelease(CountDownLatch entered, CountDownLatch release) {
entered.countDown();
try {
assertTrue(release.await(30, TimeUnit.SECONDS));
} catch (InterruptedException interruption) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interruption);
}
}
@SuppressWarnings("unchecked")
private static MockedStatic<J> scheduler(ExecutorService workers, AtomicReference<Throwable> logged) {
MockedStatic<J> scheduler = mockStatic(J.class);
scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> {
Callable<Void> task = invocation.getArgument(0);
return workers.submit(() -> {
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
logging.when(() -> IrisLogging.reportError(any(Throwable.class))).thenAnswer(call -> {
logged.set(call.getArgument(0));
return null;
});
return task.call();
}
});
});
return scheduler;
}
private static Fixture fixture() {
EngineBackgroundTasks tasks = new EngineBackgroundTasks();
tasks.openBackgroundTaskAdmission();
GenerationSessionManager sessions = new GenerationSessionManager(true);
Engine engine = mock(Engine.class);
when(engine.getGenerationSessions()).thenReturn(sessions);
when(engine.getGenerationSessionId()).thenAnswer(invocation -> sessions.currentSessionId());
return new Fixture(tasks, engine);
}
private record Fixture(EngineBackgroundTasks tasks, Engine engine) {
}
}
@@ -641,7 +641,7 @@ public class EngineShutdownDrainTest {
private GenerationKernelRegistry.RuntimeKernel prepareComplexHotload(IrisComplex replacement) throws Exception {
GenerationKernelRegistry.RuntimeKernel kernel = mock(GenerationKernelRegistry.RuntimeKernel.class);
when(kernel.createComplex(engine, null)).thenReturn(replacement);
when(kernel.createComplex(engine, null, false)).thenReturn(replacement);
when(generation.runtimeKernel()).thenReturn(kernel);
GenerationRuntime nextGeneration = generation(target, mantle);
when(nextGeneration.complex()).thenReturn(replacement);
@@ -0,0 +1,216 @@
package art.arcane.iris.engine;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EnginePlatformHooks;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.engine.framework.SeedManager;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.StudioMode;
import art.arcane.iris.engine.platform.studio.BiomeBuffetLayout;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.testsupport.PlatformLeakGuard;
import art.arcane.iris.util.common.data.B;
import org.junit.ClassRule;
import org.junit.Before;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertNotNull;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class IrisComplexBiomeBuffetTest {
@ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
@Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
private IrisSettings previous;
@Before
public void prepareRuntime() {
previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class));
}
@After
public void releaseRuntime() {
IrisPlatforms.unbind();
IrisServices.remove(PreservationRegistry.class);
IrisSettings.settings = previous;
}
@Test
public void neighboringCellsUseTheirOwnRealBiomeStreamsWithoutChangingAuthoredFocus() throws Exception {
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.platformName()).thenReturn("bukkit");
IrisPlatforms.bind(platform);
Path pack = fixture();
IrisData data = IrisData.openRuntime(pack.toFile());
try (MockedStatic<B> blocks = mockStatic(B.class)) {
PlatformBlockState block = mock(PlatformBlockState.class);
when(block.isFluid()).thenReturn(true);
blocks.when(() -> B.getState(anyString())).thenReturn(block);
blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block);
IrisDimension dimension = data.getDimensionLoader().load("main");
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
doReturn(data).when(engine).getData();
doReturn(dimension).when(engine).getDimension();
doReturn(data.getBiomeLoader().load("alpha")).when(engine).getFocus();
doReturn(true).when(engine).isStudio();
doReturn(384).when(engine).getHeight();
doReturn(-64).when(engine).getMinHeight();
doReturn(320).when(engine).getMaxHeight();
doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks();
doReturn(new SeedManager(1337L)).when(engine).getSeedManager();
IrisComplex complex = new IrisComplex(engine);
doReturn(complex).when(engine).getComplex();
try {
assertEquals("alpha", complex.getTrueBiomeStream().get(15, 0).getLoadKey());
assertEquals("beta", complex.getTrueBiomeStream().get(16, 0).getLoadKey());
assertEquals("gamma", complex.getTrueBiomeStream().get(0, 16).getLoadKey());
assertEquals(137D, complex.getBaseTerrainHeightStream().getDouble(15, 0), 0D);
assertEquals(147D, complex.getBaseTerrainHeightStream().getDouble(16, 0), 0D);
assertEquals(157D, complex.getBaseTerrainHeightStream().getDouble(0, 16), 0D);
assertEquals("owned", complex.getRegionStream().get(16, 0).getLoadKey());
assertEquals("iris-focus/", complex.getRegionStream().get(0, 16).getLoadKey().substring(0, 11));
assertEquals("alpha", dimension.getFocus());
try (ExecutorService readers = Executors.newFixedThreadPool(2)) {
List<Callable<String>> queries = List.of(
() -> complex.getTrueBiomeStream().get(15, 0).getLoadKey(),
() -> complex.getTrueBiomeStream().get(16, 0).getLoadKey());
List<Future<String>> values = readers.invokeAll(queries);
assertEquals("alpha", values.get(0).get());
assertEquals("beta", values.get(1).get());
}
doReturn(false).when(engine).isStudio();
IrisComplex production = new IrisComplex(engine);
try {
assertNull(production.getBiomeBuffet());
assertEquals("alpha", production.getTrueBiomeStream().get(16, 0).getLoadKey());
} finally {
production.close();
}
} finally {
complex.close();
}
} finally {
data.close();
}
}
@Test
public void everyBuffetSizeHasStableCellsAndNoWrappedOrNegativeCells() throws Exception {
IrisData data = IrisData.openRuntime(fixture().toFile());
try {
IrisDimension dimension = data.getDimensionLoader().load("main");
for (StudioMode mode : StudioMode.values()) {
int size = mode.biomeSizeChunks();
if (size == 0) {
continue;
}
dimension.setStudioMode(mode);
BiomeBuffetLayout layout = new BiomeBuffetLayout(dimension, () -> data);
assertEquals("alpha", layout.chunk(size - 1, size - 1).biome().getLoadKey());
assertEquals("beta", layout.chunk(size, 0).biome().getLoadKey());
assertEquals("gamma", layout.chunk(0, size).biome().getLoadKey());
assertNotNull(layout.chunk(size, size));
assertNull(layout.chunk(size * 2, 0));
assertNull(layout.chunk(0, size * 2));
assertNull(layout.chunk(-1, 0));
assertNull(layout.chunk(0, -1));
}
} finally {
data.close();
}
}
@Test
public void unrelatedBuffetBiomesDoNotChangeTheFocusedBiomesGeneratorNoise() throws Exception {
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.platformName()).thenReturn("bukkit");
IrisPlatforms.bind(platform);
Path pack = fixture();
Files.writeString(pack.resolve("biomes/alpha.json"),
"{\"children\":[\"beta\"],\"generators\":[{\"generator\":\"flat\",\"min\":0,\"max\":40}]}");
Files.writeString(pack.resolve("biomes/gamma.json"),
"{\"generators\":[{\"generator\":\"unrelated\",\"min\":0,\"max\":40}]}");
Files.writeString(pack.resolve("generators/unrelated.json"),
"{\"composite\":[{\"enabled\":false,\"offsetY\":1}]}");
IrisData data = IrisData.openRuntime(pack.toFile());
try (MockedStatic<B> blocks = mockStatic(B.class)) {
PlatformBlockState block = mock(PlatformBlockState.class);
when(block.isFluid()).thenReturn(true);
blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block);
IrisDimension dimension = data.getDimensionLoader().load("main");
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
doReturn(data).when(engine).getData();
doReturn(dimension).when(engine).getDimension();
doReturn(data.getBiomeLoader().load("alpha")).when(engine).getFocus();
doReturn(true).when(engine).isStudio();
doReturn(384).when(engine).getHeight();
doReturn(-64).when(engine).getMinHeight();
doReturn(320).when(engine).getMaxHeight();
doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks();
doReturn(new SeedManager(1337L)).when(engine).getSeedManager();
IrisComplex buffet = new IrisComplex(engine);
doReturn(false).when(engine).isStudio();
IrisComplex focused = new IrisComplex(engine);
try {
assertEquals(127D, focused.getBaseTerrainHeightStream().getDouble(0, 0), 0D);
assertEquals(focused.getBaseTerrainHeightStream().getDouble(0, 0),
buffet.getBaseTerrainHeightStream().getDouble(0, 0), 0D);
assertEquals(167D, buffet.getBaseTerrainHeightStream().getDouble(0, 16), 0D);
} finally {
focused.close();
buffet.close();
}
} finally {
data.close();
}
}
private Path fixture() throws Exception {
Path pack = temporaryFolder.newFolder().toPath();
Files.createDirectories(pack.resolve("dimensions"));
Files.createDirectories(pack.resolve("regions"));
Files.createDirectories(pack.resolve("biomes"));
Files.createDirectories(pack.resolve("generators"));
Files.writeString(pack.resolve("dimensions/main.json"), """
{"focus":"alpha","regions":["owned"],"studioMode":"BIOME_BUFFET_1x1",
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
""");
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"alpha\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
String[] names = {"alpha", "beta", "gamma", "zeta"};
for (int index = 0; index < names.length; index++) {
int height = (index + 1) * 10;
Files.writeString(pack.resolve("biomes/" + names[index] + ".json"),
"{\"generators\":[{\"generator\":\"flat\",\"min\":" + height + ",\"max\":" + height + "}]"
+ (index == 0 ? ",\"children\":[\"beta\"]" : "") + "}");
}
return pack;
}
}
@@ -11,12 +11,22 @@ public class IrisComplexStudioCacheTest {
@Test
public void studioUsesWideNoiseCacheWithoutChangingConfiguredLiveWorldSize() {
Engine engine = mock(Engine.class);
when(engine.isStudio()).thenReturn(false);
assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024));
assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, false));
when(engine.isStudio()).thenReturn(true);
assertEquals(32_768, IrisComplex.noiseCacheSize(engine, 1_024));
assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536));
assertEquals(32_768, IrisComplex.noiseCacheSize(engine, 1_024, false));
assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536, false));
}
@Test
public void detachedRuntimesKeepTheConfiguredCacheSizeInStudio() {
// Historical activation runtimes only answer transition-band and saved-biome queries; every
// one of them carrying the Studio-wide cache multiplied resident memory per hotload.
Engine engine = mock(Engine.class);
when(engine.isStudio()).thenReturn(true);
assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, true));
assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536, true));
when(engine.isStudio()).thenReturn(false);
assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, true));
}
}
@@ -345,7 +345,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
"1".repeat(64),
Map.of(
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
(engine, transitionPlan) -> complexOne
(engine, transitionPlan, detached) -> complexOne
)
),
new GenerationKernelRegistry.Kernel(
@@ -353,7 +353,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
"2".repeat(64),
Map.of(
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
(engine, transitionPlan) -> complexTwo
(engine, transitionPlan, detached) -> complexTwo
)
)
)
@@ -362,9 +362,9 @@ public class IrisEngineGenerationRuntimeScopeTest {
GenerationKernelRegistry.RuntimeKernel kernelTwo = kernels.select(versionTwo);
IrisEngine factoryContext = mock(IrisEngine.class);
RuntimeFixture first = runtime(
1, 1D, 1D, 1D, kernelOne, kernelOne.createComplex(factoryContext, null));
1, 1D, 1D, 1D, kernelOne, kernelOne.createComplex(factoryContext, null, false));
RuntimeFixture second = runtime(
2, 1D, 1D, 1D, kernelTwo, kernelTwo.createComplex(factoryContext, null));
2, 1D, 1D, 1D, kernelTwo, kernelTwo.createComplex(factoryContext, null, false));
IrisEngine engine = engine(first.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
IrisEngine.GenerationRuntimeBinding firstBinding = new IrisEngine.GenerationRuntimeBinding(
engine, first.runtime);
@@ -1006,7 +1006,7 @@ public class IrisEngineGenerationRuntimeScopeTest {
return new GenerationKernelRegistry.RuntimeKernel(
version,
"f".repeat(64),
(engine, transitionPlan) -> mock(IrisComplex.class)
(engine, transitionPlan, detached) -> mock(IrisComplex.class)
);
}
@@ -0,0 +1,209 @@
package art.arcane.iris.engine;
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
import art.arcane.iris.engine.framework.EngineWorldManager;
import art.arcane.iris.engine.framework.GenerationSessionManager;
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
import art.arcane.iris.engine.mantle.EngineMantle;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.scheduling.J;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.concurrent.Callable;
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 java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
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.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class IrisEngineWorldSaveTest {
@Test
public void ownerReturnsWhileStorageFlushesAndShutdownDrainWaits() throws Exception {
Fixture fixture = fixture();
Thread owner = Thread.currentThread();
EngineMantle mantle = fixture.engine().getMantle();
EngineWorldManager worldManager = fixture.engine().getWorldManager();
doAnswer(invocation -> {
assertSame(owner, Thread.currentThread());
return null;
}).when(mantle).save();
doAnswer(invocation -> {
assertSame(owner, Thread.currentThread());
return null;
}).when(worldManager).onSave();
doAnswer(invocation -> {
assertSame(owner, Thread.currentThread());
return null;
}).when(fixture.engine()).saveEngineData();
try (ExecutorService io = Executors.newSingleThreadExecutor();
ExecutorService closer = Executors.newSingleThreadExecutor();
MockedStatic<J> scheduler = schedule(fixture, io)) {
try {
assertTrue(fixture.engine().requestSave());
assertTrue(fixture.entered().await(5, TimeUnit.SECONDS));
assertFalse(owner == fixture.flushThread().get());
verify(fixture.engine().getWorldManager()).onSave();
verify(fixture.engine()).saveEngineData();
fixture.tasks().closeBackgroundTaskAdmission();
fixture.sessions().sealAndAwait("close", 1_000L, true);
Future<EngineBackgroundTasks.BackgroundTaskDrain> close = closer.submit(
() -> fixture.tasks().drainBackgroundTasks("close"));
assertThrows(TimeoutException.class, () -> close.get(100, TimeUnit.MILLISECONDS));
fixture.release().countDown();
close.get(5, TimeUnit.SECONDS).requireComplete("close");
assertEquals(0, fixture.sessions().activeLeases());
} finally {
fixture.release().countDown();
}
}
}
@Test
public void alreadyAdmittedSaveSurvivesConcurrentAdmissionClosure() throws Exception {
Fixture fixture = fixture();
try (ExecutorService io = Executors.newSingleThreadExecutor();
MockedStatic<J> scheduler = schedule(fixture, io)) {
try {
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save_event", () -> {
fixture.tasks().closeBackgroundTaskAdmission();
when(fixture.engine().isClosing()).thenReturn(true);
assertTrue(fixture.engine().requestSave());
}));
assertTrue(fixture.entered().await(5, TimeUnit.SECONDS));
assertFalse(fixture.tasks().scheduleTrackedTask(() -> {
throw new AssertionError("New background task entered after admission closed");
}));
fixture.release().countDown();
fixture.tasks().drainBackgroundTasks("close").requireComplete("close");
} finally {
fixture.release().countDown();
}
}
}
@Test
public void storageFailureLogsOriginalCauseAndFailsAnActiveShutdownDrain() throws Exception {
Fixture fixture = fixture();
IllegalStateException failure = new IllegalStateException("ownership storage write failed");
fixture.failure().set(failure);
AtomicReference<Thread> closeThread = new AtomicReference<>();
try (ExecutorService io = Executors.newSingleThreadExecutor();
ExecutorService closer = Executors.newSingleThreadExecutor();
MockedStatic<J> scheduler = schedule(fixture, io)) {
try {
assertTrue(fixture.engine().requestSave());
assertTrue(fixture.entered().await(5, TimeUnit.SECONDS));
Future<EngineBackgroundTasks.BackgroundTaskDrain> close = closer.submit(() -> {
closeThread.set(Thread.currentThread());
return fixture.tasks().drainBackgroundTasks("close");
});
awaitDrainWaiter(closeThread);
fixture.release().countDown();
EngineBackgroundTasks.BackgroundTaskDrain drain = close.get(5, TimeUnit.SECONDS);
assertNotNull(drain.failure());
assertSame(failure, drain.failure().getCause());
assertThrows(IllegalStateException.class, () -> drain.requireComplete("close"));
io.submit(() -> {}).get(5, TimeUnit.SECONDS);
assertSame(failure, fixture.logged().get());
} finally {
fixture.release().countDown();
}
}
}
@Test
public void continuationRequiresAnActiveLifecycleLease() throws Exception {
Fixture fixture = fixture();
assertThrows(IllegalStateException.class,
() -> fixture.tasks().scheduleAdmittedTask(fixture.engine(), () -> {}));
fixture.sessions().sealAndAwait("close", 1_000L, true);
when(fixture.engine().isClosing()).thenReturn(true);
assertFalse(fixture.engine().requestSave());
}
@SuppressWarnings("unchecked")
private static MockedStatic<J> schedule(Fixture fixture, ExecutorService io) {
MockedStatic<J> scheduler = mockStatic(J.class);
scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> {
Callable<Void> task = invocation.getArgument(0);
return io.submit(() -> {
try (MockedStatic<NativeStructureOwnershipStore> storage = mockStatic(NativeStructureOwnershipStore.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
storage.when(() -> NativeStructureOwnershipStore.flush(fixture.engine())).thenAnswer(call -> {
fixture.flushThread().set(Thread.currentThread());
fixture.entered().countDown();
assertTrue(fixture.release().await(5, TimeUnit.SECONDS));
if (fixture.failure().get() != null) {
throw fixture.failure().get();
}
return null;
});
logging.when(() -> IrisLogging.reportError(any(Throwable.class))).thenAnswer(call -> {
fixture.logged().set(call.getArgument(0));
return null;
});
return task.call();
}
});
});
return scheduler;
}
private static Fixture fixture() throws Exception {
EngineBackgroundTasks tasks = new EngineBackgroundTasks();
tasks.openBackgroundTaskAdmission();
GenerationSessionManager sessions = new GenerationSessionManager(true);
IrisEngine engine = mock(IrisEngine.class);
when(engine.getGenerationSessions()).thenReturn(sessions);
when(engine.getGenerationSessionId()).thenAnswer(invocation -> sessions.currentSessionId());
when(engine.getMantle()).thenReturn(mock(EngineMantle.class));
when(engine.getWorldManager()).thenReturn(mock(EngineWorldManager.class));
Field background = IrisEngine.class.getDeclaredField("backgroundTasks");
background.setAccessible(true);
background.set(engine, tasks);
doCallRealMethod().when(engine).requestSave();
return new Fixture(engine, tasks, sessions, new CountDownLatch(1), new CountDownLatch(1),
new AtomicReference<>(), new AtomicReference<>(), new AtomicReference<>());
}
private static void awaitDrainWaiter(AtomicReference<Thread> reference) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (System.nanoTime() < deadline) {
Thread thread = reference.get();
if (thread != null && thread.getState() == Thread.State.TIMED_WAITING) {
for (StackTraceElement frame : thread.getStackTrace()) {
if (frame.getClassName().equals(EngineBackgroundTasks.class.getName())
&& frame.getMethodName().equals("drainBackgroundTasks")) {
return;
}
}
}
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
}
throw new AssertionError("Shutdown did not wait for its ownership storage flush");
}
private record Fixture(IrisEngine engine, EngineBackgroundTasks tasks, GenerationSessionManager sessions,
CountDownLatch entered, CountDownLatch release, AtomicReference<Thread> flushThread,
AtomicReference<IllegalStateException> failure, AtomicReference<Throwable> logged) {
}
}
@@ -11,6 +11,7 @@ import org.junit.rules.TemporaryFolder;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.Assert.assertEquals;
@@ -20,6 +21,7 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -47,6 +49,81 @@ public final class StudioGenerationUpdateTest {
verifyNoMoreInteractions(history);
}
@Test
public void unchangedAuthoringRootAliasNeedsNoSnapshot() throws Exception {
Path source = temporary.newFolder("unchanged-alias-target").toPath();
Files.createDirectories(source.resolve("dimensions"));
Files.writeString(source.resolve("dimensions/test.json"), "{}");
Path alias = temporary.getRoot().toPath().resolve("unchanged-alias");
Files.createSymbolicLink(alias, source);
String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION);
IrisEngine engine = mock(IrisEngine.class);
GenerationHistory history = mock(GenerationHistory.class);
GenerationEpoch active = mock(GenerationEpoch.class);
when(engine.getStudioGenerationSource()).thenReturn(alias);
when(history.activeEpoch()).thenReturn(active);
when(active.packFingerprintVersion()).thenReturn(GenerationPackFingerprint.CURRENT_VERSION);
when(active.packFingerprint()).thenReturn(fingerprint);
assertNull(StudioGenerationUpdate.prepare(engine, history));
assertThrows(IOException.class, () -> GenerationPackFingerprint.compute(alias, GenerationPackFingerprint.CURRENT_VERSION));
verify(history).activeEpoch();
verifyNoMoreInteractions(history);
}
@Test
public void changedAuthoringRootAliasReachesValidationAndCleansRejectedSnapshot() throws Exception {
Path source = temporary.newFolder("changed-alias-target").toPath();
Files.createDirectories(source.resolve("dimensions"));
Files.writeString(source.resolve("dimensions/test.json"), "{}");
Path alias = temporary.getRoot().toPath().resolve("changed-alias");
Files.createSymbolicLink(alias, source);
GenerationHistoryPaths paths = GenerationHistoryPaths.forDimension(temporary.newFolder("alias-world").toPath());
Files.createDirectories(paths.generationRoot());
Files.writeString(paths.manifest(), "unchanged manifest");
IrisEngine engine = mock(IrisEngine.class);
when(engine.getStudioGenerationSource()).thenReturn(alias);
GenerationHistory history = mock(GenerationHistory.class);
GenerationEpoch active = mock(GenerationEpoch.class);
when(history.activeEpoch()).thenReturn(active);
when(history.paths()).thenReturn(paths);
when(active.packFingerprintVersion()).thenReturn(GenerationPackFingerprint.CURRENT_VERSION);
when(active.packFingerprint()).thenReturn("0".repeat(64));
IOException failure = assertThrows(IOException.class, () -> StudioGenerationUpdate.prepare(engine, history));
assertTrue(failure.getMessage(), failure.getMessage().contains(
"Studio pack update is invalid: Dimension 'test' declares no regions."));
assertEquals("unchanged manifest", Files.readString(paths.manifest()));
try (Stream<Path> entries = Files.list(paths.generationRoot())) {
assertEquals(1L, entries.count());
}
verify(history, times(2)).activeEpoch();
verify(history).paths();
verifyNoMoreInteractions(history);
}
@Test
public void authoringRootAliasStillRejectsInternalFileAndDirectoryLinks() throws Exception {
for (boolean directory : List.of(false, true)) {
Path source = temporary.newFolder("internal-link-pack-" + directory).toPath();
Path outside = directory ? temporary.newFolder("external-directory").toPath()
: temporary.newFile("external-file.json").toPath();
Files.createSymbolicLink(source.resolve("linked-resource"), outside);
Path alias = temporary.getRoot().toPath().resolve("unsafe-alias-" + directory);
Files.createSymbolicLink(alias, source);
IrisEngine engine = mock(IrisEngine.class);
when(engine.getStudioGenerationSource()).thenReturn(alias);
GenerationHistory history = mock(GenerationHistory.class);
IOException failure = assertThrows(IOException.class, () -> StudioGenerationUpdate.prepare(engine, history));
assertTrue(failure.getMessage(), failure.getMessage().contains("rejected symbolic link"));
verifyNoInteractions(history);
}
}
@Test
public void invalidPackLeavesManifestUntouchedAndRemovesItsTemporarySnapshot() throws Exception {
Path source = temporary.newFolder("invalid-pack").toPath();
@@ -0,0 +1,195 @@
package art.arcane.iris.engine.history;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
final class DenseTransitionGeometryReference {
private static final int MAXIMUM_OPENING_HEIGHT = 64;
private static final BoundaryColumnGeometry.Voxel AIR = new BoundaryColumnGeometry.Voxel(
"minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false);
private static final BoundaryColumnGeometry.Voxel FLUID_BARRIER = new BoundaryColumnGeometry.Voxel(
"minecraft:obsidian", BoundaryColumnGeometry.Phase.SOLID, "", false);
private DenseTransitionGeometryReference() {
}
public static BoundaryColumnGeometry blendColumn(
BoundaryGeometryInfluence influence,
int blockX,
int blockZ,
BoundaryColumnGeometry currentGeometry
) {
Objects.requireNonNull(influence, "Boundary influence");
Objects.requireNonNull(currentGeometry, "Current geometry");
if (influence.newTerrainWeight() == 1D || influence.contributions().isEmpty()
|| currentGeometry.height() == 0) {
return currentGeometry;
}
ColumnProfile current = new ColumnProfile(currentGeometry);
ArrayList<WeightedProfile> historical = new ArrayList<>(influence.contributions().size());
for (BoundaryGeometryInfluence.Contribution contribution : influence.contributions()) {
BoundaryColumnGeometry geometry = contribution.geometry();
if (geometry.minimumY() != currentGeometry.minimumY() || geometry.height() != currentGeometry.height()) {
throw new IllegalArgumentException("Boundary geometry does not match the current vertical layout");
}
historical.add(new WeightedProfile(new ColumnProfile(geometry), contribution.weight()));
}
ArrayList<BoundaryColumnGeometry.Voxel> result = new ArrayList<>(currentGeometry.height());
boolean changed = false;
for (int offset = 0; offset < currentGeometry.height(); offset++) {
BoundaryColumnGeometry.Voxel value = blendVoxel(current, historical, offset,
influence.newTerrainWeight(), influence.openingWeight(), GenerationBlend.usesHistoricalMaterial(blockX,
currentGeometry.minimumY() + offset, blockZ, influence.newTerrainWeight()));
result.add(value);
changed |= !value.equals(current.voxels.get(offset));
}
return changed ? BoundaryColumnGeometry.fromVoxels(currentGeometry.minimumY(), result) : currentGeometry;
}
private static BoundaryColumnGeometry.Voxel blendVoxel(
ColumnProfile current,
List<WeightedProfile> historical,
int offset,
double currentWeight,
double openingWeight,
boolean historicalMaterial
) {
BoundaryColumnGeometry.Voxel currentVoxel = current.voxels.get(offset);
if (currentVoxel.protectedContent()) {
return currentVoxel;
}
boolean historicalOpening = false;
double solid = 0D;
double fluid = 0D;
double total = 0D;
double solidMaterialWeight = -1D;
double fluidMaterialWeight = -1D;
BoundaryColumnGeometry.Voxel oldSolid = null;
BoundaryColumnGeometry.Voxel oldFluid = null;
for (WeightedProfile contribution : historical) {
ColumnProfile profile = contribution.profile();
if (profile.voxels.get(offset).protectedContent()) {
continue;
}
historicalOpening |= profile.enclosedOpenings[offset];
double weight = contribution.weight();
total += weight;
solid += profile.solidDistances[offset] * weight;
fluid += profile.fluidDistances[offset] * weight;
if (profile.solidMaterials[offset] != null && weight > solidMaterialWeight) {
oldSolid = profile.solidMaterials[offset];
solidMaterialWeight = weight;
}
if (profile.fluidMaterials[offset] != null && weight > fluidMaterialWeight) {
oldFluid = profile.fluidMaterials[offset];
fluidMaterialWeight = weight;
}
}
if (total == 0D) {
return currentVoxel;
}
double blendedSolid = GenerationBlend.interpolate(solid / total,
current.solidDistances[offset], historicalOpening ? openingWeight : currentWeight);
if (blendedSolid > 0D) {
return selectMaterial(oldSolid, current.solidMaterials[offset], historicalMaterial);
}
double blendedFluid = GenerationBlend.interpolate(fluid / total,
current.fluidDistances[offset], historicalOpening ? openingWeight : currentWeight);
if (blendedFluid > 0D) {
BoundaryColumnGeometry.Voxel newFluid = current.fluidMaterials[offset];
if (oldFluid != null && newFluid != null && !fluidFamily(oldFluid).equals(fluidFamily(newFluid))) {
return FLUID_BARRIER;
}
return selectMaterial(oldFluid, newFluid, currentWeight <= 0.5D);
}
return AIR;
}
private static String fluidFamily(BoundaryColumnGeometry.Voxel voxel) {
String key = voxel.fluidStateKey();
int properties = key.indexOf('[');
return properties < 0 ? key : key.substring(0, properties);
}
private static BoundaryColumnGeometry.Voxel selectMaterial(
BoundaryColumnGeometry.Voxel historical,
BoundaryColumnGeometry.Voxel current,
boolean historicalMaterial
) {
if (historical == null) {
return current == null ? AIR : current;
}
return current == null || historicalMaterial ? historical : current;
}
private static BoundaryColumnGeometry.Voxel[] nearestMaterials(
List<BoundaryColumnGeometry.Voxel> voxels,
BoundaryColumnGeometry.Phase phase
) {
BoundaryColumnGeometry.Voxel[] materials = new BoundaryColumnGeometry.Voxel[voxels.size()];
int[] distances = new int[voxels.size()];
int last = -voxels.size() - 1;
BoundaryColumnGeometry.Voxel nearest = null;
for (int index = 0; index < voxels.size(); index++) {
BoundaryColumnGeometry.Voxel voxel = voxels.get(index);
if (voxel.phase() == phase && !voxel.protectedContent()) {
nearest = voxel;
last = index;
}
materials[index] = nearest;
distances[index] = index - last;
}
last = voxels.size() * 2;
nearest = null;
for (int index = voxels.size() - 1; index >= 0; index--) {
BoundaryColumnGeometry.Voxel voxel = voxels.get(index);
if (voxel.phase() == phase && !voxel.protectedContent()) {
nearest = voxel;
last = index;
}
if (nearest != null && last - index < distances[index]) {
materials[index] = nearest;
}
}
return materials;
}
private static boolean[] enclosedOpenings(List<BoundaryColumnGeometry.Voxel> voxels) {
boolean[] openings = new boolean[voxels.size()];
int lowerSolid = -1;
for (int index = 0; index < voxels.size(); index++) {
BoundaryColumnGeometry.Voxel voxel = voxels.get(index);
if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) {
if (lowerSolid >= 0 && index - lowerSolid - 1 <= MAXIMUM_OPENING_HEIGHT) {
for (int opening = lowerSolid + 1; opening < index; opening++) {
openings[opening] = !voxels.get(opening).protectedContent();
}
}
lowerSolid = index;
}
}
return openings;
}
private static final class ColumnProfile {
private final List<BoundaryColumnGeometry.Voxel> voxels;
private final boolean[] enclosedOpenings;
private final double[] solidDistances;
private final double[] fluidDistances;
private final BoundaryColumnGeometry.Voxel[] solidMaterials;
private final BoundaryColumnGeometry.Voxel[] fluidMaterials;
private ColumnProfile(BoundaryColumnGeometry geometry) {
voxels = geometry.voxels();
enclosedOpenings = enclosedOpenings(voxels);
solidDistances = geometry.solidDistances();
fluidDistances = geometry.fluidDistances();
solidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.SOLID);
fluidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.FLUID);
}
}
private record WeightedProfile(ColumnProfile profile, double weight) {
}
}
@@ -52,7 +52,7 @@ public class GenerationHistoryRecoveryTest extends GenerationHistorySupport {
"1".repeat(64),
Map.of(
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
(engine, transitionPlan) -> {
(engine, transitionPlan, detached) -> {
throw new AssertionError("History-only kernel factory was invoked.");
}
)
@@ -62,7 +62,7 @@ public class GenerationHistoryRecoveryTest extends GenerationHistorySupport {
"2".repeat(64),
Map.of(
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
(engine, transitionPlan) -> {
(engine, transitionPlan, detached) -> {
throw new AssertionError("History-only kernel factory was invoked.");
}
)
@@ -233,7 +233,7 @@ public final class GenerationHistoryRuntimeRouterPromotionTest extends Generatio
GenerationKernelRegistry upgraded = new GenerationKernelRegistry(version, List.of(
new GenerationKernelRegistry.Kernel(1, "9".repeat(64), Map.of(
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
(engine, plan) -> { throw new AssertionError("Mock runtime factory owns this test."); }))));
(engine, plan, detached) -> { throw new AssertionError("Mock runtime factory owns this test."); }))));
GenerationHistory history = GenerationHistory.open(world, upgraded);
IrisEngine engine = mock(IrisEngine.class);
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
@@ -158,7 +158,7 @@ abstract class GenerationHistoryRuntimeRouterSupport extends GenerationHistorySu
"1".repeat(64),
Map.of(
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
(engine, transitionPlan) -> {
(engine, transitionPlan, detached) -> {
throw new AssertionError("Mock runtime factory owns this test.");
}
)
@@ -168,7 +168,7 @@ abstract class GenerationHistoryRuntimeRouterSupport extends GenerationHistorySu
"2".repeat(64),
Map.of(
new GenerationKernelRegistry.AlgorithmVersion(1, 1),
(engine, transitionPlan) -> {
(engine, transitionPlan, detached) -> {
throw new AssertionError("Mock runtime factory owns this test.");
}
)
@@ -270,7 +270,7 @@ abstract class GenerationHistoryRuntimeRouterSupport extends GenerationHistorySu
when(binding.runtimeKernel()).thenReturn(new GenerationKernelRegistry.RuntimeKernel(
version,
epoch.kernelImplementationFingerprint(),
(engine, transitionPlan) -> mock(IrisComplex.class)
(engine, transitionPlan, detached) -> mock(IrisComplex.class)
));
when(binding.mantleStorageDirectory()).thenReturn(mantle);
when(binding.transitionPlan()).thenReturn(plan);
@@ -28,7 +28,7 @@ public abstract class GenerationHistorySupport {
return new GenerationKernelRegistry(version, List.of(new GenerationKernelRegistry.Kernel(
version.generatorAbi(), fingerprint.repeat(64),
Map.of(new GenerationKernelRegistry.AlgorithmVersion(version.rngVersion(), version.seedDerivationVersion()),
(engine, transition) -> {
(engine, transition, detached) -> {
throw new AssertionError("Saved terrain capture invoked a generator factory");
}))));
}
@@ -78,7 +78,7 @@ public class GenerationKernelRegistryTest {
version.rngVersion(),
version.seedDerivationVersion()
),
(engine, transitionPlan) -> mock(IrisComplex.class)
(engine, transitionPlan, detached) -> mock(IrisComplex.class)
)
))
);
@@ -87,7 +87,7 @@ public class GenerationKernelRegistryTest {
private static GenerationKernelRegistry.RuntimeFactory seedFactory(SeedManager seedManager) {
return new GenerationKernelRegistry.RuntimeFactory() {
@Override
public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan) {
public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan, boolean detached) {
return mock(IrisComplex.class);
}
@@ -1,9 +1,13 @@
package art.arcane.iris.engine.history;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.framework.BiomeEnvironment;
import art.arcane.iris.engine.framework.PreservationRegistry;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.testsupport.Await;
import art.arcane.iris.testsupport.PlatformLeakGuard;
@@ -235,6 +239,99 @@ public class SavedBiomeRuntimeTest {
}
}
@Test
public void archivedFocusedChildResolvesSurfaceCaveAndFloodedRulesWithItsAuthoredRegion() throws Exception {
assertFocusedEnvironments(true, false, false);
}
@Test
public void archivedUnownedFocusReconstructsItsRegionWithoutUsingCurrentFocusOrRemappingMissingRegions() throws Exception {
assertFocusedEnvironments(false, false, false);
}
@Test
public void archivedStackFocusReconstructsTheReferencedDimensionsRegion() throws Exception {
assertFocusedEnvironments(false, true, false);
}
@Test
public void archivedBuffetResolvesUnownedCellsWithoutAnAuthoredFocusOrNewGeneration() throws Exception {
assertFocusedEnvironments(false, false, true);
}
private void assertFocusedEnvironments(boolean authoredOwner, boolean stacked, boolean buffet) throws Exception {
String epochId = "a".repeat(64);
Path pack = history.paths().packRoot(epochId);
Files.createDirectories(pack.resolve("dimensions"));
Files.createDirectories(pack.resolve("biomes"));
Files.createDirectories(pack.resolve("regions"));
String focusedDimension = buffet
? "{\"regions\":[\"tropical\"],\"studioMode\":\"BIOME_BUFFET_3x3\"}"
: "{\"regions\":[\"tropical\"],\"focus\":\"highlands\"}";
Files.writeString(pack.resolve("dimensions/main.json"), stacked
? "{\"regions\":[\"tropical\"],\"dimensionStack\":{\"dimensions\":[\"lower\",\"main\"]}}"
: focusedDimension);
if (stacked) {
Files.writeString(pack.resolve("dimensions/lower.json"), focusedDimension);
}
Files.writeString(pack.resolve("regions/tropical.json"), "{\"landBiomes\":[\"wilds\"]}");
Files.writeString(pack.resolve("biomes/wilds.json"), authoredOwner ? "{\"children\":[\"highlands\"]}" : "{}");
Files.writeString(pack.resolve("biomes/highlands.json"), """
{"carvingBiome":"chalk-gardens","riverPolicy":{"floodedCaveBiomes":["flooded"]}}
""");
Files.writeString(pack.resolve("biomes/chalk-gardens.json"), "{}");
Files.writeString(pack.resolve("biomes/flooded.json"), "{}");
GenerationEpoch epoch = history.manifest().epoch(epochId).orElseThrow();
GenerationEpoch.DimensionContract contract = mock(GenerationEpoch.DimensionContract.class);
when(epoch.dimensionContract()).thenReturn(contract);
when(epoch.registryContract()).thenReturn(GenerationRegistryContract.empty());
when(contract.dimensionKey()).thenReturn("main");
when(history.packRoot(1L)).thenReturn(pack);
when(engine.getDimension()).thenReturn(new IrisDimension().setFocus("current-unrelated-focus"));
IrisData capturedData = IrisData.openRuntime(pack.toFile());
String regionKey;
try {
IrisDimension dimension = capturedData.getDimensionLoader().load(stacked ? "lower" : "main");
IrisBiome biome = capturedData.getBiomeLoader().load("highlands");
IrisRegion region = dimension.resolveFocusRegion(biome, () -> capturedData);
regionKey = region.getLoadKey();
} finally {
capturedData.close();
}
assertEquals(authoredOwner, regionKey.equals("tropical"));
SavedBiomeChunk.Cell surface = new SavedBiomeChunk.Cell(1L, "highlands", regionKey);
SavedBiomeChunk.Cell cave = new SavedBiomeChunk.Cell(1L, "chalk-gardens", regionKey);
SavedBiomeChunk.Cell flooded = new SavedBiomeChunk.Cell(1L, "flooded", regionKey);
SavedBiomeChunk.Cell missing = new SavedBiomeChunk.Cell(1L, "highlands", "missing-region");
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(0, 0, 1L, 0, 128));
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
builder.column(x, z, new SavedBiomeChunk.Column(x == 15 ? missing : surface, cave,
List.of(new SavedBiomeChunk.Span(0, 64, cave), new SavedBiomeChunk.Span(64, 128, flooded))));
}
}
when(store.get(0, 0)).thenReturn(Optional.of(builder.build()));
try (SavedBiomeRuntime runtime = new SavedBiomeRuntime(engine, history)) {
assertTrue(assertThrows(SavedBiomeUnavailableException.class, () -> runtime.prepareChunk(0, 0)).isLoading());
awaitIdle(runtime);
BiomeEnvironment surfaceEnvironment = runtime.resolve(0, 100, 0, true).orElseThrow();
BiomeEnvironment caveEnvironment = runtime.resolveCaveBase(0, 0).orElseThrow();
BiomeEnvironment floodedEnvironment = runtime.resolve(0, 80, 0, false).orElseThrow();
assertEquals("highlands", surfaceEnvironment.biome().getLoadKey());
assertEquals("chalk-gardens", caveEnvironment.biome().getLoadKey());
assertEquals("flooded", floodedEnvironment.biome().getLoadKey());
for (BiomeEnvironment environment : List.of(surfaceEnvironment, caveEnvironment, floodedEnvironment)) {
assertEquals(1L, environment.activationId());
assertEquals(regionKey, environment.region().getLoadKey());
assertEquals(pack.toFile(), environment.data().getDataFolder());
}
SavedBiomeUnavailableException unavailable = assertThrows(SavedBiomeUnavailableException.class,
() -> runtime.resolve(15, 100, 0, true));
assertEquals(false, unavailable.isLoading());
assertTrue(unavailable.getMessage().contains("missing-region"));
}
}
@Test
public void backgroundPreviewWaitsForTheReadWithoutRepeatingTheQuery() throws Exception {
allowUnownedChunks();
@@ -0,0 +1,148 @@
package art.arcane.iris.engine.history;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertSame;
public class TransitionGeometryCursorTest {
private static final BoundaryColumnGeometry.Voxel AIR = voxel("air", BoundaryColumnGeometry.Phase.AIR, false);
private static final BoundaryColumnGeometry.Voxel STONE = voxel("stone", BoundaryColumnGeometry.Phase.SOLID, false);
private static final BoundaryColumnGeometry.Voxel DIRT = voxel("dirt", BoundaryColumnGeometry.Phase.SOLID, false);
private static final BoundaryColumnGeometry.Voxel WATER = voxel("water[level=0]", BoundaryColumnGeometry.Phase.FLUID, false);
private static final List<BoundaryColumnGeometry.Voxel> STATES = List.of(AIR, STONE, DIRT, WATER,
voxel("cave_air", BoundaryColumnGeometry.Phase.AIR, false),
voxel("water[level=7]", BoundaryColumnGeometry.Phase.FLUID, false),
voxel("lava[level=0]", BoundaryColumnGeometry.Phase.FLUID, false),
voxel("stone", BoundaryColumnGeometry.Phase.SOLID, true),
voxel("water[level=0]", BoundaryColumnGeometry.Phase.FLUID, true),
voxel("light", BoundaryColumnGeometry.Phase.AIR, true));
@Test
public void matchesDenseBlendForRandomMaterialsProtectionWeightsAndRunLengths() {
Random random = new Random(0x72AB490EL);
int[] heights = {1, 2, 63, 64, 65, 128, 768, 4096};
for (int sample = 0; sample < 320; sample++) {
int height = heights[sample % heights.length];
int minimumY = switch (sample % 3) {
case 0 -> Integer.MIN_VALUE;
case 1 -> Integer.MAX_VALUE - height + 1;
default -> -256;
};
BoundaryColumnGeometry current = randomGeometry(random, minimumY, height, sample % 2 == 0);
ArrayList<BoundaryColumnGeometry> historical = new ArrayList<>();
for (int index = 0; index < 1 + sample % 4; index++) {
historical.add(randomGeometry(random, minimumY, height, sample % 2 == 0));
}
assertMatches(current, historical, random.nextDouble(), random.nextDouble(), random);
}
}
@Test
public void matchesUniformColumnsAtBothHeightLimitsAndCoordinateLimits() {
Random random = new Random(44);
for (int height : new int[]{1, BoundaryColumnGeometry.MAXIMUM_HEIGHT}) {
for (BoundaryColumnGeometry.Voxel current : STATES) {
ArrayList<BoundaryColumnGeometry> historical = new ArrayList<>();
for (BoundaryColumnGeometry.Voxel old : List.of(AIR, STONE, WATER)) {
historical.add(geometry(Integer.MAX_VALUE - height + 1, Collections.nCopies(height, old)));
}
assertMatches(geometry(Integer.MAX_VALUE - height + 1, Collections.nCopies(height, current)),
historical, 0.5D, 0.125D, random);
}
}
}
@Test
public void matchesOpeningCutoffAndProtectedVoxelsInsideOpenings() {
Random random = new Random(1234);
for (int opening : new int[]{1, 63, 64, 65, 66}) {
for (boolean protectedMiddle : new boolean[]{false, true}) {
ArrayList<BoundaryColumnGeometry.Voxel> old = new ArrayList<>();
old.add(STONE);
old.addAll(Collections.nCopies(opening, AIR));
old.add(DIRT);
if (protectedMiddle) {
old.set(opening / 2 + 1, STATES.get(7));
}
BoundaryColumnGeometry current = geometry(-256, Collections.nCopies(old.size(), STONE));
for (double weight : new double[]{0D, 0.25D, 0.5D, 0.75D, 1D}) {
assertMatches(current, List.of(geometry(-256, old)), weight, 0D, random);
}
}
}
}
@Test
public void keepsLowerMaterialOnEquidistantTiesAndPreservesSingleVoxelPhases() {
Random random = new Random(77);
BoundaryColumnGeometry current = geometry(0, List.of(STONE, STONE, STONE, STONE, STONE));
BoundaryColumnGeometry old = geometry(0, List.of(STONE, AIR, AIR, AIR, DIRT));
BoundaryGeometryInfluence influence = influence(List.of(old), 0.25D, 1D);
assertEquals(STONE, TransitionGeometryBlender.blendColumn(influence, 0, 0, current).voxelAt(2));
assertMatches(current, List.of(old), 0.25D, 1D, random);
for (BoundaryColumnGeometry.Voxel middle : STATES) {
assertMatches(geometry(0, List.of(AIR, WATER, STONE, WATER, AIR)),
List.of(geometry(0, List.of(STONE, AIR, middle, AIR, DIRT))), 0.5D, 0.125D, random);
}
}
@Test
public void retainsIdentityForUnchangedAndEmptyColumns() {
BoundaryColumnGeometry empty = BoundaryColumnGeometry.empty();
assertSame(empty, TransitionGeometryBlender.blendColumn(BoundaryGeometryInfluence.none(), 0, 0, empty));
BoundaryColumnGeometry current = geometry(0, List.of(STONE, AIR, WATER));
assertSame(current, TransitionGeometryBlender.blendColumn(influence(List.of(current), 0.5D, 0.5D),
0, 0, current));
}
private static BoundaryColumnGeometry randomGeometry(Random random, int minimumY, int height, boolean singleVoxelRuns) {
ArrayList<BoundaryColumnGeometry.Voxel> voxels = new ArrayList<>(height);
while (voxels.size() < height) {
BoundaryColumnGeometry.Voxel voxel = STATES.get(random.nextInt(STATES.size()));
int end = Math.min(height, voxels.size() + (singleVoxelRuns ? 1 : random.nextInt(128) + 1));
while (voxels.size() < end) {
voxels.add(voxel);
}
}
return geometry(minimumY, voxels);
}
private static void assertMatches(BoundaryColumnGeometry current, List<BoundaryColumnGeometry> historical,
double currentWeight, double openingWeight, Random random) {
BoundaryGeometryInfluence influence = influence(historical, currentWeight, openingWeight);
int x = random.nextInt();
int z = random.nextInt();
BoundaryColumnGeometry expected = DenseTransitionGeometryReference.blendColumn(influence, x, z, current);
BoundaryColumnGeometry actual = TransitionGeometryBlender.blendColumn(influence, x, z, current);
assertEquals(expected, actual);
if (expected == current) {
assertSame(current, actual);
}
}
private static BoundaryGeometryInfluence influence(List<BoundaryColumnGeometry> historical,
double currentWeight, double openingWeight) {
ArrayList<BoundaryGeometryInfluence.Contribution> contributions = new ArrayList<>();
double total = historical.size() * (historical.size() + 1) / 2D;
for (int index = 0; index < historical.size(); index++) {
contributions.add(new BoundaryGeometryInfluence.Contribution(historical.get(index), (index + 1) / total));
}
return new BoundaryGeometryInfluence(currentWeight, openingWeight, contributions);
}
private static BoundaryColumnGeometry geometry(int minimumY, List<BoundaryColumnGeometry.Voxel> voxels) {
return BoundaryColumnGeometry.fromVoxels(minimumY, voxels);
}
private static BoundaryColumnGeometry.Voxel voxel(String key, BoundaryColumnGeometry.Phase phase, boolean protection) {
return new BoundaryColumnGeometry.Voxel("minecraft:" + key, phase,
phase == BoundaryColumnGeometry.Phase.FLUID ? "minecraft:" + key : "", protection);
}
}
@@ -1216,7 +1216,8 @@ public class HydrologyCaveCourseFilterTest {
base.deepFluids(),
base.surfacePools(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -14,13 +14,15 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
10L,
100L,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 63, 0)),
true
true,
128
);
HydrologyCrossTileSurfaceAdmission.Claim blocker = claim(
20L,
200L,
List.of(new HydrologyPoint(0, 70, 48), new HydrologyPoint(112, 63, 24)),
true
true,
128
);
HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit(
@@ -29,8 +31,7 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
new HydrologyTileKey(0, 0),
0,
blocker
)),
128
))
);
assertEquals(1, result.rejections().size());
@@ -43,13 +44,15 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
10L,
100L,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 65, 100)),
false
false,
64
);
HydrologyCrossTileSurfaceAdmission.Claim blocker = claim(
20L,
200L,
List.of(new HydrologyPoint(0, 70, 100), new HydrologyPoint(100, 65, 0)),
false
false,
64
);
HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit(
@@ -58,8 +61,7 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
new HydrologyTileKey(0, 0),
0,
blocker
)),
64
))
);
assertEquals(1, result.rejections().size());
@@ -71,13 +73,15 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
10L,
100L,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(512, 63, 0)),
true
true,
192
);
HydrologyCrossTileSurfaceAdmission.Claim blocker = claim(
20L,
200L,
List.of(new HydrologyPoint(0, 70, 128), new HydrologyPoint(-512, 63, 128)),
true
true,
192
);
HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit(
@@ -86,8 +90,7 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
new HydrologyTileKey(0, 0),
0,
blocker
)),
192
))
);
assertEquals(1, result.rejections().size());
@@ -99,13 +102,15 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
10L,
100L,
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 63, 0)),
true
true,
128
);
HydrologyCrossTileSurfaceAdmission.Claim blocker = claim(
20L,
200L,
List.of(new HydrologyPoint(0, 70, 256), new HydrologyPoint(100, 63, 256)),
true
true,
128
);
HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit(
@@ -114,18 +119,38 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
new HydrologyTileKey(0, 0),
0,
blocker
)),
128
))
);
assertTrue(result.rejections().isEmpty());
}
@Test
public void localSpacingKeepsDenseNetworksButHonorsTheNeighboringAreaInEitherOrder() {
HydrologyCrossTileSurfaceAdmission.Claim tropical = claim(10L, 100L,
List.of(new HydrologyPoint(-64, 70, 0), new HydrologyPoint(800, 63, 0)), true, 160);
HydrologyCrossTileSurfaceAdmission.Claim otherTropical = claim(20L, 200L,
List.of(new HydrologyPoint(-64, 70, 200), new HydrologyPoint(800, 63, 200)), true, 160);
HydrologyCrossTileSurfaceAdmission.Claim neighboringRegion = claim(30L, 300L,
List.of(new HydrologyPoint(-64, 70, 200), new HydrologyPoint(800, 63, 200)), true, 384);
assertTrue(HydrologyCrossTileSurfaceAdmission.admit(List.of(tropical), List.of(
new HydrologyCrossTileSurfaceAdmission.RankedClaim(new HydrologyTileKey(-1, 0), 0, otherTropical)))
.rejections().isEmpty());
assertEquals(1, HydrologyCrossTileSurfaceAdmission.admit(List.of(tropical), List.of(
new HydrologyCrossTileSurfaceAdmission.RankedClaim(new HydrologyTileKey(-1, 0), 0, neighboringRegion)))
.rejections().size());
assertEquals(1, HydrologyCrossTileSurfaceAdmission.admit(List.of(neighboringRegion), List.of(
new HydrologyCrossTileSurfaceAdmission.RankedClaim(new HydrologyTileKey(-1, 0), 0, tropical)))
.rejections().size());
}
private HydrologyCrossTileSurfaceAdmission.Claim claim(
long courseId,
long outletId,
List<HydrologyPoint> centerline,
boolean reachesOutlet
boolean reachesOutlet,
int sourceSpacing
) {
return new HydrologyCrossTileSurfaceAdmission.Claim(
courseId,
@@ -133,7 +158,8 @@ public class HydrologyCrossTileSurfaceAdmissionTest {
centerline.getLast(),
reachesOutlet,
4,
centerline
centerline,
sourceSpacing
);
}
}
@@ -315,7 +315,8 @@ public class HydrologyFootprintCompilerTest {
HydrologyPlannerSettings.Geometry.defaults(),
List.of(deepFluid), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
HydraulicSegment segment = new HydraulicSegment(
15L,
@@ -2163,7 +2164,9 @@ public class HydrologyFootprintCompilerTest {
return new HydrologyPlannerSettings(
base.seaLevel(), base.routing(), base.surface(), base.hydraulics(), base.underground(),
base.outlets(), geometry, base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(),
base.seaCaves());
base.seaCaves(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
private static HydrologyPlannerSettings withUndergroundShape(HydrologyPlannerSettings.ChannelShape shape) {
@@ -2193,7 +2196,9 @@ public class HydrologyFootprintCompilerTest {
return new HydrologyPlannerSettings(
base.seaLevel(), base.routing(), tunedSurface, base.hydraulics(), base.underground(),
base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(),
base.seaCaves());
base.seaCaves(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
private static HydrologyPlannerSettings seaCaveSettings(int maximumOceanApron, int chamberRadius) {
@@ -2227,7 +2232,8 @@ public class HydrologyFootprintCompilerTest {
base.deepFluids(),
base.surfacePools(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import org.junit.Test;
@@ -15,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 = "de765d4be6c95954f8df344ee2d1dd72c97e8640fc28c92c3ec4717cff0d88e4";
private static final String EXPECTED_PLAN_DIGEST = "4ff5293a3d4110f54ffa82630ac95efa98cd7d51cf57911475f466c66f1433c8";
private static final List<HydrologyTileKey> TILES = List.of(
new HydrologyTileKey(0, 0),
@@ -138,7 +140,8 @@ public class HydrologyPlanHashTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -175,7 +178,8 @@ public class HydrologyPlanHashTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -222,6 +226,8 @@ public class HydrologyPlanHashTest {
List.of(),
List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled());
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import org.junit.Test;
import java.util.ArrayList;
@@ -101,7 +103,8 @@ public class HydrologyPlannerConfinementTest {
!ocean, !ocean, !ocean, false, false, false,
0D, ocean ? 0D : 1D, 0D, 1D, 1D, 1D, 1D, 1D,
"land", "land", "land", "land", "land", "land",
List.of("default"), List.of(), Double.NaN, confines, Double.NaN, true
List.of("default"), List.of(), Double.NaN, confines, Double.NaN, true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -126,7 +129,8 @@ public class HydrologyPlannerConfinementTest {
HydrologyPlannerSettings.Geometry.defaults().drops()),
List.of(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition;
@@ -339,7 +341,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
stableGeometry(),
List.of(deepFluid), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -417,7 +420,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
base.geometry(),
base.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -463,7 +467,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
};
}
@@ -508,7 +513,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
};
}
@@ -545,7 +551,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition;
@@ -710,7 +712,8 @@ public class HydrologyPlannerGlobalInvariantTest {
HydrologyPlannerSettings.Geometry.defaults(),
deepFluids, List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -761,7 +764,8 @@ public class HydrologyPlannerGlobalInvariantTest {
HydrologyPlannerSettings.Geometry.defaults(),
base.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -958,7 +962,8 @@ public class HydrologyPlannerGlobalInvariantTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -1013,7 +1018,8 @@ public class HydrologyPlannerGlobalInvariantTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -1066,7 +1072,8 @@ public class HydrologyPlannerGlobalInvariantTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import org.junit.Test;
@@ -283,12 +285,13 @@ public class HydrologyPlannerSeaCaveTest {
"shore",
"dry",
"flooded",
List.of("beta", "alpha"),
List.of("default"),
List.of(),
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -327,7 +330,8 @@ public class HydrologyPlannerSeaCaveTest {
List.of(),
List.of(),
surface.shoreWidth(),
seaCaves
seaCaves,
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
}
@@ -66,7 +66,9 @@ public class HydrologyPlannerSettingsTest {
HydrologyPlannerSettings.Geometry.defaults(),
List.of(overlongChannel), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled())
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
)
);
}
@@ -209,7 +211,9 @@ public class HydrologyPlannerSettingsTest {
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(),
surface.maximumWidth(), surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(),
surface.shoreWidth(), flared),
base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), 0D, HydrologyPlannerSettings.SeaCaves.disabled());
base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(), 0D, HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
assertEquals(1.6D, banks.mouthFlareRatio(), 0D);
int widened = (int) StrictMath.ceil(surface.maximumWidth() * (4D - banks.mouthFlareRatio()) / 2D);
@@ -372,7 +376,8 @@ public class HydrologyPlannerSettingsTest {
base.deepFluids(),
base.surfacePools(),
base.widestShoreBiomeWidth(),
seaCaves
seaCaves,
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
assertEquals(seaCaves, withCaves.seaCaves());
assertTrue(base.fingerprint() != withCaves.fingerprint());
@@ -397,7 +402,8 @@ public class HydrologyPlannerSettingsTest {
List.of(),
List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
HydrologyPlannerSettings withCaves = new HydrologyPlannerSettings(
base.seaLevel(),
@@ -410,7 +416,8 @@ public class HydrologyPlannerSettingsTest {
List.of(),
List.of(),
surface.shoreWidth(),
HydrologyPlannerSettings.SeaCaves.of(true, 1, 64, 8, 12)
HydrologyPlannerSettings.SeaCaves.of(true, 1, 64, 8, 12),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
int alignedHalo = Math.min(base.routing().maximumRouteLength(), base.routing().sampleSpacing() * 2)
@@ -709,7 +716,9 @@ public class HydrologyPlannerSettingsTest {
HydrologyPlannerSettings.SeaCaves seaCaves
) {
return new HydrologyPlannerSettings(base.seaLevel(), base.routing(), surface, base.hydraulics(), underground, outlets,
geometry, base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(), seaCaves);
geometry, base.deepFluids(), base.surfacePools(), base.widestShoreBiomeWidth(), seaCaves,
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
private static HydrologyPlannerSettings.Surface surfaceWith(HydrologyPlannerSettings.Surface surface, HydrologyPlannerSettings.Banks banks) {
@@ -115,7 +115,8 @@ public class HydrologyPlannerSurfacePoolTest {
List.of(new HydrologyPlannerSettings.SurfacePool(
"lava_pool", true, density, spacing, minimumRadius, maximumRadius, depth, 64, null)),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
@@ -415,7 +417,9 @@ public class HydrologyPlannerTest {
routing.valleyPreference(), routing.uphillPenalty(), routing.slopePenalty(),
routing.confluenceAttraction(), routing.lengthPreference(), routing.tributaries()),
settings.surface(), settings.hydraulics(), settings.underground(), settings.outlets(), settings.geometry(),
settings.deepFluids(), settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves());
settings.deepFluids(), settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
private static Set<?> cachedOwnerKeys(HydrologyPlanner planner) throws Exception {
@@ -1124,7 +1128,8 @@ public class HydrologyPlannerTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
};
HydrologyTile tile = new HydrologyPlanner(812L, settings, terrain, solidCaveView()).plan(TILE);
@@ -1200,7 +1205,8 @@ public class HydrologyPlannerTest {
base.geometry(),
base.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
HydrologyTerrainSampler repeatedRidges = (int x, int z) -> {
if (x >= 112) {
@@ -1737,7 +1743,8 @@ public class HydrologyPlannerTest {
HydrologyPlannerSettings.Geometry.defaults(),
base.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
HydrologyPlannerSettings settings = withOutlets(shaped, HydrologyPlannerSettings.Outlets.of(
true,
@@ -2014,7 +2021,8 @@ public class HydrologyPlannerTest {
base.geometry(),
base.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
HydrologyTerrainSampler coast = (int x, int z) -> {
if (x >= 112) {
@@ -2214,7 +2222,8 @@ public class HydrologyPlannerTest {
base.geometry(),
base.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
HydrologyTerrainSampler terrain = (int x, int z) -> {
if (x >= 112) {
@@ -2606,7 +2615,8 @@ public class HydrologyPlannerTest {
stableGeometry(),
deepFluids, List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -2666,7 +2676,8 @@ public class HydrologyPlannerTest {
HydrologyPlannerSettings.Geometry.defaults(),
List.of(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -2739,7 +2750,8 @@ public class HydrologyPlannerTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
};
}
@@ -2808,7 +2820,8 @@ public class HydrologyPlannerTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -2844,7 +2857,8 @@ public class HydrologyPlannerTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -2889,7 +2903,8 @@ public class HydrologyPlannerTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -2925,7 +2940,8 @@ public class HydrologyPlannerTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -2961,7 +2977,8 @@ public class HydrologyPlannerTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -2989,7 +3006,8 @@ public class HydrologyPlannerTest {
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -3021,7 +3039,8 @@ public class HydrologyPlannerTest {
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -3054,7 +3073,8 @@ public class HydrologyPlannerTest {
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -3086,7 +3106,8 @@ public class HydrologyPlannerTest {
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -3148,7 +3169,8 @@ public class HydrologyPlannerTest {
HydrologyPlannerSettings.Geometry.defaults(),
settings.deepFluids(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -3430,9 +3452,9 @@ public class HydrologyPlannerTest {
constructor.setAccessible(true);
Object node = constructor.newInstance(0, 0, 0, 0, 0, 719L, terrain);
Method method = HydrologyRouteGeometry.class.getDeclaredMethod("anchorScore", nodeType,
int.class, int.class, int.class, int.class, double.class);
int.class, int.class, int.class, int.class, double.class, boolean.class);
method.setAccessible(true);
return (double) method.invoke(planner.routeGeometry, node, x, z, 0, 0, 0.3D);
return (double) method.invoke(planner.routeGeometry, node, x, z, 0, 0, 0.3D, false);
}
private List<?> routeCandidates(HydrologyPlanner planner) throws Exception {
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import org.junit.Test;
import java.util.ArrayList;
@@ -194,7 +196,8 @@ public class HydrologyPlannerTributaryTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -260,7 +263,8 @@ public class HydrologyPlannerTributaryTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -299,7 +303,8 @@ public class HydrologyPlannerTributaryTest {
List.of(),
List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@Test
@@ -407,7 +412,9 @@ public class HydrologyPlannerTributaryTest {
54, 0D, true, false, 30, 32, false, false, false, false, false, false,
0D, 0D, 0D, 1D, 1D, 1D, 1D, 1D,
"ocean_parent", "surface", "mouth", "shore", "dry", "flooded",
List.of("default"), List.of(), Double.NaN, null, Double.NaN, true);
List.of("default"), List.of(), Double.NaN, null, Double.NaN, true,
SurfaceRiverPolicy.INHERIT
);
}
int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D);
boolean source = x >= 0 && x <= 24 && (z >= 8 && z <= 40 || z >= 72 && z <= 104);
@@ -415,7 +422,9 @@ public class HydrologyPlannerTributaryTest {
height, 1D, false, true, 70, 74, true, true, false, false, source, false,
0D, 0D, source ? 1D : 0D, 1D, 1D, 1D, 1D, 1D,
"parent", "surface", "mouth", "shore", "dry", "flooded",
List.of("default"), List.of(), Double.NaN, null, Double.NaN, true);
List.of("default"), List.of(), Double.NaN, null, Double.NaN, true,
SurfaceRiverPolicy.INHERIT
);
}
private static HydrologyPlannerSettings undergroundSettings(int tributaries) {
@@ -449,7 +458,8 @@ public class HydrologyPlannerTributaryTest {
List.of(),
List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
@@ -324,7 +326,8 @@ public class HydrologyPlannerUndergroundKnobsTest {
settings.deepFluids(),
settings.surfacePools(),
settings.widestShoreBiomeWidth(),
settings.seaCaves()
settings.seaCaves(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -357,7 +360,8 @@ public class HydrologyPlannerUndergroundKnobsTest {
settings.deepFluids(),
settings.surfacePools(),
settings.widestShoreBiomeWidth(),
settings.seaCaves()
settings.seaCaves(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -385,7 +389,8 @@ public class HydrologyPlannerUndergroundKnobsTest {
settings.deepFluids(),
settings.surfacePools(),
settings.widestShoreBiomeWidth(),
settings.seaCaves()
settings.seaCaves(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -468,7 +473,8 @@ public class HydrologyPlannerUndergroundKnobsTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -603,7 +609,8 @@ public class HydrologyPlannerUndergroundKnobsTest {
List.of(),
List.of(),
surface.shoreWidth(),
seaCaves
seaCaves,
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -673,7 +680,8 @@ public class HydrologyPlannerUndergroundKnobsTest {
List.of(),
List.of(),
0D,
seaCaves
seaCaves,
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -0,0 +1,242 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import org.junit.Test;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalSurfacePolicyTest {
private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0);
private static final SurfaceRiverPolicy TROPICAL = new SurfaceRiverPolicy("region:tropical", 8D, 160, 0, 3, 4, null, null);
@Test
public void sourcesAndOutletsUseOnlyTheirOwnedEligibleArea() {
HydrologySampledGrid grid = grid((x, z) -> land(x < 512 ? TROPICAL : SurfaceRiverPolicy.INHERIT, false));
HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults());
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources());
assertEquals(4, budgets.sourceTarget(budgets.area(TROPICAL), 0, planner, TILE));
assertEquals(2, budgets.outletTarget(budgets.area(TROPICAL), true, planner, TILE));
int inland = budgets.outletTarget(budgets.area(TROPICAL), false, planner, TILE);
assertTrue(inland == 1 || inland == 2);
assertTrue(budgets.sourceTarget(budgets.area(SurfaceRiverPolicy.INHERIT), 0, planner, TILE) <= 1);
HydrologySampledGrid tiny = grid((x, z) -> land(x == 0 && z == 0 ? TROPICAL : SurfaceRiverPolicy.INHERIT, false));
HydrologySurfaceBudgets tinyBudgets = HydrologySurfaceBudgets.sample(tiny, planner.settings.surface().sources());
int selected = 0;
for (int seed = 0; seed < 256; seed++) {
int target = tinyBudgets.sourceTarget(tinyBudgets.area(TROPICAL), 0,
planner(seed, planner.settings), TILE);
assertTrue(target <= 1);
selected += target;
}
assertTrue("tiny areas acquired a per-tile minimum: " + selected, selected > 0 && selected < 32);
}
@Test
public void geometrySubareasShareSourceAndOutletBudgets() {
SurfaceRiverPolicy narrow = new SurfaceRiverPolicy("region:tropical", 8D, 160, 0, 3, 4, 128, 24);
HydrologySampledGrid mixed = grid((x, z) -> land(x < 512 ? TROPICAL : narrow, false));
HydrologySampledGrid uniform = grid((x, z) -> land(TROPICAL, false));
HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults());
HydrologySurfaceBudgets mixedBudgets = HydrologySurfaceBudgets.sample(mixed, planner.settings.surface().sources());
HydrologySurfaceBudgets uniformBudgets = HydrologySurfaceBudgets.sample(uniform, planner.settings.surface().sources());
assertEquals(1, mixedBudgets.areas().size());
assertEquals(mixedBudgets.area(TROPICAL), mixedBudgets.area(narrow));
assertEquals(8, mixedBudgets.sourceTarget(mixedBudgets.area(narrow), 0, planner, TILE));
assertEquals(uniformBudgets.outletTarget(uniformBudgets.area(TROPICAL), true, planner, TILE),
mixedBudgets.outletTarget(mixedBudgets.area(narrow), true, planner, TILE));
assertEquals(select(planner, uniform, routing(uniform, false), true).selectedNodeIndices(),
select(planner, mixed, routing(mixed, false), true).selectedNodeIndices());
assertEquals(planner.outletPlanner.limitSurfaceOutlets(TILE, uniform, routing(uniform, false).outlets(), true),
planner.outletPlanner.limitSurfaceOutlets(TILE, mixed, routing(mixed, false).outlets(), true));
}
@Test
public void aRegionCannotSpendAnotherRegionsUnusedSourceBudget() {
HydrologySampledGrid grid = grid((x, z) -> land(x < 512 ? TROPICAL : SurfaceRiverPolicy.INHERIT, false));
HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults());
HydrologyRoutingPlan routing = routing(grid, false);
SourceSelection both = select(planner, grid, routing, true);
assertEquals(4, selectedIn(both, grid, TROPICAL));
assertTrue(selectedIn(both, grid, SurfaceRiverPolicy.INHERIT) <= 1);
for (HydrologyGridNode node : grid.nodes()) {
if (node.terrain().surfacePolicy().equals(TROPICAL)) {
routing.potential()[node.index()] = Double.POSITIVE_INFINITY;
}
}
SourceSelection onlyOrdinary = select(planner, grid, routing, true);
assertEquals(0, selectedIn(onlyOrdinary, grid, TROPICAL));
assertTrue(onlyOrdinary.selectedNodeIndices().size() <= 1);
assertEquals(onlyOrdinary.selectedNodeIndices(), select(planner(1337L, planner.settings), grid, routing, true).selectedNodeIndices());
}
@Test
public void localTributaryCapsApplyToAdmissionAndReplacement() {
SurfaceRiverPolicy branching = new SurfaceRiverPolicy("region:tropical", 8D, 160, 3, 3, 4, null, null);
HydrologySampledGrid grid = grid((x, z) -> land(branching, false));
HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults());
SourceSelection selected = select(planner, grid, routing(grid, true), true);
assertEquals(4, selected.selectedNodeIndices().size());
Set<Integer> original = new HashSet<>(selected.selectedNodeIndices());
assertTrue(selected.advanceAfterPublication(List.of(), grid));
assertEquals(4, selected.selectedNodeIndices().size());
assertTrue(selected.selectedNodeIndices().stream().noneMatch(original::contains));
HydrologySampledGrid unbranched = grid((x, z) -> land(TROPICAL, false));
assertEquals(1, select(planner, unbranched, routing(unbranched, true), true).selectedNodeIndices().size());
}
@Test
public void requiredHeadwatersKeepTheirOwnMinimumWhenExpectedDensityIsZero() {
SurfaceRiverPolicy requiredArea = new SurfaceRiverPolicy("biome:headwater", null, 160, null, null, null, null, null);
SurfaceRiverPolicy disabledArea = new SurfaceRiverPolicy("region:disabled", 0D, 160, null, null, null, null, null);
HydrologySampledGrid grid = grid((x, z) -> land(x == 0 && z < 256 ? requiredArea : disabledArea, true));
HydrologyPlanner planner = planner(7L, withSources(new HydrologyPlannerSettings.Source(true, 0D, 88, 2, 2, 384)));
SourceSelection selected = select(planner, grid, routing(grid, false), true);
assertEquals(2, selected.selectedNodeIndices().size());
assertEquals(2, selectedIn(selected, grid, requiredArea));
Set<Integer> original = new HashSet<>(selected.selectedNodeIndices());
assertTrue(selected.advanceAfterPublication(List.of(), grid));
assertEquals(2, selectedIn(selected, grid, requiredArea));
assertTrue(selected.selectedNodeIndices().stream().noneMatch(original::contains));
assertEquals(0, selectedIn(selected, grid, disabledArea));
HydrologySampledGrid inherited = grid((x, z) -> land(x == 0 && z < 256 ? SurfaceRiverPolicy.INHERIT : disabledArea, true));
assertEquals(2, selectedIn(select(planner, inherited, routing(inherited, false), true),
inherited, SurfaceRiverPolicy.INHERIT));
}
@Test
public void localSurfaceControlsDoNotAlterUndergroundAdmission() {
HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults());
HydrologySampledGrid ordinary = grid((x, z) -> land(SurfaceRiverPolicy.INHERIT, false));
HydrologySampledGrid tropical = grid((x, z) -> land(TROPICAL, false));
assertEquals(select(planner, ordinary, routing(ordinary, true), false).selectedNodeIndices(),
select(planner, tropical, routing(tropical, true), false).selectedNodeIndices());
}
@Test
public void coastalBudgetsStayLocalAndCanBeExplicitlyDisabled() {
SurfaceRiverPolicy volcanic = new SurfaceRiverPolicy("biome:volcanic", 6D, 128, 2, 3, 0, null, null);
HydrologySampledGrid grid = grid((x, z) -> land(x < 512 ? TROPICAL : volcanic, false));
HydrologyPlanner planner = planner(1337L, HydrologyPlannerSettings.defaults());
List<OutletCandidate> candidates = routing(grid, false).outlets();
List<OutletCandidate> selected = planner.outletPlanner.limitSurfaceOutlets(TILE, grid, candidates, true);
assertEquals(2, selected.size());
for (OutletCandidate outlet : selected) {
assertEquals(TROPICAL, grid.node(outlet.landIndex()).terrain().surfacePolicy());
}
assertEquals(selected, planner.outletPlanner.limitSurfaceOutlets(TILE, grid, candidates, true));
}
@Test
public void disabledLocalCoastTargetsDoNotSuppressInlandOutlets() {
SurfaceRiverPolicy inlandOnly = new SurfaceRiverPolicy("region:inland", 8D, 160, 3, 3, 0, null, null);
HydrologyTerrainSampler localTerrain = (x, z) -> x >= 512
? HydrologyTerrainSample.ocean(50, "ocean") : land(inlandOnly, false);
HydrologyTerrainSampler ordinaryTerrain = (x, z) -> x >= 512
? HydrologyTerrainSample.ocean(50, "ocean") : land(SurfaceRiverPolicy.INHERIT, false);
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Outlets outlets = base.outlets();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(), base.surface(),
base.hydraulics(), base.underground(), HydrologyPlannerSettings.Outlets.of(outlets.oceanEnabled(),
outlets.coastalGrotto(), outlets.inlandGrotto(), true, outlets.coastalCliffMinimumHeight(),
outlets.mouthLevelingDistance(), outlets.maximumOceanApron(), outlets.maximumPerTile(),
outlets.maximumCoastalPerTile()), base.geometry(), base.deepFluids(), base.surfacePools(),
base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
HydrologyPlanner local = new HydrologyPlanner(1337L, settings, localTerrain);
HydrologyPlanner ordinary = new HydrologyPlanner(1337L, settings, ordinaryTerrain);
List<OutletCandidate> localOutlets = local.outletPlanner.resolveOutlets(TILE, grid(localTerrain), true, new ArrayList<>());
assertTrue(localOutlets.toString(), !localOutlets.isEmpty());
for (OutletCandidate candidate : localOutlets) {
assertEquals(HydrologyFeatureType.INLAND_GROTTO, candidate.outlet().type());
}
List<OutletCandidate> ordinaryOutlets = ordinary.outletPlanner.resolveOutlets(TILE, grid(ordinaryTerrain), true, new ArrayList<>());
assertTrue(ordinaryOutlets.toString(), !ordinaryOutlets.isEmpty());
for (OutletCandidate candidate : ordinaryOutlets) {
assertTrue(candidate.outlet().type() != HydrologyFeatureType.INLAND_GROTTO);
}
assertEquals(ordinary.outletPlanner.resolveOutlets(TILE, grid(ordinaryTerrain), false, new ArrayList<>()),
local.outletPlanner.resolveOutlets(TILE, grid(localTerrain), false, new ArrayList<>()));
}
private static SourceSelection select(HydrologyPlanner planner, HydrologySampledGrid grid,
HydrologyRoutingPlan routing, boolean surface) {
return planner.sourcePlanner.selectSources(TILE, grid, routing, surface, false, new ArrayList<>(), new HashMap<>());
}
private static int selectedIn(SourceSelection selection, HydrologySampledGrid grid, SurfaceRiverPolicy policy) {
int count = 0;
for (int index : selection.selectedNodeIndices()) {
if (grid.node(index).terrain().surfacePolicy().equals(policy)) {
count++;
}
}
return count;
}
private static HydrologyPlanner planner(long seed, HydrologyPlannerSettings settings) {
return new HydrologyPlanner(seed, settings, (x, z) -> land(SurfaceRiverPolicy.INHERIT, false));
}
private static HydrologySampledGrid grid(HydrologyTerrainSampler terrain) {
ArrayList<HydrologyGridNode> nodes = new ArrayList<>(256);
for (int z = 0; z < 16; z++) {
for (int x = 0; x < 16; x++) {
int index = z * 16 + x;
nodes.add(new HydrologyGridNode(index, x, z, x * 64, z * 64, index + 1L, terrain.sample(x * 64, z * 64)));
}
}
return new HydrologySampledGrid(0, 0, 0, 0, 1024, 16, 64, List.copyOf(nodes));
}
private static HydrologyRoutingPlan routing(HydrologySampledGrid grid, boolean sharedOutlet) {
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);
Arrays.fill(lengths, 512);
ArrayList<OutletCandidate> candidates = new ArrayList<>(count);
for (HydrologyGridNode node : grid.nodes()) {
outlets[node.index()] = sharedOutlet ? 0 : node.index();
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 HydrologyPlannerSettings withSources(HydrologyPlannerSettings.Source sources) {
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(true, sources, 4, 8, 2, 4, 10,
1.5D, HydrologyPlannerSettings.Banks.defaults());
return 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());
}
private static HydrologyTerrainSample land(SurfaceRiverPolicy policy, boolean required) {
return new HydrologyTerrainSample(200, 0D, false, true, 160, 162, true, true, true, required, true, false,
0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D, "land", "land", "land", "land", "land", "land",
List.of("default"), List.of(), Double.NaN, null, Double.NaN, true, policy);
}
}
@@ -207,7 +207,7 @@ public class HydrologyRouteTurnCacheTest {
tangent = directionConstructor.newInstance(1D, 0D);
Class<?> candidate = RouteCandidate.class;
candidateConstructor = candidate.getDeclaredConstructor(HydrologyPoint.class, double.class, double.class,
double.class, direction, boolean.class);
double.class, double.class, double.class, direction, boolean.class);
candidateConstructor.setAccessible(true);
solve = HydrologyRouteGeometry.class.getDeclaredMethod("selectCurvatureAwareTerrainRoute", List.class,
double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class);
@@ -221,7 +221,8 @@ public class HydrologyRouteTurnCacheTest {
for (int layer = 0; layer < fixture.points().length; layer++) {
ArrayList<Object> candidates = new ArrayList<>();
for (int candidate = 0; candidate < fixture.points()[layer].length; candidate++) {
candidates.add(candidateConstructor.newInstance(fixture.points()[layer][candidate], 0D,
HydrologyPoint point = fixture.points()[layer][candidate];
candidates.add(candidateConstructor.newInstance(point, point.x(), point.z(), 0D,
fixture.scores()[layer][candidate], 0D, tangent, true));
}
layers.add(candidates);
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
@@ -235,7 +237,8 @@ public class HydrologyStyledGeometryTest {
HydrologyPlannerSettings.Geometry.defaults(),
deepFluids, List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -272,7 +275,8 @@ public class HydrologyStyledGeometryTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D);
@@ -308,7 +312,8 @@ public class HydrologyStyledGeometryTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -0,0 +1,122 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.surface.ChannelProfile;
import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline;
import art.arcane.iris.engine.hydrology.surface.SurfaceTerminal;
import art.arcane.iris.engine.hydrology.surface.ValleyProfile;
import art.arcane.iris.engine.hydrology.surface.ValleyProfileSolver;
import org.junit.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologySurfaceGeometryPolicyTest {
private static final SurfaceRiverPolicy LOCAL = new SurfaceRiverPolicy("region:tropical", null, null,
null, null, null, 64, 24);
@Test
public void aShortCourseUsesItsSourcePolicyAfterTheAnchorMoves() {
HydrologyPlanner planner = planner((x, z) -> land(100, SurfaceRiverPolicy.INHERIT));
HydrologyCoursePath path = new HydrologyCoursePath(
List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(96, 100, 0)),
List.of(), List.of(), outlet(), false, false, null);
HydrologyGridNode ordinary = node(0, land(100, SurfaceRiverPolicy.INHERIT));
HydrologyGridNode local = node(0, land(100, LOCAL));
assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT,
planner.surfaceCourses.buildSurfaceCourseAtPath(8L, "default", ordinary, path).rejection());
assertNotNull(planner.surfaceCourses.buildSurfaceCourseAtPath(8L, "default", local, path).course());
assertEquals(planner.settings.routing().minimumUndergroundCourseLength(),
planner.sourcePlanner.minimumCourseLength(local.terrain(), false));
}
@Test
public void anUphillEdgeUsesTheDownstreamCutLimit() {
HydrologyPlanner planner = planner((x, z) -> land(100, SurfaceRiverPolicy.INHERIT));
HydrologySampledGrid localDownstream = grid(SurfaceRiverPolicy.INHERIT, LOCAL);
HydrologySampledGrid ordinaryDownstream = grid(LOCAL, SurfaceRiverPolicy.INHERIT);
List<OutletCandidate> outlets = List.of(new OutletCandidate(1, -1, outlet()));
assertEquals(1, planner.sourcePlanner.buildRouting(localDownstream, outlets, true).parent()[0]);
assertEquals(-1, planner.sourcePlanner.buildRouting(ordinaryDownstream, outlets, true).parent()[0]);
assertEquals(1, planner.sourcePlanner.buildRouting(ordinaryDownstream, outlets, false).parent()[0]);
assertTrue(planner.outletPlanner.outletReachable(localDownstream, outlets, true)[0]);
}
@Test
public void geometryOverridesDoNotPreventAnchorsWithinOneBudgetArea() {
SurfaceRiverPolicy neighbor = new SurfaceRiverPolicy("region:tropical", null, null,
null, null, null, 128, 16);
HydrologyPlanner planner = planner((x, z) -> land(99, neighbor));
assertTrue(Double.isFinite(planner.routeGeometry.anchorScore(node(0, land(100, LOCAL)),
2, 0, 2, 0, 0.3D, true)));
}
@Test
public void localIncisionCanContainATwentyBlockCut() {
ValleyProfile ordinary = valley(false);
ValleyProfile local = valley(true);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, ordinary.rejection());
assertEquals(20, ordinary.rejectionDetail());
assertNull(local.rejection());
}
@Test
public void aLocalIncisionLimitDoesNotCarryIntoOrdinaryGround() {
HydrologyTerrainSampler sampler = (x, z) -> land(x >= 32 && x <= 64 && z != 0 ? 82 : 100,
x < 72 ? LOCAL : SurfaceRiverPolicy.INHERIT);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, solve(sampler).rejection());
}
private ValleyProfile valley(boolean local) {
return solve((x, z) -> land(x >= 32 && x <= 64 && z != 0 ? 82 : 100,
local ? LOCAL : SurfaceRiverPolicy.INHERIT));
}
private ValleyProfile solve(HydrologyTerrainSampler sampler) {
SurfaceCenterline line = SurfaceCenterline.densify(
List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(96, 100, 0)));
double[] widths = new double[line.size()];
double[] depths = new double[line.size()];
double[] banks = new double[line.size()];
Arrays.fill(widths, 2D);
Arrays.fill(depths, 2D);
Arrays.fill(banks, 1D);
return new ValleyProfileSolver(HydrologyPlannerSettings.defaults().surface(), sampler, 63, 64)
.solve(line, new ChannelProfile(widths, depths, banks), SurfaceTerminal.SINKHOLE, 40);
}
private HydrologySampledGrid grid(SurfaceRiverPolicy upstream, SurfaceRiverPolicy downstream) {
return new HydrologySampledGrid(0, 0, 0, 0, 128, 2, 64,
List.of(node(0, land(100, upstream)), node(1, land(120, downstream)),
node(2, HydrologyTerrainSample.ocean(50, "ocean")),
node(3, HydrologyTerrainSample.ocean(50, "ocean"))));
}
private HydrologyGridNode node(int index, HydrologyTerrainSample terrain) {
return new HydrologyGridNode(index, index % 2, index / 2, index % 2 * 64, index / 2 * 64,
index + 1L, terrain);
}
private RiverOutlet outlet() {
return new RiverOutlet(4L, HydrologyFeatureType.INLAND_GROTTO, 2L,
new HydrologyPoint(64, 120, 0), new HydrologyPoint(64, 40, 0), 63, false);
}
private HydrologyTerrainSample land(int height, SurfaceRiverPolicy policy) {
return HydrologyTerrainSample.openLand(height, 0D, "land").withSurfacePolicy(policy);
}
private HydrologyPlanner planner(HydrologyTerrainSampler sampler) {
return new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), sampler);
}
}
@@ -0,0 +1,294 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.surface.SurfaceFootprint;
import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologySurfaceProfilesTest {
@Test
public void volcanicParentAndChildShareTheirExclusiveProfile() {
HydrologyTerrainSample plains = sample(List.of("volcanic_lava"), false, "volcanic-plains");
HydrologyTerrainSample volcano = sample(List.of("volcanic_lava"), false, "volcanoes");
assertTrue(HydrologySurfaceProfiles.sharesProfile(plains, volcano));
assertTrue(HydrologySurfaceProfiles.sharesProfile(volcano, plains));
assertFalse(HydrologySurfaceProfiles.sharesProfile(plains, sample(List.of("water"), false, "land")));
assertFalse(HydrologySurfaceProfiles.sharesProfile(plains, sample(List.of("lava"), false, "land")));
assertFalse(HydrologySurfaceProfiles.sharesProfile(plains, sample(List.of("default"), false, "land")));
assertFalse(HydrologySurfaceProfiles.sharesProfile(plains, null));
}
@Test
public void unchangedPermissionsKeepTheExistingDeterministicProfileChoice() {
HydrologyTerrainSample terrain = sample(List.of("first", "second"), false, "land");
HydrologyPlanner planner = planner((x, z) -> terrain);
HydrologyCoursePath path = inlandPath(List.of(point(0), point(16)));
assertEquals(planner.segments.chooseProfile(terrain, path.outlet().id()),
HydrologySurfaceProfiles.chooseProfile(planner, path, terrain));
}
@Test
public void sourcePermissionsRemainEffectiveWhenItsAnchorMoves() {
HydrologyTerrainSample anchor = sample(List.of("volcanic_lava", "water"), false, "land");
HydrologyTerrainSample source = sample(List.of("volcanic_lava"), false, "volcanoes");
HydrologyPlanner planner = planner((x, z) -> anchor);
assertEquals("volcanic_lava", HydrologySurfaceProfiles.chooseProfile(
planner, inlandPath(List.of(point(0), point(16))), source));
}
@Test
public void profileIntersectionCoversTheWholeRoute() {
HydrologyTerrainSample first = sample(List.of("a", "b"), false, "first");
HydrologyTerrainSample middle = sample(List.of("b", "c"), false, "middle");
HydrologyTerrainSample last = sample(List.of("a", "c"), false, "last");
HydrologyPlanner planner = planner((x, z) -> x < 4 ? first : x < 8 ? middle : last);
assertTrue(HydrologySurfaceProfiles.sharesProfile(first, middle));
assertTrue(HydrologySurfaceProfiles.sharesProfile(middle, last));
assertNull(HydrologySurfaceProfiles.chooseProfile(
planner, inlandPath(List.of(point(0), point(12))), first));
}
@Test
public void anExcludedColumnBetweenRouteStationsRejectsTheCrossing() {
HydrologyTerrainSample water = sample(List.of("water"), false, "land");
HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes");
HydrologyPlanner planner = planner((x, z) -> x == 7 ? volcanic : water);
HydrologyCoursePath path = inlandPath(List.of(point(0), point(16)));
assertNull(HydrologySurfaceProfiles.chooseProfile(planner, path, water));
assertFalse(HydrologySurfaceProfiles.allowsProfile(planner, path, "water"));
}
@Test
public void directOceanOutletsMustPermitTheChosenProfile() {
HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes");
HydrologyPlanner planner = planner((x, z) -> x >= 17
? sample(List.of("water"), true, "ocean") : volcanic);
List<HydrologyPoint> points = List.of(point(0), point(16));
RiverOutlet outlet = new RiverOutlet(99L, HydrologyFeatureType.MOUTH, 1L,
point(16), new HydrologyPoint(17, 63, 0), 63, true);
HydrologyCoursePath path = new HydrologyCoursePath(points, List.of(), List.of(), outlet, true, false, null);
assertNull(HydrologySurfaceProfiles.chooseProfile(planner, path, volcanic));
}
@Test
public void oceanNeighborSelectionCanSkipAnIncompatibleOcean() {
HydrologyTerrainSample land = sample(List.of("water"), false, "land");
HydrologyGridNode source = node(0, 0, 0, land);
HydrologyGridNode incompatible = node(1, 1, 0, sample(List.of("volcanic_lava"), true, "ocean"));
HydrologyGridNode compatible = node(2, 0, 1, sample(List.of("water"), true, "ocean"));
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 32, 2, 16,
List.of(source, incompatible, compatible, node(3, 1, 1, land)));
HydrologyPlanner planner = planner((x, z) -> land);
assertEquals(compatible, planner.outletPlanner.firstOceanNeighbor(grid, source, true));
assertEquals(incompatible, planner.outletPlanner.firstOceanNeighbor(grid, source, false));
}
@Test
public void refinedCoastProfilesRejectSurfaceMouthsWithoutChangingUndergroundAdmission() {
HydrologyTerrainSample land = sample(List.of("water"), false, "land");
HydrologyTerrainSample coarseOcean = sample(List.of("water"), true, "ocean");
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 32, 2, 16, List.of(
node(0, 0, 0, land), node(1, 1, 0, coarseOcean),
node(2, 0, 1, land), node(3, 1, 1, coarseOcean)));
HydrologyPlanner planner = planner((x, z) -> x >= 8
? sample(List.of("volcanic_lava"), true, "ocean") : land);
assertTrue(planner.outletPlanner.oceanOutletCandidates(grid, true).isEmpty());
assertFalse(planner.outletPlanner.oceanOutletCandidates(grid, false).isEmpty());
}
@Test
public void aTributaryUsesThePermittedStemProfileAtTheJoin() {
HydrologyTerrainSample terrain = sample(List.of("volcanic_lava", "water"), false, "land");
HydrologyPlanner planner = planner((x, z) -> terrain);
SurfaceCourseDraft draft = draft(terrain);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
RiverCourse tributary = planner.tributaries.buildSurfaceTributary(draft, stem("volcanic_lava"), diagnostics);
assertNotNull(diagnostics.toString(), tributary);
assertEquals("volcanic_lava", tributary.profileKey());
assertEquals(256, tributary.segments().getLast().end().x());
}
@Test
public void aJoinCannotReplaceTheSourcesExclusiveProfile() {
HydrologyTerrainSample terrain = sample(List.of("volcanic_lava", "water"), false, "land");
HydrologyPlanner planner = planner((x, z) -> terrain);
SurfaceCourseDraft draft = draft(sample(List.of("water"), false, "source"));
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
assertNull(planner.tributaries.buildSurfaceTributary(draft, stem("volcanic_lava"), diagnostics));
assertEquals(HydrologyCandidateRejection.NO_DRAINAGE_PATH, diagnostics.getFirst().rejection());
}
@Test
public void wetChannelWidthCannotEscapeThePermittedProfile() {
HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes");
HydrologyTerrainSample ambient = sample(List.of("water"), false, "land");
HydrologyTerrainSampler sampler = (x, z) -> z == 0 ? volcanic : ambient;
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler);
ArrayList<RiverCourse> courses = new ArrayList<>(List.of(stem("volcanic_lava")));
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
HydrologySurfaceProfiles.rejectExcludedWetFootprints(compiler, courses, diagnostics);
assertTrue(courses.isEmpty());
assertEquals(HydrologyCandidateRejection.POLICY_EXCLUDED, diagnostics.getFirst().rejection());
assertTrue(diagnostics.getFirst().detail() > 0);
}
@Test
public void dryValleyBanksMayExtendBeyondThePermittedProfile() {
HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes");
HydrologyTerrainSample ambient = sample(List.of("water"), false, "land");
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
RiverCourse course = stem("volcanic_lava");
SurfaceFootprint reference = new HydrologyFootprintCompiler(settings, (x, z) -> volcanic).surfaceFootprint(course);
int wetRadius = 0;
for (SurfaceLayerColumn column : reference.columns()) {
if (column.layer().fluidOwned() && column.layer().bedY() < column.layer().fluidHeadY()) {
wetRadius = Math.max(wetRadius, Math.abs(column.z()));
}
}
int permittedRadius = wetRadius;
HydrologyTerrainSampler sampler = (x, z) -> Math.abs(z) <= permittedRadius ? volcanic : ambient;
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(settings, sampler);
assertTrue(compiler.surfaceFootprint(course).columns().stream()
.anyMatch(column -> !column.layer().fluidOwned() && column.terrain().equals(ambient)));
ArrayList<RiverCourse> courses = new ArrayList<>(List.of(course));
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
HydrologySurfaceProfiles.rejectExcludedWetFootprints(compiler, courses, diagnostics);
assertEquals(List.of(course), courses);
assertTrue(diagnostics.isEmpty());
}
@Test
public void rejectingTheMainStemAlsoRemovesItsDependentTributary() {
HydrologyTerrainSample volcanic = sample(List.of("volcanic_lava"), false, "volcanoes");
HydrologyTerrainSample ambient = sample(List.of("water"), false, "land");
HydrologyTerrainSampler sampler = (x, z) -> x < 600 && z != 0 ? ambient : volcanic;
RiverCourse main = new RiverCourse(30L, RiverCourseType.SURFACE,
OptionalLong.of(1L), OptionalLong.of(99L), "volcanic_lava", 1, List.of(), List.of(
new HydraulicSegment(31L, 30L, HydrologyFeatureType.MOUTH,
84, 84, 4, 2, false, false, List.of(point(256), point(512)))));
RiverCourse tributary = new RiverCourse(40L, RiverCourseType.SURFACE,
OptionalLong.of(2L), OptionalLong.of(99L), "volcanic_lava", 1, List.of(), List.of(
new HydraulicSegment(41L, 40L, HydrologyFeatureType.SURFACE_POOL,
84, 84, 4, 2, false, false, List.of(point(800), point(1024)))));
ArrayList<RiverCourse> courses = new ArrayList<>(List.of(tributary, main));
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
HydrologySurfaceProfiles.rejectExcludedWetFootprints(
new HydrologyFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler), courses, diagnostics);
assertTrue(courses.isEmpty());
assertEquals(2, diagnostics.size());
assertTrue(diagnostics.stream().anyMatch(candidate -> candidate.rejection() == HydrologyCandidateRejection.NO_DRAINAGE_PATH));
}
@Test
public void independentStandingPoolsKeepTheirOwnProfilePolicy() {
RiverCourse stem = stem("volcanic_pool");
RiverCourse pool = new RiverCourse(stem.id(), RiverCourseType.SURFACE_POOL,
OptionalLong.empty(), OptionalLong.empty(), stem.profileKey(), stem.discharge(),
List.of(), stem.segments());
HydrologyTerrainSampler sampler = (x, z) -> sample(List.of("water"), false, "land");
ArrayList<RiverCourse> courses = new ArrayList<>(List.of(pool));
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
HydrologySurfaceProfiles.rejectExcludedWetFootprints(
new HydrologyFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler), courses, diagnostics);
assertEquals(List.of(pool), courses);
assertTrue(diagnostics.isEmpty());
}
@Test
public void surfaceOutletNormalizationDoesNotAdoptAnUndergroundFluidsProfile() {
HydrologyPoint lip = point(256);
HydrologyPoint pool = new HydrologyPoint(256, 70, 0);
RiverCourse surface = new RiverCourse(30L, RiverCourseType.SURFACE,
OptionalLong.of(1L), OptionalLong.of(99L), "volcanic_lava", 1, List.of(), List.of(
new HydraulicSegment(31L, 30L, HydrologyFeatureType.SURFACE_POOL,
84, 84, 4, 2, false, false, List.of(point(0), lip)),
new HydraulicSegment(32L, 30L, HydrologyFeatureType.SINKHOLE,
84, 70, 4, 2, true, true, List.of(lip, pool)),
new HydraulicSegment(33L, 30L, HydrologyFeatureType.INLAND_GROTTO,
70, 70, 4, 2, false, true, List.of(pool))));
RiverCourse underground = new RiverCourse(40L, RiverCourseType.UNDERGROUND,
OptionalLong.of(2L), OptionalLong.of(99L), "water", 1, List.of(), List.of(
new HydraulicSegment(41L, 40L, HydrologyFeatureType.INLAND_GROTTO,
70, 70, 4, 2, false, true, List.of(pool))));
HydrologyPlanner planner = planner((x, z) -> sample(List.of("volcanic_lava"), false, "volcanoes"));
assertEquals(List.of(surface, underground),
planner.tributaries.normalizeOutletContinuations(List.of(surface, underground)));
}
private static HydrologyPlanner planner(HydrologyTerrainSampler sampler) {
return new HydrologyPlanner(19L, HydrologyPlannerSettings.defaults(), sampler);
}
private static HydrologyTerrainSample sample(List<String> profiles, boolean ocean, String biome) {
int height = ocean ? 60 : 84;
return new HydrologyTerrainSample(
height, 0D, ocean, false, height - 32, height - 30,
!ocean, !ocean, !ocean, false, false, false,
0D, ocean ? 0D : 1D, 0D, 1D, 1D, 1D, 1D, 1D,
biome, biome, biome, biome, biome, biome,
profiles, List.of(), Double.NaN, null, Double.NaN, true,
SurfaceRiverPolicy.INHERIT
);
}
private static HydrologyPoint point(int x) {
return new HydrologyPoint(x, 84, 0);
}
private static HydrologyGridNode node(int index, int gridX, int gridZ, HydrologyTerrainSample terrain) {
return new HydrologyGridNode(index, gridX, gridZ, gridX * 16, gridZ * 16, index, terrain);
}
private static HydrologyCoursePath inlandPath(List<HydrologyPoint> points) {
RiverOutlet outlet = new RiverOutlet(99L, HydrologyFeatureType.INLAND_GROTTO, 1L,
points.getLast(), points.getLast(), 63, false);
ArrayList<DrainageEdge> edges = new ArrayList<>();
for (int index = 1; index < points.size(); index++) {
edges.add(new DrainageEdge(index, index, index + 1, outlet.id(), 1D, 1, 0,
List.of(points.get(index - 1), points.get(index))));
}
return new HydrologyCoursePath(points, edges, edges, outlet, true, false, null);
}
private static SurfaceCourseDraft draft(HydrologyTerrainSample terrain) {
return new SurfaceCourseDraft(node(1, 0, 0, terrain), 20L, "water",
inlandPath(List.of(point(0), point(128), point(256), point(512))));
}
private static RiverCourse stem(String profile) {
HydraulicSegment segment = new HydraulicSegment(31L, 30L, HydrologyFeatureType.SURFACE_POOL,
84, 84, 4, 2, false, false, List.of(point(256), point(512)));
return new RiverCourse(30L, RiverCourseType.SURFACE, OptionalLong.of(2L), OptionalLong.of(99L),
profile, 1, List.of(), List.of(segment));
}
}
@@ -0,0 +1,96 @@
package art.arcane.iris.engine.hydrology;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class HydrologySurfaceRouteGeometryTest {
@Test
public void surfaceAnchorKeepsTheCoarseValleyWhenSmoothGroundIsMuchHigher() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(
x == 0 && z == 0 ? 84 : 137, x == 0 && z == 0 ? 100D : 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), sampler);
HydrologyGridNode node = new HydrologyGridNode(0, 0, 0, 0, 0, 719L, sampler.sample(0, 0));
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
HydrologyPoint caveAnchor = planner.routeGeometry.routeAnchor(node, false);
HydrologyPoint surfaceAnchor = planner.routeGeometry.routeAnchor(node, true);
assertEquals(137, caveAnchor.y());
assertEquals(node.naturalPoint(), surfaceAnchor);
assertEquals(caveAnchor, planner.routeGeometry.routeAnchor(node, false));
assertEquals(surfaceAnchor, planner.routeGeometry.routeAnchor(node, true));
}
@Test
public void surfaceAnchorReliefFitsTheExistingIncisionBudget() {
HydrologyTerrainSample coarse = HydrologyTerrainSample.openLand(84, 0D, "land");
HydrologyGridNode node = new HydrologyGridNode(0, 0, 0, 0, 0, 719L, coarse);
for (int candidateHeight : List.of(85, 84, 80, 75)) {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(candidateHeight, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), sampler);
double score = planner.routeGeometry.anchorScore(node, 2, 0, 2, 0, 0.3D, true);
assertEquals("candidate height=" + candidateHeight,
candidateHeight <= 84 && candidateHeight >= 76, Double.isFinite(score));
}
}
@Test
public void steepSurfaceEdgesCanDetourAroundAContainedTerrainPit() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(
128 - Math.floorDiv(x, 2) - (x >= 30 && x <= 34 && Math.abs(z) <= 1 ? 14 : 0),
0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(), sampler);
HydrologyPoint start = new HydrologyPoint(0, 128, 0);
HydrologyPoint end = new HydrologyPoint(64, 96, 0);
HydrologyPoint continuation = new HydrologyPoint(96, 80, 0);
List<HydrologyPoint> cave = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, false);
List<HydrologyPoint> surface = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, true);
assertTrue(cave.isEmpty());
assertFalse(surface.isEmpty());
assertTrue(surface.stream().anyMatch(point -> Math.abs(point.z()) > 1));
assertTrue(surface.stream().allMatch(point -> Math.abs(point.z()) <= 16));
assertTrue(planner.routePaths.traversableRoute(surface));
assertFalse(planner.routePaths.containsTerrainPit(surface));
}
@Test
public void curvatureUsesTheContinuousCurveBeforeBlockRounding() {
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
(x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land"));
RouteCandidate first = candidate(0D, 0D);
RouteCandidate middle = candidate(3.6D, 0.49D);
RouteCandidate last = candidate(7D, 2.1D);
assertTrue(HydrologyRoutePath.routeTurnDegrees(first.point(), middle.point(), last.point()) > 20D);
assertTrue(planner.routeGeometry.continuousTurnDegrees(first, middle, last) < 20D);
RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute(
List.of(List.of(first), List.of(middle), List.of(last)), 4D, 20D, 2.5D);
assertEquals(3, route.length);
}
@Test
public void continuousCurvatureStillRejectsASharpTurn() {
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
(x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land"));
RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute(
List.of(List.of(candidate(0D, 0D)), List.of(candidate(4D, 0D)), List.of(candidate(4D, 4D))),
4D, 20D, 2.5D);
assertEquals(0, route.length);
}
private RouteCandidate candidate(double x, double z) {
return new RouteCandidate(
new HydrologyPoint((int) StrictMath.round(x), 84, (int) StrictMath.round(z)),
x, z, 0D, 0D, 0D, new RouteDirection(1D, 0D), true);
}
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import org.junit.Test;
import java.util.ArrayList;
@@ -84,7 +86,8 @@ public final class HydrologyTerrainSampleTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@Test
@@ -122,7 +125,8 @@ public final class HydrologyTerrainSampleTest {
80, 0D, false, false, 48, 50, true, true, true, false, false, false,
0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D,
"parent", "parent", "parent", "parent", "parent", "parent",
profiles, List.of(), shoreBiomeWidth, confinesKey, shoreWidth, erosion
profiles, List.of(), shoreBiomeWidth, confinesKey, shoreWidth, erosion,
SurfaceRiverPolicy.INHERIT
);
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.testsupport.Await;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.volmlib.util.cache.CacheKey;
@@ -641,7 +643,8 @@ public class HydrologyTileCacheTest {
HydrologyPlannerSettings.Geometry.defaults(),
List.of(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -691,7 +694,8 @@ public class HydrologyTileCacheTest {
HydrologyPlannerSettings.Geometry.defaults(),
List.of(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -737,7 +741,8 @@ public class HydrologyTileCacheTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
@@ -80,7 +82,8 @@ public class StudioHydrologyTileStoreTest {
}
private HydrologyTile tile() {
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0.25D, "plains");
HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0.25D, "plains")
.withSurfacePolicy(new SurfaceRiverPolicy("region:tropical", 8D, 160, 3, 3, 4, 128, 24));
DrainageNode node = new DrainageNode(1L, 0, 0, terrain, 10D, 2L);
RiverOutlet outlet = new RiverOutlet(
2L,
@@ -18,6 +18,78 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class RiverPolicyResolverTest {
@Test
public void surfaceBudgetsInheritFieldsAndFollowTheDeclaringArea() {
IrisRiverPolicy dimension = new IrisRiverPolicy().setSurfaceSourceDensity(2D).setSurfaceTributaries(1);
IrisRiverPolicy region = new IrisRiverPolicy().setSurfaceSourceDensity(8D).setSurfaceSourceSpacing(160)
.setSurfaceTributaries(3).setSurfaceInlandOutlets(3).setSurfaceCoastalOutlets(4);
IrisRiverPolicy biome = new IrisRiverPolicy().setSurfaceSourceDensity(6D).setSurfaceCoastalOutlets(0);
SurfaceRiverPolicy inherited = RiverPolicyResolver.resolve(dimension, region, new IrisRiverPolicy()).surfacePolicy();
SurfaceRiverPolicy local = RiverPolicyResolver.resolve(dimension, region, biome).surfacePolicy();
assertEquals(new SurfaceRiverPolicy("region", 8D, 160, 3, 3, 4, null, null), inherited);
assertEquals(new SurfaceRiverPolicy("biome", 6D, 160, 3, 3, 0, null, null), local);
assertEquals(SurfaceRiverPolicy.INHERIT, RiverPolicyResolver.resolve(
(IrisRiverPolicy) null, null, null).surfacePolicy());
}
@Test
public void geometryOverridesInheritWithoutChangingBudgetOwnership() {
IrisRiverPolicy dimension = new IrisRiverPolicy().setSurfaceMinimumCourseLength(384).setSurfaceMaximumIncision(16);
IrisRiverPolicy region = new IrisRiverPolicy().setSurfaceSourceDensity(8D).setSurfaceMinimumCourseLength(128);
IrisRiverPolicy biome = new IrisRiverPolicy().setSurfaceMaximumIncision(24);
SurfaceRiverPolicy regional = RiverPolicyResolver.resolve(dimension, region, null).surfacePolicy();
SurfaceRiverPolicy local = RiverPolicyResolver.resolve(dimension, region, biome).surfacePolicy();
assertEquals(128, local.minimumCourseLength(384));
assertEquals(24, local.maximumIncision(16));
assertEquals("region", local.areaKey());
assertEquals(regional.budget(), local.budget());
assertTrue(local.overridden());
SurfaceRiverPolicy geometryOnly = RiverPolicyResolver.resolve(dimension, null, biome).surfacePolicy();
assertFalse(geometryOnly.overridden());
assertTrue(geometryOnly.configured());
assertEquals(SurfaceRiverPolicy.INHERIT.budget(), geometryOnly.budget());
assertEquals(384, SurfaceRiverPolicy.INHERIT.minimumCourseLength(384));
assertEquals(16, SurfaceRiverPolicy.INHERIT.maximumIncision(16));
}
@Test
public void geometryOverrideBoundsValidateBeforeSampling() {
for (int length : new int[]{15, 4097}) {
assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve(
null, new IrisRiverPolicy().setSurfaceMinimumCourseLength(length), null));
}
for (int incision : new int[]{0, 33}) {
assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve(
null, null, new IrisRiverPolicy().setSurfaceMaximumIncision(incision)));
}
SurfaceRiverPolicy lower = RiverPolicyResolver.resolve(null, null, new IrisRiverPolicy()
.setSurfaceMinimumCourseLength(16).setSurfaceMaximumIncision(1)).surfacePolicy();
SurfaceRiverPolicy upper = RiverPolicyResolver.resolve(null, null, new IrisRiverPolicy()
.setSurfaceMinimumCourseLength(4096).setSurfaceMaximumIncision(32)).surfacePolicy();
assertEquals(16, lower.minimumCourseLength(384));
assertEquals(1, lower.maximumIncision(16));
assertEquals(4096, upper.minimumCourseLength(384));
assertEquals(32, upper.maximumIncision(16));
}
@Test
public void rejectsInvalidSurfaceBudgetsBeforeSampling() {
for (double density : new double[]{Double.NaN, Double.POSITIVE_INFINITY, -1D, 65D}) {
assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve(
null, new IrisRiverPolicy().setSurfaceSourceDensity(density), null));
}
assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve(
null, new IrisRiverPolicy().setSurfaceSourceSpacing(8193), null));
assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve(
null, null, new IrisRiverPolicy().setSurfaceTributaries(5)));
assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve(
null, null, new IrisRiverPolicy().setSurfaceInlandOutlets(-1)));
assertThrows(IllegalArgumentException.class, () -> RiverPolicyResolver.resolve(
null, null, new IrisRiverPolicy().setSurfaceCoastalOutlets(65)));
}
@Test
public void suppliesCanonicalDefaultsWithoutConfiguredPolicies() {
EffectiveRiverPolicy policy = RiverPolicyResolver.resolve(
@@ -237,7 +309,8 @@ public class RiverPolicyResolverTest {
policy.shoreBiomeWidth(),
policy.confinement(),
shoreWidth,
policy.erosion()
policy.erosion(),
SurfaceRiverPolicy.INHERIT
);
}
}
@@ -21,12 +21,67 @@ import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisHydrologyRuntimeSettingsTest {
@Test
@SuppressWarnings("unchecked")
public void regionalSurfaceControlsFingerprintAllScopesAndExpandSpacingReach() {
IrisDimension dimension = new IrisDimension().setRegions(new KList<>("tropical"));
IrisRegion tropical = new IrisRegion().setRiverPolicy(new IrisRiverPolicy().setSurfaceSourceDensity(8D)
.setSurfaceSourceSpacing(160).setSurfaceTributaries(3).setSurfaceInlandOutlets(3).setSurfaceCoastalOutlets(4));
tropical.setLoadKey("tropical");
IrisBiome volcanic = new IrisBiome().setRiverPolicy(new IrisRiverPolicy().setSurfaceCoastalOutlets(0));
volcanic.setLoadKey("volcanic");
IrisData data = mock(IrisData.class);
ResourceLoader<IrisRegion> regions = mock(ResourceLoader.class);
ResourceLoader<IrisBiome> biomes = mock(ResourceLoader.class);
when(data.getRegionLoader()).thenReturn(regions);
when(data.getBiomeLoader()).thenReturn(biomes);
when(regions.load("tropical")).thenReturn(tropical);
when(biomes.getPossibleKeys()).thenReturn(new String[]{"volcanic"});
when(biomes.loadAll(any(String[].class))).thenReturn(new KList<>(volcanic));
HydrologyPlannerSettings baseline = settings(dimension);
HydrologyPlannerSettings regional = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data);
assertEquals(HydrologyPlannerSettings.SurfacePolicyBounds.NONE, baseline.surfacePolicyBounds());
assertNotEquals(baseline.fingerprint(), regional.fingerprint());
assertEquals(384, regional.maximumSurfaceSourceSpacing());
volcanic.getRiverPolicy().setSurfaceSourceSpacing(1600);
HydrologyPlannerSettings widened = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data);
assertNotEquals(regional.fingerprint(), widened.fingerprint());
assertEquals(1600, widened.maximumSurfaceSourceSpacing());
assertTrue(widened.publicationRadius() >= 1600);
assertEquals(widened.fingerprint(), IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data).fingerprint());
dimension.getRiverPolicy().setSurfaceInlandOutlets(7);
assertNotEquals(widened.fingerprint(), IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology(), () -> data).fingerprint());
}
@Test
public void geometryOnlyPoliciesInvalidateThePlanWithoutChangingDimensionLimits() {
IrisDimension dimension = new IrisDimension();
HydrologyPlannerSettings baseline = settings(dimension);
dimension.getRiverPolicy().setSurfaceMinimumCourseLength(128);
HydrologyPlannerSettings shortened = settings(dimension);
assertNotEquals(baseline.fingerprint(), shortened.fingerprint());
assertEquals(baseline.routing().minimumSurfaceCourseLength(), shortened.routing().minimumSurfaceCourseLength());
assertEquals(baseline.maximumSurfaceSourceSpacing(), shortened.maximumSurfaceSourceSpacing());
dimension.getRiverPolicy().setSurfaceMaximumIncision(24);
HydrologyPlannerSettings deeper = settings(dimension);
assertNotEquals(shortened.fingerprint(), deeper.fingerprint());
assertEquals(baseline.surface().maximumIncision(), deeper.surface().maximumIncision());
assertEquals(deeper.fingerprint(), settings(dimension).fingerprint());
dimension.getRiverPolicy().setSurfaceMinimumCourseLength(null).setSurfaceMaximumIncision(null);
assertEquals(baseline.fingerprint(), settings(dimension).fingerprint());
}
@Test
public void deepChannelsRemainLocalWhenConfiguredSpacingIsLarge() {
IrisDeepFluidConfig deepFluid = new IrisDeepFluidConfig()
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
@@ -206,7 +208,8 @@ public class ChannelProfileBuilderTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY);
}
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.hydrology.surface;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.engine.hydrology.HydrologyPoint;
@@ -611,7 +613,8 @@ public class ErosionFieldCompilerTest {
open.surfaceSourceWeight(), open.undergroundSourceWeight(), open.widthMultiplier(), open.depthMultiplier(),
open.incisionMultiplier(), open.routingMultiplier(), open.bankMultiplier(), open.parentBiomeKey(),
open.surfaceBiomeKey(), open.mouthBiomeKey(), open.shoreBiomeKey(), open.bankBiomeKey(),
open.floodedCaveBiomeKey(), open.preferredProfileKeys(), open.surfacePoolKeys(), shoreBiomeWidth, null, Double.NaN, true
open.floodedCaveBiomeKey(), open.preferredProfileKeys(), open.surfacePoolKeys(), shoreBiomeWidth, null, Double.NaN, true,
SurfaceRiverPolicy.INHERIT
);
}
@Test
@@ -36,7 +36,8 @@ public class SurfaceCourseBuilderTest {
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(140, 0, 0), new HydrologyPoint(280, 0, 0)),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL
SEA_LEVEL,
64
);
assertNull(result.rejection());
@@ -73,7 +74,8 @@ public class SurfaceCourseBuilderTest {
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.SINKHOLE,
40
40,
64
);
assertNull(result.rejection());
@@ -92,7 +94,8 @@ public class SurfaceCourseBuilderTest {
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.SINKHOLE,
40
40,
64
);
assertNull(result.rejection());
@@ -107,10 +110,10 @@ public class SurfaceCourseBuilderTest {
public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() {
HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land");
SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40);
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64);
HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land");
SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40);
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64);
assertNull(gentleResult.rejection());
assertNull(steepResult.rejection());
@@ -128,7 +131,8 @@ public class SurfaceCourseBuilderTest {
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL
SEA_LEVEL,
64
);
assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT, result.rejection());
@@ -143,9 +147,9 @@ public class SurfaceCourseBuilderTest {
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(241, 0, 0));
HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface();
HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet();
SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL);
SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64);
SurfaceCourseResult plain = builder(defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none()), sampler)
.build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL);
.build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64);
assertNull(result.rejection());
assertEquals(SEA_LEVEL, result.lastHead());
@@ -163,7 +167,7 @@ public class SurfaceCourseBuilderTest {
}
private static SurfaceCourseBuilder builder(HydrologyTerrainSampler sampler) {
return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64);
return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL);
}
private static SurfaceCourseBuilder builder(HydrologyPlannerSettings.Banks banks, HydrologyTerrainSampler sampler) {
@@ -171,6 +175,6 @@ public class SurfaceCourseBuilderTest {
HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(
defaults.enabled(), defaults.sources(), defaults.minimumWidth(), defaults.maximumWidth(),
defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(), banks);
return new SurfaceCourseBuilder(surface, sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64);
return new SurfaceCourseBuilder(surface, sampler, CONSTANT_GEOMETRY, SEA_LEVEL);
}
}
@@ -19,7 +19,9 @@ import org.junit.Test;
import art.arcane.iris.util.common.parallel.MultiBurst;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
@@ -28,6 +30,7 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.Future;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@@ -67,6 +70,193 @@ public class MatterGeneratorConcurrencyTest {
IrisSettings.settings = previousSettings;
}
@Test
public void externalWindowsBoundManagedBlockedComponentSubmissions() throws Exception {
GeneratorFixture fixture = new GeneratorFixture(true);
int maximum = 2 * MatterGenerator.COMPONENT_SUBMISSION_BATCH_SIZE;
CountDownLatch admitted = new CountDownLatch(maximum);
CountDownLatch excess = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger started = new AtomicInteger();
AtomicInteger completed = new AtomicInteger();
RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 64) {
@Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
if (started.incrementAndGet() > maximum) {
excess.countDown();
}
admitted.countDown();
awaitManaged(release);
completed.incrementAndGet();
}
};
TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 4, 0)));
CompletableFuture<Void> first = CompletableFuture.runAsync(
() -> generator.generateMatter(0, 0, true, fixture.context));
CompletableFuture<Void> second = CompletableFuture.runAsync(
() -> generator.generateMatter(32, 0, true, fixture.context));
try {
await(admitted);
assertFalse(excess.await(300L, TimeUnit.MILLISECONDS));
assertEquals(maximum, started.get());
assertEquals(maximum, activeClaims(fixture, 0, 4) + activeClaims(fixture, 32, 4));
verify(fixture.mantle.getChunk(-4, -4), never()).release();
} finally {
release.countDown();
first.get(10L, TimeUnit.SECONDS);
second.get(10L, TimeUnit.SECONDS);
}
assertEquals(162, completed.get());
assertEquals(0, activeClaims(fixture, 0, 4) + activeClaims(fixture, 32, 4));
verify(fixture.mantle.getChunk(-4, -4)).release();
}
@Test
public void externalWindowDefersSharedClaimsUntilItsOwnBatchesComplete() throws Exception {
GeneratorFixture fixture = new GeneratorFixture(true);
List<MatterGenerator.MatterComponentTask> shared = new ArrayList<>();
for (int z = -4; z < -4 + MatterGenerator.COMPONENT_SUBMISSION_BATCH_SIZE; z++) {
MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(
fixture.mantle, -4, z, ReservedFlag.OBJECT);
MatterGenerator.MatterComponentTask task = new MatterGenerator.MatterComponentTask(key);
assertTrue(task.start());
assertNull(MatterGenerator.IN_FLIGHT_COMPONENTS.putIfAbsent(key, task));
shared.add(task);
}
AtomicInteger completed = new AtomicInteger();
RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 64) {
@Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
completed.incrementAndGet();
}
};
TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 4, 0)));
CompletableFuture<Void> generation = CompletableFuture.runAsync(
() -> generator.generateMatter(0, 0, true, fixture.context));
try {
Await.until("all privately owned component batches", Duration.ofSeconds(10L),
() -> completed.get() == 81 - shared.size());
assertFalse(generation.isDone());
} finally {
for (MatterGenerator.MatterComponentTask task : shared) {
task.finish(null);
}
generation.get(10L, TimeUnit.SECONDS);
}
}
@Test
public void failedSubmissionBatchDrainsItsWritersBeforeReturning() throws Exception {
GeneratorFixture fixture = new GeneratorFixture(true);
CountDownLatch failed = new CountDownLatch(1);
CountDownLatch started = new CountDownLatch(MatterGenerator.COMPONENT_SUBMISSION_BATCH_SIZE - 1);
CountDownLatch release = new CountDownLatch(1);
IllegalStateException failure = new IllegalStateException("Component rejected its input");
RecordingComponent component = new RecordingComponent(ReservedFlag.OBJECT, 0, 64) {
@Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
if (x == -4 && z == -4) {
failed.countDown();
throw failure;
}
started.countDown();
awaitManaged(release);
}
};
TestMatterGenerator generator = fixture.generator(List.of(new MantlePass(List.of(component), 4, 0)));
CompletableFuture<Void> generation = CompletableFuture.runAsync(
() -> generator.generateMatter(0, 0, true, fixture.context));
try {
await(failed);
await(started);
if (MatterGenerator.COMPONENT_SUBMISSION_BATCH_SIZE > 1) {
assertFalse(generation.isDone());
verify(fixture.mantle.getChunk(-4, -4), never()).release();
}
} finally {
release.countDown();
}
ExecutionException observed = assertThrows(ExecutionException.class,
() -> generation.get(10L, TimeUnit.SECONDS));
assertSame(failure, observed.getCause());
verify(fixture.mantle.getChunk(-4, -4)).release();
assertEquals(0, activeClaims(fixture, 0, 4));
}
@Test
public void boundedAndReentrantGenerationMatchSerialComponentResults() throws Exception {
Map<Long, Long> serial = componentResults(false, false);
assertEquals(81, serial.size());
assertEquals(serial, componentResults(true, false));
assertEquals(serial, componentResults(true, true));
}
private static Map<Long, Long> componentResults(boolean multicore, boolean reentrant) throws Exception {
GeneratorFixture fixture = new GeneratorFixture(true);
Map<Long, Long> values = new ConcurrentHashMap<>();
RecordingComponent first = new RecordingComponent(ReservedFlag.CARVED, 0, 32) {
@Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
long key = (((long) x) << 32) ^ (z & 0xffffffffL);
values.put(key, ((long) x * 341873128712L) ^ ((long) z * 132897987541L));
}
};
RecordingComponent second = new RecordingComponent(ReservedFlag.OBJECT, 1, 32) {
@Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
assertEquals(81, values.size());
long key = (((long) x) << 32) ^ (z & 0xffffffffL);
values.compute(key, (ignored, value) -> value * 31L + 17L);
}
};
TestMatterGenerator generator = fixture.generator(List.of(
new MantlePass(List.of(first), 4, 32),
new MantlePass(List.of(second), 2, 0)));
if (reentrant) {
MultiBurst.burst.completeValueAsync(() -> {
generator.generateMatter(0, 0, multicore, fixture.context);
return null;
}).get(10L, TimeUnit.SECONDS);
} else {
generator.generateMatter(0, 0, multicore, fixture.context);
}
return values;
}
private static int activeClaims(GeneratorFixture fixture, int centerX, int radius) {
int active = 0;
for (int x = centerX - radius; x <= centerX + radius; x++) {
for (int z = -radius; z <= radius; z++) {
MatterGenerator.MatterTaskKey key = new MatterGenerator.MatterTaskKey(
fixture.mantle, x, z, ReservedFlag.OBJECT);
if (MatterGenerator.IN_FLIGHT_COMPONENTS.containsKey(key)) {
active++;
}
}
}
return active;
}
private static void awaitManaged(CountDownLatch latch) {
try {
ForkJoinPool.managedBlock(new ForkJoinPool.ManagedBlocker() {
@Override
public boolean block() {
await(latch);
return true;
}
@Override
public boolean isReleasable() {
return latch.getCount() == 0L;
}
});
} catch (InterruptedException interruption) {
Thread.currentThread().interrupt();
throw new AssertionError(interruption);
}
}
@Test
public void multicoreChunksOverlapAndCompleteBeforeTheNextPass() {
GeneratorFixture fixture = new GeneratorFixture();
@@ -1,5 +1,7 @@
package art.arcane.iris.engine.mantle.components;
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.render.IrisRenderer;
@@ -1645,7 +1647,8 @@ public class MantleHydrologyComponentTest {
HydrologyPlannerSettings.Geometry.defaults(),
List.of(), List.of(),
0D,
HydrologyPlannerSettings.SeaCaves.disabled()
HydrologyPlannerSettings.SeaCaves.disabled(),
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
);
}
@@ -1686,7 +1689,8 @@ public class MantleHydrologyComponentTest {
Double.NaN,
null,
Double.NaN,
true
true,
SurfaceRiverPolicy.INHERIT
);
}
@@ -0,0 +1,119 @@
package art.arcane.iris.engine.object;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.util.Set;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class IrisDimensionFocusRegionTest {
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
private IrisData data;
private ResourceLoader<IrisBiome> biomes;
private ResourceLoader<IrisRegion> regions;
private IrisDimension dimension;
@Before
@SuppressWarnings("unchecked")
public void preparePack() {
data = mock(IrisData.class);
biomes = mock(ResourceLoader.class);
regions = mock(ResourceLoader.class);
when(data.getBiomeLoader()).thenReturn(biomes);
when(data.getRegionLoader()).thenReturn(regions);
when(data.getDataFolder()).thenReturn(temporary.getRoot());
when(regions.getFolderName()).thenReturn("regions");
dimension = new IrisDimension().setRegions(new KList<>("tropical"));
dimension.setLoadKey("overworld");
}
@Test(timeout = 2000L)
public void resolvesNestedChildrenCarvingAndFloodedCavesThroughCycles() {
IrisRegion tropical = region("tropical").setLandBiomes(new KList<>("wilds"));
biome("wilds").setChildren(new KList<>("highlands"));
IrisBiome highlands = biome("highlands").setChildren(new KList<>("wilds"))
.setCarvingBiome("chalk-gardens")
.setRiverPolicy(new IrisRiverPolicy().setFloodedCaveBiomes(new KList<>("flooded")));
IrisBiome chalk = biome("chalk-gardens").setChildren(new KList<>("chalk-child"));
IrisBiome chalkChild = biome("chalk-child");
IrisBiome flooded = biome("flooded").setChildren(new KList<>("flooded-child"));
IrisBiome floodedChild = biome("flooded-child").setCarvingBiome("wilds");
for (IrisBiome focus : List.of(highlands, chalk, chalkChild, flooded, floodedChild)) {
assertSame(tropical, dimension.resolveFocusRegion(focus, () -> data));
}
Set<String> natural = tropical.getNaturalBiomes(() -> data).stream()
.map(IrisBiome::getLoadKey).collect(Collectors.toSet());
assertEquals(Set.of("wilds", "highlands", "chalk-gardens", "chalk-child"), natural);
}
@Test
public void directCaveAndRegionFloodedReferencesKeepTheirOwner() {
IrisRegion tropical = region("tropical").setCaveBiomes(new KList<>("cave"))
.setRiverPolicy(new IrisRiverPolicy().setFloodedCaveBiomes(new KList<>("flooded")));
assertSame(tropical, dimension.resolveFocusRegion(biome("cave"), () -> data));
assertSame(tropical, dimension.resolveFocusRegion(biome("flooded"), () -> data));
}
@Test
public void directOwnerTakesPrecedenceOverAnotherRegionsChildReference() {
region("tropical").setLandBiomes(new KList<>("wilds"));
biome("wilds").setChildren(new KList<>("highlands"));
IrisRegion direct = region("direct").setLandBiomes(new KList<>("highlands"));
dimension.setRegions(new KList<>("tropical", "direct"));
assertSame(direct, dimension.resolveFocusRegion(biome("highlands"), () -> data));
}
@Test
public void unownedFocusIsNeutralAndStableAcrossDimensionInstances() {
region("tropical").setLandBiomes(new KList<>("unrelated"));
biome("unrelated");
IrisBiome focus = biome("standalone");
IrisRegion first = dimension.resolveFocusRegion(focus, () -> data);
IrisDimension reopened = new IrisDimension().setRegions(new KList<>("tropical"));
reopened.setLoadKey("overworld");
IrisRegion second = reopened.resolveFocusRegion(focus, () -> data);
assertEquals(first.getLoadKey(), second.getLoadKey());
assertEquals(new KList<>("standalone"), first.getLandBiomes());
assertEquals(new KList<>("standalone"), first.getSeaBiomes());
assertEquals(new KList<>("standalone"), first.getShoreBiomes());
assertEquals(new KList<>(), first.getCaveBiomes());
assertSame(data, first.getLoader());
assertFalse(first.getLoadFile().exists());
reopened.setLoadKey("underworld");
assertNotEquals(first.getLoadKey(), reopened.resolveFocusRegion(focus, () -> data).getLoadKey());
assertNotEquals(first.getLoadKey(), dimension.resolveFocusRegion(biome("other"), () -> data).getLoadKey());
}
private IrisBiome biome(String key) {
IrisBiome biome = new IrisBiome();
biome.setLoadKey(key);
when(biomes.load(key)).thenReturn(biome);
return biome;
}
private IrisRegion region(String key) {
IrisRegion region = new IrisRegion();
region.setLoadKey(key);
when(regions.load(key)).thenReturn(region);
return region;
}
}
@@ -3,6 +3,7 @@ package art.arcane.iris.engine.platform;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.io.ReactiveFolder;
import art.arcane.volmlib.util.scheduling.Looper;
import org.bukkit.generator.BlockPopulator;
import org.junit.Test;
@@ -14,6 +15,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -75,6 +77,42 @@ public class BukkitChunkGeneratorCloseTest {
assertFalse(fixture.generator.isClosing());
}
@Test
public void closeCancelsPendingWatcherBeforeQueuingExclusiveCleanup() throws Exception {
CloseFixture fixture = new CloseFixture();
CompletableFuture<Void> pending = new CompletableFuture<>();
fixture.watcherHotload.set(pending);
CompletableFuture<Void> cleanup = new CompletableFuture<>();
doAnswer(invocation -> {
assertTrue(fixture.generator.isClosing());
assertTrue(pending.isCancelled());
assertNull(fixture.watcherHotload.get());
return cleanup;
}).when(fixture.generator).withExclusiveControlFuture(any(Runnable.class));
CompletableFuture<Void> closed = fixture.generator.closeAsync();
assertFalse(closed.isDone());
cleanup.complete(null);
closed.join();
}
@Test
public void shutdownQuiesceCancelsPendingWatcherWithoutClosingGeneration() throws Exception {
CloseFixture fixture = new CloseFixture();
CompletableFuture<Void> pending = new CompletableFuture<>();
fixture.watcherHotload.set(pending);
Looper watcher = mock(Looper.class);
setField(fixture.generator, "hotloader", watcher);
fixture.generator.quiesceForServerShutdown();
assertTrue(pending.isCancelled());
assertNull(fixture.watcherHotload.get());
assertFalse(fixture.generator.isClosing());
verify(watcher).interrupt();
}
private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception {
Field field = BukkitChunkGenerator.class.getDeclaredField(name);
field.setAccessible(true);
@@ -85,10 +123,12 @@ public class BukkitChunkGeneratorCloseTest {
private final BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.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 {
setField(generator, "closeFuture", new AtomicReference<CompletableFuture<Void>>());
setField(generator, "watcherHotload", watcherHotload);
setField(generator, "startupReady", CompletableFuture.completedFuture(null));
setField(generator, "engine", engine);
setField(generator, "folder", mock(ReactiveFolder.class));
@@ -1,10 +1,8 @@
package art.arcane.iris.engine.platform;
import art.arcane.iris.engine.data.chunk.TerrainChunk;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.WrongEngineBroException;
import art.arcane.iris.engine.platform.studio.StudioGenerator;
import art.arcane.iris.spi.IrisLogging;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@@ -21,7 +19,6 @@ import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
import java.util.function.Supplier;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -29,7 +26,7 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.mockStatic;
public class BukkitChunkGeneratorGenerationStageGateTest {
@Test
@@ -154,103 +151,6 @@ public class BukkitChunkGeneratorGenerationStageGateTest {
}
}
@Test
public void exclusiveGenerationStageDowngradesWithoutReleasingItsRetainedPermit() {
AtomicBoolean closing = new AtomicBoolean(false);
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(3, closing::get);
BukkitChunkGenerator.GenerationStageExclusivePermit exclusive =
gate.acquireExclusiveStage("prepare");
assertEquals(0, gate.availablePermits());
BukkitChunkGenerator.GenerationStagePermit stage = exclusive.downgradeToStage();
assertEquals(2, gate.availablePermits());
exclusive.close();
assertEquals(2, gate.availablePermits());
stage.close();
assertEquals(3, gate.availablePermits());
}
@Test
public void preparedGenerationRetriesChangedGeneratorWhileHoldingExclusiveAdmission() {
AtomicBoolean closing = new AtomicBoolean(false);
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(2, closing::get);
PreparingStudioGenerator first = new PreparingStudioGenerator(gate, false);
PreparingStudioGenerator second = new PreparingStudioGenerator(gate, false);
AtomicInteger resolutions = new AtomicInteger();
Supplier<StudioGenerator> resolver = () -> resolutions.getAndIncrement() == 0 ? first : second;
Engine engine = mock(Engine.class);
BukkitChunkGenerator.GenerationStagePermit stage =
BukkitChunkGenerator.acquirePreparedGenerationStage(
gate,
"prepared",
resolver,
engine,
4,
7);
assertEquals(0, first.preparations());
assertEquals(1, second.preparations());
assertEquals(0, second.observedPermits());
assertTrue(resolutions.get() >= 4);
assertEquals(1, gate.availablePermits());
verifyNoInteractions(engine);
stage.close();
assertEquals(2, gate.availablePermits());
}
@Test
public void failedPreparationReleasesEveryExclusivePermit() {
AtomicBoolean closing = new AtomicBoolean(false);
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(2, closing::get);
PreparingStudioGenerator generator = new PreparingStudioGenerator(gate, true);
Engine engine = mock(Engine.class);
IllegalStateException failure = assertThrows(
IllegalStateException.class,
() -> BukkitChunkGenerator.acquirePreparedGenerationStage(
gate,
"prepared-failure",
() -> generator,
engine,
2,
3));
assertTrue(failure.getMessage().contains("could not prepare"));
assertTrue(failure.getCause() instanceof WrongEngineBroException);
assertEquals(1, generator.preparations());
assertEquals(0, generator.observedPermits());
assertEquals(2, gate.availablePermits());
verifyNoInteractions(engine);
}
@Test
public void defaultStudioGeneratorUsesOneOrdinaryGenerationPermit() {
AtomicBoolean closing = new AtomicBoolean(false);
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(2, closing::get);
StudioGenerator generator = new DefaultStudioGenerator();
Engine engine = mock(Engine.class);
BukkitChunkGenerator.GenerationStagePermit stage =
BukkitChunkGenerator.acquirePreparedGenerationStage(
gate,
"ordinary",
() -> generator,
engine,
0,
0);
assertEquals(1, gate.availablePermits());
verifyNoInteractions(engine);
stage.close();
assertEquals(2, gate.availablePermits());
}
@Test
public void exclusiveControlSuccessReleasesGateBeforeSynchronousCompletionDependent() throws Exception {
AtomicBoolean closing = new AtomicBoolean(false);
@@ -409,6 +309,108 @@ public class BukkitChunkGeneratorGenerationStageGateTest {
}
}
@Test
public void untimedExclusiveCancellationRemovesWaiterWithoutLeakingInterruptOrPermits() throws Exception {
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(1, () -> false);
BukkitChunkGenerator.GenerationStagePermit active = gate.acquireStage("active");
CompletableFuture<Void> pending = new CompletableFuture<>();
AtomicBoolean operationRan = new AtomicBoolean();
AtomicBoolean interrupted = new AtomicBoolean();
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<?> worker = executor.submit(() -> {
BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> operationRan.set(true), pending);
interrupted.set(Thread.currentThread().isInterrupted());
});
awaitQueueLength(gate, 1);
assertTrue(pending.cancel(true));
worker.get(2L, TimeUnit.SECONDS);
assertFalse(operationRan.get());
assertFalse(interrupted.get());
assertEquals(0, gate.queueLength());
assertEquals(0, gate.availablePermits());
active.close();
assertEquals(1, gate.availablePermits());
} finally {
active.close();
executor.shutdownNow();
assertTrue(executor.awaitTermination(2L, TimeUnit.SECONDS));
}
}
@Test
public void untimedCancellationAfterAdmissionDoesNotInterruptCutover() throws Exception {
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(1, () -> false);
CompletableFuture<Void> pending = new CompletableFuture<>();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicBoolean interrupted = new AtomicBoolean();
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<?> worker = executor.submit(() -> BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> {
entered.countDown();
try {
assertTrue(release.await(2L, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
interrupted.set(true);
Thread.currentThread().interrupt();
}
}, pending));
assertTrue(entered.await(2L, TimeUnit.SECONDS));
assertTrue(pending.cancel(true));
assertEquals(0, gate.availablePermits());
release.countDown();
worker.get(2L, TimeUnit.SECONDS);
assertFalse(interrupted.get());
assertEquals(1, gate.availablePermits());
} finally {
release.countDown();
executor.shutdownNow();
assertTrue(executor.awaitTermination(2L, TimeUnit.SECONDS));
}
}
@Test
public void preCancelledUntimedControlDoesNotQueueOrRun() {
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(1, () -> false);
CompletableFuture<Void> pending = new CompletableFuture<>();
AtomicBoolean operationRan = new AtomicBoolean();
pending.cancel(true);
BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> operationRan.set(true), pending);
assertFalse(operationRan.get());
assertEquals(0, gate.queueLength());
assertEquals(1, gate.availablePermits());
}
@Test
public void cancelledUntimedControlReportsAnAdmittedCutoverFailure() {
BukkitChunkGenerator.GenerationStageGate gate =
new BukkitChunkGenerator.GenerationStageGate(1, () -> false);
CompletableFuture<Void> pending = new CompletableFuture<>();
IllegalStateException failure = new IllegalStateException("Admitted cutover failed");
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
BukkitChunkGenerator.completeExclusiveControlFuture(gate, () -> {
pending.cancel(true);
throw failure;
}, pending);
logging.verify(() -> IrisLogging.reportError(failure));
}
assertTrue(pending.isCancelled());
assertFalse(Thread.currentThread().isInterrupted());
assertEquals(1, gate.availablePermits());
}
@Test
public void cancelledExclusiveControlStopsWaitingWithoutReleasingAStagePermit() throws Exception {
AtomicBoolean closing = new AtomicBoolean(false);
@@ -604,52 +606,4 @@ public class BukkitChunkGeneratorGenerationStageGateTest {
assertTrue("Expected at least " + expected + " queued gate threads", gate.queueLength() >= expected);
}
private static final class DefaultStudioGenerator implements StudioGenerator {
@Override
public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) {
}
}
private static final class PreparingStudioGenerator implements StudioGenerator {
private final BukkitChunkGenerator.GenerationStageGate gate;
private final boolean fail;
private final AtomicInteger preparations;
private int observedPermits;
private PreparingStudioGenerator(
BukkitChunkGenerator.GenerationStageGate gate,
boolean fail
) {
this.gate = gate;
this.fail = fail;
this.preparations = new AtomicInteger();
this.observedPermits = -1;
}
@Override
public boolean requiresPreSessionPreparation() {
return true;
}
@Override
public void prepareChunkBeforeSession(Engine engine, int x, int z) throws WrongEngineBroException {
preparations.incrementAndGet();
observedPermits = gate.availablePermits();
if (fail) {
throw new WrongEngineBroException("prepared failure");
}
}
@Override
public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) {
}
private int preparations() {
return preparations.get();
}
private int observedPermits() {
return observedPermits;
}
}
}
@@ -1,52 +0,0 @@
package art.arcane.iris.engine.platform.studio.generators;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.util.common.data.B;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BiomeBuffetGeneratorPreparationTest {
@Test
public void focusHotloadPreparationIsOptedInAndIdempotent() {
Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class);
IrisBiome biome = mock(IrisBiome.class);
PlatformBlockState barrier = mock(PlatformBlockState.class);
AtomicReference<String> focus = new AtomicReference<>("");
when(engine.getDimension()).thenReturn(dimension);
when(dimension.getAllBiomes(engine)).thenReturn(new KList<>(biome));
when(dimension.getFocus()).thenAnswer(invocation -> focus.get());
when(dimension.setFocus(anyString())).thenAnswer(invocation -> {
focus.set(invocation.getArgument(0, String.class));
return dimension;
});
when(biome.getLoadKey()).thenReturn("prepared_biome");
try (MockedStatic<B> blocks = mockStatic(B.class)) {
blocks.when(() -> B.getState("BARRIER")).thenReturn(barrier);
BiomeBuffetGenerator generator = new BiomeBuffetGenerator(engine, 1);
assertTrue(generator.requiresPreSessionPreparation());
generator.prepareChunkBeforeSession(engine, 0, 0);
generator.prepareChunkBeforeSession(engine, 0, 0);
generator.prepareChunkBeforeSession(engine, 2, 0);
}
verify(dimension, times(1)).setFocus("prepared_biome");
verify(engine, times(1)).hotloadComplex();
}
}
@@ -42,6 +42,7 @@ import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -139,9 +140,17 @@ public class ObjectStudioGeometryTest {
position.y() - fixture.cell().originY(), position.z() - fixture.cell().originZ(),
entry.getValue());
}
captured.setUnsignedTile(0, 0, 0, tile);
Position placedTile = new Position(fixture.cell().originX(), fixture.cell().originY(), fixture.cell().originZ());
assertEquals(1, fixture.tiles().size());
assertEquals(tile, fixture.tiles().get(placedTile));
assertNotSame(tile, fixture.tiles().get(placedTile));
for (Map.Entry<Position, TileData> entry : fixture.tiles().entrySet()) {
Position position = entry.getKey();
captured.setUnsignedTile(position.x() - fixture.cell().originX(),
position.y() - fixture.cell().originY(), position.z() - fixture.cell().originZ(), entry.getValue());
}
assertSame(tile, captured.getStates().get(new IrisBlockVector(minimum.x(), minimum.y(), minimum.z())));
assertEquals(tile, captured.getStates().get(new IrisBlockVector(minimum.x(), minimum.y(), minimum.z())));
assertEquals(serialized(source), serialized(captured));
}
}
@@ -209,13 +218,24 @@ public class ObjectStudioGeometryTest {
mock(PlatformBlockState.class), mock(PlatformBlockState.class), mock(PlatformBlockState.class));
TerrainChunk initial = mock(TerrainChunk.class);
when(initial.getMaxHeight()).thenReturn(320);
Map<Position, TileData> tiles = new HashMap<>();
ObjectStudioSaveService service = mock(ObjectStudioSaveService.class);
doAnswer(invocation -> {
ObjectStudioGenerator.ChunkTiles queued = invocation.getArgument(1);
for (ObjectStudioGenerator.PlacedTile tile : queued.tiles()) {
assertTrue(tile.x() >= 0 && tile.x() < 16 && tile.z() >= 0 && tile.z() < 16);
tiles.put(new Position((queued.chunkX() << 4) + tile.x(), tile.y(),
(queued.chunkZ() << 4) + tile.z()), tile.data());
}
return null;
}).when(service).queueTiles(any(), any());
try (MockedStatic<ObjectStudioSaveService> saves = mockStatic(ObjectStudioSaveService.class)) {
saves.when(ObjectStudioSaveService::get).thenReturn(mock(ObjectStudioSaveService.class));
saves.when(ObjectStudioSaveService::get).thenReturn(service);
generator.generateChunk(engine, initial, cell.chunkMinX(), cell.chunkMinZ());
}
ObjectStudioLayout.GridCell placed = generator.getLayout().get("jungleclutt6");
assertNotNull(placed);
return new Fixture(generator, engine, placed, source);
return new Fixture(generator, engine, placed, source, tiles, service);
}
private static Map<Position, PlatformBlockState> render(Fixture fixture) throws Exception {
@@ -238,7 +258,10 @@ public class ObjectStudioGeometryTest {
}
return null;
}).when(chunk).setBlock(anyInt(), anyInt(), anyInt(), any());
fixture.generator().generateChunk(fixture.engine(), chunk, chunkX, chunkZ);
try (MockedStatic<ObjectStudioSaveService> saves = mockStatic(ObjectStudioSaveService.class)) {
saves.when(ObjectStudioSaveService::get).thenReturn(fixture.service());
fixture.generator().generateChunk(fixture.engine(), chunk, chunkX, chunkZ);
}
}
}
return rendered;
@@ -300,7 +323,7 @@ public class ObjectStudioGeometryTest {
}
private record Fixture(ObjectStudioGenerator generator, Engine engine, ObjectStudioLayout.GridCell cell,
IrisObject source) {
IrisObject source, Map<Position, TileData> tiles, ObjectStudioSaveService service) {
}
private record SerializedObject(int width, int height, int depth, Map<Position, String> blocks,
@@ -2,279 +2,43 @@ package art.arcane.iris.engine.terrain;
import org.junit.Test;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.lang.ref.WeakReference;
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.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class Terrain3DFragmentFilterTest {
@Test
public void removesAnEntire512BlockIslandAcrossChunkBoundaries() {
Set<Voxel> blocks = new HashSet<>();
for (int x = 12; x <= 19; x++) {
for (int z = -20; z <= -13; z++) {
for (int y = 24; y <= 31; y++) {
blocks.add(new Voxel(x, y, z));
}
public void perThreadTraversalScratchDoesNotPinTheFilterAfterItIsDropped() throws Exception {
// A hotload retires the runtime that owns the filter; the worker threads that classified
// fragments for it keep living. Their per-thread scratch must not keep the retired runtime
// (and the stream caches behind it) reachable.
ExecutorService worker = Executors.newSingleThreadExecutor();
try {
WeakReference<Terrain3DFragmentFilter> dropped = worker.submit(this::classifyAndDrop)
.get(10, TimeUnit.SECONDS);
assertNotNull(dropped);
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
while (dropped.get() != null && System.nanoTime() < deadline) {
System.gc();
Thread.sleep(20);
}
}
Fixture fixture = new Fixture(blocks, true);
for (Voxel block : blocks) {
Terrain3DColumn column = fixture.filter.column(block.x(), block.z());
assertFalse(column.isSolid(block.y()));
assertEquals(8, column.topY());
assertEquals(1, column.spanCount());
assertTrue(column.isSolid(8));
assertNull("Retired fragment filter is still reachable from a worker thread", dropped.get());
} finally {
worker.shutdownNow();
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
}
}
@Test
public void preservesThinShelvesConnectedSidewaysAroundACorner() {
Set<Voxel> blocks = new HashSet<>();
for (int x = 0; x <= 15; x++) {
blocks.add(new Voxel(x, 24, 0));
}
for (int z = 1; z <= 15; z++) {
blocks.add(new Voxel(15, 24, z));
}
for (int y = 9; y < 24; y++) {
blocks.add(new Voxel(15, y, 15));
}
Fixture fixture = new Fixture(blocks, true);
for (Voxel block : blocks) {
assertTrue(fixture.filter.column(block.x(), block.z()).isSolid(block.y()));
}
assertFalse(fixture.filter.column(0, 0).isSolid(23));
assertEquals(24, fixture.filter.column(0, 0).surfaceY(24));
}
@Test
public void aDiagonalContactDoesNotKeepAnOtherwiseDetachedBlock() {
Set<Voxel> blocks = new HashSet<>();
for (int y = 9; y <= 24; y++) {
blocks.add(new Voxel(0, y, 0));
}
blocks.add(new Voxel(1, 24, 1));
Fixture fixture = new Fixture(blocks, true);
assertTrue(fixture.filter.column(0, 0).isSolid(24));
assertFalse(fixture.filter.column(1, 1).isSolid(24));
}
@Test
public void preservesAll513BlocksAfterAPartialComponentClassification() {
Set<Voxel> blocks = new HashSet<>();
for (int x = 0; x < 513; x++) {
blocks.add(new Voxel(x, 24, 0));
}
Fixture fixture = new Fixture(blocks, true);
assertTrue(fixture.filter.column(0, 0).isSolid(24));
assertTrue(fixture.lookups.get() <= 1 + 4 * 512);
for (int x = 512; x >= 0; x--) {
assertTrue(fixture.filter.column(x, 0).isSolid(24));
}
}
@Test
public void aLargeVerticalIslandNeedsNoNeighbourSampling() {
Set<Voxel> blocks = new HashSet<>();
for (int y = 24; y < 537; y++) {
blocks.add(new Voxel(0, y, 0));
}
Fixture fixture = new Fixture(blocks, true);
assertEquals(536, fixture.filter.column(0, 0).topY());
assertEquals(1, fixture.lookups.get());
}
@Test
public void completeCacheEvictionAndConcurrentQueriesPreserveDecisions() throws Exception {
Set<Voxel> blocks = new HashSet<>();
for (int x = -18; x <= -15; x++) {
for (int z = 14; z <= 17; z++) {
for (int y = 24; y <= 27; y++) {
blocks.add(new Voxel(x, y, z));
}
}
}
for (int x = 14; x <= 526; x++) {
blocks.add(new Voxel(x, 24, 16));
}
Fixture evicted = new Fixture(blocks, false);
Fixture cached = new Fixture(blocks, true);
int[] coordinates = {-18, -17, -16, -15, 14, 15, 16, 526};
try (ExecutorService executor = Executors.newFixedThreadPool(4)) {
List<Future<Terrain3DColumn>> results = new ArrayList<>();
for (int x : coordinates) {
results.add(executor.submit(() -> evicted.filter.column(x, 16)));
}
for (int index = results.size() - 1; index >= 0; index--) {
assertEquals(cached.filter.column(coordinates[index], 16),
results.get(index).get(10, TimeUnit.SECONDS));
}
}
}
@Test
public void sharedConcurrentQueriesPublishWholeRemovedAndKeptComponents() throws Exception {
Set<Voxel> blocks = new HashSet<>();
for (int x = 12; x <= 19; x++) {
for (int z = -20; z <= -13; z++) {
for (int y = 24; y <= 31; y++) {
blocks.add(new Voxel(x, y, z));
}
}
}
for (int x = 32; x <= 544; x++) {
blocks.add(new Voxel(x, 24, 16));
}
Fixture fixture = new Fixture(blocks, true);
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
List<Future<Terrain3DColumn>> removed = new ArrayList<>();
List<Future<Terrain3DColumn>> kept = new ArrayList<>();
for (int index = 0; index < 64; index++) {
int removedX = 12 + index % 8;
int removedZ = -20 + index / 8;
int keptX = 32 + index * 8;
removed.add(executor.submit(() -> {
assertTrue(start.await(10, TimeUnit.SECONDS));
return fixture.filter.column(removedX, removedZ);
}));
kept.add(executor.submit(() -> {
assertTrue(start.await(10, TimeUnit.SECONDS));
return fixture.filter.column(keptX, 16);
}));
}
start.countDown();
for (int index = 63; index >= 0; index--) {
assertEquals(8, removed.get(index).get(10, TimeUnit.SECONDS).topY());
assertTrue(kept.get(index).get(10, TimeUnit.SECONDS).isSolid(24));
}
}
}
@Test
public void spanFilteringMatchesAnIndependentVoxelComponentOracle() {
Random random = new Random(74921);
for (int sample = 0; sample < 18; sample++) {
Set<Voxel> blocks = new HashSet<>();
double probability = 0.12D + sample % 6 * 0.14D;
for (int x = -3; x <= 3; x++) {
for (int z = -3; z <= 3; z++) {
for (int y = 9; y <= 20; y++) {
if (random.nextDouble() < probability) {
blocks.add(new Voxel(x, y, z));
}
}
}
}
Set<Voxel> retained = retainedBlocks(blocks);
Fixture fixture = new Fixture(blocks, true);
for (int x = 3; x >= -3; x--) {
for (int z = -3; z <= 3; z++) {
Terrain3DColumn column = fixture.filter.column(x, z);
for (int y = 9; y <= 20; y++) {
Voxel block = new Voxel(x, y, z);
assertEquals("Sample " + sample + " at " + block,
retained.contains(block), column.isSolid(y));
}
}
}
}
}
private static Set<Voxel> retainedBlocks(Set<Voxel> blocks) {
Set<Voxel> remaining = new HashSet<>(blocks);
Set<Voxel> retained = new HashSet<>();
while (!remaining.isEmpty()) {
Voxel start = remaining.iterator().next();
remaining.remove(start);
Set<Voxel> component = new HashSet<>();
ArrayDeque<Voxel> pending = new ArrayDeque<>();
pending.add(start);
boolean grounded = false;
while (!pending.isEmpty()) {
Voxel block = pending.removeFirst();
component.add(block);
grounded |= block.y() == 9;
for (Voxel neighbour : List.of(new Voxel(block.x() - 1, block.y(), block.z()),
new Voxel(block.x() + 1, block.y(), block.z()),
new Voxel(block.x(), block.y() - 1, block.z()),
new Voxel(block.x(), block.y() + 1, block.z()),
new Voxel(block.x(), block.y(), block.z() - 1),
new Voxel(block.x(), block.y(), block.z() + 1))) {
if (remaining.remove(neighbour)) {
pending.addLast(neighbour);
}
}
}
if (grounded || component.size() > 512) {
retained.addAll(component);
}
}
return retained;
}
private record Voxel(int x, int y, int z) {
}
private static final class Fixture {
private final Set<Voxel> blocks;
private final boolean cache;
private final int maximumY;
private final Map<Long, Terrain3DFragmentFilter.DensityColumn> columns = new ConcurrentHashMap<>();
private final AtomicInteger lookups = new AtomicInteger();
private final Terrain3DFragmentFilter filter = new Terrain3DFragmentFilter(this::column);
private Fixture(Set<Voxel> blocks, boolean cache) {
this.blocks = Set.copyOf(blocks);
this.cache = cache;
int highest = 9;
for (Voxel block : blocks) {
highest = Math.max(highest, block.y());
}
maximumY = highest + 1;
}
private Terrain3DFragmentFilter.DensityColumn column(int x, int z) {
lookups.incrementAndGet();
long key = (long) x << 32 ^ z & 0xffffffffL;
return cache ? columns.computeIfAbsent(key, ignored -> createColumn(x, z)) : createColumn(x, z);
}
private Terrain3DFragmentFilter.DensityColumn createColumn(int x, int z) {
int[] boundaries = new int[maximumY * 2];
boundaries[0] = 0;
int count = 1;
boolean previousSolid = true;
for (int y = 9; y <= maximumY; y++) {
boolean solid = blocks.contains(new Voxel(x, y, z));
if (solid != previousSolid) {
boundaries[count++] = solid ? y : y - 1;
previousSolid = solid;
}
}
return new Terrain3DFragmentFilter.DensityColumn(
new Terrain3DColumn(8, 9, true, Arrays.copyOf(boundaries, count)));
}
private WeakReference<Terrain3DFragmentFilter> classifyAndDrop() {
Terrain3DColumn raw = new Terrain3DColumn(64D, 0, false, new int[]{0, 40, 50, 60});
Terrain3DFragmentFilter.DensityColumn density = new Terrain3DFragmentFilter.DensityColumn(raw);
Terrain3DFragmentFilter filter = new Terrain3DFragmentFilter((x, z) -> density);
assertNotNull(filter.column(0, 0));
return new WeakReference<>(filter);
}
}
@@ -84,6 +84,69 @@ public class Terrain3DRuntimeTest {
}
}
@Test
public void subLatticeHeightPeaksDoNotJumpBetweenSeparateDensityLedges() {
IrisBiome biome = new IrisBiome().setTerrain3D(profile());
Terrain3DRuntime.NoiseSource noise = (x, y, z) -> StrictMath.sin(y * StrictMath.PI / 16D);
Terrain3DRuntime reference = runtime(profile(), noise);
Terrain3DRuntime runtime = new Terrain3DRuntime(
new Terrain3DRuntime.Sources((x, z) -> Math.floorMod(x, 4) == 2
&& Math.floorMod(z, 4) == 2 ? 114D : 96D, (x, z) -> biome),
options(true), (style, seed) -> noise);
for (int x = -18; x <= 18; x++) {
Terrain3DColumn expected = reference.column(x, -14);
Terrain3DColumn actual = runtime.column(x, -14);
assertEquals(Math.floorMod(x, 4) == 2 ? 114D : 96D, actual.baseHeight(), 0D);
assertEquals(expected.topY(), actual.topY());
assertTrue(actual.spanCount() > 1);
for (int y = 1; y < 160; y++) {
assertEquals("at " + x + "," + y + ",-14", expected.isSolid(y), actual.isSolid(y));
}
}
Terrain3DColumn beforeClear = runtime.column(-14, -14);
runtime.clear();
assertEquals(beforeClear, runtime.column(-14, -14));
}
@Test
public void baseHeightCorrectionFadesIntoAnOmittedProfile() {
IrisBiome shaped = new IrisBiome().setTerrain3D(profile());
IrisBiome unchanged = new IrisBiome();
Terrain3DRuntime runtime = new Terrain3DRuntime(
new Terrain3DRuntime.Sources((x, z) -> Math.floorMod(x, 4) == 0 ? 96D : 116D,
(x, z) -> x < 16 ? shaped : unchanged),
options(true), (style, seed) -> (x, y, z) -> 0D);
assertEquals(96, runtime.column(12, 0).topY());
assertEquals(101, runtime.column(13, 0).topY());
assertEquals(106, runtime.column(14, 0).topY());
assertEquals(111, runtime.column(15, 0).topY());
assertEquals(96, runtime.column(16, 0).topY());
assertEquals(116, runtime.column(17, 0).topY());
assertFalse(runtime.column(17, 0).shaped());
}
@Test
public void baseHeightCorrectionUsesTheExistingWaterAndSlopeFade() {
IrisBiome shore = new IrisBiome().setTerrain3D(profile().setFluidClearance(8).setFluidFade(16));
Terrain3DRuntime water = new Terrain3DRuntime(
new Terrain3DRuntime.Sources((x, z) -> Math.floorMod(x, 4) == 0 ? 48D : 64D,
(x, z) -> shore),
new Terrain3DRuntime.Options(12, 256, 32, null, true, 4096),
(style, seed) -> (x, y, z) -> 0D);
assertEquals(56, water.column(2, 0).topY());
assertEquals(64D, water.column(2, 0).baseHeight(), 0D);
IrisBiome slope = new IrisBiome().setTerrain3D(profile().setMinimumSlope(0.25D).setSlopeFade(0.5D));
Terrain3DRuntime terrain = new Terrain3DRuntime(
new Terrain3DRuntime.Sources((x, z) -> 96D + x * 0.5D
+ (Math.floorMod(x, 4) == 0 ? 0D : 16D), (x, z) -> slope),
options(true), (style, seed) -> (x, y, z) -> 0D);
assertEquals(105, terrain.column(2, 0).topY());
assertEquals(113D, terrain.column(2, 0).baseHeight(), 0D);
}
@Test
public void isolatedDensityPeakIsRemovedFromEveryColumnQuery() {
Terrain3DRuntime runtime = runtime(profile(),