mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 13:30:08 +00:00
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:
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);
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -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 {
|
||||
|
||||
+137
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
+111
@@ -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();
|
||||
|
||||
+195
@@ -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) {
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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.");
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1216,7 +1216,8 @@ public class HydrologyCaveCourseFilterTest {
|
||||
base.deepFluids(),
|
||||
base.surfacePools(),
|
||||
0D,
|
||||
HydrologyPlannerSettings.SeaCaves.disabled()
|
||||
HydrologyPlannerSettings.SeaCaves.disabled(),
|
||||
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+44
-18
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+12
-5
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-3
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+15
-6
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+16
-6
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+14
-6
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+242
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+8
-3
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+122
@@ -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);
|
||||
}
|
||||
}
|
||||
+294
@@ -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));
|
||||
}
|
||||
}
|
||||
+96
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+74
-1
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -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()
|
||||
|
||||
+4
-1
@@ -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);
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
+14
-10
@@ -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();
|
||||
|
||||
+6
-2
@@ -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));
|
||||
|
||||
+105
-151
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-52
@@ -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();
|
||||
}
|
||||
}
|
||||
+29
-6
@@ -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,
|
||||
|
||||
+26
-262
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user