mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-21 15:57:38 +00:00
Improved world generation
- Terrain and cave scans skip repeated work - River and structure lookups reuse cached results - Chunk history saves share disk writes - History lookups continue during disk writes - Failed saves retain their original errors - Deposit failures stop incomplete chunks - Tests check output and save safety
This commit is contained in:
1 parent
151677f92f
commit
0dcd1900be
41 files changed
+4398
-191
No files matched your search
@@ -0,0 +1,245 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.EngineMetrics;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.mantle.TerrainMatterView;
|
||||
import art.arcane.iris.engine.modifier.IrisCarveModifier;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomePaletteLayer;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.function.Consumer4;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyDouble;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doCallRealMethod;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class IrisCarveWallPaletteScopeTest {
|
||||
@Test
|
||||
public void naturalWallsUseOneSelectedDataLookup() throws Exception {
|
||||
verifyWalls(Mode.NATURAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contentWallsRestoreDataAfterNestedSampling() throws Exception {
|
||||
verifyWalls(Mode.CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedWallsUseDetachedData() throws Exception {
|
||||
verifyWalls(Mode.DETACHED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void assemblyWallsUseUnpublishedData() throws Exception {
|
||||
verifyWalls(Mode.ASSEMBLY);
|
||||
}
|
||||
|
||||
private static void verifyWalls(Mode mode) throws Exception {
|
||||
PlatformBlockState air = block("minecraft:cave_air", false);
|
||||
PlatformBlockState stone = block("minecraft:stone", true);
|
||||
PlatformBlockState wall = block("minecraft:calcite", true);
|
||||
try (MockedStatic<B> blocks = mockStatic(B.class)) {
|
||||
blocks.when(() -> B.getState(anyString())).thenReturn(air);
|
||||
blocks.when(() -> B.isSolid(any())).thenAnswer(call ->
|
||||
call.<PlatformBlockState>getArgument(0).isSolid());
|
||||
Fixture fixture = new Fixture(air, wall);
|
||||
GenerationRuntime selected = mode == Mode.DETACHED ? fixture.detached : fixture.active;
|
||||
IrisData expectedData = mode == Mode.ASSEMBLY ? fixture.assemblyData : selected.data();
|
||||
IrisComplex expectedComplex = mode == Mode.ASSEMBLY ? fixture.assemblyComplex : selected.complex();
|
||||
fixture.expectedData = expectedData;
|
||||
doReturn(expectedComplex).when(fixture.context).getComplex();
|
||||
doReturn(mode == Mode.NATURAL).when(fixture.context).isNaturalTerrain();
|
||||
Hunk<PlatformBlockState> output = Hunk.newArrayHunk(16, 64, 16);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int y = 0; y < 64; y++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
output.setRaw(x, y, z, stone);
|
||||
}
|
||||
}
|
||||
}
|
||||
try (IrisEngine.GenerationRuntimeScope generation = fixture.engine.openGenerationRuntimeScope(
|
||||
new IrisEngine.GenerationRuntimeBinding(fixture.engine, selected));
|
||||
IrisContext.Scope context = IrisContext.open(fixture.engine, 71L, fixture.context);
|
||||
MockedStatic<TerrainMatterView> terrain = mockStatic(TerrainMatterView.class)) {
|
||||
if (mode == Mode.ASSEMBLY) {
|
||||
fixture.engine.runtimeAssembly.set(fixture.assembly);
|
||||
}
|
||||
terrain.when(() -> TerrainMatterView.iterate(eq(fixture.chunk), eq(MatterCavern.class), any()))
|
||||
.thenAnswer(call -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = call.getArgument(2);
|
||||
for (int coordinate : new int[]{4, 8, 12}) {
|
||||
consumer.accept(coordinate, 10, coordinate, new MatterCavern(true, "", (byte) 0));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
try {
|
||||
assertSame(expectedData, fixture.engine.getData());
|
||||
assertEquals(mode == Mode.NATURAL, expectedComplex.isNaturalTerrainContext());
|
||||
fixture.modifier.onModify(-2, 3, output, false, fixture.context);
|
||||
assertSame(expectedData, fixture.engine.getData());
|
||||
assertEquals(12, fixture.paletteCalls.get());
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int y = 0; y < 64; y++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
PlatformBlockState expected = stone;
|
||||
for (int coordinate : new int[]{4, 8, 12}) {
|
||||
if (y == 10 && x == coordinate && z == coordinate) {
|
||||
expected = air;
|
||||
} else if (y == 10 && Math.abs(x - coordinate) + Math.abs(z - coordinate) == 1) {
|
||||
expected = wall;
|
||||
}
|
||||
}
|
||||
assertSame(expected, output.getRaw(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
verify(fixture.chunk).release();
|
||||
assertEquals(1, fixture.dataLookups.get());
|
||||
} finally {
|
||||
fixture.engine.runtimeAssembly.remove();
|
||||
}
|
||||
}
|
||||
assertSame(fixture.active.data(), fixture.engine.getData());
|
||||
}
|
||||
}
|
||||
|
||||
private static PlatformBlockState block(String key, boolean solid) {
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
doReturn(key).when(block).key();
|
||||
doReturn(solid).when(block).isSolid();
|
||||
return block;
|
||||
}
|
||||
|
||||
private static void field(Object owner, Class<?> type, String name, Object value) throws Exception {
|
||||
Field field = type.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(owner, value);
|
||||
}
|
||||
|
||||
private static GenerationRuntime runtime(IrisData data, IrisComplex complex, EngineMantle mantle) {
|
||||
GenerationRuntime runtime = mock(GenerationRuntime.class);
|
||||
doReturn(data).when(runtime).data();
|
||||
doReturn(complex).when(runtime).complex();
|
||||
doReturn(mantle).when(runtime).mantle();
|
||||
doReturn(mock(IrisDimension.class)).when(runtime).dimension();
|
||||
return runtime;
|
||||
}
|
||||
|
||||
private enum Mode {
|
||||
NATURAL,
|
||||
CONTENT,
|
||||
DETACHED,
|
||||
ASSEMBLY
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final IrisEngine engine = mock(IrisEngine.class, CALLS_REAL_METHODS);
|
||||
private final IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
private final ChunkContext context = mock(ChunkContext.class);
|
||||
private final MantleChunk<Matter> chunk;
|
||||
private final GenerationRuntime active;
|
||||
private final GenerationRuntime detached;
|
||||
private final IrisData assemblyData = mock(IrisData.class);
|
||||
private final IrisComplex assemblyComplex = mock(IrisComplex.class);
|
||||
private final EngineRuntimeBuilder.RuntimeAssembly assembly;
|
||||
private final AtomicInteger dataLookups = new AtomicInteger();
|
||||
private final AtomicInteger paletteCalls = new AtomicInteger();
|
||||
private IrisData expectedData;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Fixture(PlatformBlockState air, PlatformBlockState wall) throws Exception {
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
doReturn(new KMap<>()).when(mantle).getLoadedRegions();
|
||||
chunk = mock(MantleChunk.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
doReturn(mantle).when(engineMantle).getMantle();
|
||||
doReturn(chunk).when(mantle).getChunk(-2, 3);
|
||||
doReturn(chunk).when(chunk).use();
|
||||
active = runtime(mock(IrisData.class), mock(IrisComplex.class), engineMantle);
|
||||
detached = runtime(mock(IrisData.class), mock(IrisComplex.class), engineMantle);
|
||||
field(engine, IrisEngine.class, "lifecycleLock", new Object());
|
||||
field(engine, IrisEngine.class, "runtimeAssembly", new ThreadLocal<EngineRuntimeBuilder.RuntimeAssembly>());
|
||||
field(engine, IrisEngine.class, "biomeEnvironmentScopes", new ThreadLocal<>());
|
||||
field(engine, IrisEngine.class, "generationRuntimeScopes", new GenerationRuntimeScopeState());
|
||||
field(engine, IrisEngine.class, "detachedGenerationRuntimes", Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
field(engine, IrisEngine.class, "retiringGenerationRuntimes", Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
engine.detachedGenerationRuntimes.add(detached);
|
||||
EngineRuntime published = mock(EngineRuntime.class);
|
||||
doReturn(active).when(published).generation();
|
||||
engine.runtime = published;
|
||||
doReturn(IrisWorld.builder().minHeight(-64).maxHeight(0).build()).when(engine).getWorld();
|
||||
doReturn(64).when(engine).getHeight();
|
||||
doReturn(mock(EngineMetrics.class, RETURNS_DEEP_STUBS)).when(engine).getMetrics();
|
||||
doReturn(48).when(context).getRoundedHeight(anyInt(), anyInt());
|
||||
IrisComplex activeComplex = active.complex();
|
||||
IrisComplex detachedComplex = detached.complex();
|
||||
doCallRealMethod().when(activeComplex).isNaturalTerrainContext();
|
||||
doCallRealMethod().when(detachedComplex).isNaturalTerrainContext();
|
||||
doCallRealMethod().when(assemblyComplex).isNaturalTerrainContext();
|
||||
EngineTarget assemblyTarget = mock(EngineTarget.class);
|
||||
doReturn(engine.getWorld()).when(assemblyTarget).getWorld();
|
||||
doReturn(assemblyData).when(assemblyTarget).getData();
|
||||
doReturn(mock(IrisDimension.class)).when(assemblyTarget).getDimension();
|
||||
assembly = new EngineRuntimeBuilder.RuntimeAssembly(19, assemblyTarget);
|
||||
assembly.complex = assemblyComplex;
|
||||
assembly.mantle = engineMantle;
|
||||
IrisBiome biome = mock(IrisBiome.class);
|
||||
IrisBiomePaletteLayer palette = mock(IrisBiomePaletteLayer.class);
|
||||
doReturn(palette).when(biome).getWall();
|
||||
doAnswer(call -> {
|
||||
assertSame(expectedData, call.getArgument(4));
|
||||
paletteCalls.incrementAndGet();
|
||||
return wall;
|
||||
}).when(palette).get(any(RNG.class), anyDouble(), anyDouble(), anyDouble(), any(IrisData.class));
|
||||
doAnswer(call -> {
|
||||
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(
|
||||
new IrisEngine.GenerationRuntimeBinding(engine, active))) {
|
||||
assertSame(engine.runtimeAssembly.get() == null ? active.data() : assemblyData, engine.getData());
|
||||
}
|
||||
return biome;
|
||||
}).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doAnswer(call -> {
|
||||
dataLookups.incrementAndGet();
|
||||
return call.callRealMethod();
|
||||
}).when(modifier).getData();
|
||||
field(modifier, IrisCarveModifier.class, "rng", new RNG(319L));
|
||||
field(modifier, IrisCarveModifier.class, "AIR", air);
|
||||
field(modifier, IrisCarveModifier.class, "LAVA", air);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ import art.arcane.iris.engine.history.GenerationAdmission;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingEntry;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.engine.object.IrisRange;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
@@ -32,7 +34,9 @@ import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.common.parallel.MultiBurst;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.iris.util.project.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Test;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
@@ -90,6 +94,51 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carvingResolverRestoresNestedRuntimeDefinitions() throws Exception {
|
||||
RuntimeFixture active = runtime(1, 1D, 1D, 1D);
|
||||
RuntimeFixture first = runtime(2, 2D, 2D, 2D);
|
||||
RuntimeFixture second = runtime(3, 3D, 3D, 3D);
|
||||
IrisEngine engine = engine(active.runtime, mock(EngineEffects.class), mock(EngineWorldManager.class));
|
||||
IrisDimensionCarvingEntry root = mock(IrisDimensionCarvingEntry.class);
|
||||
IrisDimensionCarvingEntry child = mock(IrisDimensionCarvingEntry.class);
|
||||
when(root.isEnabled()).thenReturn(true);
|
||||
when(root.getWorldYRange()).thenReturn(new IrisRange(-64, 320));
|
||||
when(root.getChildRecursionDepth()).thenReturn(1);
|
||||
when(root.getChildren()).thenReturn(new KList<>("child"));
|
||||
when(child.isEnabled()).thenReturn(true);
|
||||
CNG generator = mock(CNG.class);
|
||||
when(generator.noiseFast2D(19D, -3D)).thenReturn(1D);
|
||||
IrisBiome[] expected = new IrisBiome[3];
|
||||
RuntimeFixture[] fixtures = {active, first, second};
|
||||
for (int index = 0; index < fixtures.length; index++) {
|
||||
RuntimeFixture fixture = fixtures[index];
|
||||
IrisBiome parentBiome = mock(IrisBiome.class);
|
||||
IrisBiome childBiome = mock(IrisBiome.class);
|
||||
when(parentBiome.getRarity()).thenReturn(1);
|
||||
when(childBiome.getRarity()).thenReturn(1);
|
||||
when(root.getRealBiome(fixture.data)).thenReturn(parentBiome);
|
||||
when(child.getRealBiome(fixture.data)).thenReturn(childBiome);
|
||||
when(root.getChildrenGenerator(fixture.runtime.seedManager().getCarve() ^ 0x9E3779B97F4A7C15L,
|
||||
fixture.data)).thenReturn(generator);
|
||||
when(fixture.dimension.getCarving()).thenReturn(new KList<>(root));
|
||||
when(fixture.dimension.getCarvingEntryIndex()).thenReturn(Map.of("child", child));
|
||||
expected[index] = childBiome;
|
||||
}
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
assertSame(expected[0], carvingBiome(engine, state));
|
||||
try (IrisEngine.GenerationRuntimeScope outer = engine.openGenerationRuntimeScope(
|
||||
detachedBinding(engine, first.runtime))) {
|
||||
assertSame(expected[1], carvingBiome(engine, state));
|
||||
try (IrisEngine.GenerationRuntimeScope inner = engine.openGenerationRuntimeScope(
|
||||
detachedBinding(engine, second.runtime))) {
|
||||
assertSame(expected[2], carvingBiome(engine, state));
|
||||
}
|
||||
assertSame(expected[1], carvingBiome(engine, state));
|
||||
}
|
||||
assertSame(expected[0], carvingBiome(engine, state));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void naturalCaveSamplesKeepSelectedRuntimeAcrossChunkEdges() throws Exception {
|
||||
@@ -813,6 +862,12 @@ public class IrisEngineGenerationRuntimeScopeTest {
|
||||
assertSame(active.dimension, engine.getDimension());
|
||||
}
|
||||
|
||||
private static IrisBiome carvingBiome(IrisEngine engine, IrisDimensionCarvingResolver.State state) {
|
||||
IrisDimensionCarvingEntry root = IrisDimensionCarvingResolver.resolveRootEntry(engine, 80, state);
|
||||
IrisDimensionCarvingEntry child = IrisDimensionCarvingResolver.resolveFromRoot(engine, root, 19, -3, state);
|
||||
return IrisDimensionCarvingResolver.resolveEntryBiome(engine, child, state);
|
||||
}
|
||||
|
||||
private static GenerationHistoryRuntimeRouter historyRouter(IrisEngine engine) {
|
||||
GenerationHistory history = mock(GenerationHistory.class);
|
||||
when(history.retainRuntime()).thenReturn(mock(GenerationAdmission.RuntimeLease.class));
|
||||
|
||||
+214
-4
@@ -7,9 +7,19 @@ import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.management.ManagementFactory;
|
||||
import java.lang.management.ThreadInfo;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
@@ -23,6 +33,7 @@ import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
@@ -55,10 +66,9 @@ public class GenerationHistoryAdmissionTest {
|
||||
});
|
||||
admitted.get(1, TimeUnit.SECONDS);
|
||||
assertFalse(persisted.isDone());
|
||||
Future<?> observed = executor.submit(() -> history.semantics(1, 2));
|
||||
assertThrows(TimeoutException.class, () -> observed.get(100, TimeUnit.MILLISECONDS));
|
||||
Future<Optional<ChunkGenerationSemantics>> observed = executor.submit(() -> history.semantics(1, 2));
|
||||
assertTrue(observed.get(1, TimeUnit.SECONDS).isEmpty());
|
||||
release.countDown();
|
||||
observed.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(persisted.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(claim, history.semantics(1, 2).orElseThrow());
|
||||
assertEquals(claim, GenerationSemanticIndex.loadRequired(history.paths().dimensionRoot()).get(1, 2).orElseThrow());
|
||||
@@ -165,12 +175,212 @@ public class GenerationHistoryAdmissionTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedClaimsShareOneForceAndRetainIndividualValidation() throws Exception {
|
||||
GenerationHistory history = history();
|
||||
GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
|
||||
List<GenerationHistory.GenerationStage> stages = new ArrayList<>();
|
||||
for (int x : new int[]{1, 2, 3, 1, 1, 4, 32, 33}) {
|
||||
stages.add(history.openStage(x, 0));
|
||||
}
|
||||
history.claimGeneratedSemantics(stages.getFirst(), claim(stages.getFirst()));
|
||||
List<Thread> workers = new CopyOnWriteArrayList<>();
|
||||
ExecutorService executor = Executors.newFixedThreadPool(stages.size(), task -> {
|
||||
Thread thread = new Thread(task);
|
||||
workers.add(thread);
|
||||
return thread;
|
||||
});
|
||||
AtomicInteger forces = new AtomicInteger();
|
||||
List<Future<Boolean>> results = new ArrayList<>();
|
||||
try {
|
||||
synchronized (history) {
|
||||
for (int index = 0; index < stages.size(); index++) {
|
||||
GenerationHistory.GenerationStage stage = stages.get(index);
|
||||
ChunkGenerationSemantics update = index == 4
|
||||
? ChunkGenerationSemantics.builder(1, 0, stage.activation().activationId())
|
||||
.addSurfaceBiome("iris:conflict").seal().build()
|
||||
: claim(stage);
|
||||
results.add(executor.submit(() -> persistCountingForces(history, stage, update, forces)));
|
||||
awaitBlockedOnHistory(history, workers, index + 1);
|
||||
}
|
||||
stages.get(5).close();
|
||||
}
|
||||
assertFalse(results.get(0).get(5, TimeUnit.SECONDS));
|
||||
assertTrue(results.get(1).get(5, TimeUnit.SECONDS));
|
||||
assertTrue(results.get(2).get(5, TimeUnit.SECONDS));
|
||||
assertFalse(results.get(3).get(5, TimeUnit.SECONDS));
|
||||
assertTrue(assertThrows(ExecutionException.class, () -> results.get(4).get(5, TimeUnit.SECONDS))
|
||||
.getCause() instanceof IllegalStateException);
|
||||
assertTrue(assertThrows(ExecutionException.class, () -> results.get(5).get(5, TimeUnit.SECONDS))
|
||||
.getCause() instanceof IllegalStateException);
|
||||
assertTrue(results.get(6).get(5, TimeUnit.SECONDS));
|
||||
assertTrue(results.get(7).get(5, TimeUnit.SECONDS));
|
||||
assertEquals(2, forces.get());
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(history.paths().dimensionRoot());
|
||||
assertEquals(5, loaded.recordCount());
|
||||
assertEquals(claim(stages.getFirst()), loaded.get(1, 0).orElseThrow());
|
||||
assertTrue(loaded.get(4, 0).isEmpty());
|
||||
} finally {
|
||||
drain(executor);
|
||||
for (GenerationHistory.GenerationStage stage : stages) {
|
||||
stage.close();
|
||||
}
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fullClaimQueueRetainsEveryAdmittedRequest() throws Exception {
|
||||
GenerationHistory history = history();
|
||||
GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
|
||||
List<GenerationHistory.GenerationStage> stages = new ArrayList<>();
|
||||
List<Future<Boolean>> results = new ArrayList<>();
|
||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
CountDownLatch started = new CountDownLatch(132);
|
||||
try {
|
||||
synchronized (history) {
|
||||
for (int x = 0; x < 132; x++) {
|
||||
GenerationHistory.GenerationStage stage = history.openStage(x, 0);
|
||||
stages.add(stage);
|
||||
results.add(executor.submit(() -> {
|
||||
started.countDown();
|
||||
return history.claimGeneratedSemantics(stage, claim(stage));
|
||||
}));
|
||||
}
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
Field queueField = GenerationHistory.class.getDeclaredField("pendingSemanticClaims");
|
||||
queueField.setAccessible(true);
|
||||
ArrayBlockingQueue<?> queue = (ArrayBlockingQueue<?>) queueField.get(history);
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
|
||||
while (queue.remainingCapacity() != 0 && System.nanoTime() < deadline) {
|
||||
Thread.sleep(1L);
|
||||
}
|
||||
assertEquals(0, queue.remainingCapacity());
|
||||
assertEquals(128, queue.size());
|
||||
}
|
||||
for (Future<Boolean> result : results) {
|
||||
assertTrue(result.get(10, TimeUnit.SECONDS));
|
||||
}
|
||||
assertEquals(132, GenerationSemanticIndex.loadRequired(history.paths().dimensionRoot()).recordCount());
|
||||
} finally {
|
||||
drain(executor);
|
||||
for (GenerationHistory.GenerationStage stage : stages) {
|
||||
stage.close();
|
||||
}
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unexpectedLaterBatchFailureReachesWaitersAndKeepsEarlierSuccess() throws Exception {
|
||||
GenerationHistory history = history();
|
||||
GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
|
||||
List<GenerationHistory.GenerationStage> stages = new ArrayList<>();
|
||||
for (int x : new int[]{0, 32, 32, 64}) {
|
||||
stages.add(history.openStage(x, 0));
|
||||
}
|
||||
List<Thread> workers = new CopyOnWriteArrayList<>();
|
||||
ExecutorService executor = Executors.newFixedThreadPool(stages.size(), task -> {
|
||||
Thread thread = new Thread(task);
|
||||
workers.add(thread);
|
||||
return thread;
|
||||
});
|
||||
IllegalStateException failure = new IllegalStateException("Unexpected second journal failure");
|
||||
List<Future<Boolean>> results = new ArrayList<>();
|
||||
try {
|
||||
synchronized (history) {
|
||||
for (int index = 0; index < stages.size(); index++) {
|
||||
GenerationHistory.GenerationStage stage = stages.get(index);
|
||||
results.add(executor.submit(() -> persistFailingRegion(history, stage, failure)));
|
||||
awaitBlockedOnHistory(history, workers, index + 1);
|
||||
}
|
||||
}
|
||||
assertTrue(results.getFirst().get(5, TimeUnit.SECONDS));
|
||||
for (int index = 1; index < results.size(); index++) {
|
||||
Future<Boolean> result = results.get(index);
|
||||
assertSame(failure, assertThrows(ExecutionException.class,
|
||||
() -> result.get(5, TimeUnit.SECONDS)).getCause());
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> history.semantics(0, 0));
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(history.paths().dimensionRoot());
|
||||
assertEquals(1, loaded.recordCount());
|
||||
assertEquals(claim(stages.getFirst()), loaded.get(0, 0).orElseThrow());
|
||||
} finally {
|
||||
drain(executor);
|
||||
for (GenerationHistory.GenerationStage stage : stages) {
|
||||
stage.close();
|
||||
}
|
||||
runtime.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean persistFailingRegion(GenerationHistory history, GenerationHistory.GenerationStage stage,
|
||||
IllegalStateException failure) throws Exception {
|
||||
try (MockedStatic<FileChannel> ignored = mockStatic(FileChannel.class, invocation -> {
|
||||
FileChannel source = (FileChannel) invocation.callRealMethod();
|
||||
Path path = invocation.getArgument(0);
|
||||
if (invocation.getMethod().getParameterCount() != 2
|
||||
|| !path.getFileName().toString().equals("r.1.0.iswal")) {
|
||||
return source;
|
||||
}
|
||||
FileChannel intercepted = mock(FileChannel.class, delegatesTo(source));
|
||||
doAnswer(write -> {
|
||||
throw failure;
|
||||
}).when(intercepted).write(any(ByteBuffer.class));
|
||||
return intercepted;
|
||||
})) {
|
||||
return history.claimGeneratedSemantics(stage, claim(stage));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean persistCountingForces(GenerationHistory history, GenerationHistory.GenerationStage stage,
|
||||
ChunkGenerationSemantics claim, AtomicInteger forces) throws Exception {
|
||||
try (MockedStatic<FileChannel> ignored = mockStatic(FileChannel.class, invocation -> {
|
||||
FileChannel source = (FileChannel) invocation.callRealMethod();
|
||||
Path path = invocation.getArgument(0);
|
||||
if (invocation.getMethod().getParameterCount() != 2
|
||||
|| !path.getFileName().toString().endsWith(".iswal")) {
|
||||
return source;
|
||||
}
|
||||
FileChannel intercepted = mock(FileChannel.class, delegatesTo(source));
|
||||
doAnswer(force -> {
|
||||
forces.incrementAndGet();
|
||||
source.force(true);
|
||||
return null;
|
||||
}).when(intercepted).force(true);
|
||||
return intercepted;
|
||||
})) {
|
||||
return history.claimGeneratedSemantics(stage, claim);
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitBlockedOnHistory(GenerationHistory history, List<Thread> workers, int count)
|
||||
throws InterruptedException {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
|
||||
while (System.nanoTime() < deadline) {
|
||||
int blocked = 0;
|
||||
for (Thread worker : workers) {
|
||||
ThreadInfo info = ManagementFactory.getThreadMXBean().getThreadInfo(worker.threadId());
|
||||
if (info != null && info.getThreadState() == Thread.State.BLOCKED
|
||||
&& info.getLockInfo().getIdentityHashCode() == System.identityHashCode(history)) {
|
||||
blocked++;
|
||||
}
|
||||
}
|
||||
if (blocked == count) {
|
||||
return;
|
||||
}
|
||||
Thread.sleep(1L);
|
||||
}
|
||||
throw new AssertionError("Semantic claim callers did not queue behind the History monitor");
|
||||
}
|
||||
|
||||
private static boolean persistPaused(GenerationHistory history, GenerationHistory.GenerationStage stage,
|
||||
ChunkGenerationSemantics claim, CountDownLatch entered, CountDownLatch release) throws Exception {
|
||||
try (MockedStatic<FileChannel> ignored = mockStatic(FileChannel.class, invocation -> {
|
||||
FileChannel channel = (FileChannel) invocation.callRealMethod();
|
||||
Path path = invocation.getArgument(0);
|
||||
if (!path.getFileName().toString().endsWith(".iswal")) {
|
||||
if (invocation.getMethod().getParameterCount() != 2
|
||||
|| !path.getFileName().toString().endsWith(".iswal")) {
|
||||
return channel;
|
||||
}
|
||||
FileChannel intercepted = mock(FileChannel.class, delegatesTo(channel));
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class GenerationSemanticBatchWriteTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void batchedJournalRetainsSerialBytesAndPerClaimResults() throws Exception {
|
||||
Path serialRoot = temporaryFolder.newFolder().toPath();
|
||||
Path batchedRoot = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex serial = GenerationSemanticIndex.initialize(serialRoot);
|
||||
GenerationSemanticIndex batched = GenerationSemanticIndex.initialize(batchedRoot);
|
||||
List<GenerationSemanticIndex.Claim> claims = List.of(
|
||||
pending(1, "first"), pending(2, "second"), pending(1, "first"), pending(1, "conflict"));
|
||||
serial.claimAndPersist(claims.get(0).update);
|
||||
serial.claimAndPersist(claims.get(1).update);
|
||||
|
||||
batched.claimAndPersistBatch(claims);
|
||||
|
||||
assertTrue(claims.get(0).result());
|
||||
assertTrue(claims.get(1).result());
|
||||
assertFalse(claims.get(2).result());
|
||||
assertThrows(IllegalStateException.class, claims.get(3)::result);
|
||||
assertArrayEquals(Files.readAllBytes(serial.storageDirectory().resolve("r.0.0.iswal")),
|
||||
Files.readAllBytes(batched.storageDirectory().resolve("r.0.0.iswal")));
|
||||
assertEquals(serial.recordsSnapshot(), GenerationSemanticIndex.loadRequired(batchedRoot).recordsSnapshot());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialBatchRollbackFailsDependentDuplicatesAndKeepsDurableDuplicates() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
index.claimAndPersist(pending(0, "durable").update);
|
||||
Path journal = index.storageDirectory().resolve("r.0.0.iswal");
|
||||
byte[] original = Files.readAllBytes(journal);
|
||||
List<GenerationSemanticIndex.Claim> claims = List.of(
|
||||
pending(1, "first"), pending(1, "first"), pending(2, "second"), pending(0, "durable"));
|
||||
IOException failure = new IOException("Second frame write failed");
|
||||
AtomicInteger writes = new AtomicInteger();
|
||||
try (MockedStatic<FileChannel> ignored = channels((path, source, intercepted) ->
|
||||
doAnswer(invocation -> {
|
||||
ByteBuffer bytes = invocation.getArgument(0);
|
||||
if (writes.incrementAndGet() == 2) {
|
||||
int limit = bytes.limit();
|
||||
bytes.limit(bytes.position() + 7);
|
||||
source.write(bytes);
|
||||
bytes.limit(limit);
|
||||
throw failure;
|
||||
}
|
||||
return source.write(bytes);
|
||||
}).when(intercepted).write(any(ByteBuffer.class)))) {
|
||||
index.claimAndPersistBatch(claims);
|
||||
}
|
||||
for (int indexInBatch = 0; indexInBatch < 3; indexInBatch++) {
|
||||
assertSame(failure, assertThrows(IOException.class, claims.get(indexInBatch)::result));
|
||||
}
|
||||
assertFalse(claims.get(3).result());
|
||||
assertArrayEquals(original, Files.readAllBytes(journal));
|
||||
assertEquals(1, GenerationSemanticIndex.loadRequired(root).recordCount());
|
||||
List<GenerationSemanticIndex.Claim> retry = List.of(pending(1, "first"), pending(2, "second"));
|
||||
index.claimAndPersistBatch(retry);
|
||||
assertTrue(retry.get(0).result());
|
||||
assertTrue(retry.get(1).result());
|
||||
assertEquals(3, GenerationSemanticIndex.loadRequired(root).recordCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedRegionDoesNotUndoOtherDurableRegionResults() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
List<GenerationSemanticIndex.Claim> claims = List.of(
|
||||
pending(0, "first"), pending(32, "failed"), pending(64, "last"));
|
||||
IOException failure = new IOException("Middle region force failed");
|
||||
AtomicInteger forces = new AtomicInteger();
|
||||
try (MockedStatic<FileChannel> ignored = channels((path, source, intercepted) -> {
|
||||
if (!path.getFileName().toString().equals("r.1.0.iswal")) {
|
||||
return;
|
||||
}
|
||||
doAnswer(invocation -> {
|
||||
if (forces.incrementAndGet() == 1) {
|
||||
throw failure;
|
||||
}
|
||||
source.force(true);
|
||||
return null;
|
||||
}).when(intercepted).force(true);
|
||||
})) {
|
||||
index.claimAndPersistBatch(claims);
|
||||
}
|
||||
assertTrue(claims.get(0).result());
|
||||
assertSame(failure, assertThrows(IOException.class, claims.get(1)::result));
|
||||
assertTrue(claims.get(2).result());
|
||||
GenerationSemanticIndex loaded = GenerationSemanticIndex.loadRequired(root);
|
||||
assertEquals(2, loaded.recordCount());
|
||||
assertTrue(loaded.get(32, 0).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unexpectedBatchFailureCompletesEveryClaimAndPoisonsFurtherReads() throws Exception {
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(temporaryFolder.newFolder().toPath());
|
||||
List<GenerationSemanticIndex.Claim> claims = List.of(
|
||||
pending(0, "first"), pending(0, "first"), pending(32, "other-region"));
|
||||
IllegalStateException failure = new IllegalStateException("Unexpected channel failure");
|
||||
try (MockedStatic<FileChannel> ignored = channels((path, source, intercepted) ->
|
||||
doAnswer(invocation -> {
|
||||
throw failure;
|
||||
}).when(intercepted).write(any(ByteBuffer.class)))) {
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, () -> index.claimAndPersistBatch(claims)));
|
||||
}
|
||||
for (GenerationSemanticIndex.Claim claim : claims) {
|
||||
assertTrue(claim.completed);
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, claim::result));
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> index.get(0, 0));
|
||||
assertThrows(IOException.class, () -> index.claimAndPersist(pending(1, "later").update));
|
||||
}
|
||||
|
||||
private static GenerationSemanticIndex.Claim pending(int chunkX, String biome) {
|
||||
return new GenerationSemanticIndex.Claim(ChunkGenerationSemantics.builder(chunkX, 0, 1L)
|
||||
.addSurfaceBiome("iris:" + biome).seal().build());
|
||||
}
|
||||
|
||||
private static MockedStatic<FileChannel> channels(ChannelMutation mutation) {
|
||||
return mockStatic(FileChannel.class, invocation -> {
|
||||
FileChannel source = (FileChannel) invocation.callRealMethod();
|
||||
Path path = invocation.getArgument(0);
|
||||
if (invocation.getMethod().getParameterCount() != 2
|
||||
|| !path.getFileName().toString().endsWith(".iswal")) {
|
||||
return source;
|
||||
}
|
||||
FileChannel intercepted = mock(FileChannel.class, delegatesTo(source));
|
||||
mutation.apply(path, source, intercepted);
|
||||
return intercepted;
|
||||
});
|
||||
}
|
||||
|
||||
private interface ChannelMutation {
|
||||
void apply(Path path, FileChannel source, FileChannel intercepted) throws IOException;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementMarker;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.volmlib.util.function.Consumer4;
|
||||
@@ -20,6 +21,7 @@ import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
@@ -106,6 +108,55 @@ public final class GenerationSemanticCaptureTest {
|
||||
verify(fixture.engine, never()).getCaveBiome(anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cavePositionsRemainDistinctAcrossColumnsAndSectionBoundaries() {
|
||||
Fixture fixture = new Fixture();
|
||||
List<Integer> heights = List.of(0, 15, 16, 255, 256, 767, 4063);
|
||||
Set<String> expected = new HashSet<>();
|
||||
List<ChunkGenerationSemantics.BlockPosition> resolved = new ArrayList<>();
|
||||
when(fixture.engine.getCaveBiome(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
int x = invocation.getArgument(0);
|
||||
int y = invocation.getArgument(1);
|
||||
int z = invocation.getArgument(2);
|
||||
resolved.add(new ChunkGenerationSemantics.BlockPosition(x, y, z));
|
||||
IrisBiome biome = new IrisBiome();
|
||||
biome.setLoadKey(x + ":" + y + ":" + z);
|
||||
return biome;
|
||||
});
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, MatterCavern> consumer = invocation.getArgument(3);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int y : heights) {
|
||||
consumer.accept(x, y, z, new MatterCavern(true, null, (byte) 0));
|
||||
expected.add((x - 32) + ":" + y + ":" + (z + 48));
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(MatterCavern.class), any());
|
||||
HydrologyCaveCell duplicate = mock(HydrologyCaveCell.class);
|
||||
when(duplicate.floodedBiomeKey()).thenReturn("iris:duplicate");
|
||||
when(duplicate.fluidProfileKey()).thenReturn("iris:river");
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, HydrologyCaveCell> consumer = invocation.getArgument(3);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int y : heights) {
|
||||
consumer.accept(x, y, z, duplicate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}).when(fixture.mantle).iterateChunk(eq(-2), eq(3), eq(HydrologyCaveCell.class), any());
|
||||
|
||||
ChunkGenerationSemantics captured = GenerationSemanticCapture.capture(fixture.engine, -2, 3, 5L);
|
||||
|
||||
assertEquals(expected, captured.caveBiomeKeys());
|
||||
assertEquals(expected.size(), resolved.size());
|
||||
assertEquals(Set.of("iris:river"), captured.riverProfileKeys());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void unsealedPoiQueryUsesMantleWithWorldHorizontalCoordinates() {
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assume.assumeTrue;
|
||||
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class GenerationSemanticJournalWriteTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void partialAppendRollsBackBeforeAConcurrentClaimCanPublish() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
index.claimAndPersist(claim(0, "first"));
|
||||
Path journal = index.storageDirectory().resolve("r.0.0.iswal");
|
||||
byte[] original = Files.readAllBytes(journal);
|
||||
IOException failure = new IOException("Partial journal write");
|
||||
CountDownLatch written = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Boolean> rejected = executor.submit(() -> {
|
||||
try (MockedStatic<FileChannel> ignored = journalChannels((source, intercepted) ->
|
||||
doAnswer(invocation -> {
|
||||
ByteBuffer bytes = invocation.getArgument(0);
|
||||
int limit = bytes.limit();
|
||||
bytes.limit(bytes.position() + 7);
|
||||
source.write(bytes);
|
||||
bytes.limit(limit);
|
||||
written.countDown();
|
||||
assertTrue(release.await(5L, TimeUnit.SECONDS));
|
||||
throw failure;
|
||||
}).when(intercepted).write(any(ByteBuffer.class)))) {
|
||||
return index.claimAndPersist(claim(1, "failed"));
|
||||
}
|
||||
});
|
||||
assertTrue(written.await(5L, TimeUnit.SECONDS));
|
||||
CountDownLatch attempted = new CountDownLatch(1);
|
||||
Future<Boolean> accepted = executor.submit(() -> {
|
||||
attempted.countDown();
|
||||
return index.claimAndPersist(claim(2, "second"));
|
||||
});
|
||||
assertTrue(attempted.await(5L, TimeUnit.SECONDS));
|
||||
release.countDown();
|
||||
assertSame(failure, assertThrows(ExecutionException.class,
|
||||
() -> rejected.get(5L, TimeUnit.SECONDS)).getCause());
|
||||
assertTrue(accepted.get(5L, TimeUnit.SECONDS));
|
||||
assertFalse(index.get(1, 0).isPresent());
|
||||
GenerationSemanticIndex replayed = GenerationSemanticIndex.loadRequired(root);
|
||||
assertEquals(2, replayed.recordCount());
|
||||
assertEquals(Optional.of(claim(0, "first")), replayed.get(0, 0));
|
||||
assertEquals(Optional.of(claim(2, "second")), replayed.get(2, 0));
|
||||
assertTrue(replayed.get(1, 0).isEmpty());
|
||||
assertArrayEquals(original, Arrays.copyOf(Files.readAllBytes(journal), original.length));
|
||||
} finally {
|
||||
release.countDown();
|
||||
executor.shutdownNow();
|
||||
assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedForceRestoresBytesAndLeavesTheClaimRetryable() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
index.claimAndPersist(claim(0, "first"));
|
||||
Path journal = index.storageDirectory().resolve("r.0.0.iswal");
|
||||
byte[] original = Files.readAllBytes(journal);
|
||||
IOException failure = new IOException("Journal force failed");
|
||||
AtomicInteger forces = new AtomicInteger();
|
||||
try (MockedStatic<FileChannel> ignored = journalChannels((source, intercepted) ->
|
||||
doAnswer(invocation -> {
|
||||
if (forces.incrementAndGet() == 1) {
|
||||
throw failure;
|
||||
}
|
||||
source.force(true);
|
||||
return null;
|
||||
}).when(intercepted).force(true))) {
|
||||
assertSame(failure, assertThrows(IOException.class, () -> index.claimAndPersist(claim(1, "second"))));
|
||||
}
|
||||
assertEquals(2, forces.get());
|
||||
assertArrayEquals(original, Files.readAllBytes(journal));
|
||||
assertTrue(index.get(1, 0).isEmpty());
|
||||
assertTrue(index.claimAndPersist(claim(1, "second")));
|
||||
assertEquals(Optional.of(claim(1, "second")), GenerationSemanticIndex.loadRequired(root).get(1, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rollbackFailurePreservesCausesAndRejectsFurtherMutation() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
index.claimAndPersist(claim(0, "first"));
|
||||
IOException failure = new IOException("Journal force failed");
|
||||
IOException rollback = new IOException("Rollback force failed");
|
||||
AtomicInteger forces = new AtomicInteger();
|
||||
IOException reported;
|
||||
try (MockedStatic<FileChannel> ignored = journalChannels((source, intercepted) ->
|
||||
doAnswer(invocation -> {
|
||||
throw forces.incrementAndGet() == 1 ? failure : rollback;
|
||||
}).when(intercepted).force(true))) {
|
||||
reported = assertThrows(IOException.class, () -> index.claimAndPersist(claim(1, "failed")));
|
||||
}
|
||||
assertSame(failure, reported.getCause());
|
||||
assertArrayEquals(new Throwable[]{rollback}, failure.getSuppressed());
|
||||
Path journal = index.storageDirectory().resolve("r.0.0.iswal");
|
||||
byte[] afterFailure = Files.readAllBytes(journal);
|
||||
assertThrows(IOException.class, () -> index.claimAndPersist(claim(2, "rejected")));
|
||||
assertThrows(IOException.class, () -> index.recordAndPersist(claim(2, "rejected")));
|
||||
assertThrows(IOException.class, index::compactJournals);
|
||||
assertThrows(IllegalStateException.class, () -> index.get(0, 0));
|
||||
assertThrows(IllegalStateException.class, () -> match(index, "first"));
|
||||
assertThrows(IllegalStateException.class, () -> index.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION, "iris:first",
|
||||
new ChunkGenerationSemantics.BlockPosition(1_000_000, 64, 1_000_000), 32)));
|
||||
assertArrayEquals(afterFailure, Files.readAllBytes(journal));
|
||||
assertEquals(1, index.recordCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeFailureDoesNotAllowFurtherWritesOverAnUnpublishedClaim() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
IOException failure = new IOException("Journal close failed");
|
||||
try (MockedStatic<FileChannel> ignored = journalChannels((source, intercepted) ->
|
||||
doAnswer(invocation -> {
|
||||
source.close();
|
||||
throw failure;
|
||||
}).when(intercepted).close())) {
|
||||
IOException reported = assertThrows(IOException.class, () -> index.claimAndPersist(claim(1, "second")));
|
||||
assertSame(failure, reported.getCause());
|
||||
}
|
||||
assertEquals(0, index.recordCount());
|
||||
assertThrows(IOException.class, () -> index.claimAndPersist(claim(2, "rejected")));
|
||||
assertThrows(IllegalStateException.class, index::recordsSnapshot);
|
||||
assertThrows(IOException.class, () -> index.forEachRecord(semantics -> {
|
||||
throw new AssertionError("Failed journal reached record consumer");
|
||||
}));
|
||||
assertThrows(IOException.class, () -> index.forEachSealedClaim(1L, (chunkX, chunkZ) -> {
|
||||
throw new AssertionError("Failed journal reached sealed claim consumer");
|
||||
}));
|
||||
GenerationSemanticIndex replayed = GenerationSemanticIndex.loadRequired(root);
|
||||
assertEquals(Optional.of(claim(1, "second")), replayed.get(1, 0));
|
||||
assertTrue(replayed.get(2, 0).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void newJournalDirectoryFailureRollsBackBeforeRetry() throws Exception {
|
||||
assumeTrue(File.separatorChar != '\\');
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
IOException failure = new IOException("Journal directory force failed");
|
||||
AtomicInteger directoryForces = new AtomicInteger();
|
||||
try (MockedStatic<FileChannel> ignored = mockStatic(FileChannel.class, invocation -> {
|
||||
FileChannel source = (FileChannel) invocation.callRealMethod();
|
||||
Path path = invocation.getArgument(0);
|
||||
if (invocation.getMethod().getParameterCount() != 2 || !path.equals(index.storageDirectory())) {
|
||||
return source;
|
||||
}
|
||||
FileChannel intercepted = mock(FileChannel.class, delegatesTo(source));
|
||||
doAnswer(force -> {
|
||||
if (directoryForces.incrementAndGet() == 1) {
|
||||
throw failure;
|
||||
}
|
||||
source.force(true);
|
||||
return null;
|
||||
}).when(intercepted).force(true);
|
||||
return intercepted;
|
||||
})) {
|
||||
assertSame(failure, assertThrows(IOException.class, () -> index.claimAndPersist(claim(0, "first"))));
|
||||
}
|
||||
assertEquals(2, directoryForces.get());
|
||||
assertEquals(0L, Files.size(index.storageDirectory().resolve("r.0.0.iswal")));
|
||||
assertTrue(index.get(0, 0).isEmpty());
|
||||
assertTrue(index.claimAndPersist(claim(0, "first")));
|
||||
assertEquals(Optional.of(claim(0, "first")), GenerationSemanticIndex.loadRequired(root).get(0, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queriedSummariesIncludeNewClaimsAfterMutationAndCompaction() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
index.claimAndPersist(claim(0, "first"));
|
||||
assertEquals(0, match(index, "first").chunk().chunkX());
|
||||
index.claimAndPersist(claim(1, "second"));
|
||||
assertEquals(1, match(index, "second").chunk().chunkX());
|
||||
assertEquals(0, match(index, "first").chunk().chunkX());
|
||||
index.compactJournals();
|
||||
GenerationSemanticIndex replayed = GenerationSemanticIndex.loadRequired(root);
|
||||
assertEquals(0, match(replayed, "first").chunk().chunkX());
|
||||
assertEquals(1, match(replayed, "second").chunk().chunkX());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queriedSummariesTrackRecordedUpdatesAndPrunedClaims() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
GenerationSemanticIndex index = GenerationSemanticIndex.initialize(root);
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).addRegion("iris:first").build());
|
||||
assertEquals(0, match(index, "first").chunk().chunkX());
|
||||
index.recordAndPersist(ChunkGenerationSemantics.builder(0, 0, 1L).addRegion("iris:updated").build());
|
||||
assertEquals(0, match(index, "updated").chunk().chunkX());
|
||||
index.claimAndPersist(claim(1, "second"));
|
||||
assertEquals(1, match(index, "second").chunk().chunkX());
|
||||
|
||||
WorldChunkInventory stored = WorldChunkInventory.ofPackedChunks(ChunkGenerationOwnership.packChunk(0, 0));
|
||||
assertEquals(1, index.discardUnstoredClaims(stored, Set.of(1L)));
|
||||
assertTrue(index.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION, "iris:second",
|
||||
new ChunkGenerationSemantics.BlockPosition(0, 64, 0), 32)).isEmpty());
|
||||
assertEquals(0, match(index, "first").chunk().chunkX());
|
||||
assertEquals(0, match(index, "updated").chunk().chunkX());
|
||||
GenerationSemanticIndex replayed = GenerationSemanticIndex.loadRequired(root);
|
||||
assertEquals(0, match(replayed, "updated").chunk().chunkX());
|
||||
assertTrue(replayed.get(1, 0).isEmpty());
|
||||
}
|
||||
|
||||
private static GenerationSemanticIndex.Match match(GenerationSemanticIndex index, String region) {
|
||||
return index.findNearest(GenerationSemanticIndex.Query.acrossActivations(
|
||||
GenerationSemanticIndex.SemanticKind.REGION, "iris:" + region,
|
||||
new ChunkGenerationSemantics.BlockPosition(0, 64, 0), 32)).orElseThrow();
|
||||
}
|
||||
|
||||
private static ChunkGenerationSemantics claim(int chunkX, String region) {
|
||||
return ChunkGenerationSemantics.builder(chunkX, 0, 1L).addRegion("iris:" + region).seal().build();
|
||||
}
|
||||
|
||||
private static MockedStatic<FileChannel> journalChannels(ChannelMutation mutation) {
|
||||
return mockStatic(FileChannel.class, invocation -> {
|
||||
FileChannel source = (FileChannel) invocation.callRealMethod();
|
||||
Path path = invocation.getArgument(0);
|
||||
if (invocation.getMethod().getParameterCount() != 2
|
||||
|| !path.getFileName().toString().endsWith(".iswal")) {
|
||||
return source;
|
||||
}
|
||||
FileChannel intercepted = mock(FileChannel.class, delegatesTo(source));
|
||||
mutation.apply(source, intercepted);
|
||||
return intercepted;
|
||||
});
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ChannelMutation {
|
||||
void apply(FileChannel source, FileChannel intercepted) throws IOException;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
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.Set;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public final class GenerationSemanticKeyValidationTest {
|
||||
@Test
|
||||
public void duplicateKeysRetainValidationAndCanonicalOrdering() {
|
||||
ChunkGenerationSemantics.Builder builder = ChunkGenerationSemantics.builder(-2, 3, 5L);
|
||||
List<String> keys = List.of("iris:deep", "Caves/Crystal Gallery", "Région/Highlands", "cave/\ud801\udc00");
|
||||
for (int repetition = 0; repetition < 8; repetition++) {
|
||||
for (String key : keys) {
|
||||
builder.addSurfaceBiome(new String(key)).addCaveBiome(new String(key))
|
||||
.addRegion(new String(key)).addRiverProfile(new String(key)).addObject(new String(key));
|
||||
}
|
||||
}
|
||||
for (String invalid : List.of("", " iris:deep", "iris:deep ", "iris:\\deep", "iris:\ndeep",
|
||||
"é".repeat(ChunkGenerationSemantics.MAX_KEY_BYTES))) {
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.addSurfaceBiome(invalid));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.addCaveBiome(invalid));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.addRegion(invalid));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.addRiverProfile(invalid));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.addObject(invalid));
|
||||
}
|
||||
assertEquals("key", assertThrows(NullPointerException.class, () -> builder.addCaveBiome(null)).getMessage());
|
||||
ChunkGenerationSemantics result = builder.seal().build();
|
||||
assertEquals(Set.copyOf(keys), result.surfaceBiomeKeys());
|
||||
assertEquals(Set.copyOf(keys), result.caveBiomeKeys());
|
||||
assertEquals(Set.copyOf(keys), result.regionKeys());
|
||||
assertEquals(Set.copyOf(keys), result.riverProfileKeys());
|
||||
assertEquals(Set.copyOf(keys), result.objectKeys());
|
||||
List<String> sorted = new ArrayList<>(keys);
|
||||
Collections.sort(sorted);
|
||||
for (Set<String> recorded : List.of(result.surfaceBiomeKeys(), result.caveBiomeKeys(), result.regionKeys(),
|
||||
result.riverProfileKeys(), result.objectKeys())) {
|
||||
assertEquals(sorted, List.copyOf(recorded));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fullKeySetAcceptsDuplicatesAndRejectsNewKeysWithoutMutation() {
|
||||
ChunkGenerationSemantics.Builder builder = ChunkGenerationSemantics.builder(0, 0, 5L);
|
||||
for (int index = 0; index < ChunkGenerationSemantics.MAX_KEYS_PER_KIND; index++) {
|
||||
builder.addCaveBiome("iris:cave_" + index);
|
||||
}
|
||||
builder.addCaveBiome(new String("iris:cave_0"));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.addCaveBiome("iris:overflow"));
|
||||
assertThrows(IllegalArgumentException.class, () -> builder.addCaveBiome(" iris:cave_0"));
|
||||
assertEquals(ChunkGenerationSemantics.MAX_KEYS_PER_KIND, builder.build().caveBiomeKeys().size());
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
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.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class GenerationSemanticPointReadTest {
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void readsRetainDurableShardAfterEvictionDuringUnforcedAppend() throws Exception {
|
||||
GenerationSemanticIndex index = index();
|
||||
for (int region = 0; region <= 70; region++) {
|
||||
index.claimAndPersist(claim(region * 32, 1));
|
||||
}
|
||||
ExecutorService reader = Executors.newSingleThreadExecutor();
|
||||
try (AppendGate gate = append(() -> index.claimAndPersist(claim(1, 1)), Failure.NONE)) {
|
||||
Future<?> inspected = reader.submit(() -> {
|
||||
assertSameRecord(claim(0, 1), index.get(0, 0));
|
||||
assertTrue(index.get(1, 0).isEmpty());
|
||||
assertTrue(index.hasSealedClaim(0, 0, 1));
|
||||
assertFalse(index.hasSealedClaim(1, 0, 1));
|
||||
for (int region = 1; region <= 70; region++) {
|
||||
assertSameRecord(claim(region * 32, 1), index.get(region * 32, 0));
|
||||
}
|
||||
assertSameRecord(claim(0, 1), index.get(0, 0));
|
||||
assertTrue(index.get(1, 0).isEmpty());
|
||||
assertTrue(index.get(-1, -1).isEmpty());
|
||||
});
|
||||
inspected.get(2, TimeUnit.SECONDS);
|
||||
gate.finish();
|
||||
assertNull(gate.failure.get());
|
||||
assertSameRecord(claim(1, 1), index.get(1, 0));
|
||||
assertTrue(index.cachedRegionCount() <= 64);
|
||||
assertEquals(72, index.recordCount());
|
||||
} finally {
|
||||
drain(reader);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void newRegionRemainsAbsentUntilItsFirstForceCompletes() throws Exception {
|
||||
GenerationSemanticIndex index = index();
|
||||
ExecutorService reader = Executors.newSingleThreadExecutor();
|
||||
try (AppendGate gate = append(() -> index.claimAndPersist(claim(-32, 1)), Failure.NONE)) {
|
||||
assertTrue(reader.submit(() -> index.get(-32, 0).isEmpty()).get(2, TimeUnit.SECONDS));
|
||||
gate.finish();
|
||||
assertNull(gate.failure.get());
|
||||
assertSameRecord(claim(-32, 1), index.get(-32, 0));
|
||||
} finally {
|
||||
drain(reader);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void confirmedRollbackKeepsPublishedReadsAndPermitsRetry() throws Exception {
|
||||
GenerationSemanticIndex index = index();
|
||||
index.claimAndPersist(claim(0, 1));
|
||||
try (AppendGate gate = append(() -> index.claimAndPersist(claim(1, 1)), Failure.ROLLBACK_CONFIRMED)) {
|
||||
gate.finish();
|
||||
assertSame(gate.originalFailure, gate.failure.get());
|
||||
assertSameRecord(claim(0, 1), index.get(0, 0));
|
||||
assertTrue(index.get(1, 0).isEmpty());
|
||||
assertTrue(index.claimAndPersist(claim(1, 1)));
|
||||
assertEquals(index.recordsSnapshot(),
|
||||
GenerationSemanticIndex.loadRequired(index.storageDirectory().getParent().getParent().getParent()).recordsSnapshot());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uncertainRollbackAndClosePoisonHitsAndMisses() throws Exception {
|
||||
for (Failure mode : new Failure[]{Failure.ROLLBACK_UNCERTAIN, Failure.CLOSE_UNCERTAIN}) {
|
||||
GenerationSemanticIndex index = index();
|
||||
index.claimAndPersist(claim(0, 1));
|
||||
try (AppendGate gate = append(() -> index.claimAndPersist(claim(1, 1)), mode)) {
|
||||
gate.finish();
|
||||
assertTrue(gate.failure.get() instanceof IOException);
|
||||
assertSame(gate.originalFailure, gate.failure.get().getCause());
|
||||
assertThrows(IllegalStateException.class, () -> index.get(0, 0));
|
||||
assertThrows(IllegalStateException.class, () -> index.get(1, 0));
|
||||
assertThrows(IllegalStateException.class, () -> index.get(999, 999));
|
||||
assertThrows(IllegalStateException.class, () -> index.hasSealedClaim(0, 0, 1));
|
||||
assertThrows(IOException.class, () -> index.claimAndPersist(claim(2, 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void maintenanceWaitsForAppendAndCallbacksRemainReentrant() throws Exception {
|
||||
GenerationSemanticIndex index = index();
|
||||
index.claimAndPersist(claim(0, 1));
|
||||
ExecutorService maintenance = Executors.newFixedThreadPool(2);
|
||||
Future<?> compact;
|
||||
Future<Integer> prune;
|
||||
try (AppendGate gate = append(() -> index.claimAndPersist(claim(1, 1)), Failure.NONE)) {
|
||||
CountDownLatch started = new CountDownLatch(2);
|
||||
compact = maintenance.submit(() -> {
|
||||
started.countDown();
|
||||
index.compactJournals();
|
||||
return null;
|
||||
});
|
||||
prune = maintenance.submit(() -> {
|
||||
started.countDown();
|
||||
return index.discardUnstoredClaims(WorldChunkInventory.ofPackedChunks(
|
||||
ChunkGenerationOwnership.packChunk(0, 0)), Set.of(1L));
|
||||
});
|
||||
assertTrue(started.await(2, TimeUnit.SECONDS));
|
||||
assertThrows(TimeoutException.class, () -> compact.get(100, TimeUnit.MILLISECONDS));
|
||||
assertThrows(TimeoutException.class, () -> prune.get(100, TimeUnit.MILLISECONDS));
|
||||
gate.finish();
|
||||
assertNull(gate.failure.get());
|
||||
compact.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(1, prune.get(5, TimeUnit.SECONDS).intValue());
|
||||
AtomicBoolean recorded = new AtomicBoolean();
|
||||
maintenance.submit(() -> {
|
||||
index.forEachRecord(record -> {
|
||||
assertEquals(record, index.get(record.chunkX(), record.chunkZ()).orElseThrow());
|
||||
if (recorded.compareAndSet(false, true)) {
|
||||
index.claimAndPersist(claim(2, 1));
|
||||
}
|
||||
});
|
||||
return null;
|
||||
}).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(2, index.recordCount());
|
||||
assertTrue(index.get(1, 0).isEmpty());
|
||||
assertSameRecord(claim(2, 1), index.get(2, 0));
|
||||
} finally {
|
||||
drain(maintenance);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void historyPointReadsProceedWhileClaimStillFencesPromotion() throws Exception {
|
||||
GenerationHistory history = history();
|
||||
Path replacement = pack("replacement", "beta");
|
||||
GenerationActivation pending = history.stageUpdate(replacement, fingerprint(replacement),
|
||||
history.activeEpoch().dimensionContract(), GenerationRegistryContract.empty(), 256);
|
||||
ExecutorService tasks = Executors.newFixedThreadPool(2);
|
||||
try (GenerationAdmission.RuntimeLease runtime = history.retainRuntime();
|
||||
GenerationHistory.GenerationStage stage = history.openStage(1, 0);
|
||||
AppendGate gate = append(() -> history.claimGeneratedSemantics(stage, claim(1, 1)), Failure.NONE)) {
|
||||
tasks.submit(() -> {
|
||||
assertTrue(history.semantics(1, 0).isEmpty());
|
||||
assertEquals(stage.activation(), history.resolveActivation(1, 0));
|
||||
assertEquals(stage.activation(), history.resolveActivation(-32, -32));
|
||||
}).get(2, TimeUnit.SECONDS);
|
||||
stage.close();
|
||||
CountDownLatch cutoverEntered = new CountDownLatch(1);
|
||||
Future<GenerationActivation> promoted = tasks.submit(() -> {
|
||||
try (GenerationHistory.LiveCutover cutover = history.beginLiveCutover()) {
|
||||
cutoverEntered.countDown();
|
||||
return cutover.promote(boundary -> (x, z) -> null);
|
||||
}
|
||||
});
|
||||
assertTrue(cutoverEntered.await(2, TimeUnit.SECONDS));
|
||||
assertThrows(TimeoutException.class, () -> promoted.get(100, TimeUnit.MILLISECONDS));
|
||||
assertTrue(tasks.submit(() -> history.semantics(1, 0).isEmpty()).get(2, TimeUnit.SECONDS));
|
||||
gate.finish();
|
||||
assertNull(gate.failure.get());
|
||||
assertEquals(pending.activationId(), promoted.get(5, TimeUnit.SECONDS).activationId());
|
||||
assertEquals(pending.activationId(), history.resolveActivation(1, 0).activationId());
|
||||
assertEquals(pending.activationId(), history.resolveActivation(-32, -32).activationId());
|
||||
assertTrue(history.semantics(1, 0).isEmpty());
|
||||
} finally {
|
||||
drain(tasks);
|
||||
}
|
||||
}
|
||||
|
||||
private GenerationSemanticIndex index() throws IOException {
|
||||
return GenerationSemanticIndex.initialize(temporaryFolder.newFolder().toPath());
|
||||
}
|
||||
|
||||
private GenerationHistory history() throws Exception {
|
||||
Path world = temporaryFolder.newFolder().toPath();
|
||||
Path pack = pack("initial", "alpha");
|
||||
GenerationEpoch.DimensionContract contract = new GenerationEpoch.DimensionContract(
|
||||
"overworld", "iris:overworld_type", "NORMAL", "OVERWORLD", 127, -64, 384, 384, 1D,
|
||||
false, "none", 0, "0".repeat(64),
|
||||
GenerationEpochContractFactory.CURRENT_DIMENSION_TYPE_FINGERPRINT_SCHEMA, "c".repeat(64));
|
||||
return GenerationHistory.create(world, pack, fingerprint(pack), 42L, contract, GenerationRegistryContract.empty());
|
||||
}
|
||||
|
||||
private Path pack(String name, String content) throws IOException {
|
||||
Path pack = temporaryFolder.newFolder(name).toPath();
|
||||
Files.createDirectories(pack.resolve("dimensions"));
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), content);
|
||||
return pack;
|
||||
}
|
||||
|
||||
private static String fingerprint(Path pack) throws IOException {
|
||||
return GenerationPackFingerprint.compute(pack, GenerationPackFingerprint.CURRENT_VERSION);
|
||||
}
|
||||
|
||||
private static ChunkGenerationSemantics claim(int chunkX, long activation) {
|
||||
return ChunkGenerationSemantics.builder(chunkX, 0, activation)
|
||||
.addSurfaceBiome("iris:forest").seal().build();
|
||||
}
|
||||
|
||||
private static void assertSameRecord(ChunkGenerationSemantics expected, Optional<ChunkGenerationSemantics> actual) {
|
||||
assertEquals(expected, actual.orElseThrow());
|
||||
}
|
||||
|
||||
private static AppendGate append(Callable<?> operation, Failure mode) throws InterruptedException {
|
||||
AppendGate gate = new AppendGate(operation, mode);
|
||||
gate.worker.start();
|
||||
assertTrue(gate.forcing.await(5, TimeUnit.SECONDS));
|
||||
return gate;
|
||||
}
|
||||
|
||||
private static void drain(ExecutorService executor) throws InterruptedException {
|
||||
executor.shutdown();
|
||||
assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
private enum Failure {
|
||||
NONE,
|
||||
ROLLBACK_CONFIRMED,
|
||||
ROLLBACK_UNCERTAIN,
|
||||
CLOSE_UNCERTAIN
|
||||
}
|
||||
|
||||
private static final class AppendGate implements AutoCloseable {
|
||||
private final CountDownLatch forcing = new CountDownLatch(1);
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
private final AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
private final IOException originalFailure = new IOException("Semantic journal failed");
|
||||
private final Thread worker;
|
||||
|
||||
private AppendGate(Callable<?> operation, Failure mode) {
|
||||
worker = new Thread(() -> {
|
||||
try (MockedStatic<FileChannel> ignored = mockStatic(FileChannel.class, invocation -> {
|
||||
FileChannel source = (FileChannel) invocation.callRealMethod();
|
||||
Path path = invocation.getArgument(0);
|
||||
if (invocation.getMethod().getParameterCount() != 2
|
||||
|| !path.getFileName().toString().endsWith(".iswal")) {
|
||||
return source;
|
||||
}
|
||||
FileChannel intercepted = mock(FileChannel.class, delegatesTo(source));
|
||||
AtomicInteger forces = new AtomicInteger();
|
||||
doAnswer(call -> {
|
||||
int count = forces.incrementAndGet();
|
||||
if (count == 1) {
|
||||
forcing.countDown();
|
||||
if (!release.await(10, TimeUnit.SECONDS)) {
|
||||
throw new IOException("Timed out waiting to resume semantic force");
|
||||
}
|
||||
if (mode == Failure.ROLLBACK_CONFIRMED || mode == Failure.ROLLBACK_UNCERTAIN) {
|
||||
throw originalFailure;
|
||||
}
|
||||
}
|
||||
if (count == 2 && mode == Failure.ROLLBACK_UNCERTAIN) {
|
||||
throw new IOException("Semantic rollback failed");
|
||||
}
|
||||
source.force(true);
|
||||
return null;
|
||||
}).when(intercepted).force(true);
|
||||
if (mode == Failure.CLOSE_UNCERTAIN) {
|
||||
doAnswer(call -> {
|
||||
source.close();
|
||||
throw originalFailure;
|
||||
}).when(intercepted).close();
|
||||
}
|
||||
return intercepted;
|
||||
})) {
|
||||
operation.call();
|
||||
} catch (Throwable caught) {
|
||||
failure.set(caught);
|
||||
}
|
||||
}, "semantic-point-read-test");
|
||||
}
|
||||
|
||||
private void finish() throws InterruptedException {
|
||||
release.countDown();
|
||||
worker.join(5_000L);
|
||||
assertFalse("Semantic append did not finish", worker.isAlive());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws InterruptedException {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,23 @@ package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UTFDataFormatException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class NativeTerrainReceiptTest {
|
||||
@Test
|
||||
@@ -19,6 +27,7 @@ public class NativeTerrainReceiptTest {
|
||||
SavedTerrainChunk chunk = chunk(boundary);
|
||||
String epoch = "epoch-\u0000-é-水-\ud83c\udf32";
|
||||
byte[] encoded = NativeTerrainReceipt.encode(chunk, 7, epoch);
|
||||
assertArrayEquals(legacyEncode(chunk, 7, epoch), encoded);
|
||||
byte[] payload;
|
||||
try (GZIPInputStream input = new GZIPInputStream(new ByteArrayInputStream(encoded))) {
|
||||
payload = input.readAllBytes();
|
||||
@@ -51,6 +60,114 @@ public class NativeTerrainReceiptTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saturatedStringCachesPreserveModifiedUtfBytes() throws Exception {
|
||||
for (int padding : new int[]{0, 300}) {
|
||||
List<String> keys = new ArrayList<>(300);
|
||||
for (int index = 0; index < 300; index++) {
|
||||
keys.add("iris:" + index + "-é-水-\u0000-\ud83c\udf32-\ud800-\udc00-" + "x".repeat(padding));
|
||||
}
|
||||
for (int index = 0; index < 300; index++) {
|
||||
keys.add(keys.getFirst());
|
||||
}
|
||||
SavedTerrainChunk chunk = biomeChunk(keys);
|
||||
byte[] encoded = NativeTerrainReceipt.encode(chunk, 9, "epoch");
|
||||
assertArrayEquals(legacyEncode(chunk, 9, "epoch"), encoded);
|
||||
TerrainBoundarySignature restored = NativeTerrainReceipt.decode(encoded, "minecraft:noise")
|
||||
.terrain().column(0, 0);
|
||||
for (int index = 0; index < keys.size() * 2; index++) {
|
||||
assertEquals(keys.get(index % keys.size()), restored.biomeAtSample(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void maximumModifiedUtfLengthAndOversizedFailuresRemainUnchanged() throws Exception {
|
||||
for (String key : List.of("a".repeat(65_535), "é".repeat(32_767) + "a")) {
|
||||
SavedTerrainChunk chunk = biomeChunk(List.of(key));
|
||||
byte[] encoded = NativeTerrainReceipt.encode(chunk, 9, "epoch");
|
||||
assertArrayEquals(legacyEncode(chunk, 9, "epoch"), encoded);
|
||||
assertEquals(key, NativeTerrainReceipt.decode(encoded, "minecraft:noise")
|
||||
.terrain().column(0, 0).biomeAtSample(0));
|
||||
}
|
||||
for (String key : List.of("a".repeat(65_536), "é".repeat(32_768), "\u0000".repeat(32_768))) {
|
||||
SavedTerrainChunk chunk = biomeChunk(List.of(key));
|
||||
UTFDataFormatException expected = assertThrows(UTFDataFormatException.class,
|
||||
() -> legacyEncode(chunk, 9, "epoch"));
|
||||
UTFDataFormatException actual = assertThrows(UTFDataFormatException.class,
|
||||
() -> NativeTerrainReceipt.encode(chunk, 9, "epoch"));
|
||||
assertEquals(expected.getMessage(), actual.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static SavedTerrainChunk biomeChunk(List<String> keys) {
|
||||
List<TerrainBoundarySignature> columns = new ArrayList<>(256);
|
||||
short[] samples = new short[keys.size() * 2];
|
||||
for (int index = 0; index < samples.length; index++) {
|
||||
samples[index] = (short) (index % keys.size());
|
||||
}
|
||||
BoundaryColumnGeometry geometry = new BoundaryColumnGeometry(0,
|
||||
List.of(new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false)),
|
||||
new int[]{samples.length * 4}, new short[]{0});
|
||||
columns.add(new TerrainBoundarySignature(new TerrainBoundarySignature.Column(0, 0, 1, 1,
|
||||
OptionalInt.empty(), OptionalInt.empty()),
|
||||
new TerrainBoundarySignature.Samples(new TerrainBoundarySignature.VerticalLayout(0, 4, samples.length),
|
||||
new TerrainBoundarySignature.BiomeEncoding(keys, samples)), geometry));
|
||||
for (int index = 1; index < 256; index++) {
|
||||
columns.add(null);
|
||||
}
|
||||
return new SavedTerrainChunk(0, 0, "minecraft:noise", columns);
|
||||
}
|
||||
|
||||
private static byte[] legacyEncode(SavedTerrainChunk chunk, long activation, String epoch) throws IOException {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
try (DataOutputStream output = new DataOutputStream(new BufferedOutputStream(new GZIPOutputStream(bytes)))) {
|
||||
output.writeInt(0x4952544E);
|
||||
output.writeInt(1);
|
||||
output.writeInt(chunk.chunkX());
|
||||
output.writeInt(chunk.chunkZ());
|
||||
output.writeLong(activation);
|
||||
output.writeUTF(epoch);
|
||||
for (int x = 0; x < 16; x++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
output.writeBoolean(chunk.hasColumn(x, z));
|
||||
if (!chunk.hasColumn(x, z)) {
|
||||
continue;
|
||||
}
|
||||
TerrainBoundarySignature signature = chunk.column((chunk.chunkX() << 4) + x, (chunk.chunkZ() << 4) + z);
|
||||
output.writeInt(signature.surfaceHeight());
|
||||
output.writeInt(signature.oceanFloorHeight());
|
||||
output.writeInt(signature.fluidHeight().orElse(-1));
|
||||
output.writeInt(signature.upperCeilingDepth().orElse(-1));
|
||||
TerrainBoundarySignature.VerticalLayout layout = signature.samples().layout();
|
||||
output.writeInt(layout.minimumY());
|
||||
output.writeInt(layout.sampleStep());
|
||||
output.writeInt(layout.sampleCount());
|
||||
for (int index = 0; index < layout.sampleCount(); index++) {
|
||||
output.writeUTF(signature.biomeAtSample(index));
|
||||
}
|
||||
BoundaryColumnGeometry geometry = signature.geometry();
|
||||
output.writeInt(geometry.minimumY());
|
||||
output.writeInt(geometry.palette().size());
|
||||
for (BoundaryColumnGeometry.Voxel voxel : geometry.palette()) {
|
||||
output.writeUTF(voxel.stateKey());
|
||||
output.writeByte(voxel.phase().ordinal());
|
||||
output.writeUTF(voxel.fluidStateKey());
|
||||
output.writeBoolean(voxel.protectedContent());
|
||||
}
|
||||
int[] ends = geometry.runEnds();
|
||||
short[] indices = geometry.paletteIndices();
|
||||
output.writeInt(ends.length);
|
||||
for (int index = 0; index < ends.length; index++) {
|
||||
output.writeInt(ends[index]);
|
||||
output.writeShort(indices[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return bytes.toByteArray();
|
||||
}
|
||||
|
||||
private static SavedTerrainChunk chunk(boolean boundary) {
|
||||
List<TerrainBoundarySignature> columns = new ArrayList<>(256);
|
||||
List<BoundaryColumnGeometry.Voxel> palette = List.of(
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package art.arcane.iris.engine.history;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedConstruction;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockConstruction;
|
||||
import static org.mockito.Mockito.withSettings;
|
||||
|
||||
public class SavedBiomeStoreBatchTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void queuedClaimsShareOneForceAndPublishAfterItCompletes() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
store.claimAndPersist(chunk(0, 3L));
|
||||
BatchControl control = new BatchControl();
|
||||
ExecutorService workers = Executors.newFixedThreadPool(8);
|
||||
try {
|
||||
List<Future<ClaimResult>> results = enqueue(store, root, control, workers,
|
||||
List.of(chunk(1, 3L), chunk(2, 3L), chunk(3, 3L), chunk(4, 3L),
|
||||
chunk(5, 3L), chunk(6, 3L), chunk(7, 3L), chunk(8, 3L)));
|
||||
assertTrue(control.forcing.await(5, TimeUnit.SECONDS));
|
||||
for (int index = 1; index <= 8; index++) {
|
||||
assertTrue(store.cached(index, 0).isEmpty());
|
||||
assertFalse(results.get(index - 1).isDone());
|
||||
}
|
||||
control.release.countDown();
|
||||
for (Future<ClaimResult> result : results) {
|
||||
ClaimResult claim = result.get(5, TimeUnit.SECONDS);
|
||||
assertNull(claim.failure);
|
||||
assertTrue(claim.persisted);
|
||||
}
|
||||
assertEquals(1, control.forces.get());
|
||||
SavedBiomeStore reopened = SavedBiomeStore.open(root);
|
||||
for (int index = 1; index <= 8; index++) {
|
||||
assertEquals(chunk(index, 3L), reopened.get(index, 0).orElseThrow());
|
||||
}
|
||||
} finally {
|
||||
control.release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void batchForceFailureRollsBackEveryNewClaimButPreservesExistingDuplicates() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
store.claimAndPersist(chunk(0, 3L));
|
||||
Path region = root.resolve("iris/generation/biomes/r.0.0.ibio");
|
||||
byte[] original = Files.readAllBytes(region);
|
||||
BatchControl control = new BatchControl();
|
||||
control.failure = new IOException("Batch force failed");
|
||||
ExecutorService workers = Executors.newFixedThreadPool(4);
|
||||
try {
|
||||
List<Future<ClaimResult>> results = enqueue(store, root, control, workers,
|
||||
List.of(chunk(0, 3L), chunk(1, 3L), chunk(1, 3L), chunk(2, 3L)));
|
||||
assertTrue(control.forcing.await(5, TimeUnit.SECONDS));
|
||||
control.release.countDown();
|
||||
ClaimResult existing = results.getFirst().get(5, TimeUnit.SECONDS);
|
||||
assertFalse(existing.persisted);
|
||||
assertNull(existing.failure);
|
||||
for (int index = 1; index < results.size(); index++) {
|
||||
ClaimResult result = results.get(index).get(5, TimeUnit.SECONDS);
|
||||
assertFalse(result.persisted);
|
||||
assertSame(control.failure, result.failure);
|
||||
}
|
||||
assertEquals(2, control.forces.get());
|
||||
assertArrayEquals(original, Files.readAllBytes(region));
|
||||
assertTrue(store.cached(1, 0).isEmpty());
|
||||
assertTrue(store.claimAndPersist(chunk(1, 3L)));
|
||||
assertTrue(SavedBiomeStore.open(root).get(2, 0).isEmpty());
|
||||
} finally {
|
||||
control.release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedDuplicateAndConflictingClaimsHaveOneWinner() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
store.claimAndPersist(chunk(0, 3L));
|
||||
BatchControl control = new BatchControl();
|
||||
ExecutorService workers = Executors.newFixedThreadPool(3);
|
||||
try {
|
||||
List<Future<ClaimResult>> results = enqueue(store, root, control, workers,
|
||||
List.of(chunk(1, 3L), chunk(1, 3L), chunk(1, 4L)));
|
||||
assertTrue(control.forcing.await(5, TimeUnit.SECONDS));
|
||||
control.release.countDown();
|
||||
int winners = 0;
|
||||
SavedBiomeChunk actual = null;
|
||||
for (int index = 0; index < results.size(); index++) {
|
||||
ClaimResult result = results.get(index).get(5, TimeUnit.SECONDS);
|
||||
if (result.persisted) {
|
||||
winners++;
|
||||
actual = chunk(1, index == 2 ? 4L : 3L);
|
||||
}
|
||||
}
|
||||
assertEquals(1, winners);
|
||||
assertEquals(1, control.forces.get());
|
||||
assertEquals(actual, SavedBiomeStore.open(root).get(1, 0).orElseThrow());
|
||||
for (int index = 0; index < results.size(); index++) {
|
||||
ClaimResult result = results.get(index).get(5, TimeUnit.SECONDS);
|
||||
assertEquals(index == 2 ? actual.activationId() != 4L : actual.activationId() != 3L,
|
||||
result.failure != null);
|
||||
}
|
||||
} finally {
|
||||
control.release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uncheckedBatchFailureSettlesClaimsAndRejectsEvictedRetries() throws Exception {
|
||||
Path root = temporaryFolder.newFolder().toPath();
|
||||
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||
store.claimAndPersist(chunk(0, 3L));
|
||||
BatchControl control = new BatchControl();
|
||||
control.uncheckedFailure = new IllegalStateException("Batch force failed unexpectedly");
|
||||
ExecutorService workers = Executors.newFixedThreadPool(4);
|
||||
try {
|
||||
List<Future<ClaimResult>> results = enqueue(store, root, control, workers,
|
||||
List.of(chunk(1, 3L), chunk(2, 3L), chunk(3, 3L), chunk(4, 3L)));
|
||||
assertTrue(control.forcing.await(5, TimeUnit.SECONDS));
|
||||
for (int region = 1; region <= 132; region++) {
|
||||
if ((region & 63) != 0) {
|
||||
assertTrue(store.get(region << 5, 0).isEmpty());
|
||||
}
|
||||
}
|
||||
control.release.countDown();
|
||||
for (Future<ClaimResult> future : results) {
|
||||
ClaimResult result = future.get(5, TimeUnit.SECONDS);
|
||||
assertFalse(result.persisted);
|
||||
assertSame(control.uncheckedFailure, rootCause(result.failure));
|
||||
}
|
||||
assertEquals(1, control.forces.get());
|
||||
IOException retry = assertThrows(IOException.class, () -> store.claimAndPersist(chunk(1, 3L)));
|
||||
assertSame(control.uncheckedFailure, rootCause(retry));
|
||||
IOException read = assertThrows(IOException.class, () -> store.get(1, 0));
|
||||
assertSame(control.uncheckedFailure, rootCause(read));
|
||||
} finally {
|
||||
control.release.countDown();
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable rootCause(Throwable failure) {
|
||||
if (failure == null) {
|
||||
return null;
|
||||
}
|
||||
Throwable cause = failure;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause;
|
||||
}
|
||||
|
||||
private static List<Future<ClaimResult>> enqueue(SavedBiomeStore store, Path root, BatchControl control,
|
||||
ExecutorService workers, List<SavedBiomeChunk> chunks) throws Exception {
|
||||
Field locksField = SavedBiomeStore.class.getDeclaredField("regionLocks");
|
||||
locksField.setAccessible(true);
|
||||
Object stripe = ((Object[]) locksField.get(store))[0];
|
||||
Field writingField = stripe.getClass().getDeclaredField("writing");
|
||||
Field pendingField = stripe.getClass().getDeclaredField("pending");
|
||||
writingField.setAccessible(true);
|
||||
pendingField.setAccessible(true);
|
||||
Queue<?> pending = (Queue<?>) pendingField.get(stripe);
|
||||
ArrayList<Future<ClaimResult>> results = new ArrayList<>(chunks.size());
|
||||
synchronized (stripe) {
|
||||
writingField.setBoolean(stripe, true);
|
||||
try {
|
||||
for (SavedBiomeChunk chunk : chunks) {
|
||||
results.add(workers.submit(() -> claim(store, root, control, chunk)));
|
||||
}
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (pending.size() != chunks.size() && System.nanoTime() < deadline) {
|
||||
Thread.sleep(1L);
|
||||
}
|
||||
assertEquals(chunks.size(), pending.size());
|
||||
} finally {
|
||||
writingField.setBoolean(stripe, false);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private static ClaimResult claim(SavedBiomeStore store, Path root, BatchControl control,
|
||||
SavedBiomeChunk chunk) throws Exception {
|
||||
try (RandomAccessFile actual = new RandomAccessFile(
|
||||
root.resolve("iris/generation/biomes/r.0.0.ibio").toFile(), "rw")) {
|
||||
FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel()));
|
||||
doAnswer(invocation -> {
|
||||
int force = control.forces.incrementAndGet();
|
||||
if (force == 1) {
|
||||
control.forcing.countDown();
|
||||
assertTrue(control.release.await(5, TimeUnit.SECONDS));
|
||||
if (control.failure != null) {
|
||||
throw control.failure;
|
||||
}
|
||||
if (control.uncheckedFailure != null) {
|
||||
throw control.uncheckedFailure;
|
||||
}
|
||||
}
|
||||
actual.getChannel().force(true);
|
||||
return null;
|
||||
}).when(channel).force(true);
|
||||
try (MockedConstruction<RandomAccessFile> ignored = mockConstruction(RandomAccessFile.class,
|
||||
withSettings().defaultAnswer(delegatesTo(actual)),
|
||||
(file, context) -> doReturn(channel).when(file).getChannel())) {
|
||||
try {
|
||||
return new ClaimResult(store.claimAndPersist(chunk), null);
|
||||
} catch (IOException | RuntimeException failure) {
|
||||
return new ClaimResult(false, failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static SavedBiomeChunk chunk(int x, long activation) {
|
||||
SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(activation, "biome", "region");
|
||||
SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell,
|
||||
List.of(new SavedBiomeChunk.Span(-64, 320, cell)));
|
||||
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(x, 0, activation, -64, 384));
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int localX = 0; localX < 16; localX++) {
|
||||
builder.column(localX, z, column);
|
||||
}
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
private static final class BatchControl {
|
||||
private final CountDownLatch forcing = new CountDownLatch(1);
|
||||
private final CountDownLatch release = new CountDownLatch(1);
|
||||
private final AtomicInteger forces = new AtomicInteger();
|
||||
private IOException failure;
|
||||
private RuntimeException uncheckedFailure;
|
||||
}
|
||||
|
||||
private record ClaimResult(boolean persisted, Throwable failure) {
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,13 @@ public class SavedBiomeStoreWriteTest {
|
||||
assertArrayEquals(original, Files.readAllBytes(regionPath(root)));
|
||||
assertTrue(store.get(1, 0).isEmpty());
|
||||
assertTrue(SavedBiomeStore.open(root).get(1, 0).isEmpty());
|
||||
assertTrue(store.claimAndPersist(chunk(1, 3L)));
|
||||
if (failRollback) {
|
||||
assertSame(failure, assertThrows(IOException.class,
|
||||
() -> store.claimAndPersist(chunk(1, 3L))).getCause());
|
||||
assertTrue(SavedBiomeStore.open(root).claimAndPersist(chunk(1, 3L)));
|
||||
} else {
|
||||
assertTrue(store.claimAndPersist(chunk(1, 3L)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package art.arcane.iris.engine.hydrology.runtime;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.compat.CompatStatus;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyTerrainSample;
|
||||
import art.arcane.iris.engine.hydrology.policy.RiverPolicyResolver;
|
||||
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.IrisRiverPolicy;
|
||||
import art.arcane.iris.engine.object.IrisRiverProfile;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public class IrisHydrologyPolicyCacheTest {
|
||||
private static IrisSettings previousSettings;
|
||||
|
||||
@BeforeClass
|
||||
public static void useDefaultSettings() {
|
||||
previousSettings = IrisSettings.settings;
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void restoreSettings() {
|
||||
IrisSettings.settings = previousSettings;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedOwnersReuseFilteredPolicyAndProfilesAcrossCoordinates() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river"))
|
||||
.setProfiles(new KList<>("water", "unknown", "water", "lava")));
|
||||
fixture.dimension.getHydrology().getRivers().setProfiles(new KList<>(
|
||||
new IrisRiverProfile().setId("water"), new IrisRiverProfile().setId("lava")));
|
||||
try (IrisHydrologyRuntime runtime = fixture.runtime()) {
|
||||
for (int index = 0; index < 128; index++) {
|
||||
HydrologyTerrainSample sample = sample(runtime, index * 17, -index * 13);
|
||||
assertEquals("river", sample.surfaceBiomeKey());
|
||||
assertEquals(List.of("lava", "water"), sample.preferredProfileKeys());
|
||||
}
|
||||
verify(fixture.loader, times(1)).load("river");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownerIdentityAndExplicitEmptyOverridesRemainDistinct() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river"))
|
||||
.setWidthMultiplier(2D));
|
||||
IrisRegion first = new IdentityRegion();
|
||||
first.setLoadKey("same-region");
|
||||
first.setRiverPolicy(new IrisRiverPolicy().setWidthMultiplier(3D));
|
||||
IrisBiome empty = new IdentityBiome();
|
||||
empty.setLoadKey("same-biome");
|
||||
empty.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>()));
|
||||
fixture.region.set(first);
|
||||
fixture.biome.set(empty);
|
||||
try (IrisHydrologyRuntime runtime = fixture.runtime()) {
|
||||
assertEquals("same-biome", sample(runtime, 0, 0).surfaceBiomeKey());
|
||||
assertEquals(3D, sample(runtime, 1, 0).widthMultiplier(), 0D);
|
||||
IrisRegion second = new IdentityRegion();
|
||||
second.setLoadKey("same-region");
|
||||
second.setRiverPolicy(new IrisRiverPolicy().setWidthMultiplier(4D));
|
||||
fixture.region.set(second);
|
||||
IrisBiome inherited = new IdentityBiome();
|
||||
inherited.setLoadKey("same-biome");
|
||||
fixture.biome.set(inherited);
|
||||
assertEquals("river", sample(runtime, 2, 0).surfaceBiomeKey());
|
||||
assertEquals(4D, sample(runtime, 3, 0).widthMultiplier(), 0D);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void selectedLoaderReplacementAndRuntimeReloadCannotReuseOldFiltering() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river")));
|
||||
IrisData regionData = mock(IrisData.class);
|
||||
IrisData biomeData = mock(IrisData.class);
|
||||
ResourceLoader<IrisBiome> excludedLoader = loader(true);
|
||||
ResourceLoader<IrisBiome> replacementLoader = loader(false);
|
||||
when(regionData.getBiomeLoader()).thenReturn(excludedLoader);
|
||||
when(biomeData.getBiomeLoader()).thenReturn(excludedLoader);
|
||||
fixture.region.get().setLoader(regionData);
|
||||
fixture.biome.get().setLoader(biomeData);
|
||||
try (IrisHydrologyRuntime runtime = fixture.runtime()) {
|
||||
assertEquals("parent", sample(runtime, 0, 0).surfaceBiomeKey());
|
||||
when(biomeData.getBiomeLoader()).thenReturn(replacementLoader);
|
||||
assertEquals("river", sample(runtime, 1, 0).surfaceBiomeKey());
|
||||
fixture.biome.get().setLoader(null);
|
||||
assertEquals("parent", sample(runtime, 2, 0).surfaceBiomeKey());
|
||||
verify(excludedLoader, times(2)).load("river");
|
||||
fixture.region.get().setLoader(null);
|
||||
assertEquals("river", sample(runtime, 3, 0).surfaceBiomeKey());
|
||||
}
|
||||
fixture.dimension.getRiverPolicy().setWidthMultiplier(7D);
|
||||
try (IrisHydrologyRuntime replacement = fixture.runtime()) {
|
||||
assertEquals(7D, sample(replacement, 0, 0).widthMultiplier(), 0D);
|
||||
}
|
||||
fixture.dimension.getRiverPolicy().setWidthMultiplier(8D);
|
||||
assertEquals(8D, RiverPolicyResolver.resolve(fixture.dimension, fixture.region.get(), fixture.biome.get())
|
||||
.widthMultiplier(), 0D);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unresolvedReferencesAreRecheckedInsteadOfFreezingAFirstLoadFailure() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river")));
|
||||
when(fixture.loader.load("river")).thenReturn(null);
|
||||
when(fixture.loader.isLoaded("river")).thenReturn(false);
|
||||
try (IrisHydrologyRuntime runtime = fixture.runtime()) {
|
||||
assertEquals("river", sample(runtime, 0, 0).surfaceBiomeKey());
|
||||
IrisBiome excluded = new IrisBiome();
|
||||
excluded.setCompat(CompatStatus.excludedBy(List.of()));
|
||||
when(fixture.loader.load("river")).thenReturn(excluded);
|
||||
when(fixture.loader.isLoaded("river")).thenReturn(true);
|
||||
assertEquals("parent", sample(runtime, 1, 0).surfaceBiomeKey());
|
||||
assertEquals("parent", sample(runtime, 2, 0).surfaceBiomeKey());
|
||||
verify(fixture.loader, times(2)).load("river");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aConcurrentLoadAfterTheResolutionMissCannotMakeThatResultCacheable() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.dimension.setRiverPolicy(new IrisRiverPolicy().setSurfaceBiomes(new KList<>("river")));
|
||||
IrisBiome excluded = new IrisBiome();
|
||||
excluded.setCompat(CompatStatus.excludedBy(List.of()));
|
||||
when(fixture.loader.load("river")).thenReturn(null, excluded);
|
||||
when(fixture.loader.isLoaded("river")).thenReturn(true);
|
||||
try (IrisHydrologyRuntime runtime = fixture.runtime()) {
|
||||
assertEquals("river", sample(runtime, 0, 0).surfaceBiomeKey());
|
||||
assertEquals("parent", sample(runtime, 1, 0).surfaceBiomeKey());
|
||||
assertEquals("parent", sample(runtime, 2, 0).surfaceBiomeKey());
|
||||
verify(fixture.loader, times(2)).load("river");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void policyEntriesRemainBoundedAndCloseClearsThem() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
IrisHydrologyRuntime runtime = fixture.runtime();
|
||||
Cache<?, ?> policies = policies(runtime);
|
||||
try {
|
||||
for (int index = 0; index < 2048; index++) {
|
||||
IrisBiome biome = new IdentityBiome();
|
||||
biome.setLoadKey("parent-" + index);
|
||||
fixture.biome.set(biome);
|
||||
assertEquals("parent-" + index, sample(runtime, index, 0).parentBiomeKey());
|
||||
}
|
||||
policies.cleanUp();
|
||||
assertTrue(policies.estimatedSize() <= 1024);
|
||||
assertTrue(policies.estimatedSize() > 0);
|
||||
} finally {
|
||||
runtime.close();
|
||||
}
|
||||
assertEquals(0L, policies.estimatedSize());
|
||||
}
|
||||
|
||||
private static HydrologyTerrainSample sample(IrisHydrologyRuntime runtime, int x, int z) throws Exception {
|
||||
Method method = IrisHydrologyRuntime.class.getDeclaredMethod("createTerrainBasis", int.class, int.class, double.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return ((IrisHydrologyRoutingTerrainSampler.TerrainBasis) method.invoke(runtime, x, z, 180D)).terrain();
|
||||
} catch (InvocationTargetException failure) {
|
||||
if (failure.getCause() instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure.getCause() instanceof Exception exception) {
|
||||
throw exception;
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static Cache<?, ?> policies(IrisHydrologyRuntime runtime) throws ReflectiveOperationException {
|
||||
Field field = IrisHydrologyRuntime.class.getDeclaredField("resolvedPolicies");
|
||||
field.setAccessible(true);
|
||||
return (Cache<?, ?>) field.get(runtime);
|
||||
}
|
||||
|
||||
private static ResourceLoader<IrisBiome> loader(boolean excluded) {
|
||||
ResourceLoader<IrisBiome> loader = mock(ResourceLoader.class);
|
||||
IrisBiome river = new IrisBiome();
|
||||
river.setLoadKey("river");
|
||||
river.setCompat(excluded ? CompatStatus.excludedBy(List.of()) : CompatStatus.OK);
|
||||
when(loader.load("river")).thenReturn(river);
|
||||
when(loader.isLoaded("river")).thenReturn(true);
|
||||
when(loader.getPossibleKeys()).thenReturn(new String[0]);
|
||||
when(loader.loadAll(any(String[].class))).thenReturn(new KList<>());
|
||||
return loader;
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final IrisDimension dimension = new IrisDimension().setRegions(new KList<>());
|
||||
private final IrisData data = mock(IrisData.class);
|
||||
private final ResourceLoader<IrisBiome> loader = loader(false);
|
||||
private final AtomicReference<IrisRegion> region = new AtomicReference<>(new IrisRegion());
|
||||
private final AtomicReference<IrisBiome> biome = new AtomicReference<>(new IrisBiome());
|
||||
|
||||
private Fixture() {
|
||||
dimension.setLoadKey("dimension");
|
||||
dimension.setLoader(data);
|
||||
region.get().setLoadKey("region");
|
||||
biome.get().setLoadKey("parent");
|
||||
when(data.getBiomeLoader()).thenReturn(loader);
|
||||
}
|
||||
|
||||
private IrisHydrologyRuntime runtime() {
|
||||
return new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(17L, 768, dimension, data,
|
||||
(x, z, height) -> new IrisHydrologyNaturalSample(height, false, biome.get(), region.get()),
|
||||
(x, z) -> 180D, (x, z) -> "constant", (x, z) -> false, footprint -> null, () -> false));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class IdentityRegion extends IrisRegion {
|
||||
@Override
|
||||
public boolean equals(Object compared) {
|
||||
throw new AssertionError("Policy cache must use region identity");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
throw new AssertionError("Policy cache must use region identity");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class IdentityBiome extends IrisBiome {
|
||||
@Override
|
||||
public boolean equals(Object compared) {
|
||||
throw new AssertionError("Policy cache must use biome identity");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
throw new AssertionError("Policy cache must use biome identity");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package art.arcane.iris.engine.mantle;
|
||||
|
||||
import art.arcane.iris.engine.IrisEngineMantle;
|
||||
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Answers.CALLS_REAL_METHODS;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class EngineMantleCoverageTest {
|
||||
@Test
|
||||
public void zeroRadiusCleansOnlyItsRealChunkOnce() {
|
||||
CoverageFixture fixture = fixture(0);
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
assertTrue(actual.isEmpty());
|
||||
fixture.real().add(new Chunk(0, 0));
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
|
||||
assertEquals(List.of(new Chunk(0, 0)), actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realRadiusDefinesTheLargerCoverageHalo() {
|
||||
CoverageFixture fixture = fixture(0);
|
||||
doReturn(2).when(fixture.engine()).getRealRadius();
|
||||
fill(fixture, 2);
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, false,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
|
||||
assertEquals(List.of(new Chunk(0, 0)), actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanupMatchesFullScanAcrossCoverageShapes() {
|
||||
for (int radius : new int[]{0, 1, 2, 4}) {
|
||||
for (int shape = 0; shape < 4; shape++) {
|
||||
CoverageFixture fixture = fixture(radius);
|
||||
for (int x = -2 * radius; x <= 2 * radius; x++) {
|
||||
for (int z = -2 * radius; z <= 2 * radius; z++) {
|
||||
if (shape == 0 || shape == 1 && x <= 0 || shape == 2 && z <= x) {
|
||||
fixture.real().add(new Chunk(x - 7, z - 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
fixture.cleaned().add(new Chunk(-7, -4));
|
||||
List<Chunk> expected = fullScan(fixture, -7, -4, radius);
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(-7, -4, false,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
|
||||
assertEquals("radius=" + radius + ", shape=" + shape, expected, actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanupRechecksMissingNeighborThatFinishesGeneration() {
|
||||
CoverageFixture fixture = fixture(1);
|
||||
fill(fixture, 2);
|
||||
Chunk neighbor = new Chunk(-2, 0);
|
||||
fixture.real().remove(neighbor);
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
when(fixture.mantle().hasFlag(-2, 0, MantleFlag.REAL)).thenAnswer(invocation -> {
|
||||
if (reads.incrementAndGet() > 1) {
|
||||
fixture.real().add(neighbor);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, false,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
|
||||
assertEquals(List.of(new Chunk(-1, 0), new Chunk(-1, 1),
|
||||
new Chunk(0, -1), new Chunk(0, 0), new Chunk(0, 1),
|
||||
new Chunk(1, -1), new Chunk(1, 0), new Chunk(1, 1)), actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cleanupCallbackCanInvalidateLaterCoverage() {
|
||||
CoverageFixture fixture = fixture(1);
|
||||
fill(fixture, 2);
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, true, (x, z) -> {
|
||||
actual.add(new Chunk(x, z));
|
||||
fixture.real().remove(new Chunk(0, 0));
|
||||
});
|
||||
|
||||
assertEquals(List.of(new Chunk(-1, -1)), actual);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void neighboringFrontierTargetsReuseARecheckedMissingNeighbor() {
|
||||
CoverageFixture fixture = fixture(4);
|
||||
for (int x = -8; x <= 8; x++) {
|
||||
for (int z = -8; z <= 0; z++) {
|
||||
fixture.real().add(new Chunk(x, z));
|
||||
}
|
||||
}
|
||||
List<Chunk> expected = fullScan(fixture, 0, 0, 4);
|
||||
List<Chunk> actual = new ArrayList<>();
|
||||
int baselineReads = fullScanReads(fixture, 4);
|
||||
|
||||
fixture.engine().cleanupChunksCoveredBy(0, 0, false,
|
||||
(x, z) -> actual.add(new Chunk(x, z)));
|
||||
|
||||
assertEquals(expected, actual);
|
||||
assertTrue("baseline=" + baselineReads + ", actual=" + fixture.reads().get(),
|
||||
fixture.reads().get() < baselineReads * 4 / 5);
|
||||
}
|
||||
|
||||
private static void fill(CoverageFixture fixture, int radius) {
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
fixture.real().add(new Chunk(x, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Chunk> fullScan(CoverageFixture fixture, int x, int z, int radius) {
|
||||
List<Chunk> result = new ArrayList<>();
|
||||
for (int offsetX = -radius; offsetX <= radius; offsetX++) {
|
||||
for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) {
|
||||
Chunk target = new Chunk(x + offsetX, z + offsetZ);
|
||||
if (fixture.cleaned().contains(target)) {
|
||||
continue;
|
||||
}
|
||||
boolean covered = true;
|
||||
for (int dx = -radius; dx <= radius && covered; dx++) {
|
||||
for (int dz = -radius; dz <= radius; dz++) {
|
||||
if (!fixture.real().contains(new Chunk(target.x() + dx, target.z() + dz))) {
|
||||
covered = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (covered) {
|
||||
result.add(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int fullScanReads(CoverageFixture fixture, int radius) {
|
||||
int reads = 0;
|
||||
for (int x = -radius; x <= radius; x++) {
|
||||
for (int z = -radius; z <= radius; z++) {
|
||||
boolean covered = true;
|
||||
for (int dx = -radius; dx <= radius && covered; dx++) {
|
||||
for (int dz = -radius; dz <= radius; dz++) {
|
||||
reads++;
|
||||
if (!fixture.real().contains(new Chunk(x + dx, z + dz))) {
|
||||
covered = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return reads;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static CoverageFixture fixture(int radius) {
|
||||
EngineMantle engine = mock(IrisEngineMantle.class, CALLS_REAL_METHODS);
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
Set<Chunk> real = new HashSet<>();
|
||||
Set<Chunk> cleaned = new HashSet<>();
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
doReturn(mantle).when(engine).getMantle();
|
||||
doReturn(radius).when(engine).getRadius();
|
||||
doReturn(0).when(engine).getRealRadius();
|
||||
when(mantle.hasFlag(anyInt(), anyInt(), any())).thenAnswer(invocation -> {
|
||||
Chunk position = new Chunk(invocation.getArgument(0), invocation.getArgument(1));
|
||||
MantleFlag flag = invocation.getArgument(2);
|
||||
if (flag == MantleFlag.REAL) {
|
||||
reads.incrementAndGet();
|
||||
return real.contains(position);
|
||||
}
|
||||
return cleaned.contains(position);
|
||||
});
|
||||
when(mantle.getChunk(anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
cleaned.add(new Chunk(invocation.getArgument(0), invocation.getArgument(1)));
|
||||
return chunk;
|
||||
});
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
doAnswer(invocation -> null).when(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any());
|
||||
return new CoverageFixture(engine, mantle, real, cleaned, reads);
|
||||
}
|
||||
|
||||
private record Chunk(int x, int z) {
|
||||
}
|
||||
|
||||
private record CoverageFixture(EngineMantle engine, Mantle<Matter> mantle,
|
||||
Set<Chunk> real, Set<Chunk> cleaned, AtomicInteger reads) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package art.arcane.iris.engine.mantle;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.util.project.matter.PreObjectMatterCell;
|
||||
import art.arcane.iris.util.project.matter.slices.HydrologyCaveMatter;
|
||||
import art.arcane.iris.util.project.matter.slices.PreObjectMatter;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.IrisMatter;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import art.arcane.volmlib.util.matter.MatterSlice;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
public class TerrainComposedCavernTest {
|
||||
@BeforeClass
|
||||
public static void registerMatter() {
|
||||
IrisMatter.registerSliceType(new PreObjectMatter());
|
||||
IrisMatter.registerSliceType(new HydrologyCaveMatter());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allHydrologyActionsAndPartialJournalCapturesMatchTheExistingComposition() {
|
||||
MatterCavern natural = new MatterCavern(true, "iris:natural", (byte) 0);
|
||||
MatterCavern tagged = new MatterCavern(false, "iris:tagged", (byte) 2);
|
||||
List<MatterCavern> caverns = Arrays.asList(null, natural, tagged);
|
||||
List<HydrologyCaveCell> hydrology = new ArrayList<>();
|
||||
hydrology.add(null);
|
||||
for (HydrologyCaveAction action : HydrologyCaveAction.values()) {
|
||||
hydrology.add(new HydrologyCaveCell(action, "river", "iris:flooded"));
|
||||
}
|
||||
List<PreObjectMatterCell> journals = new ArrayList<>();
|
||||
journals.add(null);
|
||||
journals.add(PreObjectMatterCell.string(null));
|
||||
journals.add(PreObjectMatterCell.cavern(null));
|
||||
journals.add(PreObjectMatterCell.cavern(natural));
|
||||
for (HydrologyCaveCell cell : hydrology) {
|
||||
journals.add(PreObjectMatterCell.hydrology(cell));
|
||||
journals.add(PreObjectMatterCell.cavern(null).captureHydrology(cell));
|
||||
journals.add(PreObjectMatterCell.cavern(natural).captureHydrology(cell));
|
||||
}
|
||||
Matter matter = new IrisMatter(16, 16, 16);
|
||||
MantleChunk<Matter> chunk = chunk(matter);
|
||||
for (MatterCavern rawCavern : caverns) {
|
||||
for (HydrologyCaveCell rawHydrology : hydrology) {
|
||||
for (PreObjectMatterCell journal : journals) {
|
||||
matter.<MatterCavern>slice(MatterCavern.class).set(15, 3, 2, rawCavern);
|
||||
matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class).set(15, 3, 2, rawHydrology);
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(15, 3, 2, journal);
|
||||
MatterCavern effectiveCavern = journal != null && journal.cavernCaptured()
|
||||
? journal.cavern() : rawCavern;
|
||||
HydrologyCaveCell effectiveHydrology = journal != null && journal.hydrologyCaptured()
|
||||
? journal.hydrology() : rawHydrology;
|
||||
MatterCavern expected = effectiveHydrology == null
|
||||
? effectiveCavern : effectiveHydrology.asCavern();
|
||||
assertSame(expected, legacy(chunk, -1, 35, 18));
|
||||
assertSame(expected, TerrainMatterView.getComposedCavern(chunk, -1, 35, 18));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void absentChunksSectionsAndVerticalBoundsRemainEmpty() {
|
||||
MantleChunk<Matter> chunk = chunk(null);
|
||||
assertNull(TerrainMatterView.getComposedCavern(null, 1, 2, 3));
|
||||
assertNull(TerrainMatterView.getComposedCavern(chunk, 1, -1, 3));
|
||||
assertNull(TerrainMatterView.getComposedCavern(chunk, 1, Integer.MIN_VALUE, 3));
|
||||
verifyNoInteractions(chunk);
|
||||
assertNull(TerrainMatterView.getComposedCavern(chunk, 1, 0, 3));
|
||||
assertNull(TerrainMatterView.getComposedCavern(chunk, 1, Integer.MAX_VALUE, 3));
|
||||
assertNull(TerrainMatterView.getComposedCavern(chunk, 1, 35, 3));
|
||||
assertNull(legacy(chunk, 1, 35, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composedReadUsesOneLockedSectionAndJournalLookup() {
|
||||
Matter matter = spy(new IrisMatter(16, 16, 16));
|
||||
MatterCavern natural = new MatterCavern(true, "iris:natural", (byte) 0);
|
||||
HydrologyCaveCell seal = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD);
|
||||
matter.<MatterCavern>slice(MatterCavern.class).set(15, 3, 2, natural);
|
||||
matter.<HydrologyCaveCell>slice(HydrologyCaveCell.class).set(15, 3, 2, seal);
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(15, 3, 2,
|
||||
PreObjectMatterCell.string("iris:owner"));
|
||||
MatterSlice<PreObjectMatterCell> journal = spy(matter.<PreObjectMatterCell>getSlice(PreObjectMatterCell.class));
|
||||
MatterSlice<HydrologyCaveCell> hydrology = spy(matter.<HydrologyCaveCell>getSlice(HydrologyCaveCell.class));
|
||||
matter.getSliceMap().put(PreObjectMatterCell.class, journal);
|
||||
matter.getSliceMap().put(HydrologyCaveCell.class, hydrology);
|
||||
MantleChunk<Matter> chunk = chunk(matter);
|
||||
clearInvocations(matter);
|
||||
|
||||
assertNull(TerrainMatterView.getComposedCavern(chunk, -1, 35, 18));
|
||||
|
||||
verify(chunk, times(1)).exists(2);
|
||||
verify(chunk, times(1)).get(2);
|
||||
verify(journal, times(1)).get(15, 3, 2);
|
||||
verify(hydrology, times(1)).get(15, 3, 2);
|
||||
verify(matter, never()).hasSlice(MatterCavern.class);
|
||||
verify(matter, never()).getSlice(MatterCavern.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lookupFailuresRetainTheirOriginalCause() {
|
||||
MantleChunk<Matter> chunk = chunk(null);
|
||||
IllegalStateException failure = new IllegalStateException("section unavailable");
|
||||
doThrow(failure).when(chunk).get(2);
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> TerrainMatterView.getComposedCavern(chunk, 1, 35, 3)));
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> legacy(chunk, 1, 35, 3)));
|
||||
}
|
||||
|
||||
private static MatterCavern legacy(MantleChunk<Matter> chunk, int x, int y, int z) {
|
||||
MatterCavern cavern = TerrainMatterView.get(chunk, x, y, z, MatterCavern.class);
|
||||
HydrologyCaveCell hydrology = TerrainMatterView.get(chunk, x, y, z, HydrologyCaveCell.class);
|
||||
return hydrology == null ? cavern : hydrology.asCavern();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MantleChunk<Matter> chunk(Matter matter) {
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
doAnswer(invocation -> {
|
||||
assertTrue(Thread.holdsLock(chunk));
|
||||
return true;
|
||||
}).when(chunk).exists(2);
|
||||
doAnswer(invocation -> {
|
||||
assertTrue(Thread.holdsLock(chunk));
|
||||
return matter;
|
||||
}).when(chunk).get(2);
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package art.arcane.iris.engine.mantle;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.matter.IrisMatterSupport;
|
||||
import art.arcane.iris.util.project.matter.PreObjectMatterCell;
|
||||
import art.arcane.iris.util.project.matter.slices.PreObjectMatterTest;
|
||||
@@ -14,14 +17,24 @@ import org.junit.Test;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class TerrainMatterViewTest {
|
||||
@@ -59,12 +72,115 @@ public class TerrainMatterViewTest {
|
||||
assertEquals(natural, TerrainMatterView.get(chunk, 3, 3, 2, MatterCavern.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void journaledTypesReuseIteratedValuesAndKeepOriginalIdentityAndOrder() {
|
||||
assertJournalIteration(MatterCavern.class,
|
||||
new MatterCavern(true, "iris:natural", (byte) 0),
|
||||
new MatterCavern(true, "iris:object", (byte) 0), PreObjectMatterCell::cavern);
|
||||
assertJournalIteration(String.class, "iris:natural", "iris:object", PreObjectMatterCell::string);
|
||||
assertJournalIteration(HydrologyCaveCell.class,
|
||||
HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR),
|
||||
HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), PreObjectMatterCell::hydrology);
|
||||
assertJournalIteration(PlatformBlockState.class,
|
||||
mock(PlatformBlockState.class), mock(PlatformBlockState.class), PreObjectMatterCell::block);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void callbackFailuresEscapeOutsideTheChunkLock() {
|
||||
Matter matter = new IrisMatter(16, 16, 16);
|
||||
MatterCavern original = new MatterCavern(true, "iris:natural", (byte) 0);
|
||||
matter.<MatterCavern>slice(MatterCavern.class).set(1, 3, 2, original);
|
||||
MantleChunk<Matter> chunk = chunk(matter);
|
||||
IllegalStateException failure = new IllegalStateException("terrain callback");
|
||||
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> TerrainMatterView.iterate(chunk, MatterCavern.class, (x, y, z, value) -> {
|
||||
assertFalse(Thread.holdsLock(chunk));
|
||||
assertSame(original, value);
|
||||
throw failure;
|
||||
})));
|
||||
List<MatterCavern> recovered = new ArrayList<>();
|
||||
TerrainMatterView.iterate(chunk, MatterCavern.class, (x, y, z, value) -> recovered.add(value));
|
||||
assertEquals(List.of(original), recovered);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstPassDoesNotReadTheSuppliedRawValuesAgain() {
|
||||
Matter matter = new IrisMatter(16, 16, 16);
|
||||
MatterCavern original = new MatterCavern(true, "iris:natural", (byte) 0);
|
||||
matter.<MatterCavern>slice(MatterCavern.class).set(1, 3, 2, original);
|
||||
matter.<MatterCavern>slice(MatterCavern.class).set(4, 3, 2, original);
|
||||
MantleChunk<Matter> chunk = chunk(matter);
|
||||
List<MatterCavern> values = new ArrayList<>();
|
||||
|
||||
TerrainMatterView.iterate(chunk, MatterCavern.class, (x, y, z, value) -> values.add(value));
|
||||
|
||||
assertEquals(List.of(original, original), values);
|
||||
verify(chunk, times(2)).get(0);
|
||||
verify(chunk, times(2)).exists(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void unjournaledTypesKeepTheDirectIteratorPath() {
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
Consumer4<Integer, Integer, Integer, Integer> consumer = (x, y, z, value) -> {
|
||||
assertFalse(Thread.holdsLock(chunk));
|
||||
assertEquals(7, value.intValue());
|
||||
};
|
||||
doAnswer(invocation -> {
|
||||
Consumer4<Integer, Integer, Integer, Integer> supplied = invocation.getArgument(1);
|
||||
assertSame(consumer, supplied);
|
||||
supplied.accept(1, 3, 2, 7);
|
||||
return null;
|
||||
}).when(chunk).iterate(Integer.class, consumer);
|
||||
|
||||
TerrainMatterView.iterate(chunk, Integer.class, consumer);
|
||||
|
||||
verify(chunk).iterate(Integer.class, consumer);
|
||||
verifyNoMoreInteractions(chunk);
|
||||
}
|
||||
|
||||
private static <T> void assertJournalIteration(Class<T> type, T original, T content,
|
||||
Function<T, PreObjectMatterCell> capture) {
|
||||
Matter matter = new IrisMatter(16, 16, 16);
|
||||
matter.<T>slice(type).set(1, 3, 2, content);
|
||||
matter.<T>slice(type).set(2, 3, 2, content);
|
||||
matter.<T>slice(type).set(4, 3, 2, original);
|
||||
matter.<T>slice(type).set(5, 3, 2, content);
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(1, 3, 2, capture.apply(null));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(2, 3, 2, capture.apply(original));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(3, 3, 2, capture.apply(original));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(5, 3, 2,
|
||||
type == String.class ? PreObjectMatterCell.cavern(null) : PreObjectMatterCell.string(null));
|
||||
matter.<PreObjectMatterCell>slice(PreObjectMatterCell.class).set(6, 3, 2, capture.apply(null));
|
||||
MantleChunk<Matter> chunk = chunk(matter);
|
||||
List<Integer> positions = new ArrayList<>();
|
||||
List<T> values = new ArrayList<>();
|
||||
|
||||
TerrainMatterView.iterate(chunk, type, (x, y, z, value) -> {
|
||||
assertFalse(Thread.holdsLock(chunk));
|
||||
assertEquals(3, y.intValue());
|
||||
assertEquals(2, z.intValue());
|
||||
positions.add(x);
|
||||
values.add(value);
|
||||
matter.<T>slice(type).set(4, 3, 2, content);
|
||||
});
|
||||
|
||||
assertEquals(List.of(2, 4, 5, 3), positions);
|
||||
assertSame(original, values.get(0));
|
||||
assertSame(original, values.get(1));
|
||||
assertSame(content, values.get(2));
|
||||
assertSame(original, values.get(3));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MantleChunk<Matter> chunk(Matter matter) {
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
when(chunk.exists(0)).thenReturn(true);
|
||||
when(chunk.get(0)).thenReturn(matter);
|
||||
doAnswer(invocation -> {
|
||||
assertTrue(Thread.holdsLock(chunk));
|
||||
Class<Object> type = invocation.getArgument(0);
|
||||
Consumer4<Integer, Integer, Integer, Object> consumer = invocation.getArgument(1);
|
||||
if (!matter.hasSlice(type)) {
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package art.arcane.iris.engine.modifier;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.volmlib.util.math.BlockPosition;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
public class IrisCarveModifierBiomeBlendTest {
|
||||
private static final long SEED = 371924L;
|
||||
|
||||
@Test
|
||||
public void everyRollPreservesAllNeighborIdentityAndNullCombinations() throws Exception {
|
||||
IrisBiome center = new IrisBiome();
|
||||
IrisBiome first = new IrisBiome();
|
||||
IrisBiome second = new IrisBiome();
|
||||
IrisBiome[] choices = {null, center, first, second};
|
||||
AtomicReference<IrisBiome[]> samples = new AtomicReference<>();
|
||||
int[] position = new int[3];
|
||||
Engine engine = mock(Engine.class);
|
||||
doAnswer(call -> {
|
||||
int x = call.getArgument(0);
|
||||
int z = call.getArgument(2);
|
||||
return samples.get()[sampleIndex(x - position[0], z - position[2])];
|
||||
}).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
RNG parent = new RNG(SEED);
|
||||
RNG control = new RNG(SEED);
|
||||
assertEquals(control.nextInt(), parent.nextInt());
|
||||
IrisCarveModifier modifier = modifier(engine, parent);
|
||||
for (int sign : new int[]{1, -1}) {
|
||||
for (int roll = 0; roll < 8; roll++) {
|
||||
int[] coordinate = coordinate(roll, sign);
|
||||
System.arraycopy(coordinate, 0, position, 0, position.length);
|
||||
for (int pattern = 0; pattern < 256; pattern++) {
|
||||
IrisBiome[] values = {center, choices[pattern & 3], choices[(pattern >> 2) & 3],
|
||||
choices[(pattern >> 4) & 3], choices[(pattern >> 6) & 3]};
|
||||
samples.set(values);
|
||||
IrisBiome selected = roll < 4 ? center : values[roll - 3];
|
||||
assertSame(selected == null ? center : selected,
|
||||
resolve(modifier, coordinate, new Long2ObjectOpenHashMap<>()));
|
||||
}
|
||||
samples.set(new IrisBiome[]{null, first, second, first, second});
|
||||
assertNull(resolve(modifier, coordinate, new Long2ObjectOpenHashMap<>()));
|
||||
}
|
||||
}
|
||||
assertEquals(control.nextLong(), parent.nextLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onlyCenterAndChosenNeighborAreQueriedInThatOrder() throws Exception {
|
||||
List<Long> queries = new ArrayList<>();
|
||||
IrisBiome center = new IrisBiome();
|
||||
Engine engine = mock(Engine.class);
|
||||
doAnswer(call -> {
|
||||
queries.add(BlockPosition.toLong(call.getArgument(0), call.getArgument(1), call.getArgument(2)));
|
||||
return center;
|
||||
}).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
IrisCarveModifier modifier = modifier(engine, new RNG(SEED));
|
||||
for (int roll = 0; roll < 8; roll++) {
|
||||
int[] coordinate = coordinate(roll, -1);
|
||||
queries.clear();
|
||||
assertSame(center, resolve(modifier, coordinate, new Long2ObjectOpenHashMap<>()));
|
||||
List<Long> expected = new ArrayList<>();
|
||||
expected.add(BlockPosition.toLong(coordinate[0], coordinate[1], coordinate[2]));
|
||||
if (roll >= 4) {
|
||||
int[] neighbor = neighbor(coordinate, roll - 4);
|
||||
expected.add(BlockPosition.toLong(neighbor[0], neighbor[1], neighbor[2]));
|
||||
}
|
||||
assertEquals(expected, queries);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedCacheTraversalMatchesEagerSampling() throws Exception {
|
||||
IrisBiome[] biomes = {new IrisBiome(), new IrisBiome(), null, new IrisBiome()};
|
||||
Engine engine = mock(Engine.class);
|
||||
doAnswer(call -> biomeAt(biomes, call.getArgument(0), call.getArgument(1), call.getArgument(2)))
|
||||
.when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
IrisCarveModifier modifier = modifier(engine, new RNG(SEED));
|
||||
Long2ObjectOpenHashMap<IrisBiome> cache = new Long2ObjectOpenHashMap<>();
|
||||
Long2ObjectOpenHashMap<IrisBiome> eagerCache = new Long2ObjectOpenHashMap<>();
|
||||
for (int x = -16; x < 0; x++) {
|
||||
for (int z = -32; z < -16; z++) {
|
||||
for (int y : new int[]{-12, 3, 17, 40}) {
|
||||
int[] coordinate = {x, y, z};
|
||||
assertSame(eager(biomes, coordinate, eagerCache), resolve(modifier, coordinate, cache));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void selectedNeighborFailureKeepsOriginalCause() throws Exception {
|
||||
int[] coordinate = coordinate(4, -1);
|
||||
IllegalStateException failure = new IllegalStateException("selected cave query failed");
|
||||
Engine engine = mock(Engine.class);
|
||||
doAnswer(call -> {
|
||||
if ((int) call.getArgument(0) != coordinate[0]) {
|
||||
throw failure;
|
||||
}
|
||||
return new IrisBiome();
|
||||
}).when(engine).getCaveBiome(anyInt(), anyInt(), anyInt(), any(IrisDimensionCarvingResolver.State.class));
|
||||
IrisCarveModifier modifier = modifier(engine, new RNG(SEED));
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> resolve(modifier, coordinate, new Long2ObjectOpenHashMap<>())));
|
||||
}
|
||||
|
||||
private static IrisCarveModifier modifier(Engine engine, RNG rng) throws ReflectiveOperationException {
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
Field field = IrisCarveModifier.class.getDeclaredField("rng");
|
||||
field.setAccessible(true);
|
||||
field.set(modifier, rng);
|
||||
return modifier;
|
||||
}
|
||||
|
||||
private static IrisBiome resolve(IrisCarveModifier modifier, int[] coordinate,
|
||||
Long2ObjectOpenHashMap<IrisBiome> cache) {
|
||||
return modifier.resolveCaveBoundaryBiome(null, coordinate[0], coordinate[1], coordinate[2],
|
||||
new IrisDimensionCarvingResolver.State(), cache, new HashMap<>());
|
||||
}
|
||||
|
||||
private static int[] coordinate(int roll, int sign) {
|
||||
for (int y = 1; y < 10_000; y++) {
|
||||
int[] coordinate = {sign * 31, sign * y, sign * 23};
|
||||
if (roll(coordinate) == roll) {
|
||||
return coordinate;
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Missing cave blend roll " + roll);
|
||||
}
|
||||
|
||||
private static int roll(int[] coordinate) {
|
||||
return Math.floorMod(new RNG(SEED).nextParallelRNG(
|
||||
BlockPosition.toLong(coordinate[0], coordinate[1], coordinate[2])).nextInt(), 8);
|
||||
}
|
||||
|
||||
private static int[] neighbor(int[] coordinate, int index) {
|
||||
return new int[]{coordinate[0] + (index == 0 ? 3 : index == 1 ? -3 : 0), coordinate[1],
|
||||
coordinate[2] + (index == 2 ? 3 : index == 3 ? -3 : 0)};
|
||||
}
|
||||
|
||||
private static int sampleIndex(int x, int z) {
|
||||
if (x == 3) {
|
||||
return 1;
|
||||
}
|
||||
if (x == -3) {
|
||||
return 2;
|
||||
}
|
||||
return z == 3 ? 3 : z == -3 ? 4 : 0;
|
||||
}
|
||||
|
||||
private static IrisBiome biomeAt(IrisBiome[] biomes, int x, int y, int z) {
|
||||
return biomes[Math.floorMod(x * 13 + y * 7 + z * 5, biomes.length)];
|
||||
}
|
||||
|
||||
private static IrisBiome eager(IrisBiome[] biomes, int[] coordinate, Long2ObjectOpenHashMap<IrisBiome> cache) {
|
||||
IrisBiome center = cached(biomes, coordinate, cache);
|
||||
if (center == null) {
|
||||
return null;
|
||||
}
|
||||
IrisBiome[] neighbors = new IrisBiome[4];
|
||||
boolean identical = true;
|
||||
for (int index = 0; index < neighbors.length; index++) {
|
||||
neighbors[index] = cached(biomes, neighbor(coordinate, index), cache);
|
||||
identical &= neighbors[index] == center;
|
||||
}
|
||||
int roll = roll(coordinate);
|
||||
IrisBiome selected = identical || roll < 4 ? center : neighbors[roll - 4];
|
||||
return selected == null ? center : selected;
|
||||
}
|
||||
|
||||
private static IrisBiome cached(IrisBiome[] biomes, int[] coordinate, Long2ObjectOpenHashMap<IrisBiome> cache) {
|
||||
long key = BlockPosition.toLong(coordinate[0], coordinate[1], coordinate[2]);
|
||||
IrisBiome value = cache.get(key);
|
||||
if (value == null) {
|
||||
value = biomeAt(biomes, coordinate[0], coordinate[1], coordinate[2]);
|
||||
if (value != null) {
|
||||
cache.put(key, value);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -13,9 +13,11 @@ import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import art.arcane.volmlib.util.matter.MatterSlice;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -32,7 +34,7 @@ import static org.mockito.Mockito.mock;
|
||||
public class IrisCarveModifierBoundarySupportTest {
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void boundaryBiomeUsesCustomMatterAtItsOwnY() {
|
||||
public void boundaryBiomeUsesCustomMatterAtItsOwnY() throws ReflectiveOperationException {
|
||||
IrisBiome customFloor = mock(IrisBiome.class);
|
||||
IrisBiome resolvedCeiling = mock(IrisBiome.class);
|
||||
IrisData data = mock(IrisData.class);
|
||||
@@ -46,6 +48,9 @@ public class IrisCarveModifierBoundarySupportTest {
|
||||
|
||||
IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
Field rng = IrisCarveModifier.class.getDeclaredField("rng");
|
||||
rng.setAccessible(true);
|
||||
rng.set(modifier, new RNG(71L));
|
||||
|
||||
MantleChunk<Matter> mantleChunk = mock(MantleChunk.class);
|
||||
Matter floorMatter = mock(Matter.class);
|
||||
|
||||
+61
-11
@@ -35,11 +35,13 @@ import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -68,9 +70,9 @@ public class IrisDepositModifierContextTest {
|
||||
IrisEngine engine = mock(IrisEngine.class, RETURNS_DEEP_STUBS);
|
||||
try (Fixture fixture = new Fixture(engine, true)) {
|
||||
fixture.generateAndCheckCleanup();
|
||||
verify(engine, times(3)).captureGenerationRuntimeBinding();
|
||||
verify(engine, times(3)).openGenerationRuntimeScope(fixture.binding);
|
||||
assertEquals(3, fixture.closedScopes.get());
|
||||
verify(engine, times(1)).captureGenerationRuntimeBinding();
|
||||
verify(engine, times(4)).openGenerationRuntimeScope(fixture.binding);
|
||||
assertEquals(4, fixture.closedScopes.get());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +93,57 @@ public class IrisDepositModifierContextTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void workerPreparationFailurePropagatesAfterAllScopesClose() throws Exception {
|
||||
try (Fixture fixture = new Fixture(mock(IrisEngine.class, RETURNS_DEEP_STUBS), true)) {
|
||||
IllegalStateException failure = new IllegalStateException("worker clump preparation failed");
|
||||
doThrow(failure).when(fixture.dimensionDeposit).getClump(any(), any(), any());
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, () ->
|
||||
fixture.modifier.modify(32, -48, Hunk.newArrayHunk(16, 16, 16), true, fixture.context)));
|
||||
verify(fixture.chunk).release();
|
||||
assertEquals(4, fixture.closedScopes.get());
|
||||
assertNull(IrisContext.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void callerPlanFailureStillDrainsWorkersAndRestoresContext() throws Exception {
|
||||
try (Fixture fixture = new Fixture(mock(IrisEngine.class, RETURNS_DEEP_STUBS), true)) {
|
||||
IllegalStateException failure = new IllegalStateException("caller deposit plan failed");
|
||||
doThrow(failure).when(fixture.biomeDeposit).getSpawnChance();
|
||||
ChunkContext callerContext = mock(ChunkContext.class);
|
||||
try (IrisContext.Scope ignored = IrisContext.open(fixture.engine, 19L, callerContext)) {
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, () ->
|
||||
fixture.modifier.modify(32, -48, Hunk.newArrayHunk(16, 16, 16), true, fixture.context)));
|
||||
assertSame(callerContext, IrisContext.require().getChunkContext());
|
||||
assertEquals(19L, IrisContext.require().getGenerationSessionId());
|
||||
}
|
||||
verify(fixture.chunk).release();
|
||||
assertEquals(3, fixture.closedScopes.get());
|
||||
assertEquals(10, fixture.completed.get());
|
||||
assertNull(IrisContext.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void callerFailureRetainsDistinctWorkerFailuresAfterDraining() throws Exception {
|
||||
try (Fixture fixture = new Fixture(mock(IrisEngine.class, RETURNS_DEEP_STUBS), true)) {
|
||||
IllegalStateException caller = new IllegalStateException("caller failure");
|
||||
IllegalArgumentException firstWorker = new IllegalArgumentException("first worker failure");
|
||||
UnsupportedOperationException secondWorker = new UnsupportedOperationException("second worker failure");
|
||||
doThrow(firstWorker, secondWorker).when(fixture.dimensionDeposit).getClump(any(), any(), any());
|
||||
doThrow(caller).when(fixture.biomeDeposit).getSpawnChance();
|
||||
assertSame(caller, assertThrows(IllegalStateException.class, () ->
|
||||
fixture.modifier.modify(32, -48, Hunk.newArrayHunk(16, 16, 16), true, fixture.context)));
|
||||
assertEquals(2, caller.getSuppressed().length);
|
||||
assertSame(firstWorker, caller.getSuppressed()[0]);
|
||||
assertSame(secondWorker, caller.getSuppressed()[1]);
|
||||
verify(fixture.chunk).release();
|
||||
assertEquals(3, fixture.closedScopes.get());
|
||||
assertNull(IrisContext.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedClumpChanceDoesNotResampleTheConfiguredAttemptCount() {
|
||||
Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS);
|
||||
@@ -119,6 +172,8 @@ public class IrisDepositModifierContextTest {
|
||||
private final AtomicInteger completed = new AtomicInteger();
|
||||
private final AtomicInteger closedScopes = new AtomicInteger();
|
||||
private final IrisDepositModifier modifier;
|
||||
private final IrisDepositGenerator dimensionDeposit = mock(IrisDepositGenerator.class);
|
||||
private final IrisDepositGenerator biomeDeposit = mock(IrisDepositGenerator.class);
|
||||
private final MantleChunk<Matter> chunk;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -131,9 +186,7 @@ public class IrisDepositModifierContextTest {
|
||||
burst.setMulticore(true);
|
||||
when(engine.burst()).thenReturn(pool);
|
||||
when(pool.burst(true)).thenReturn(burst);
|
||||
IrisDepositGenerator dimensionDeposit = mock(IrisDepositGenerator.class);
|
||||
IrisDepositGenerator regionDeposit = mock(IrisDepositGenerator.class);
|
||||
IrisDepositGenerator biomeDeposit = mock(IrisDepositGenerator.class);
|
||||
when(engine.getDimension().getDeposits()).thenReturn(new KList<>(dimensionDeposit));
|
||||
when(context.getRegion().get(7, 7).getDeposits())
|
||||
.thenReturn(new KList<>(regionDeposit));
|
||||
@@ -178,12 +231,9 @@ public class IrisDepositModifierContextTest {
|
||||
return clump;
|
||||
}).when(generator).getClump(any(), any(), any());
|
||||
}
|
||||
when(dimensionDeposit.getMinPerChunk()).thenReturn(9);
|
||||
when(dimensionDeposit.getMaxPerChunk()).thenReturn(9);
|
||||
modifier = new IrisDepositModifier(engine);
|
||||
doAnswer(invocation -> {
|
||||
assertEquals(3, completed.get());
|
||||
assertEquals(scoped ? 3 : 0, closedScopes.get());
|
||||
return null;
|
||||
}).when(chunk).release();
|
||||
}
|
||||
|
||||
private void generateAndCheckCleanup() throws Exception {
|
||||
@@ -194,7 +244,7 @@ public class IrisDepositModifierContextTest {
|
||||
assertSame(callerContext, IrisContext.require().getChunkContext());
|
||||
assertEquals(19L, IrisContext.require().getGenerationSessionId());
|
||||
}
|
||||
assertEquals(3, completed.get());
|
||||
assertEquals(11, completed.get());
|
||||
verify(chunk).release();
|
||||
executor.submit(() -> {
|
||||
assertNull(IrisContext.get());
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package art.arcane.iris.engine.modifier;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDepositGenerator;
|
||||
import art.arcane.iris.engine.object.IrisDepositHeightDistribution;
|
||||
import art.arcane.iris.engine.object.IrisDepositShape;
|
||||
import art.arcane.iris.engine.object.IrisDepositVariant;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.iris.util.common.parallel.MultiBurst;
|
||||
import art.arcane.iris.util.project.context.ChunkContext;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.MatterCavern;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
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 IrisDepositModifierParityTest {
|
||||
private final Map<String, PlatformBlockState> states = new HashMap<>();
|
||||
|
||||
@Before
|
||||
public void bindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(registries.block(anyString())).thenAnswer(invocation -> state(invocation.getArgument(0)));
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
|
||||
@After
|
||||
public void unbindPlatform() {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredShapesKeepRecordedBlocksAcrossScopesAndCavePolicies() {
|
||||
long[] seeds = {101L, -73L, 90181L};
|
||||
long[][] expected = {
|
||||
{-4609692219192155345L, 784L, 2118L, 53L, 67L, 11L},
|
||||
{-331296667371868787L, 818L, 2413L, 94L, 49L, 20L},
|
||||
{-3365014205342608518L, 641L, 2340L, 58L, 45L, 27L}
|
||||
};
|
||||
for (int i = 0; i < seeds.length; i++) {
|
||||
for (boolean multicore : new boolean[]{false, true}) {
|
||||
assertArrayEquals("seed=" + seeds[i] + ", multicore=" + multicore,
|
||||
expected[i], generateConfigured(seeds[i], multicore));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private long[] generateConfigured(long seed, boolean multicore) {
|
||||
Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS);
|
||||
ChunkContext context = mock(ChunkContext.class, RETURNS_DEEP_STUBS);
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
IrisRegion region = new IrisRegion();
|
||||
IrisBiome surface = new IrisBiome();
|
||||
IrisBiome cave = new IrisBiome();
|
||||
cave.setLoadKey("allowed");
|
||||
cave.setOreDepositFrequencyMultiplier(0.65D);
|
||||
cave.setOreDepositSizeMultiplier(1.5D);
|
||||
IrisBiome denied = new IrisBiome();
|
||||
denied.setLoadKey("denied");
|
||||
IrisDepositVariant variant = new IrisDepositVariant().setMinHeight(-64).setMaxHeight(64);
|
||||
variant.getRemap().put("minecraft:gold_ore", "minecraft:redstone_ore");
|
||||
cave.getDepositVariants().add(variant);
|
||||
dimension.getDeposits().add(generator("granite", IrisDepositShape.IRIS, 25, 70,
|
||||
IrisDepositHeightDistribution.CLIPPED_UNIFORM, "stone", "deepslate"));
|
||||
region.getDeposits().add(generator("iron_ore", IrisDepositShape.VANILLA_ELLIPSOID, 20, 110,
|
||||
IrisDepositHeightDistribution.TRIANGLE, "stone", "granite", "deepslate"));
|
||||
IrisDepositGenerator diamond = generator("diamond_ore", IrisDepositShape.VANILLA_SCATTERED, 12, 100,
|
||||
IrisDepositHeightDistribution.UNIFORM, "iron_ore", "deepslate_iron_ore", "granite");
|
||||
diamond.setIncludedBiomes(new KList<>("allowed"));
|
||||
diamond.setDiscardChanceOnAirExposure(0.5D);
|
||||
surface.getDeposits().add(diamond);
|
||||
IrisDepositGenerator gold = generator("gold_ore", IrisDepositShape.VANILLA_ELLIPSOID, 9, 70,
|
||||
IrisDepositHeightDistribution.TRIANGLE, "stone", "granite");
|
||||
gold.setExcludedBiomes(new KList<>("allowed"));
|
||||
gold.setDiscardChanceOnAirExposure(0.3D);
|
||||
surface.getDeposits().add(gold);
|
||||
when(engine.getHeight()).thenReturn(48);
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.getSeedManager().getDeposit()).thenReturn(seed);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(engine.getCaveBiome(anyInt(), anyInt(), anyInt(), any())).thenAnswer(invocation -> {
|
||||
int x = invocation.getArgument(0);
|
||||
int y = invocation.getArgument(1);
|
||||
int z = invocation.getArgument(2);
|
||||
return ((Math.floorDiv(x, 8) + Math.floorDiv(z, 8) + Math.floorDiv(y, 8)) & 1) == 0 ? cave : denied;
|
||||
});
|
||||
when(context.getRoundedHeight(anyInt(), anyInt())).thenReturn(40);
|
||||
when(context.getBiome().get(anyInt(), anyInt())).thenReturn(surface);
|
||||
when(context.getRegion().get(anyInt(), anyInt())).thenReturn(region);
|
||||
MantleChunk<Matter> chunk = mock(MantleChunk.class);
|
||||
Mantle<Matter> mantle = engine.getMantle().getMantle();
|
||||
doReturn(chunk).when(mantle).getChunk(-3, 5);
|
||||
when(chunk.use()).thenReturn(chunk);
|
||||
MatterCavern cavern = new MatterCavern(true, "allowed", (byte) 0);
|
||||
when(chunk.get(anyInt(), anyInt(), anyInt(), any())).thenAnswer(invocation -> {
|
||||
int x = invocation.getArgument(0);
|
||||
int y = invocation.getArgument(1);
|
||||
return x == 4 && y % 7 == 0 ? cavern : null;
|
||||
});
|
||||
Hunk<PlatformBlockState> terrain = Hunk.newArrayHunk(16, 48, 16);
|
||||
for (int y = 0; y < 48; y++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
terrain.set(x, y, z, state(y >= 40 ? "air" : y == 0 ? "bedrock"
|
||||
: x == 7 && z % 4 == 0 && y % 9 == 0 ? "cave_air"
|
||||
: y < 12 ? "deepslate" : "stone"));
|
||||
}
|
||||
}
|
||||
}
|
||||
MultiBurst burst = new MultiBurst("Deposit parity", () -> 4);
|
||||
when(engine.burst()).thenReturn(burst);
|
||||
try (MockedStatic<B> blocks = mockStatic(B.class, CALLS_REAL_METHODS)) {
|
||||
blocks.when(() -> B.getStateOrNull(anyString(), anyBoolean()))
|
||||
.thenAnswer(invocation -> state(invocation.getArgument(0)));
|
||||
blocks.when(() -> B.toDeepSlateOre(any(), any())).thenAnswer(invocation -> {
|
||||
PlatformBlockState host = invocation.getArgument(0);
|
||||
PlatformBlockState ore = invocation.getArgument(1);
|
||||
return host.isDeepSlate() && ore.isOre()
|
||||
? state("deepslate_" + ore.key().substring(10)) : ore;
|
||||
});
|
||||
new IrisDepositModifier(engine).generateDeposits(terrain, -3, 5, multicore, context);
|
||||
} finally {
|
||||
burst.close();
|
||||
}
|
||||
long[] result = {0xcbf29ce484222325L, 0L, 0L, 0L, 0L, 0L};
|
||||
for (int y = 0; y < 48; y++) {
|
||||
for (int z = 0; z < 16; z++) {
|
||||
for (int x = 0; x < 16; x++) {
|
||||
PlatformBlockState block = terrain.get(x, y, z);
|
||||
result[0] = (result[0] ^ block.key().hashCode()) * 0x100000001b3L;
|
||||
if (block.key().endsWith("granite")) {
|
||||
result[1]++;
|
||||
}
|
||||
if (block.key().endsWith("iron_ore")) {
|
||||
result[2]++;
|
||||
}
|
||||
if (block.key().endsWith("diamond_ore")) {
|
||||
result[3]++;
|
||||
}
|
||||
if (block.key().endsWith("gold_ore")) {
|
||||
result[4]++;
|
||||
}
|
||||
if (block.key().endsWith("redstone_ore")) {
|
||||
result[5]++;
|
||||
}
|
||||
if (x == 4 && y % 7 == 0 && y > 0 && y < 40) {
|
||||
assertEquals(y < 12 ? "minecraft:deepslate" : "minecraft:stone", block.key());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private IrisDepositGenerator generator(String block, IrisDepositShape shape, int size, int attempts,
|
||||
IrisDepositHeightDistribution distribution, String... hosts) {
|
||||
IrisDepositGenerator generator = new PaletteGenerator(state(block));
|
||||
return generator.setShape(shape).setMinSize(size).setMaxSize(size)
|
||||
.setMinPerChunk(attempts).setMaxPerChunk(attempts)
|
||||
.setMinHeight(-4).setMaxHeight(44).setHeightDistribution(distribution)
|
||||
.setSurfaceClearance(0).setPerClumpSpawnChance(0.7D)
|
||||
.setReplaceableBlocks(new KList<>(hosts));
|
||||
}
|
||||
|
||||
private PlatformBlockState state(String block) {
|
||||
String key = block.contains(":") ? block : "minecraft:" + block;
|
||||
return states.computeIfAbsent(key, value -> {
|
||||
PlatformBlockState state = mock(PlatformBlockState.class);
|
||||
when(state.key()).thenReturn(value);
|
||||
when(state.materialKey()).thenReturn(value);
|
||||
when(state.isOre()).thenReturn(value.endsWith("_ore"));
|
||||
when(state.isDeepSlate()).thenReturn(value.contains("deepslate"));
|
||||
when(state.isAir()).thenReturn(value.endsWith("air"));
|
||||
return state;
|
||||
});
|
||||
}
|
||||
|
||||
private static final class PaletteGenerator extends IrisDepositGenerator {
|
||||
private final KList<PlatformBlockState> blocks;
|
||||
|
||||
private PaletteGenerator(PlatformBlockState block) {
|
||||
blocks = new KList<>(block);
|
||||
}
|
||||
|
||||
@Override
|
||||
public KList<PlatformBlockState> getBlockData(IrisData data) {
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package art.arcane.iris.engine.modifier;
|
||||
|
||||
import art.arcane.iris.engine.actuator.IrisDecorantActuator;
|
||||
import art.arcane.iris.engine.decorator.IrisCeilingDecorator;
|
||||
import art.arcane.iris.engine.decorator.IrisSurfaceDecorator;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.mantle.EngineMantle;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDecorationPart;
|
||||
import art.arcane.iris.engine.object.IrisDecorator;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import art.arcane.volmlib.util.mantle.runtime.Mantle;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import art.arcane.volmlib.util.matter.slices.MarkerMatter;
|
||||
import org.junit.Test;
|
||||
import org.junit.Before;
|
||||
import org.junit.After;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
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.anyString;
|
||||
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.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class IrisNaturalCaveScanTest {
|
||||
private MockedStatic<B> blocks;
|
||||
|
||||
@Before
|
||||
public void prepareBlocks() {
|
||||
blocks = mockStatic(B.class, CALLS_REAL_METHODS);
|
||||
blocks.when(() -> B.getState(anyString())).thenReturn(null);
|
||||
}
|
||||
|
||||
@After
|
||||
public void releaseBlocks() {
|
||||
blocks.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void narrowScanPreservesDecoratedBlocksMarkersAndMutationOrder() throws Exception {
|
||||
verifyScan(5, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chunkScanPreservesDecoratedBlocksMarkersAndMutationOrder() throws Exception {
|
||||
verifyScan(16, 16);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void listenerFailureReleasesMantleChunk() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
IllegalStateException failure = new IllegalStateException("listener rejected decoration");
|
||||
Hunk<PlatformBlockState> output = fixture.output(3, 2).listen((x, y, z, state) -> {
|
||||
throw failure;
|
||||
});
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> fixture.modifier.decorateNaturalCaves(-32, 48, output)));
|
||||
verify(fixture.chunk).release();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void dimensionFailureReleasesMantleChunk() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
Hunk<PlatformBlockState> output = mock(Hunk.class);
|
||||
doReturn(3).when(output).getWidth();
|
||||
doReturn(2).when(output).getDepth();
|
||||
IllegalStateException failure = new IllegalStateException("height unavailable");
|
||||
doAnswer(call -> { throw failure; }).when(output).getHeight();
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> fixture.modifier.decorateNaturalCaves(-32, 48, output)));
|
||||
verify(fixture.chunk).release();
|
||||
}
|
||||
|
||||
private static void verifyScan(int width, int depth) throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
Hunk<PlatformBlockState> output = fixture.output(width, depth);
|
||||
List<String> writes = new ArrayList<>();
|
||||
fixture.modifier.decorateNaturalCaves(-32, 48,
|
||||
output.listen((x, y, z, state) -> writes.add(x + ":" + y + ":" + z + ":" + state.key())));
|
||||
List<String> expectedCalls = new ArrayList<>();
|
||||
List<String> expectedMarkers = new ArrayList<>();
|
||||
List<String> expectedWrites = new ArrayList<>();
|
||||
Method markerRoll = IrisCarveModifier.class.getDeclaredMethod("markerRoll", int.class, int.class, int.class, long.class);
|
||||
markerRoll.setAccessible(true);
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int z = 0; z < depth; z++) {
|
||||
for (int floor : new int[]{2, 20}) {
|
||||
int ceiling = floor == 2 ? 5 : 22;
|
||||
expectedCalls.add("floor:" + x + ":" + z + ":" + (floor - 1) + ":" + (ceiling - floor - 1));
|
||||
expectedCalls.add("ceiling:" + x + ":" + z + ":" + ceiling + ":" + (ceiling - floor - 1));
|
||||
expectedWrites.add(x + ":" + floor + ":" + z + ":minecraft:moss_block");
|
||||
if (floor == 2) {
|
||||
expectedWrites.add(x + ":15:" + z + ":minecraft:stone");
|
||||
for (int y = 20; y <= 22; y++) {
|
||||
expectedWrites.add(x + ":" + y + ":" + z + ":minecraft:cave_air");
|
||||
}
|
||||
}
|
||||
expectedWrites.add(x + ":" + ceiling + ":" + z + ":minecraft:calcite");
|
||||
if ((boolean) markerRoll.invoke(fixture.modifier, x - 32, ceiling, z + 48, 0x9E3779B97F4A7C15L)) {
|
||||
expectedMarkers.add((x - 32) + ":" + ceiling + ":" + (z + 48) + ":" + MarkerMatter.CAVE_CEILING);
|
||||
}
|
||||
if ((boolean) markerRoll.invoke(fixture.modifier, x - 32, floor, z + 48, 0xC2B2AE3D27D4EB4FL)) {
|
||||
expectedMarkers.add((x - 32) + ":" + floor + ":" + (z + 48) + ":" + MarkerMatter.CAVE_FLOOR);
|
||||
}
|
||||
}
|
||||
for (int y = 0; y < 32; y++) {
|
||||
PlatformBlockState expected = initialState(fixture, y);
|
||||
if (y == 2 || y == 20) {
|
||||
expected = fixture.floor;
|
||||
} else if (y == 5 || y == 22) {
|
||||
expected = fixture.ceiling;
|
||||
} else if (y == 15) {
|
||||
expected = fixture.stone;
|
||||
} else if (y == 21) {
|
||||
expected = fixture.air;
|
||||
}
|
||||
assertSame("block " + x + ":" + y + ":" + z, expected, output.getRaw(x, y, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(expectedCalls, fixture.calls);
|
||||
assertEquals(expectedWrites, writes);
|
||||
assertEquals(expectedMarkers, fixture.markers);
|
||||
assertTrue("fixture must exercise marker writes", !expectedMarkers.isEmpty());
|
||||
verify(fixture.chunk).use();
|
||||
verify(fixture.chunk).release();
|
||||
}
|
||||
|
||||
private static PlatformBlockState initialState(Fixture fixture, int y) {
|
||||
return y >= 2 && y <= 5 || y == 10 || y >= 14 && y <= 17 || y >= 28
|
||||
? fixture.air : fixture.stone;
|
||||
}
|
||||
|
||||
private static PlatformBlockState block(String key, boolean solid) {
|
||||
PlatformBlockState state = mock(PlatformBlockState.class);
|
||||
doReturn(key).when(state).key();
|
||||
doReturn(key).when(state).materialKey();
|
||||
doReturn(solid).when(state).isSolid();
|
||||
return state;
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final PlatformBlockState air = block("minecraft:cave_air", false);
|
||||
private final PlatformBlockState stone = block("minecraft:stone", true);
|
||||
private final PlatformBlockState floor = block("minecraft:moss_block", true);
|
||||
private final PlatformBlockState ceiling = block("minecraft:calcite", true);
|
||||
private final IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS);
|
||||
private final MantleChunk<Matter> chunk;
|
||||
private final List<String> calls = new ArrayList<>();
|
||||
private final List<String> markers = new ArrayList<>();
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Fixture() throws Exception {
|
||||
Engine engine = mock(Engine.class);
|
||||
EngineMantle engineMantle = mock(EngineMantle.class);
|
||||
Mantle<Matter> mantle = mock(Mantle.class);
|
||||
chunk = mock(MantleChunk.class);
|
||||
doReturn(engine).when(modifier).getEngine();
|
||||
doReturn(32).when(engine).getHeight();
|
||||
doReturn(new SeedManager(1337L)).when(engine).getSeedManager();
|
||||
doReturn(engineMantle).when(engine).getMantle();
|
||||
doReturn(mantle).when(engineMantle).getMantle();
|
||||
doReturn(chunk).when(mantle).getChunk(-2, 3);
|
||||
doReturn(chunk).when(chunk).use();
|
||||
doAnswer(call -> {
|
||||
markers.add(call.getArgument(0) + ":" + call.getArgument(1) + ":" + call.getArgument(2) + ":" + call.getArgument(3));
|
||||
return null;
|
||||
}).when(mantle).set(anyInt(), anyInt(), anyInt(), any());
|
||||
IrisBiome biome = mock(IrisBiome.class);
|
||||
doReturn(new IrisDecorator[]{new IrisDecorator()}).when(biome).getDecoratorBucket(IrisDecorationPart.NONE);
|
||||
doReturn(new IrisDecorator[]{new IrisDecorator()}).when(biome).getDecoratorBucket(IrisDecorationPart.CEILING);
|
||||
doReturn(biome).when(modifier).resolveCaveBoundaryBiome(eq(chunk), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(), any(), any());
|
||||
IrisDecorantActuator decorant = mock(IrisDecorantActuator.class);
|
||||
IrisSurfaceDecorator surface = mock(IrisSurfaceDecorator.class);
|
||||
IrisCeilingDecorator roof = mock(IrisCeilingDecorator.class);
|
||||
doReturn(surface).when(decorant).getSurfaceDecorator();
|
||||
doReturn(roof).when(decorant).getCeilingDecorator();
|
||||
Field field = IrisCarveModifier.class.getDeclaredField("decorant");
|
||||
field.setAccessible(true);
|
||||
field.set(modifier, decorant);
|
||||
doAnswer(call -> {
|
||||
int x = call.getArgument(0);
|
||||
int z = call.getArgument(1);
|
||||
int y = call.getArgument(11);
|
||||
Hunk<PlatformBlockState> output = call.getArgument(8);
|
||||
calls.add("floor:" + x + ":" + z + ":" + y + ":" + call.getArgument(12));
|
||||
output.setRaw(x, y + 1, z, floor);
|
||||
if (y == 1) {
|
||||
output.setRaw(x, 15, z, stone);
|
||||
for (int height = 20; height <= 22; height++) {
|
||||
output.setRaw(x, height, z, air);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}).when(surface).decorate(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(), eq(biome), any(), anyInt(), anyInt());
|
||||
doAnswer(call -> {
|
||||
int x = call.getArgument(0);
|
||||
int z = call.getArgument(1);
|
||||
int y = call.getArgument(11);
|
||||
Hunk<PlatformBlockState> output = call.getArgument(8);
|
||||
calls.add("ceiling:" + x + ":" + z + ":" + y + ":" + call.getArgument(12));
|
||||
output.setRaw(x, y, z, ceiling);
|
||||
return null;
|
||||
}).when(roof).decorate(anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), anyInt(), any(), eq(biome), any(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
private Hunk<PlatformBlockState> output(int width, int depth) {
|
||||
Hunk<PlatformBlockState> output = Hunk.newArrayHunk(width, 32, depth);
|
||||
for (int x = 0; x < width; x++) {
|
||||
for (int z = 0; z < depth; z++) {
|
||||
for (int y = 0; y < 32; y++) {
|
||||
output.setRaw(x, y, z, initialState(this, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-3
@@ -40,6 +40,7 @@ import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.clearInvocations;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -118,6 +119,7 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
assertSame("entry mismatch at worldY=" + worldY + " worldX=" + worldX + " worldZ=" + worldZ, legacyResolved, statefulResolved);
|
||||
}
|
||||
}
|
||||
fixture.clearRecordedCalls();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +140,7 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
assertSame("mixed entry mismatch at worldY=" + worldY + " worldX=" + worldX + " worldZ=" + worldZ, legacyResolved, statefulResolved);
|
||||
}
|
||||
}
|
||||
fixture.clearRecordedCalls();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +157,7 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
assertSame("cave biome mismatch at x=" + x + " y=" + y + " z=" + z, defaultBiome, stateBiome);
|
||||
}
|
||||
}
|
||||
fixture.clearRecordedCalls();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +178,7 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
secondPlan[columnIndex]
|
||||
);
|
||||
}
|
||||
fixture.clearRecordedCalls();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,6 +230,26 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
assertSame(replacementExpected, IrisDimensionCarvingResolver.resolveRootEntry(engine, worldY));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitStateInvalidatesWhenOnlyEngineDataChanges() {
|
||||
Fixture fixture = createFixture();
|
||||
Engine engine = fixture.engine;
|
||||
IrisData firstData = engine.getData();
|
||||
IrisData replacementData = mock(IrisData.class);
|
||||
IrisDimensionCarvingEntry entry = mock(IrisDimensionCarvingEntry.class);
|
||||
IrisBiome firstBiome = new IrisBiome();
|
||||
IrisBiome replacementBiome = new IrisBiome();
|
||||
doReturn(firstBiome).when(entry).getRealBiome(firstData);
|
||||
doReturn(replacementBiome).when(entry).getRealBiome(replacementData);
|
||||
IrisDimensionCarvingResolver.State state = new IrisDimensionCarvingResolver.State();
|
||||
|
||||
assertSame(firstBiome, IrisDimensionCarvingResolver.resolveEntryBiome(engine, entry, state));
|
||||
doReturn(replacementData).when(engine).getData();
|
||||
assertSame(replacementBiome, IrisDimensionCarvingResolver.resolveEntryBiome(engine, entry, state));
|
||||
doReturn(firstData).when(engine).getData();
|
||||
assertSame(firstBiome, IrisDimensionCarvingResolver.resolveEntryBiome(engine, entry, state));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolverStateUsesWeakRuntimeIdentityBindings() throws NoSuchFieldException {
|
||||
Field engineIdentity = IrisDimensionCarvingResolver.State.class.getDeclaredField("engineIdentity");
|
||||
@@ -404,7 +429,9 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
doReturn(surfaceBiome).when(engine).getSurfaceBiome(anyInt(), anyInt());
|
||||
doReturn(fallbackBiome).when(engine).getCaveBiome(anyInt(), anyInt());
|
||||
|
||||
return new Fixture(engine);
|
||||
return new Fixture(engine, new Object[]{engine, dimension, data, biomeLoader,
|
||||
rootLowBiome, rootHighBiome, childABiome, childBBiome, childCBiome,
|
||||
fallbackBiome, surfaceBiome});
|
||||
}
|
||||
|
||||
private Fixture createMixedDepthFixture() {
|
||||
@@ -490,7 +517,9 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
doReturn(surfaceBiome).when(engine).getSurfaceBiome(anyInt(), anyInt());
|
||||
doReturn(fallbackBiome).when(engine).getCaveBiome(anyInt(), anyInt());
|
||||
|
||||
return new Fixture(engine);
|
||||
return new Fixture(engine, new Object[]{engine, dimension, data, biomeLoader,
|
||||
rootLowBiome, rootHighBiome, childABiome, childBBiome, childCBiome,
|
||||
childDBiome, childEBiome, childFBiome, childGBiome, fallbackBiome, surfaceBiome});
|
||||
}
|
||||
|
||||
private IrisDimensionCarvingEntry[] buildColumnPlan(Engine engine, IrisDimensionCarvingEntry rootEntry, int chunkX, int chunkZ, IrisDimensionCarvingResolver.State state) {
|
||||
@@ -660,7 +689,10 @@ public class IrisDimensionCarvingResolverParityTest {
|
||||
return Math.min(depth, MAX_CHILD_DEPTH);
|
||||
}
|
||||
|
||||
private record Fixture(Engine engine) {
|
||||
private record Fixture(Engine engine, Object[] mocks) {
|
||||
private void clearRecordedCalls() {
|
||||
clearInvocations(mocks);
|
||||
}
|
||||
}
|
||||
|
||||
private record RetainedFixture(Engine engine, IrisDimensionCarvingEntry entry) {
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package art.arcane.iris.util.project.noise;
|
||||
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class CNGSignedStateMutationTest {
|
||||
private static final double EPSILON = 1.0E-12D;
|
||||
|
||||
@Test
|
||||
public void eachTransformRefreshesPreviouslyCachedCoordinates() {
|
||||
List<Consumer<CNG>> mutations = List.of(
|
||||
noise -> noise.scale(0.375D),
|
||||
noise -> noise.scale(0.5D).bake().scale(0.75D),
|
||||
noise -> noise.zoom(2D),
|
||||
noise -> noise.oct(3),
|
||||
noise -> noise.up(0.125D),
|
||||
noise -> noise.down(0.125D),
|
||||
noise -> noise.patch(0.75D),
|
||||
noise -> noise.pow(1.25D),
|
||||
noise -> noise.fractureWith(simplex(13L), 4D),
|
||||
noise -> noise.child(simplex(17L)).injectWith(CNG.MULTIPLY));
|
||||
for (Consumer<CNG> mutation : mutations) {
|
||||
CNG sampled = simplex(11L);
|
||||
sampled.noiseFastSigned2D(4D, 8D);
|
||||
sampled.noiseFastSigned3D(4D, 6D, 8D);
|
||||
mutation.accept(sampled);
|
||||
CNG expected = simplex(11L);
|
||||
mutation.accept(expected);
|
||||
assertEquals((expected.noiseFast2D(4D, 8D) * 2D) - 1D,
|
||||
sampled.noiseFastSigned2D(4D, 8D), EPSILON);
|
||||
assertEquals((expected.noiseFast3D(4D, 6D, 8D) * 2D) - 1D,
|
||||
sampled.noiseFastSigned3D(4D, 6D, 8D), EPSILON);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void generatorCallbackMutationInvalidatesOnlySubsequentSamples() {
|
||||
for (boolean threeDimensions : new boolean[]{false, true}) {
|
||||
RecordingNoise source = new RecordingNoise();
|
||||
CNG noise = new CNG(new RNG(11L), source, 1D, 1);
|
||||
source.onSample = () -> noise.scale(0.5D);
|
||||
assertEquals(value(threeDimensions, 4D, 6D, 8D), sample(noise, threeDimensions), 0D);
|
||||
assertEquals(value(threeDimensions, 2D, 3D, 4D), sample(noise, threeDimensions), 0D);
|
||||
assertEquals(value(threeDimensions, 2D, 3D, 4D), sample(noise, threeDimensions), 0D);
|
||||
assertEquals(2, source.calls);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nestedFractureOverridesRetainParentAndChildMutationBoundaries() {
|
||||
for (boolean threeDimensions : new boolean[]{false, true}) {
|
||||
RecordingNoise source = new RecordingNoise();
|
||||
CNG parent = new CNG(new RNG(11L), source, 1D, 1);
|
||||
RecordingNoise fractureSource = new RecordingNoise();
|
||||
MutatingFracture fracture = new MutatingFracture(fractureSource);
|
||||
fracture.beforeSample = () -> {
|
||||
parent.scale(0.5D);
|
||||
fracture.scale(0.25D);
|
||||
};
|
||||
parent.fractureWith(fracture, 8D);
|
||||
double shiftedX = 4D + value(threeDimensions, 1D, 1.5D, 2D) * 4D;
|
||||
double shiftedY = 6D + value(false, 1.5D, 0D, 1D) * 4D;
|
||||
double shiftedZ = 8D + (threeDimensions
|
||||
? value(true, 2D, 1D, 1.5D) : value(false, 2D, 0D, 1D)) * 4D;
|
||||
assertEquals(value(threeDimensions, shiftedX, shiftedY, shiftedZ), sample(parent, threeDimensions), 0D);
|
||||
assertEquals(value(threeDimensions, shiftedX * 0.5D, shiftedY * 0.5D, shiftedZ * 0.5D),
|
||||
sample(parent, threeDimensions), 0D);
|
||||
assertEquals(2, source.calls);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void callbackFailureRetainsItsMutationAndDoesNotPublishASample() {
|
||||
for (boolean threeDimensions : new boolean[]{false, true}) {
|
||||
RecordingNoise source = new RecordingNoise();
|
||||
CNG noise = new CNG(new RNG(11L), source, 1D, 1);
|
||||
IllegalStateException failure = new IllegalStateException("sample failed");
|
||||
source.onSample = () -> {
|
||||
noise.scale(0.25D);
|
||||
throw failure;
|
||||
};
|
||||
assertSame(failure, assertThrows(IllegalStateException.class, () -> sample(noise, threeDimensions)));
|
||||
assertEquals(value(threeDimensions, 1D, 1.5D, 2D), sample(noise, threeDimensions), 0D);
|
||||
assertEquals(value(threeDimensions, 1D, 1.5D, 2D), sample(noise, threeDimensions), 0D);
|
||||
assertEquals(2, source.calls);
|
||||
}
|
||||
}
|
||||
|
||||
private static CNG simplex(long seed) {
|
||||
return new CNG(new RNG(seed), new SimplexNoise(seed), 0.75D, 1);
|
||||
}
|
||||
|
||||
private static double sample(CNG noise, boolean threeDimensions) {
|
||||
return threeDimensions ? noise.noiseFastSigned3D(4D, 6D, 8D) : noise.noiseFastSigned2D(4D, 8D);
|
||||
}
|
||||
|
||||
private static double value(boolean threeDimensions, double x, double y, double z) {
|
||||
return threeDimensions ? x * 0.01D + y * 0.02D + z * 0.03D : x * 0.01D + z * 0.02D;
|
||||
}
|
||||
|
||||
private static final class RecordingNoise implements NoiseGenerator {
|
||||
private Runnable onSample;
|
||||
private int calls;
|
||||
|
||||
@Override
|
||||
public double noise(double x) {
|
||||
return noise(x, 0D);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double noise(double x, double z) {
|
||||
return (noiseSigned(x, z) + 1D) * 0.5D;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double noise(double x, double y, double z) {
|
||||
return (noiseSigned(x, y, z) + 1D) * 0.5D;
|
||||
}
|
||||
|
||||
@Override
|
||||
public double noiseSigned(double x, double z) {
|
||||
sampled();
|
||||
return value(false, x, 0D, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double noiseSigned(double x, double y, double z) {
|
||||
sampled();
|
||||
return value(true, x, y, z);
|
||||
}
|
||||
|
||||
private void sampled() {
|
||||
calls++;
|
||||
Runnable action = onSample;
|
||||
onSample = null;
|
||||
if (action != null) {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutatingFracture extends CNG {
|
||||
private Runnable beforeSample;
|
||||
|
||||
private MutatingFracture(NoiseGenerator source) {
|
||||
super(new RNG(13L), source, 1D, 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double noiseFastSigned2D(double x, double z) {
|
||||
mutate();
|
||||
return super.noiseFastSigned2D(x, z);
|
||||
}
|
||||
|
||||
@Override
|
||||
public double noiseFastSigned3D(double x, double y, double z) {
|
||||
mutate();
|
||||
return super.noiseFastSigned3D(x, y, z);
|
||||
}
|
||||
|
||||
private void mutate() {
|
||||
Runnable action = beforeSample;
|
||||
beforeSample = null;
|
||||
if (action != null) {
|
||||
action.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user