mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Production-readiness suite fixes
Surface hydrology policy split, native terrain height cache, watched-edit hotload cancellation, world-save requestSave with tracked ownership flush, background-task drain that follows saves admitted mid-drain, peaceful spawn guard, cached-completion terminal state, IO drains off the owner thread. Studio pregen at scale: detached historical runtimes keep the configured noise cache instead of the Studio-wide cache; retired runtimes are no longer pinned by the volumetric fragment filter's per-thread scratch; pregen mantle cleanup and native region flushes run on per-run threads instead of the server thread and the shared Iris IO pool (the flush on the IO pool starved tectonic plate loads and wedged every Paper worker in Mantle.getChunks); background-task cancellation claims tasks atomically.
This commit is contained in:
+24
-27
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.core.nms.v26_2_R1;
|
||||
|
||||
import art.arcane.iris.nativegen.NativeTransitionColumn;
|
||||
import art.arcane.iris.nativegen.NativeTerrainHeightCache;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import java.util.Optional;
|
||||
import art.arcane.iris.nativegen.NativeGenerationWriteGuard;
|
||||
@@ -124,6 +125,7 @@ import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -155,6 +157,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
private final int runtimeSeaLevel;
|
||||
private final ConcurrentHashMap<SpawnTableKey, WeightedList<MobSpawnSettings.SpawnerData>> mergedSpawnTables = new ConcurrentHashMap<>();
|
||||
private final ImportedFeatureStage importedFeatures;
|
||||
private final NativeTerrainHeightCache terrainHeights = new NativeTerrainHeightCache();
|
||||
private final AtomicReference<StudioStructureState> retainedStudioStructureState = new AtomicReference<>();
|
||||
private volatile ReachableStructureCache reachableStructureCache;
|
||||
private volatile StructureStepCache structureStepCache;
|
||||
@@ -184,6 +187,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
}
|
||||
|
||||
private void evictRuntimeCaches(int runtimeId) {
|
||||
terrainHeights.evictRuntime(runtimeId);
|
||||
importedFeatures.evictRuntime(runtimeId);
|
||||
mergedSpawnTables.keySet().removeIf(key -> key.runtimeId() == runtimeId);
|
||||
ReachableStructureCache reachable = reachableStructureCache;
|
||||
@@ -765,9 +769,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
@Override
|
||||
public CompletableFuture<ChunkAccess> fillFromNoise(Blender blender, RandomState randomstate, StructureManager structuremanager, ChunkAccess ichunkaccess) {
|
||||
ChunkPos chunkPos = ichunkaccess.getPos();
|
||||
BukkitChunkGenerator.GenerationStagePermit stage = requireNoiseGenerationStage(
|
||||
chunkPos,
|
||||
"bukkit_nms_chunk_pipeline");
|
||||
BukkitChunkGenerator.GenerationStagePermit stage = requireGenerationStage("bukkit_nms_chunk_pipeline");
|
||||
GenerationHistoryRuntimeRouter.RuntimeRoute route;
|
||||
try {
|
||||
route = openHistoryRoute(chunkPos.x(), chunkPos.z(), "bukkit_nms_chunk_pipeline");
|
||||
@@ -1124,10 +1126,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
}
|
||||
IrisStaticObjectLayer staticObjects = engine.getDimension().getStaticObjectLayer(engine.getData());
|
||||
Predicate<BlockPos> protectedPosition = nativeStructureProtection(staticObjects);
|
||||
WorldGenLevel boundedWorld = staticObjects.isEmpty()
|
||||
&& engine.getDimensionStackContext() == null
|
||||
? world
|
||||
: NativeStructureWorldgenAccess.create(
|
||||
WorldGenLevel boundedWorld = NativeStructureWorldgenAccess.create(
|
||||
world, chunkPos, hostWorldgenSurfaceHeight(), hostWorldgenFloorHeight(),
|
||||
engine.getDimensionStackContext() != null,
|
||||
protectedPosition);
|
||||
@@ -1139,11 +1138,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
"vegetation cleanup", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
NativeStructureSurfaceFitter.VacuumFoundationPlan vacuumFoundationPlan;
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan surfaceTerrainPlan;
|
||||
try {
|
||||
vacuumFoundationPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
surfaceTerrainPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
boundedWorld, area, terrainTargets,
|
||||
(x, z) -> Engine.hostHeight(engine, x, z, true) + engine.getMinHeight());
|
||||
surfaceTerrainPlan.primeHeightmaps(chunk);
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"terrain integration", nativeStructureBatchContext(placementGroups),
|
||||
@@ -1171,7 +1171,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
}
|
||||
try {
|
||||
NativeStructureSurfaceFitter.repairVacuumFoundations(
|
||||
boundedWorld, area, vacuumFoundationPlan);
|
||||
boundedWorld, area, surfaceTerrainPlan);
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"foundation repair", nativeStructureBatchContext(placementGroups),
|
||||
@@ -1422,21 +1422,31 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
i, j, "bukkit_nms_base_height");
|
||||
GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_nms_base_height");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
Optional<TerrainBoundarySignature> resolved = engine.getComplex().resolvedTerrainColumn(i, j);
|
||||
NativeTerrainHeightCache.Query query = new NativeTerrainHeightCache.Query(
|
||||
engine.getCacheID(), i, j, heightmap_type,
|
||||
levelheightaccessor.getMinY(), levelheightaccessor.getHeight());
|
||||
OptionalInt resolved = terrainHeights.resolvedHeight(query,
|
||||
() -> resolvedBaseHeight(i, j, heightmap_type, levelheightaccessor));
|
||||
if (resolved.isPresent()) {
|
||||
return NativeTransitionColumn.height(resolved.get(), heightmap_type, levelheightaccessor);
|
||||
return resolved.getAsInt();
|
||||
}
|
||||
boolean ignoreFluid = !heightmap_type.isOpaque().test(Blocks.WATER.defaultBlockState());
|
||||
int height = engine.getDimensionStackContext() == null
|
||||
? engine.getHeight(i, j, ignoreFluid)
|
||||
: Engine.hostHeight(engine, i, j, ignoreFluid);
|
||||
return levelheightaccessor.getMinY()
|
||||
+ height + 1;
|
||||
return levelheightaccessor.getMinY() + height + 1;
|
||||
} catch (GenerationSessionException e) {
|
||||
throw new IllegalStateException("Iris base height query could not acquire its engine runtime.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private OptionalInt resolvedBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor) {
|
||||
Optional<TerrainBoundarySignature> resolved = engine.getComplex().resolvedTerrainColumn(x, z);
|
||||
return resolved.isPresent()
|
||||
? OptionalInt.of(NativeTransitionColumn.height(resolved.get(), type, heightAccessor))
|
||||
: OptionalInt.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoiseColumn getBaseColumn(int i, int j, LevelHeightAccessor levelheightaccessor, RandomState randomstate) {
|
||||
if (platformGenerator != null && platformGenerator.usesFlatStudioTerrain()) {
|
||||
@@ -1564,19 +1574,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator implements LongPred
|
||||
: platformGenerator.acquireGenerationStage(operation);
|
||||
}
|
||||
|
||||
private BukkitChunkGenerator.GenerationStagePermit requireNoiseGenerationStage(
|
||||
ChunkPos chunkPos,
|
||||
String operation
|
||||
) {
|
||||
return platformGenerator == null
|
||||
? BukkitChunkGenerator.GenerationStagePermit.noop()
|
||||
: platformGenerator.acquireNoiseGenerationStage(
|
||||
engine,
|
||||
chunkPos.x(),
|
||||
chunkPos.z(),
|
||||
operation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Identifier> getTypeNameForDataFixer() {
|
||||
return delegate.getTypeNameForDataFixer();
|
||||
|
||||
+9
-5
@@ -120,6 +120,7 @@ import net.minecraft.world.level.levelgen.flat.FlatLayerInfo;
|
||||
import net.minecraft.world.level.levelgen.flat.FlatLevelGeneratorSettings;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Difficulty;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -1155,12 +1156,8 @@ public class NMSBinding implements INMSBinding {
|
||||
|
||||
@Override
|
||||
public void flushChunkIO(World world) {
|
||||
try {
|
||||
ServerLevel level = ((CraftWorld) world).getHandle();
|
||||
MoonriseRegionFileIO.flush(level);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1443,7 +1440,14 @@ public class NMSBinding implements INMSBinding {
|
||||
if (location == null || location.getWorld() == null || type == null || type.getEntityClass() == null) {
|
||||
return null;
|
||||
}
|
||||
return ((CraftWorld) location.getWorld()).spawn(location, type.getEntityClass(), null, reason);
|
||||
CraftWorld world = (CraftWorld) location.getWorld();
|
||||
if (world.getDifficulty() == Difficulty.PEACEFUL) {
|
||||
EntityType<?> nativeType = BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(type.getKey().toString()));
|
||||
if (nativeType == null || !nativeType.isAllowedInPeaceful()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return world.spawn(location, type.getEntityClass(), null, reason);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+124
-40
@@ -3,6 +3,8 @@ package art.arcane.iris.core.nms.v26_2_R1;
|
||||
import art.arcane.iris.engine.history.NativeTerrainReceipt;
|
||||
import art.arcane.iris.engine.history.SavedTerrainChunk;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import ca.spottedleaf.concurrentutil.completable.Completable;
|
||||
import ca.spottedleaf.concurrentutil.executor.PrioritisedExecutor;
|
||||
import ca.spottedleaf.concurrentutil.util.Priority;
|
||||
import ca.spottedleaf.moonrise.common.PlatformHooks;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO;
|
||||
@@ -26,31 +28,34 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
final class NmsSavedTerrainCapture {
|
||||
private static final long WRITE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(30L);
|
||||
private static final int SNAPSHOT_BATCH_SIZE = 64;
|
||||
|
||||
private NmsSavedTerrainCapture() {
|
||||
}
|
||||
|
||||
static CompletableFuture<SavedTerrainChunk> capture(World world, int chunkX, int chunkZ, int minimumY, int height) {
|
||||
ServerLevel level = ((CraftWorld) world).getHandle();
|
||||
CompletableFuture<CompoundTag> snapshot = new CompletableFuture<>();
|
||||
CompletableFuture<CapturedChunk> snapshot = new CompletableFuture<>();
|
||||
J.runRegionFuture(world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
NewChunkHolder holder = level.moonrise$getChunkTaskScheduler().chunkHolderManager.getChunkHolder(chunkX, chunkZ);
|
||||
ChunkAccess chunk = holder == null ? null : holder.getCurrentChunk();
|
||||
if (chunk == null) {
|
||||
snapshot.complete(null);
|
||||
snapshot.complete(new CapturedChunk(new ChunkPos(chunkX, chunkZ), CompletableFuture.completedFuture(null)));
|
||||
return;
|
||||
}
|
||||
String status = BuiltInRegistries.CHUNK_STATUS.getKey(chunk.getPersistedStatus()).toString();
|
||||
if (!SavedTerrainChunk.hasTerrain(status)) {
|
||||
throw new IOException("Native chunk " + chunkX + "," + chunkZ + " has no completed terrain at " + status);
|
||||
}
|
||||
snapshot.complete(saveSnapshot(level, chunk));
|
||||
snapshot.complete(scheduleSnapshot(level, chunk));
|
||||
} catch (Throwable failure) {
|
||||
snapshot.completeExceptionally(failure);
|
||||
}
|
||||
@@ -59,10 +64,12 @@ final class NmsSavedTerrainCapture {
|
||||
snapshot.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
return snapshot.thenApplyAsync(data -> {
|
||||
return snapshot.thenApplyAsync(captured -> {
|
||||
try {
|
||||
long deadline = System.nanoTime() + WRITE_TIMEOUT_NANOS;
|
||||
CompoundTag data = awaitSnapshot(level, captured, deadline);
|
||||
Checkpoint checkpoint = checkpoint(chunkX, chunkZ, data);
|
||||
awaitWrite(level, checkpoint.chunk(), System.nanoTime() + WRITE_TIMEOUT_NANOS);
|
||||
awaitWrite(level, checkpoint.chunk(), deadline);
|
||||
if (data == null) {
|
||||
return SavedTerrainChunk.read(world.getWorldFolder().toPath(), chunkX, chunkZ, minimumY, height);
|
||||
}
|
||||
@@ -81,37 +88,73 @@ final class NmsSavedTerrainCapture {
|
||||
static CompletableFuture<Void> flush(World world) {
|
||||
ServerLevel level = ((CraftWorld) world).getHandle();
|
||||
List<NewChunkHolder> holders = level.moonrise$getChunkTaskScheduler().chunkHolderManager.getChunkHolders();
|
||||
List<CompletableFuture<Checkpoint>> saves = new ArrayList<>(holders.size());
|
||||
for (NewChunkHolder holder : holders) {
|
||||
CompletableFuture<Checkpoint> saved = new CompletableFuture<>();
|
||||
saves.add(saved);
|
||||
J.runRegionFuture(world, holder.chunkX, holder.chunkZ, () -> {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
ChunkAccess chunk = holder.getCurrentChunk();
|
||||
saved.complete(checkpoint(holder.chunkX, holder.chunkZ,
|
||||
chunk != null && chunk.isUnsaved() ? saveSnapshot(level, chunk) : null));
|
||||
} catch (Throwable failure) {
|
||||
saved.completeExceptionally(failure);
|
||||
}
|
||||
}).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
saved.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
return CompletableFuture.allOf(saves.toArray(CompletableFuture[]::new)).thenRunAsync(() -> {
|
||||
try {
|
||||
List<Checkpoint> checkpoints = new ArrayList<>(saves.size());
|
||||
for (CompletableFuture<Checkpoint> future : saves) {
|
||||
checkpoints.add(future.join());
|
||||
}
|
||||
finishCheckpoint(level, world.getWorldFolder().toPath(), checkpoints);
|
||||
flushSnapshots(world, level, holders);
|
||||
} catch (IOException failure) {
|
||||
throw new CompletionException(failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static void flushSnapshots(World world, ServerLevel level, List<NewChunkHolder> holders) throws IOException {
|
||||
long deadline = System.nanoTime() + WRITE_TIMEOUT_NANOS;
|
||||
List<Checkpoint> checkpoints = new ArrayList<>(holders.size());
|
||||
for (int offset = 0; offset < holders.size(); offset += SNAPSHOT_BATCH_SIZE) {
|
||||
int end = Math.min(holders.size(), offset + SNAPSHOT_BATCH_SIZE);
|
||||
List<CompletableFuture<CapturedChunk>> snapshots = requestSnapshots(world, level, holders.subList(offset, end));
|
||||
awaitSnapshots(snapshots, deadline);
|
||||
for (CompletableFuture<CapturedChunk> future : snapshots) {
|
||||
CapturedChunk captured = future.join();
|
||||
CompoundTag data = awaitSnapshot(level, captured, deadline);
|
||||
checkpoints.add(checkpoint(captured.chunk().x(), captured.chunk().z(), data));
|
||||
}
|
||||
for (int index = offset; index < end; index++) {
|
||||
awaitWrite(level, checkpoints.get(index).chunk(), deadline);
|
||||
}
|
||||
}
|
||||
finishCheckpoint(level, world.getWorldFolder().toPath(), checkpoints);
|
||||
}
|
||||
|
||||
private static List<CompletableFuture<CapturedChunk>> requestSnapshots(
|
||||
World world, ServerLevel level, List<NewChunkHolder> holders) {
|
||||
List<CompletableFuture<CapturedChunk>> snapshots = new ArrayList<>(holders.size());
|
||||
for (NewChunkHolder original : holders) {
|
||||
ChunkPos position = new ChunkPos(original.chunkX, original.chunkZ);
|
||||
CompletableFuture<CapturedChunk> snapshot = new CompletableFuture<>();
|
||||
snapshots.add(snapshot);
|
||||
J.runRegionFuture(world, position.x(), position.z(), () -> {
|
||||
try {
|
||||
NewChunkHolder holder = level.moonrise$getChunkTaskScheduler().chunkHolderManager
|
||||
.getChunkHolder(position.x(), position.z());
|
||||
ChunkAccess chunk = holder == null ? null : holder.getCurrentChunk();
|
||||
snapshot.complete(chunk != null && chunk.isUnsaved() ? scheduleSnapshot(level, chunk)
|
||||
: new CapturedChunk(position, CompletableFuture.completedFuture(null)));
|
||||
} catch (Throwable failure) {
|
||||
snapshot.completeExceptionally(failure);
|
||||
}
|
||||
}).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
snapshot.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
private static void awaitSnapshots(List<CompletableFuture<CapturedChunk>> snapshots, long deadlineNanos)
|
||||
throws IOException {
|
||||
try {
|
||||
CompletableFuture.allOf(snapshots.toArray(CompletableFuture[]::new))
|
||||
.get(Math.max(0L, deadlineNanos - System.nanoTime()), TimeUnit.NANOSECONDS);
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while copying terrain checkpoint chunks.", failure);
|
||||
} catch (ExecutionException | TimeoutException failure) {
|
||||
throw new IOException("Unable to copy terrain checkpoint chunks.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
static void finishCheckpoint(ServerLevel level, Path worldFolder, List<Checkpoint> checkpoints) throws IOException {
|
||||
long deadline = System.nanoTime() + WRITE_TIMEOUT_NANOS;
|
||||
for (Checkpoint checkpoint : checkpoints) {
|
||||
@@ -147,31 +190,72 @@ final class NmsSavedTerrainCapture {
|
||||
static void awaitWrite(ServerLevel level, ChunkPos chunk, long deadlineNanos) throws IOException {
|
||||
ChunkTaskScheduler scheduler = level.moonrise$getChunkTaskScheduler();
|
||||
while (true) {
|
||||
checkWriteWait(chunk, deadlineNanos);
|
||||
if (MoonriseRegionFileIO.getPriority(level, chunk.x(), chunk.z(),
|
||||
MoonriseRegionFileIO.RegionFileType.CHUNK_DATA) == Priority.COMPLETING) {
|
||||
return;
|
||||
}
|
||||
helpSaveTasks(scheduler);
|
||||
}
|
||||
}
|
||||
|
||||
static CapturedChunk scheduleSnapshot(ServerLevel level, ChunkAccess chunk) {
|
||||
SerializableChunkData snapshot = SerializableChunkData.copyOf(level, chunk);
|
||||
PlatformHooks.get().chunkSyncSave(level, chunk, snapshot);
|
||||
Completable<CompoundTag> nativeData = new Completable<>();
|
||||
CompletableFuture<CompoundTag> data = new CompletableFuture<>();
|
||||
PrioritisedExecutor.PrioritisedTask task = level.moonrise$getChunkTaskScheduler().saveExecutor
|
||||
.createTask(() -> serializeSnapshot(snapshot, nativeData, data));
|
||||
task.queue();
|
||||
ChunkPos position = chunk.getPos();
|
||||
MoonriseRegionFileIO.scheduleSave(level, position.x(), position.z(), nativeData, task,
|
||||
MoonriseRegionFileIO.RegionFileType.CHUNK_DATA, Priority.NORMAL);
|
||||
return new CapturedChunk(position, data);
|
||||
}
|
||||
|
||||
static CompoundTag awaitSnapshot(ServerLevel level, CapturedChunk captured, long deadlineNanos) throws IOException {
|
||||
ChunkTaskScheduler scheduler = level.moonrise$getChunkTaskScheduler();
|
||||
while (!captured.data().isDone()) {
|
||||
checkWriteWait(captured.chunk(), deadlineNanos);
|
||||
helpSaveTasks(scheduler);
|
||||
}
|
||||
try {
|
||||
return captured.data().join();
|
||||
} catch (CompletionException failure) {
|
||||
throw new IOException("Unable to serialize terrain checkpoint chunk " + captured.chunk(), failure.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private static void serializeSnapshot(SerializableChunkData snapshot, Completable<CompoundTag> nativeData,
|
||||
CompletableFuture<CompoundTag> data) {
|
||||
try {
|
||||
CompoundTag serialized = snapshot.write();
|
||||
nativeData.complete(serialized);
|
||||
data.complete(serialized);
|
||||
} catch (Throwable failure) {
|
||||
nativeData.completeExceptionally(failure);
|
||||
data.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkWriteWait(ChunkPos chunk, long deadlineNanos) throws IOException {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
throw new IOException("Interrupted while saving terrain checkpoint chunk " + chunk);
|
||||
}
|
||||
if (System.nanoTime() - deadlineNanos >= 0L) {
|
||||
throw new IOException("Timed out saving terrain checkpoint chunk " + chunk);
|
||||
}
|
||||
if (MoonriseRegionFileIO.getPriority(level, chunk.x(), chunk.z(),
|
||||
MoonriseRegionFileIO.RegionFileType.CHUNK_DATA) == Priority.COMPLETING) {
|
||||
return;
|
||||
}
|
||||
|
||||
private static void helpSaveTasks(ChunkTaskScheduler scheduler) {
|
||||
boolean helped = scheduler.saveExecutor.executeTask();
|
||||
helped |= scheduler.compressionExecutor.executeTask();
|
||||
if (!helped) {
|
||||
LockSupport.parkNanos(1_000_000L);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static CompoundTag saveSnapshot(ServerLevel level, ChunkAccess chunk) {
|
||||
SerializableChunkData snapshot = SerializableChunkData.copyOf(level, chunk);
|
||||
PlatformHooks.get().chunkSyncSave(level, chunk, snapshot);
|
||||
CompoundTag data = snapshot.write();
|
||||
MoonriseRegionFileIO.scheduleSave(level, chunk.getPos().x(), chunk.getPos().z(), data,
|
||||
MoonriseRegionFileIO.RegionFileType.CHUNK_DATA);
|
||||
return data;
|
||||
record CapturedChunk(ChunkPos chunk, CompletableFuture<CompoundTag> data) {
|
||||
}
|
||||
|
||||
record Checkpoint(ChunkPos chunk, Verification verification) {
|
||||
|
||||
+91
@@ -1,9 +1,14 @@
|
||||
package art.arcane.iris.core.nms.v26_2_R1;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.iris.nativegen.NativeStructurePostProcessor;
|
||||
import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
@@ -12,10 +17,25 @@ import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.entity.EntityTypeTest;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.GravityProcessor;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
|
||||
import net.minecraft.world.level.material.Fluid;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
@@ -30,11 +50,13 @@ import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class NativeStructureWorldgenAccessTest {
|
||||
@@ -49,6 +71,69 @@ public class NativeStructureWorldgenAccessTest {
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ordinaryWorldFlattenPreparationHandlesReferencedVillagePiecesBeyondEightChunks() {
|
||||
int farX = (GENERATION_CENTER_X - 9) << 4;
|
||||
int farZ = (GENERATION_CENTER_Z + 4) << 4;
|
||||
BoundingBox bounds = new BoundingBox(farX, 64, farZ, farX + 4, 90, farZ + 4);
|
||||
PoolElementStructurePiece piece = new PoolElementStructurePiece(
|
||||
null, StructurePoolElement.single("minecraft:empty").apply(StructureTemplatePool.Projection.RIGID),
|
||||
new BlockPos(farX, 64, farZ), 1, Rotation.NONE, bounds, LiquidSettings.APPLY_WATERLOGGING);
|
||||
Structure structure = new DesertPyramidStructure(new Structure.StructureSettings(
|
||||
HolderSet.empty(), Map.of(), GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.BEARD_BOX));
|
||||
StructureStart start = new StructureStart(structure, generationCenter(), 0, new PiecesContainer(List.of(piece)));
|
||||
List<NativeStructureTerrainIntegrator.TerrainTarget> targets = List.of(
|
||||
new NativeStructureTerrainIntegrator.TerrainTarget("minecraft:village_plains", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.FLATTEN)));
|
||||
BoundingBox area = new BoundingBox(generationCenter().getMinBlockX(), -64, generationCenter().getMinBlockZ(),
|
||||
generationCenter().getMaxBlockX(), 319, generationCenter().getMaxBlockZ());
|
||||
RecordingDelegate unbounded = new RecordingDelegate();
|
||||
unbounded.rejectDistantTerrainReads = true;
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> NativeStructurePostProcessor.prepareTerrain(
|
||||
unbounded.world(), area, targets, (palette, rng, x, y, z) -> Blocks.STONE.defaultBlockState()));
|
||||
|
||||
RecordingDelegate recording = new RecordingDelegate();
|
||||
recording.rejectDistantTerrainReads = true;
|
||||
NativeStructureWorldgenAccess access = access(recording);
|
||||
NativeStructurePostProcessor.prepareTerrain(
|
||||
access, area, targets, (palette, rng, x, y, z) -> Blocks.STONE.defaultBlockState());
|
||||
|
||||
assertEquals(0, recording.heightReads);
|
||||
assertEquals(0, recording.chunkReads);
|
||||
assertEquals(0, recording.terrainReads);
|
||||
assertEquals(0, recording.mutations);
|
||||
assertEquals(91, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
generationCenter().getMinBlockX(), generationCenter().getMinBlockZ()));
|
||||
assertEquals(1, recording.heightReads);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectedTemplatePrefiltersUseRemoteTerrainAndRetainLocalHeightmaps() {
|
||||
RecordingDelegate recording = new RecordingDelegate();
|
||||
recording.rejectDistantTerrainReads = true;
|
||||
NativeStructureWorldgenAccess access = access(recording);
|
||||
GravityProcessor gravity = new GravityProcessor(Heightmap.Types.WORLD_SURFACE_WG, -1);
|
||||
BlockPos remote = new BlockPos((GENERATION_CENTER_X - 9) << 4, 64, (GENERATION_CENTER_Z + 4) << 4);
|
||||
StructureTemplate.StructureBlockInfo raw = new StructureTemplate.StructureBlockInfo(
|
||||
new BlockPos(0, 4, 0), Blocks.DIRT_PATH.defaultBlockState(), null);
|
||||
StructurePlaceSettings settings = new StructurePlaceSettings().addProcessor(gravity);
|
||||
|
||||
StructureTemplate.StructureBlockInfo projected = StructureTemplate.processBlockInfos(
|
||||
access, remote, BlockPos.ZERO, settings, List.of(raw)).getFirst();
|
||||
|
||||
assertEquals(remote.atY(SURFACE_FIRST_FREE_Y + 3), projected.pos());
|
||||
assertEquals(0, recording.heightReads);
|
||||
BlockPos local = generationCenter().getMiddleBlockPosition(64);
|
||||
StructureTemplate.StructureBlockInfo localBlock = new StructureTemplate.StructureBlockInfo(
|
||||
BlockPos.ZERO, Blocks.DIRT_PATH.defaultBlockState(), null);
|
||||
StructureTemplate.StructureBlockInfo localProjection = StructureTemplate.processBlockInfos(
|
||||
access, local, BlockPos.ZERO, settings, List.of(localBlock)).getFirst();
|
||||
assertEquals(local.atY(90), localProjection.pos());
|
||||
assertEquals(1, recording.heightReads);
|
||||
assertEquals(0, recording.chunkReads);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void distanceTwoTerrainReadsNeverReachTheDelegate() {
|
||||
RecordingDelegate recording = new RecordingDelegate();
|
||||
@@ -253,6 +338,7 @@ public class NativeStructureWorldgenAccessTest {
|
||||
private int statusChunkReads;
|
||||
private int mutations;
|
||||
private int events;
|
||||
private boolean rejectDistantTerrainReads;
|
||||
|
||||
private RecordingDelegate() {
|
||||
this.biome = Holder.direct((Biome) null);
|
||||
@@ -293,6 +379,11 @@ public class NativeStructureWorldgenAccessTest {
|
||||
return 384;
|
||||
}
|
||||
if (name.equals("getHeight")) {
|
||||
if (rejectDistantTerrainReads
|
||||
&& (Math.abs(((int) arguments[1] >> 4) - GENERATION_CENTER_X) > 1
|
||||
|| Math.abs(((int) arguments[2] >> 4) - GENERATION_CENTER_Z) > 1)) {
|
||||
throw new IllegalStateException("Requested chunk unavailable during world generation");
|
||||
}
|
||||
heightReads++;
|
||||
return 91;
|
||||
}
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package art.arcane.iris.core.nms.v26_2_R1;
|
||||
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO.RegionDataController;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
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.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NmsChunkIOFlushTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeDrainCanWaitOffOwnerUntilChunkPoiAndEntityIoFinish() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
AtomicBoolean chunkPending = new AtomicBoolean(true);
|
||||
AtomicBoolean poiPending = new AtomicBoolean(true);
|
||||
AtomicBoolean entityPending = new AtomicBoolean(true);
|
||||
CountDownLatch chunkDrain = new CountDownLatch(1);
|
||||
CountDownLatch poiDrain = new CountDownLatch(1);
|
||||
CountDownLatch entityDrain = new CountDownLatch(1);
|
||||
when(fixture.chunks.hasTasks()).thenAnswer(invocation -> {
|
||||
chunkDrain.countDown();
|
||||
return chunkPending.get();
|
||||
});
|
||||
when(fixture.pois.hasTasks()).thenAnswer(invocation -> {
|
||||
poiDrain.countDown();
|
||||
return poiPending.get();
|
||||
});
|
||||
when(fixture.entities.hasTasks()).thenAnswer(invocation -> {
|
||||
entityDrain.countDown();
|
||||
return entityPending.get();
|
||||
});
|
||||
ExecutorService io = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<?> drain = io.submit(() -> fixture.binding.flushChunkIO(fixture.world));
|
||||
assertTrue(chunkDrain.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(drain.isDone());
|
||||
chunkPending.set(false);
|
||||
assertTrue(poiDrain.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(drain.isDone());
|
||||
poiPending.set(false);
|
||||
assertTrue(entityDrain.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(drain.isDone());
|
||||
entityPending.set(false);
|
||||
drain.get(5L, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
chunkPending.set(false);
|
||||
poiPending.set(false);
|
||||
entityPending.set(false);
|
||||
io.shutdownNow();
|
||||
assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeDrainFailurePropagatesToItsCompletionReceipt() {
|
||||
Fixture fixture = new Fixture();
|
||||
IllegalStateException failure = new IllegalStateException("native IO failed");
|
||||
when(fixture.chunks.hasTasks()).thenThrow(failure);
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> fixture.binding.flushChunkIO(fixture.world)));
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS);
|
||||
private final CraftWorld world = mock(CraftWorld.class);
|
||||
private final ServerLevel level = mock(ServerLevel.class);
|
||||
private final RegionDataController chunks = mock(RegionDataController.class);
|
||||
private final RegionDataController pois = mock(RegionDataController.class);
|
||||
private final RegionDataController entities = mock(RegionDataController.class);
|
||||
|
||||
private Fixture() {
|
||||
when(world.getHandle()).thenReturn(level);
|
||||
when(level.moonrise$getChunkDataController()).thenReturn(chunks);
|
||||
when(level.moonrise$getPoiChunkDataController()).thenReturn(pois);
|
||||
when(level.moonrise$getEntityChunkDataController()).thenReturn(entities);
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package art.arcane.iris.core.nms.v26_2_R1;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import org.bukkit.Difficulty;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.craftbukkit.CraftWorld;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
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.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NmsEntitySpawnDifficultyTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void peacefulRejectsEveryNativeForbiddenTypeBeforeBukkitCreatesIt() {
|
||||
CraftWorld world = mock(CraftWorld.class);
|
||||
when(world.getDifficulty()).thenReturn(Difficulty.PEACEFUL);
|
||||
NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS);
|
||||
Location location = new Location(world, 0.5, 80, 0.5);
|
||||
int rejected = 0;
|
||||
|
||||
for (EntityType type : EntityType.values()) {
|
||||
if (type.getEntityClass() == null) {
|
||||
continue;
|
||||
}
|
||||
if (BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(type.getKey().toString())).isAllowedInPeaceful()) {
|
||||
continue;
|
||||
}
|
||||
assertNull(type.name(), binding.spawnEntity(location, type, CreatureSpawnEvent.SpawnReason.NATURAL));
|
||||
rejected++;
|
||||
}
|
||||
|
||||
assertTrue(rejected > 20);
|
||||
verify(world, never()).spawn(any(Location.class), any(Class.class), isNull(), any(CreatureSpawnEvent.SpawnReason.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void peacefulPreservesPassiveAndNativeMonsterCategoryExceptions() {
|
||||
CraftWorld world = mock(CraftWorld.class);
|
||||
when(world.getDifficulty()).thenReturn(Difficulty.PEACEFUL);
|
||||
NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS);
|
||||
Location location = new Location(world, 0.5, 80, 0.5);
|
||||
Entity created = mock(Entity.class);
|
||||
doReturn(created).when(world).spawn(eq(location), any(Class.class), isNull(), eq(CreatureSpawnEvent.SpawnReason.NATURAL));
|
||||
|
||||
for (EntityType type : List.of(EntityType.COW, EntityType.VILLAGER, EntityType.WOLF, EntityType.PIGLIN, EntityType.SHULKER)) {
|
||||
assertSame(type.name(), created, binding.spawnEntity(location, type, CreatureSpawnEvent.SpawnReason.NATURAL));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonPeacefulDifficultiesPreserveHostileSpawns() {
|
||||
CraftWorld world = mock(CraftWorld.class);
|
||||
NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS);
|
||||
Location location = new Location(world, 0.5, 80, 0.5);
|
||||
Entity created = mock(Entity.class);
|
||||
doReturn(created).when(world).spawn(eq(location), any(Class.class), isNull(), eq(CreatureSpawnEvent.SpawnReason.NATURAL));
|
||||
|
||||
for (Difficulty difficulty : List.of(Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD)) {
|
||||
when(world.getDifficulty()).thenReturn(difficulty);
|
||||
assertSame(created, binding.spawnEntity(location, EntityType.ZOMBIE, CreatureSpawnEvent.SpawnReason.NATURAL));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedBukkitSpawnFailuresStillPropagate() {
|
||||
CraftWorld world = mock(CraftWorld.class);
|
||||
when(world.getDifficulty()).thenReturn(Difficulty.PEACEFUL);
|
||||
NMSBinding binding = mock(NMSBinding.class, CALLS_REAL_METHODS);
|
||||
Location location = new Location(world, 0.5, 80, 0.5);
|
||||
IllegalStateException failure = new IllegalStateException("Spawn rejected by platform");
|
||||
doThrow(failure).when(world).spawn(eq(location), any(Class.class), isNull(), eq(CreatureSpawnEvent.SpawnReason.NATURAL));
|
||||
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> binding.spawnEntity(location, EntityType.COW, CreatureSpawnEvent.SpawnReason.NATURAL)));
|
||||
}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package art.arcane.iris.core.nms.v26_2_R1;
|
||||
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import ca.spottedleaf.concurrentutil.completable.Completable;
|
||||
import ca.spottedleaf.concurrentutil.executor.PrioritisedExecutor;
|
||||
import ca.spottedleaf.concurrentutil.executor.thread.BalancedPrioritisedThreadPool;
|
||||
import ca.spottedleaf.concurrentutil.util.Priority;
|
||||
import ca.spottedleaf.moonrise.common.PlatformHooks;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.io.MoonriseRegionFileIO;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkHolderManager;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.ChunkTaskScheduler;
|
||||
import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.NewChunkHolder;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.storage.SerializableChunkData;
|
||||
import org.bukkit.World;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NmsSavedTerrainSnapshotTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ownerReservesSaveOrderBeforeOffOwnerSerializationAndLaterAutosave() throws Exception {
|
||||
try (SaveFixture fixture = new SaveFixture();
|
||||
ExecutorService checkpoint = Executors.newSingleThreadExecutor();
|
||||
MockedStatic<SerializableChunkData> snapshots = mockStatic(SerializableChunkData.class);
|
||||
MockedStatic<PlatformHooks> hooks = mockStatic(PlatformHooks.class);
|
||||
MockedStatic<MoonriseRegionFileIO> io = mockStatic(MoonriseRegionFileIO.class)) {
|
||||
Thread owner = Thread.currentThread();
|
||||
ChunkAccess chunk = mock(ChunkAccess.class);
|
||||
SerializableChunkData snapshot = mock(SerializableChunkData.class);
|
||||
PlatformHooks platform = mock(PlatformHooks.class);
|
||||
CompoundTag capturedData = new CompoundTag();
|
||||
CompoundTag autosave = new CompoundTag();
|
||||
List<String> submissions = new ArrayList<>();
|
||||
when(chunk.getPos()).thenReturn(new ChunkPos(4, -9));
|
||||
snapshots.when(() -> SerializableChunkData.copyOf(fixture.level, chunk)).thenAnswer(invocation -> {
|
||||
assertSame(owner, Thread.currentThread());
|
||||
return snapshot;
|
||||
});
|
||||
hooks.when(PlatformHooks::get).thenReturn(platform);
|
||||
doAnswer(invocation -> {
|
||||
assertSame(owner, Thread.currentThread());
|
||||
return null;
|
||||
}).when(platform).chunkSyncSave(fixture.level, chunk, snapshot);
|
||||
when(snapshot.write()).thenAnswer(invocation -> {
|
||||
assertNotSame(owner, Thread.currentThread());
|
||||
assertEquals(List.of("checkpoint", "autosave"), submissions);
|
||||
return capturedData;
|
||||
});
|
||||
io.when(() -> MoonriseRegionFileIO.scheduleSave(eq(fixture.level), eq(4), eq(-9),
|
||||
any(Completable.class), any(PrioritisedExecutor.PrioritisedTask.class),
|
||||
eq(MoonriseRegionFileIO.RegionFileType.CHUNK_DATA), eq(Priority.NORMAL)))
|
||||
.thenAnswer(invocation -> {
|
||||
assertSame(owner, Thread.currentThread());
|
||||
submissions.add("checkpoint");
|
||||
return null;
|
||||
});
|
||||
io.when(() -> MoonriseRegionFileIO.scheduleSave(fixture.level, 4, -9, autosave,
|
||||
MoonriseRegionFileIO.RegionFileType.CHUNK_DATA)).thenAnswer(invocation -> {
|
||||
submissions.add("autosave");
|
||||
return null;
|
||||
});
|
||||
|
||||
NmsSavedTerrainCapture.CapturedChunk captured = NmsSavedTerrainCapture.scheduleSnapshot(fixture.level, chunk);
|
||||
|
||||
assertEquals(List.of("checkpoint"), submissions);
|
||||
assertFalse(captured.data().isDone());
|
||||
verify(snapshot, never()).write();
|
||||
verify(platform).chunkSyncSave(fixture.level, chunk, snapshot);
|
||||
MoonriseRegionFileIO.scheduleSave(fixture.level, 4, -9, autosave,
|
||||
MoonriseRegionFileIO.RegionFileType.CHUNK_DATA);
|
||||
Future<CompoundTag> result = checkpoint.submit(() -> NmsSavedTerrainCapture.awaitSnapshot(
|
||||
fixture.level, captured, System.nanoTime() + TimeUnit.SECONDS.toNanos(3)));
|
||||
|
||||
assertSame(capturedData, result.get(5, TimeUnit.SECONDS));
|
||||
verify(snapshot).write();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedOffOwnerSerializationCannotSucceedAsACheckpoint() throws Exception {
|
||||
try (SaveFixture fixture = new SaveFixture();
|
||||
ExecutorService checkpoint = Executors.newSingleThreadExecutor();
|
||||
MockedStatic<SerializableChunkData> snapshots = mockStatic(SerializableChunkData.class);
|
||||
MockedStatic<PlatformHooks> hooks = mockStatic(PlatformHooks.class);
|
||||
MockedStatic<MoonriseRegionFileIO> io = mockStatic(MoonriseRegionFileIO.class)) {
|
||||
ChunkAccess chunk = mock(ChunkAccess.class);
|
||||
SerializableChunkData snapshot = mock(SerializableChunkData.class);
|
||||
IllegalStateException failure = new IllegalStateException("serialization failed");
|
||||
when(chunk.getPos()).thenReturn(new ChunkPos(4, -9));
|
||||
snapshots.when(() -> SerializableChunkData.copyOf(fixture.level, chunk)).thenReturn(snapshot);
|
||||
hooks.when(PlatformHooks::get).thenReturn(mock(PlatformHooks.class));
|
||||
when(snapshot.write()).thenThrow(failure);
|
||||
NmsSavedTerrainCapture.CapturedChunk captured = NmsSavedTerrainCapture.scheduleSnapshot(fixture.level, chunk);
|
||||
|
||||
Future<IOException> result = checkpoint.submit(() -> assertThrows(IOException.class,
|
||||
() -> NmsSavedTerrainCapture.awaitSnapshot(fixture.level, captured,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(3))));
|
||||
|
||||
assertSame(failure, result.get(5, TimeUnit.SECONDS).getCause());
|
||||
assertTrue(captured.data().isCompletedExceptionally());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void checkpointBoundsOwnerRequestsUntilPriorNativeWritesFinish() throws Exception {
|
||||
try (SaveFixture fixture = new SaveFixture();
|
||||
ExecutorService owner = Executors.newSingleThreadExecutor();
|
||||
ExecutorService checkpoint = Executors.newSingleThreadExecutor()) {
|
||||
World world = mock(World.class);
|
||||
when(world.getWorldFolder()).thenReturn(new File("checkpoint-world"));
|
||||
List<NewChunkHolder> holders = new ArrayList<>(129);
|
||||
for (int index = 0; index < 129; index++) {
|
||||
NewChunkHolder holder = mock(NewChunkHolder.class);
|
||||
setField(holder, "chunkX", index);
|
||||
holders.add(holder);
|
||||
}
|
||||
AtomicInteger requested = new AtomicInteger();
|
||||
AtomicBoolean writesBlocked = new AtomicBoolean(true);
|
||||
CountDownLatch firstBatchWaiting = new CountDownLatch(1);
|
||||
Future<?> result = checkpoint.submit(() -> {
|
||||
try (MockedStatic<J> scheduling = mockStatic(J.class);
|
||||
MockedStatic<MoonriseRegionFileIO> io = mockStatic(MoonriseRegionFileIO.class)) {
|
||||
scheduling.when(() -> J.runRegionFuture(eq(world), anyInt(), anyInt(), any(Runnable.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
requested.incrementAndGet();
|
||||
Runnable action = invocation.getArgument(3);
|
||||
return CompletableFuture.runAsync(action, owner);
|
||||
});
|
||||
io.when(() -> MoonriseRegionFileIO.getPriority(eq(fixture.level), anyInt(), anyInt(),
|
||||
eq(MoonriseRegionFileIO.RegionFileType.CHUNK_DATA))).thenAnswer(invocation -> {
|
||||
firstBatchWaiting.countDown();
|
||||
return writesBlocked.get() ? Priority.NORMAL : Priority.COMPLETING;
|
||||
});
|
||||
NmsSavedTerrainCapture.flushSnapshots(world, fixture.level, holders);
|
||||
io.verify(() -> MoonriseRegionFileIO.getPriority(fixture.level, 128, 0,
|
||||
MoonriseRegionFileIO.RegionFileType.CHUNK_DATA), times(2));
|
||||
return null;
|
||||
}
|
||||
});
|
||||
try {
|
||||
assertTrue(firstBatchWaiting.await(5, TimeUnit.SECONDS));
|
||||
assertEquals(64, requested.get());
|
||||
assertTrue(owner.submit(() -> true).get(5, TimeUnit.SECONDS));
|
||||
assertFalse(result.isDone());
|
||||
writesBlocked.set(false);
|
||||
result.get(5, TimeUnit.SECONDS);
|
||||
assertEquals(129, requested.get());
|
||||
} finally {
|
||||
writesBlocked.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void setField(Object instance, String name, Object value) throws Exception {
|
||||
Field field = instance.getClass().getField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(instance, value);
|
||||
}
|
||||
|
||||
private static final class SaveFixture implements AutoCloseable {
|
||||
private final BalancedPrioritisedThreadPool pool = new BalancedPrioritisedThreadPool(
|
||||
TimeUnit.MILLISECONDS.toNanos(1), Executors.defaultThreadFactory());
|
||||
private final ServerLevel level = mock(ServerLevel.class);
|
||||
|
||||
private SaveFixture() throws Exception {
|
||||
BalancedPrioritisedThreadPool.OrderedStreamGroup group = pool.createOrderedStreamGroup();
|
||||
ChunkTaskScheduler scheduler = mock(ChunkTaskScheduler.class);
|
||||
setField(scheduler, "saveExecutor", group.createExecutor());
|
||||
setField(scheduler, "compressionExecutor", group.createExecutor());
|
||||
setField(scheduler, "chunkHolderManager", mock(ChunkHolderManager.class));
|
||||
when(level.moonrise$getChunkTaskScheduler()).thenReturn(scheduler);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws Exception {
|
||||
pool.halt(true);
|
||||
assertTrue(pool.joinInterruptable(5_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.core.nms.v26_2_R1.IrisChunkGenerator;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.history.BoundaryColumnGeometry;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public final class IrisChunkGeneratorHeightCacheTest {
|
||||
@Rule
|
||||
public final PlatformLeakGuard leakGuard = PlatformLeakGuard.clean();
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedNativeHeightSurvivesResolvedTerrainEvictionAndRuntimeChange() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
when(fixture.complex().resolvedTerrainColumn(-1, -33))
|
||||
.thenThrow(new IllegalStateException("Full terrain was evicted"));
|
||||
|
||||
for (int index = 0; index < 100; index++) {
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(
|
||||
-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
}
|
||||
verify(fixture.complex(), times(1)).resolvedTerrainColumn(-1, -33);
|
||||
|
||||
when(fixture.engine().getCacheID()).thenReturn(2);
|
||||
doReturn(Optional.of(signature())).when(fixture.complex()).resolvedTerrainColumn(-1, -33);
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(-1, -33, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
verify(fixture.complex(), times(2)).resolvedTerrainColumn(-1, -33);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ordinaryHeightRemainsLiveUntilResolvedTransitionTerrainExists() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
when(fixture.complex().resolvedTerrainColumn(0, 0))
|
||||
.thenReturn(Optional.empty(), Optional.empty(), Optional.of(signature()));
|
||||
when(fixture.engine().getHeight(0, 0, false)).thenReturn(20, 25);
|
||||
|
||||
assertEquals(5, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(10, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
verify(fixture.engine(), times(2)).getHeight(0, 0, false);
|
||||
verify(fixture.complex(), times(3)).resolvedTerrainColumn(0, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativePredicatesAndAccessorBoundsRemainExact() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
LevelHeightAccessor bounds = LevelHeightAccessor.create(-16, 32);
|
||||
for (int repeat = 0; repeat < 2; repeat++) {
|
||||
assertEquals(-11, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE, bounds, null));
|
||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.OCEAN_FLOOR, bounds, null));
|
||||
assertEquals(-13, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING, bounds, null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, bounds, null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.WORLD_SURFACE,
|
||||
LevelHeightAccessor.create(-16, 2), null));
|
||||
assertEquals(-14, fixture.generator().getBaseHeight(0, 0, Heightmap.Types.MOTION_BLOCKING_NO_LEAVES,
|
||||
LevelHeightAccessor.create(-14, 3), null));
|
||||
}
|
||||
verify(fixture.complex(), times(6)).resolvedTerrainColumn(0, 0);
|
||||
}
|
||||
|
||||
private static Fixture fixture() throws Exception {
|
||||
Engine engine = mock(Engine.class);
|
||||
IrisComplex complex = mock(IrisComplex.class);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
when(engine.getCacheID()).thenReturn(1);
|
||||
when(engine.acquireGenerationLease(anyString())).thenReturn(GenerationSessionLease.noop());
|
||||
when(complex.resolvedTerrainColumn(anyInt(), anyInt())).thenReturn(Optional.of(signature()));
|
||||
IrisChunkGenerator generator = mock(IrisChunkGenerator.class, CALLS_REAL_METHODS);
|
||||
set(generator, "engine", engine);
|
||||
set(generator, "terrainHeights", new NativeTerrainHeightCache());
|
||||
return new Fixture(generator, engine, complex);
|
||||
}
|
||||
|
||||
private static void set(IrisChunkGenerator generator, String name, Object value) throws Exception {
|
||||
Field field = IrisChunkGenerator.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(generator, value);
|
||||
}
|
||||
|
||||
private static TerrainBoundarySignature signature() {
|
||||
BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-16, List.of(
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false),
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:water", BoundaryColumnGeometry.Phase.FLUID, "minecraft:water", false),
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:oak_leaves[persistent=true]", BoundaryColumnGeometry.Phase.SOLID, "", false),
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:short_grass", BoundaryColumnGeometry.Phase.SOLID, "", false),
|
||||
new BoundaryColumnGeometry.Voxel("minecraft:light[level=15]", BoundaryColumnGeometry.Phase.SOLID, "", false)));
|
||||
return new TerrainBoundarySignature(
|
||||
new TerrainBoundarySignature.Column(0, 0, 4, 4, OptionalInt.of(1), OptionalInt.empty()),
|
||||
new TerrainBoundarySignature.Samples(new TerrainBoundarySignature.VerticalLayout(-16, 5, 1),
|
||||
new TerrainBoundarySignature.BiomeEncoding(List.of("minecraft:plains"), new short[]{0})), geometry);
|
||||
}
|
||||
|
||||
private record Fixture(IrisChunkGenerator generator, Engine engine, IrisComplex complex) {
|
||||
}
|
||||
}
|
||||
+428
-5
@@ -16,6 +16,7 @@ import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelAccessor;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
@@ -30,7 +31,9 @@ import net.minecraft.world.level.chunk.PalettedContainerFactory;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.Strategy;
|
||||
import net.minecraft.world.level.chunk.UpgradeData;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
@@ -51,6 +54,9 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.ProcessorRule
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.RuleProcessor;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.ShipwreckStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.StrongholdStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessor;
|
||||
@@ -99,6 +105,410 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenCutsAndFillsSymmetricallyAcrossItsConfiguredBlend() {
|
||||
NativeStructureSurfaceFitter.FlattenAnchor anchor = new NativeStructureSurfaceFitter.FlattenAnchor(
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 80, 2), 24, 48);
|
||||
List<NativeStructureSurfaceFitter.FlattenAnchor> anchors = List.of(anchor);
|
||||
assertEquals(80, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 0, 0, 128).targetY());
|
||||
assertEquals(80, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 0, 0, 32).targetY());
|
||||
assertEquals(152, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 0, 0, 200).targetY());
|
||||
assertEquals(48, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 0, 0, 0).targetY());
|
||||
int previousCut = 80;
|
||||
for (int x = 1; x <= 24; x++) {
|
||||
int cut = NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, x, 0, 128).targetY();
|
||||
int fill = NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, x, 0, 32).targetY();
|
||||
assertEquals(160, cut + fill);
|
||||
assertTrue(cut >= previousCut);
|
||||
assertTrue(cut - previousCut <= 3);
|
||||
previousCut = cut;
|
||||
}
|
||||
assertEquals(128, previousCut);
|
||||
assertEquals(0, NativeStructureSurfaceFitter.resolveFlattenSurface(anchors, 24, 0, 128).range());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenBlendsNeighboringFoundationsWithoutOrderDependentRidges() {
|
||||
NativeStructureSurfaceFitter.FlattenAnchor left = new NativeStructureSurfaceFitter.FlattenAnchor(
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 80, 2), 24, 64);
|
||||
NativeStructureSurfaceFitter.FlattenAnchor right = new NativeStructureSurfaceFitter.FlattenAnchor(
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(8, 8, 0, 0, 80, 2), 24, 64);
|
||||
int previous = 80;
|
||||
for (int x = 0; x <= 8; x++) {
|
||||
NativeStructureSurfaceFitter.FlattenResolution forward = NativeStructureSurfaceFitter.resolveFlattenSurface(
|
||||
List.of(left, right), x, 0, 104);
|
||||
assertEquals(forward, NativeStructureSurfaceFitter.resolveFlattenSurface(
|
||||
List.of(right, left), x, 0, 104));
|
||||
assertTrue(forward.targetY() <= 82);
|
||||
assertTrue(Math.abs(forward.targetY() - previous) <= 1);
|
||||
previous = forward.targetY();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenLevelsNonJigsawFoundationsAndRemovesDetachedRoofs() {
|
||||
StructureStart start = desertStart();
|
||||
int groundY = start.getBoundingBox().minY() - 1;
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 3, 127, 0);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
for (int x = 0; x <= 3; x++) {
|
||||
int sourceY = x < 2 ? groundY - 32 : groundY + 32;
|
||||
put(blocks, x, 12, 0, Blocks.STONE.defaultBlockState());
|
||||
put(blocks, x, sourceY - 1, 0, Blocks.DIRT.defaultBlockState());
|
||||
put(blocks, x, sourceY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
}
|
||||
assertTrue(NativeStructureSurfaceFitter.requiresFlattenTerrain(
|
||||
surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)));
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)),
|
||||
(x, z) -> x < 2 ? groundY - 32 : groundY + 32);
|
||||
|
||||
for (int x = 0; x <= 3; x++) {
|
||||
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, x, groundY, 0));
|
||||
for (int y = groundY + 1; y <= groundY + 32; y++) {
|
||||
assertTrue("Roof at " + x + "," + y, state(blocks, x, y, 0).isAir());
|
||||
}
|
||||
for (int y = 12; y < groundY; y++) {
|
||||
assertTrue("Missing foundation at " + x + "," + y, state(blocks, x, y, 0).isSolid());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenRestoresAnUnchangedFoundationSurfaceOverADeepAirGap() {
|
||||
StructureStart start = desertStart();
|
||||
int groundY = start.getBoundingBox().minY() - 1;
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
put(blocks, 0, groundY - 19, 0, Blocks.DIRT.defaultBlockState());
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), (x, z) -> groundY);
|
||||
|
||||
for (int y = groundY - 19; y <= groundY; y++) {
|
||||
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, y, 0));
|
||||
}
|
||||
assertTrue(state(blocks, 0, groundY + 1, 0).isAir());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenLeavesBuriedAndSubmergedInstancesUntouched() {
|
||||
StructureStart start = desertStart();
|
||||
int groundY = start.getBoundingBox().minY() - 1;
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 0, 127, 0);
|
||||
Map<BlockPos, BlockState> buried = flatTerrain(area, 100);
|
||||
Map<BlockPos, BlockState> originalBuried = new HashMap<>(buried);
|
||||
Map<BlockPos, BlockState> submerged = flatTerrain(area, groundY - 8);
|
||||
put(submerged, 0, groundY - 7, 0, Blocks.WATER.defaultBlockState());
|
||||
Map<BlockPos, BlockState> originalSubmerged = new HashMap<>(submerged);
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(buried), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), (x, z) -> 100);
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(submerged), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)), (x, z) -> groundY - 8);
|
||||
|
||||
assertEquals(originalBuried, buried);
|
||||
assertEquals(originalSubmerged, submerged);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenExemptsUndergroundAndSubmergedStructureTypes() {
|
||||
Structure.StructureSettings surface = new Structure.StructureSettings(
|
||||
HolderSet.empty(), Map.of(), GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.NONE);
|
||||
Structure.StructureSettings underground = new Structure.StructureSettings(
|
||||
HolderSet.empty(), Map.of(), GenerationStep.Decoration.UNDERGROUND_STRUCTURES, TerrainAdjustment.NONE);
|
||||
List<Structure> exempt = List.of(new StrongholdStructure(surface), new OceanMonumentStructure(surface),
|
||||
new ShipwreckStructure(surface, false), new DesertPyramidStructure(underground));
|
||||
for (Structure structure : exempt) {
|
||||
StructureStart start = new StructureStart(structure, new ChunkPos(0, 0), 0,
|
||||
new PiecesContainer(desertStart().getPieces()));
|
||||
assertFalse(NativeStructureSurfaceFitter.requiresFlattenTerrain(
|
||||
surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)));
|
||||
}
|
||||
assertFalse(NativeStructureSurfaceFitter.requiresFlattenTerrain(
|
||||
surfaceTarget(desertStart(TerrainAdjustment.BURY), IrisStructureTerrainMode.FLATTEN)));
|
||||
assertFalse(NativeStructureSurfaceFitter.requiresFlattenTerrain(
|
||||
surfaceTarget(desertStart(TerrainAdjustment.ENCAPSULATE), IrisStructureTerrainMode.FLATTEN)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenFoundationReadsAndWritesStayInsideConfiguredVerticalRange() throws Exception {
|
||||
PoolElementStructurePiece piece = rigidTemplatePiece(new InlineSinglePoolElement(template(List.of(
|
||||
block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))),
|
||||
new BoundingBox(0, 128, 0, 0, 136, 0), 0, Rotation.NONE);
|
||||
StructureStart start = rigidSurfaceStart(List.of(piece));
|
||||
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN);
|
||||
target.terrain().setFlattenRange(32);
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 0, 255, 0);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
put(blocks, 0, 94, 0, Blocks.GOLD_BLOCK.defaultBlockState());
|
||||
WorldGenLevel delegate = world(blocks);
|
||||
WorldGenLevel bounded = (WorldGenLevel) Proxy.newProxyInstance(WorldGenLevel.class.getClassLoader(),
|
||||
new Class<?>[]{WorldGenLevel.class}, (proxy, method, arguments) -> {
|
||||
if (method.getName().equals("getBlockState") || method.getName().equals("setBlock")) {
|
||||
BlockPos position = (BlockPos) arguments[0];
|
||||
assertTrue(position.toString(), position.getY() >= 95 && position.getY() <= 159);
|
||||
}
|
||||
return method.invoke(delegate, arguments);
|
||||
});
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(bounded, area, List.of(target), (x, z) -> 127);
|
||||
|
||||
assertEquals(Blocks.GOLD_BLOCK.defaultBlockState(), state(blocks, 0, 94, 0));
|
||||
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 95, 0));
|
||||
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 127, 0));
|
||||
assertTrue(state(blocks, 0, 128, 0).isAir());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenReferencesIncludeTheMaximumConfiguredFeather() throws Exception {
|
||||
PoolElementStructurePiece piece = rigidTemplatePiece(new InlineSinglePoolElement(template(List.of(
|
||||
block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))),
|
||||
new BoundingBox(0, 64, 0, 0, 70, 0), 0, Rotation.NONE);
|
||||
StructureStart start = rigidSurfaceStart(List.of(piece));
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain().setMode(IrisStructureTerrainMode.FLATTEN)
|
||||
.setHorizontalPadding(128).setFlattenRange(128);
|
||||
|
||||
BoundingBox references = NativeStructureReferenceEnvelope.referenceBounds(start, start.getStructure(), terrain);
|
||||
|
||||
assertEquals(-128, references.minX());
|
||||
assertEquals(128, references.maxX());
|
||||
assertEquals(-128, references.minZ());
|
||||
assertEquals(128, references.maxZ());
|
||||
NativeStructureSurfaceFitter.FlattenAnchor anchor = new NativeStructureSurfaceFitter.FlattenAnchor(
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 64, 2), 128, 128);
|
||||
assertTrue(NativeStructureSurfaceFitter.resolveFlattenSurface(List.of(anchor), 127, 0, 100).range() > 0);
|
||||
assertEquals(0, NativeStructureSurfaceFitter.resolveFlattenSurface(List.of(anchor), 128, 0, 100).range());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenPreservesNativeVillageSupportBridges() throws Exception {
|
||||
PoolElementStructurePiece lower = rigidTemplatePiece(new InlineLegacyPoolElement(template(List.of(
|
||||
block(0, 0, 0, Blocks.AIR.defaultBlockState())))),
|
||||
new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE);
|
||||
PoolElementStructurePiece upper = rigidTemplatePiece(new InlineSinglePoolElement(template(List.of(
|
||||
block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))),
|
||||
new BoundingBox(0, 64, 0, 0, 70, 0), 0, Rotation.NONE);
|
||||
StructureStart start = rigidSurfaceStart(List.of(lower, upper));
|
||||
BoundingBox area = new BoundingBox(0, 58, 0, 0, 72, 0);
|
||||
Map<BlockPos, BlockState> blocks = supportTerrain(0, 0);
|
||||
|
||||
Set<Long> written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport(world(blocks), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)),
|
||||
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager);
|
||||
|
||||
assertEquals(Set.of(BlockPos.asLong(0, 63, 0)), written);
|
||||
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 0, 63, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenDoesNotBridgeACompletelyBuriedVillage() throws Exception {
|
||||
PoolElementStructurePiece lower = rigidTemplatePiece(new InlineLegacyPoolElement(template(List.of(
|
||||
block(0, 0, 0, Blocks.AIR.defaultBlockState())))),
|
||||
new BoundingBox(0, 62, 0, 0, 65, 0), 1, Rotation.NONE);
|
||||
PoolElementStructurePiece upper = rigidTemplatePiece(new InlineSinglePoolElement(template(List.of(
|
||||
block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())))),
|
||||
new BoundingBox(0, 64, 0, 0, 70, 0), 0, Rotation.NONE);
|
||||
StructureStart start = rigidSurfaceStart(List.of(lower, upper));
|
||||
BoundingBox area = new BoundingBox(0, 58, 0, 0, 90, 0);
|
||||
Map<BlockPos, BlockState> blocks = supportTerrain(0, 0);
|
||||
put(blocks, 0, 80, 0, Blocks.STONE.defaultBlockState());
|
||||
|
||||
Set<Long> written = NativeStructureSurfaceSupportBuilder.bridgeRigidPieceSupport(world(blocks), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)),
|
||||
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager);
|
||||
|
||||
assertTrue(written.isEmpty());
|
||||
assertTrue(state(blocks, 0, 63, 0).isAir());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenKeepsPyramidPlacementAboveItsPreparedGround() {
|
||||
FlattenDesertPiece piece = new FlattenDesertPiece();
|
||||
StructureStart start = new StructureStart(desertStart().getStructure(), new ChunkPos(0, 0), 0,
|
||||
new PiecesContainer(List.of(piece)));
|
||||
BoundingBox footprint = piece.getBoundingBox();
|
||||
NativeStructureVerticalPlacer.applyVerticalPlacement(start, "minecraft:desert_pyramid", 0,
|
||||
63, 0, 256, false, false, null, (x, z) -> x == footprint.minX() ? 80 : 112);
|
||||
BoundingBox area = new BoundingBox(footprint.minX(), 0, footprint.minZ(),
|
||||
footprint.maxX(), 160, footprint.maxZ());
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
for (int x = footprint.minX(); x <= footprint.maxX(); x++) {
|
||||
for (int z = footprint.minZ(); z <= footprint.maxZ(); z++) {
|
||||
int surfaceY = x == footprint.minX() ? 80 : 112;
|
||||
put(blocks, x, surfaceY - 1, z, Blocks.DIRT.defaultBlockState());
|
||||
put(blocks, x, surfaceY, z, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
}
|
||||
}
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world(blocks), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.FLATTEN)),
|
||||
(x, z) -> x == footprint.minX() ? 80 : 112);
|
||||
|
||||
assertEquals(81, piece.getBoundingBox().minY());
|
||||
assertTrue(piece.attemptVanillaAlignment());
|
||||
assertEquals(81, piece.getBoundingBox().minY());
|
||||
assertTrue(state(blocks, footprint.maxX(), 80, footprint.maxZ()).isSolid());
|
||||
assertTrue(state(blocks, footprint.maxX(), 81, footprint.maxZ()).isAir());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenLetsTerrainMatchingPathsFollowThePreparedVillageGrade() throws Exception {
|
||||
StructureTemplate foundation = template(List.of(block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())));
|
||||
PoolElementStructurePiece left = rigidTemplatePiece(new InlineSinglePoolElement(foundation),
|
||||
new BoundingBox(0, 65, 0, 0, 75, 0), 0, Rotation.NONE);
|
||||
PoolElementStructurePiece right = rigidTemplatePiece(new InlineSinglePoolElement(foundation),
|
||||
new BoundingBox(8, 65, 0, 8, 75, 0), 0, Rotation.NONE);
|
||||
PoolElementStructurePiece path = rigidTemplatePiece(new InlineSinglePoolElement(
|
||||
template(List.of(block(0, 0, 0, Blocks.DIRT_PATH.defaultBlockState()))),
|
||||
List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING),
|
||||
new BoundingBox(4, 65, 0, 4, 75, 0), 0, Rotation.NONE);
|
||||
StructureStart start = rigidSurfaceStart(List.of(left, path, right));
|
||||
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN);
|
||||
target.terrain().setHorizontalPadding(24);
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 8, 128, 0);
|
||||
Map<BlockPos, BlockState> blocks = flatTerrain(area, 96);
|
||||
for (int x : List.of(0, 8)) {
|
||||
blocks.remove(new BlockPos(x, 95, 0));
|
||||
blocks.remove(new BlockPos(x, 96, 0));
|
||||
put(blocks, x, 63, 0, Blocks.DIRT.defaultBlockState());
|
||||
put(blocks, x, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
}
|
||||
WorldGenLevel world = world(blocks);
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(world, area, List.of(target),
|
||||
(x, z) -> x == 0 || x == 8 ? 64 : 96);
|
||||
NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve(
|
||||
world, path, BlockPos.ZERO, area,
|
||||
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager,
|
||||
area::isInside, cells -> {});
|
||||
Map<Long, NativeStructureTemplateOccupancy.LowestCell> pathGround =
|
||||
NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells());
|
||||
|
||||
assertTrue(state(blocks, 4, 96, 0).isAir());
|
||||
assertEquals(1, pathGround.size());
|
||||
int pathY = pathGround.values().iterator().next().y();
|
||||
assertTrue("Path Y " + pathY, pathY >= 64 && pathY <= 67);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenPublishesCutAndFillHeightsBeforeNativePathProjectionAtFeatures() throws Exception {
|
||||
ProtoChunk chunk = new ProtoChunk(new ChunkPos(0, 0), UpgradeData.EMPTY,
|
||||
LevelHeightAccessor.create(0, 256), containerFactory(), null);
|
||||
chunk.setPersistedStatus(ChunkStatus.CARVERS);
|
||||
for (int x = 0; x <= 8; x++) {
|
||||
int originalY = x < 4 ? 96 : 32;
|
||||
write(chunk, x, originalY - 1, 0, Blocks.DIRT.defaultBlockState());
|
||||
write(chunk, x, originalY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
}
|
||||
WorldgenTerrainHeightmaps.primeTerrain(chunk,
|
||||
(x, z) -> x == 15 ? 81 : x < 4 ? 97 : 33,
|
||||
(x, z) -> x == 15 ? 61 : x < 4 ? 97 : 33);
|
||||
StructureTemplate foundation = template(List.of(block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())));
|
||||
PoolElementStructurePiece left = rigidTemplatePiece(new InlineSinglePoolElement(foundation),
|
||||
new BoundingBox(0, 65, 0, 0, 75, 0), 0, Rotation.NONE);
|
||||
PoolElementStructurePiece right = rigidTemplatePiece(new InlineSinglePoolElement(foundation),
|
||||
new BoundingBox(8, 65, 0, 8, 75, 0), 0, Rotation.NONE);
|
||||
PoolElementStructurePiece path = rigidTemplatePiece(new InlineSinglePoolElement(
|
||||
template(List.of(block(0, 0, 0, Blocks.DIRT_PATH.defaultBlockState()),
|
||||
block(8, 0, 0, Blocks.DIRT_PATH.defaultBlockState()))),
|
||||
List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING),
|
||||
new BoundingBox(0, 65, 0, 8, 75, 0), 0, Rotation.NONE);
|
||||
StructureStart start = rigidSurfaceStart(List.of(left, path, right));
|
||||
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN);
|
||||
target.terrain().setHorizontalPadding(24);
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 8, 127, 0);
|
||||
WorldGenLevel world = world(chunk);
|
||||
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan plan = NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world, area, List.of(target), (x, z) -> x < 4 ? 96 : 32);
|
||||
|
||||
assertEquals(96, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 0, 0));
|
||||
assertEquals(32, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 8, 0));
|
||||
plan.primeHeightmaps(chunk);
|
||||
chunk.setBlockState(new BlockPos(0, 85, 0), Blocks.OAK_PLANKS.defaultBlockState(), 2);
|
||||
NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve(
|
||||
world, path, BlockPos.ZERO, area,
|
||||
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager,
|
||||
area::isInside, cells -> {});
|
||||
Map<Long, NativeStructureTemplateOccupancy.LowestCell> pathGround =
|
||||
NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells());
|
||||
|
||||
assertEquals(2, pathGround.size());
|
||||
for (NativeStructureTemplateOccupancy.LowestCell cell : pathGround.values()) {
|
||||
assertEquals(64, cell.y());
|
||||
chunk.setBlockState(new BlockPos(cell.x(), cell.y(), cell.z()), cell.occupancy().state(), 2);
|
||||
assertTrue(chunk.getBlockState(new BlockPos(cell.x(), 63, cell.z())).isSolid());
|
||||
assertTrue(chunk.getBlockState(new BlockPos(cell.x(), 65, cell.z())).isAir());
|
||||
assertTrue(chunk.getBlockState(new BlockPos(cell.x(), 66, cell.z())).isAir());
|
||||
assertEquals(64, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, cell.x(), cell.z()));
|
||||
assertEquals(64, chunk.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, cell.x(), cell.z()));
|
||||
}
|
||||
assertEquals(80, chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 15, 0));
|
||||
assertEquals(60, chunk.getHeight(Heightmap.Types.OCEAN_FLOOR_WG, 15, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenSupportsProjectedRoadsAcrossRoofCavitiesWithoutFillingAdjacentColumns() throws Exception {
|
||||
ProtoChunk chunk = new ProtoChunk(new ChunkPos(0, 0), UpgradeData.EMPTY,
|
||||
LevelHeightAccessor.create(0, 256), containerFactory(), null);
|
||||
chunk.setPersistedStatus(ChunkStatus.CARVERS);
|
||||
for (int x = 0; x <= 8; x++) {
|
||||
int originalY = x == 0 || x == 8 ? 64 : 96;
|
||||
write(chunk, x, 20, 0, Blocks.STONE.defaultBlockState());
|
||||
write(chunk, x, originalY - 1, 0, Blocks.DIRT.defaultBlockState());
|
||||
write(chunk, x, originalY, 0, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
}
|
||||
write(chunk, 6, 50, 0, Blocks.WATER.defaultBlockState());
|
||||
WorldgenTerrainHeightmaps.primeTerrain(chunk,
|
||||
(x, z) -> x == 0 || x == 8 ? 65 : 97,
|
||||
(x, z) -> x == 0 || x == 8 ? 65 : 97);
|
||||
StructureTemplate foundation = template(List.of(block(0, 0, 0, Blocks.COBBLESTONE.defaultBlockState())));
|
||||
PoolElementStructurePiece left = rigidTemplatePiece(new InlineSinglePoolElement(foundation),
|
||||
new BoundingBox(0, 65, 0, 0, 75, 0), 0, Rotation.NONE);
|
||||
PoolElementStructurePiece right = rigidTemplatePiece(new InlineSinglePoolElement(foundation),
|
||||
new BoundingBox(8, 65, 0, 8, 75, 0), 0, Rotation.NONE);
|
||||
PoolElementStructurePiece path = rigidTemplatePiece(new InlineSinglePoolElement(
|
||||
template(List.of(block(0, 0, 0, Blocks.DIRT_PATH.defaultBlockState()),
|
||||
block(1, 0, 0, Blocks.AIR.defaultBlockState()),
|
||||
block(2, 0, 0, Blocks.DIRT_PATH.defaultBlockState()))),
|
||||
List.of(), StructureTemplatePool.Projection.TERRAIN_MATCHING),
|
||||
new BoundingBox(4, 65, 0, 6, 75, 0), 0, Rotation.NONE);
|
||||
StructureStart start = rigidSurfaceStart(List.of(left, path, right));
|
||||
NativeStructureTerrainIntegrator.TerrainTarget target = surfaceTarget(start, IrisStructureTerrainMode.FLATTEN);
|
||||
target.terrain().setHorizontalPadding(24).setFlattenRange(32);
|
||||
BoundingBox area = new BoundingBox(0, 0, 0, 8, 127, 0);
|
||||
WorldGenLevel world = world(chunk);
|
||||
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan plan = NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world, area, List.of(target), (x, z) -> x == 0 || x == 8 ? 64 : 96);
|
||||
plan.primeHeightmaps(chunk);
|
||||
NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve(
|
||||
world, path, BlockPos.ZERO, area,
|
||||
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager,
|
||||
area::isInside, cells -> {});
|
||||
Map<Long, NativeStructureTemplateOccupancy.LowestCell> pathGround =
|
||||
NativeStructureTemplateOccupancy.lowestProcessedSolidCells(occupancy.cells());
|
||||
|
||||
assertEquals(2, pathGround.size());
|
||||
for (NativeStructureTemplateOccupancy.LowestCell cell : pathGround.values()) {
|
||||
write(chunk, cell.x(), cell.y(), cell.z(), cell.occupancy().state());
|
||||
assertTrue(cell.y() > 64 && cell.y() < 72);
|
||||
assertTrue(chunk.getBlockState(new BlockPos(cell.x(), cell.y() - 1, cell.z())).isSolid());
|
||||
assertTrue(chunk.getBlockState(new BlockPos(cell.x(), cell.y() + 1, cell.z())).isAir());
|
||||
}
|
||||
int roadY = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 4, 0);
|
||||
for (int y = roadY - 32; y < roadY; y++) {
|
||||
assertTrue("Missing road support at " + y, chunk.getBlockState(new BlockPos(4, y, 0)).isSolid());
|
||||
}
|
||||
assertTrue(chunk.getBlockState(new BlockPos(4, roadY - 33, 0)).isAir());
|
||||
int adjacentY = chunk.getHeight(Heightmap.Types.WORLD_SURFACE_WG, 5, 0);
|
||||
assertTrue(chunk.getBlockState(new BlockPos(5, adjacentY - 1, 0)).isAir());
|
||||
assertEquals(Blocks.WATER.defaultBlockState(), chunk.getBlockState(new BlockPos(6, 50, 0)));
|
||||
assertTrue(chunk.getBlockState(new BlockPos(6, 49, 0)).isAir());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceBeardAdjustmentsPrepareOnlyAtTheSurfaceStructuresStep() {
|
||||
assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
@@ -474,7 +884,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
BoundingBox area = new BoundingBox(0, 54, 0, 3, 74, 3);
|
||||
Map<BlockPos, BlockState> blocks = flatTerrain(area, 60);
|
||||
|
||||
NativeStructureSurfaceFitter.VacuumFoundationPlan plan =
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan plan =
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world(blocks), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)),
|
||||
@@ -514,7 +924,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
BoundingBox area = new BoundingBox(0, 54, 0, 3, 72, 3);
|
||||
Map<BlockPos, BlockState> blocks = flatTerrain(area, 60);
|
||||
|
||||
NativeStructureSurfaceFitter.VacuumFoundationPlan plan =
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan plan =
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world(blocks), area,
|
||||
List.of(surfaceTarget(start, IrisStructureTerrainMode.VACUUM)),
|
||||
@@ -546,13 +956,13 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
Map<BlockPos, BlockState> wideBlocks = flatTerrain(wideArea, 60);
|
||||
Map<BlockPos, BlockState> splitBlocks = flatTerrain(wideArea, 60);
|
||||
|
||||
NativeStructureSurfaceFitter.VacuumFoundationPlan widePlan =
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan widePlan =
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world(wideBlocks), wideArea, List.of(target), (x, z) -> 60);
|
||||
NativeStructureSurfaceFitter.VacuumFoundationPlan westPlan =
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan westPlan =
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world(splitBlocks), westArea, List.of(target), (x, z) -> 60);
|
||||
NativeStructureSurfaceFitter.VacuumFoundationPlan eastPlan =
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan eastPlan =
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world(splitBlocks), eastArea, List.of(target), (x, z) -> 60);
|
||||
for (int x = 15; x <= 16; x++) {
|
||||
@@ -2203,6 +2613,16 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
return template;
|
||||
}
|
||||
|
||||
private static final class FlattenDesertPiece extends DesertPyramidPiece {
|
||||
private FlattenDesertPiece() {
|
||||
super(RandomSource.create(29L), 0, 0);
|
||||
}
|
||||
|
||||
private boolean attemptVanillaAlignment() {
|
||||
return updateHeightPositionToLowestGroundHeight((LevelAccessor) null, -2);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InlineSinglePoolElement extends SinglePoolElement {
|
||||
private InlineSinglePoolElement(StructureTemplate template) {
|
||||
this(template, List.of());
|
||||
@@ -2319,6 +2739,9 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
if (methodName.equals("getSeed")) {
|
||||
return TEST_SEED;
|
||||
}
|
||||
if (methodName.equals("getHeight") && arguments != null && arguments.length == 3) {
|
||||
return chunk.getHeight((Heightmap.Types) arguments[0], (int) arguments[1], (int) arguments[2]) + 1;
|
||||
}
|
||||
if (methodName.equals("getLevel")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.OptionalInt;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class NativeTerrainHeightCacheTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coordinatesRuntimePredicatesAndBoundsHaveSeparateEntries() {
|
||||
NativeTerrainHeightCache cache = new NativeTerrainHeightCache();
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
NativeTerrainHeightCache.Query[] queries = {
|
||||
query(1, -1, 1),
|
||||
query(1, 1, -1),
|
||||
query(2, -1, 1),
|
||||
new NativeTerrainHeightCache.Query(1, -1, 1, Heightmap.Types.OCEAN_FLOOR, -256, 768),
|
||||
new NativeTerrainHeightCache.Query(1, -1, 1, Heightmap.Types.WORLD_SURFACE, -64, 768),
|
||||
new NativeTerrainHeightCache.Query(1, -1, 1, Heightmap.Types.WORLD_SURFACE, -256, 256)
|
||||
};
|
||||
for (int index = 0; index < queries.length; index++) {
|
||||
assertEquals(index + 1, cached(cache, queries[index], calls::incrementAndGet));
|
||||
}
|
||||
for (int index = 0; index < queries.length; index++) {
|
||||
assertEquals(index + 1, cached(cache, queries[index], calls::incrementAndGet));
|
||||
}
|
||||
assertEquals(queries.length, calls.get());
|
||||
cache.evictRuntime(1);
|
||||
assertEquals(3, cached(cache, queries[2], calls::incrementAndGet));
|
||||
assertEquals(7, cached(cache, queries[0], calls::incrementAndGet));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allIntegerResultsAreCacheable() {
|
||||
NativeTerrainHeightCache cache = new NativeTerrainHeightCache();
|
||||
NativeTerrainHeightCache.Query query = query(1, 0, 0);
|
||||
assertEquals(Integer.MIN_VALUE, cached(cache, query, () -> Integer.MIN_VALUE));
|
||||
assertEquals(Integer.MIN_VALUE, cached(cache, query, () -> 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedResolutionCanBeRetried() {
|
||||
NativeTerrainHeightCache cache = new NativeTerrainHeightCache();
|
||||
NativeTerrainHeightCache.Query query = query(1, 0, 0);
|
||||
assertThrows(IllegalStateException.class, () -> cached(cache, query, () -> {
|
||||
throw new IllegalStateException("terrain unavailable");
|
||||
}));
|
||||
assertEquals(42, cached(cache, query, () -> 42));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void coldResolutionDoesNotHoldCacheMonitor() throws Exception {
|
||||
NativeTerrainHeightCache cache = new NativeTerrainHeightCache();
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<Integer> slow = executor.submit(() -> cached(cache, query(1, 0, 0), () -> {
|
||||
entered.countDown();
|
||||
try {
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException interruption) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(interruption);
|
||||
}
|
||||
return 9;
|
||||
}));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
Future<Integer> independent = executor.submit(() -> cached(cache, query(1, 1, 0), () -> 17));
|
||||
assertEquals(Integer.valueOf(17), independent.get(1, TimeUnit.SECONDS));
|
||||
release.countDown();
|
||||
assertEquals(Integer.valueOf(9), slow.get(5, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
release.countDown();
|
||||
executor.shutdownNow();
|
||||
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void boundedCacheKeepsRecentlyReadCoordinates() {
|
||||
NativeTerrainHeightCache cache = new NativeTerrainHeightCache();
|
||||
for (int x = 0; x < 65_536; x++) {
|
||||
cached(cache, query(1, x, 0), () -> 8);
|
||||
}
|
||||
assertEquals(8, cached(cache, query(1, 0, 0), () -> 99));
|
||||
cached(cache, query(1, 65_536, 0), () -> 9);
|
||||
assertEquals(8, cached(cache, query(1, 0, 0), () -> 99));
|
||||
assertEquals(99, cached(cache, query(1, 1, 0), () -> 99));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unavailableResolvedHeightIsNotCached() {
|
||||
NativeTerrainHeightCache cache = new NativeTerrainHeightCache();
|
||||
NativeTerrainHeightCache.Query query = query(1, 0, 0);
|
||||
assertTrue(cache.resolvedHeight(query, OptionalInt::empty).isEmpty());
|
||||
assertEquals(42, cache.resolvedHeight(query, () -> OptionalInt.of(42)).orElseThrow());
|
||||
assertEquals(42, cache.resolvedHeight(query, OptionalInt::empty).orElseThrow());
|
||||
}
|
||||
|
||||
private static int cached(NativeTerrainHeightCache cache, NativeTerrainHeightCache.Query query,
|
||||
IntSupplier resolver) {
|
||||
return cache.resolvedHeight(query, () -> OptionalInt.of(resolver.getAsInt())).orElseThrow();
|
||||
}
|
||||
|
||||
private static NativeTerrainHeightCache.Query query(int runtimeId, int x, int z) {
|
||||
return new NativeTerrainHeightCache.Query(runtimeId, x, z, Heightmap.Types.WORLD_SURFACE, -256, 768);
|
||||
}
|
||||
}
|
||||
+1
@@ -63,6 +63,7 @@ public final class NativeStructureReferenceEnvelope {
|
||||
boolean usesEnvelope = mode == IrisStructureTerrainMode.BORE
|
||||
|| mode == IrisStructureTerrainMode.FORCE_CARVE
|
||||
|| mode == IrisStructureTerrainMode.VACUUM
|
||||
|| mode == IrisStructureTerrainMode.FLATTEN
|
||||
|| mode == IrisStructureTerrainMode.ENCASE;
|
||||
int horizontalPadding = mode == IrisStructureTerrainMode.VACUUM
|
||||
? NativeStructureSurfaceFitter.surfaceTerrainRadius()
|
||||
|
||||
+34
@@ -12,6 +12,7 @@ import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.RuinedPortalPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
|
||||
@@ -52,6 +53,32 @@ final class NativeStructureReflection {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
static RuinedPortalPiece.VerticalPlacement ruinedPortalVerticalPlacement(RuinedPortalPiece piece) {
|
||||
try {
|
||||
return (RuinedPortalPiece.VerticalPlacement) RuinedPortalPlacementAccess.FIELD.get(piece);
|
||||
} catch (IllegalAccessException error) {
|
||||
throw new IllegalStateException("Cannot read native ruined portal vertical placement", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static Field resolveRuinedPortalPlacementField() {
|
||||
Field resolved = null;
|
||||
for (Field field : RuinedPortalPiece.class.getDeclaredFields()) {
|
||||
if (Modifier.isStatic(field.getModifiers())
|
||||
|| field.getType() != RuinedPortalPiece.VerticalPlacement.class) {
|
||||
continue;
|
||||
}
|
||||
if (resolved != null) {
|
||||
throw new IllegalStateException("RuinedPortalPiece has multiple vertical-placement fields");
|
||||
}
|
||||
resolved = field;
|
||||
}
|
||||
if (resolved == null || !resolved.trySetAccessible()) {
|
||||
throw new IllegalStateException("RuinedPortalPiece vertical-placement field is inaccessible");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static Field resolveMonumentChildPiecesField() {
|
||||
Field resolved = null;
|
||||
for (Field field : OceanMonumentPieces.MonumentBuilding.class.getDeclaredFields()) {
|
||||
@@ -308,6 +335,13 @@ final class NativeStructureReflection {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RuinedPortalPlacementAccess {
|
||||
private static final Field FIELD = resolveRuinedPortalPlacementField();
|
||||
|
||||
private RuinedPortalPlacementAccess() {
|
||||
}
|
||||
}
|
||||
|
||||
static final class ScatteredHeightPositionAccess {
|
||||
static final Field FIELD = resolveScatteredHeightPositionField();
|
||||
|
||||
|
||||
+325
-25
@@ -8,7 +8,9 @@ import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.StructurePiece;
|
||||
@@ -16,6 +18,15 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.IglooPieces;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanRuinStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.RuinedPortalPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.ShipwreckStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.StrongholdStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.WoodlandMansionPieces;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -39,11 +50,13 @@ public final class NativeStructureSurfaceFitter {
|
||||
ConcurrentHashMap.newKeySet();
|
||||
private static final Set<String> WARNED_VACUUM_BUDGET =
|
||||
ConcurrentHashMap.newKeySet();
|
||||
private static final Set<String> WARNED_FLATTEN_BUDGET =
|
||||
ConcurrentHashMap.newKeySet();
|
||||
|
||||
private NativeStructureSurfaceFitter() {
|
||||
}
|
||||
|
||||
public static VacuumFoundationPlan prepareSurfaceStructures(
|
||||
public static SurfaceTerrainPlan prepareSurfaceStructures(
|
||||
WorldGenLevel world, BoundingBox area,
|
||||
List<NativeStructureTerrainIntegrator.TerrainTarget> targets,
|
||||
IntBinaryOperator surfaceHeight) {
|
||||
@@ -51,12 +64,12 @@ public final class NativeStructureSurfaceFitter {
|
||||
world, area, targets, surfaceHeight, MAX_SURFACE_TEMPLATE_CELLS);
|
||||
}
|
||||
|
||||
static VacuumFoundationPlan prepareSurfaceStructures(
|
||||
static SurfaceTerrainPlan prepareSurfaceStructures(
|
||||
WorldGenLevel world, BoundingBox area,
|
||||
List<NativeStructureTerrainIntegrator.TerrainTarget> targets,
|
||||
IntBinaryOperator surfaceHeight, int maximumTemplateCells) {
|
||||
if (targets == null || targets.isEmpty()) {
|
||||
return VacuumFoundationPlan.empty();
|
||||
return SurfaceTerrainPlan.empty();
|
||||
}
|
||||
Objects.requireNonNull(surfaceHeight, "Surface structure terrain fitting requires an Iris height resolver");
|
||||
List<SurfaceAnchor> anchors = collectSourceSurfaceAnchors(
|
||||
@@ -64,18 +77,22 @@ public final class NativeStructureSurfaceFitter {
|
||||
if (!anchors.isEmpty()) {
|
||||
fitSurfaceTerrain(world, area, anchors, surfaceHeight);
|
||||
}
|
||||
FlattenFootprint flatten = collectFlattenFootprint(
|
||||
world, area, targets, surfaceHeight, maximumTemplateCells);
|
||||
Map<Long, Integer> flattenedHeights = flatten.anchors().isEmpty() && flatten.projectedSupport().isEmpty() ? Map.of()
|
||||
: fitFlattenTerrain(world, area, flatten, surfaceHeight);
|
||||
VacuumFootprint vacuum = collectVacuumFootprint(
|
||||
world, area, targets, maximumTemplateCells);
|
||||
if (!vacuum.anchors().isEmpty()) {
|
||||
fitVacuumTerrain(world, area, vacuum, surfaceHeight);
|
||||
}
|
||||
return VacuumFoundationPlan.create(
|
||||
vacuum.foundationBases(), vacuum.occupiedCells(), area);
|
||||
return SurfaceTerrainPlan.create(
|
||||
vacuum, area, flattenedHeights);
|
||||
}
|
||||
|
||||
public static void repairVacuumFoundations(
|
||||
WorldGenLevel world, BoundingBox area,
|
||||
VacuumFoundationPlan plan) {
|
||||
SurfaceTerrainPlan plan) {
|
||||
if (plan == null || plan.foundationBases.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@@ -484,6 +501,7 @@ public final class NativeStructureSurfaceFitter {
|
||||
}
|
||||
IrisStructureTerrainMode mode = target.terrain().resolvedMode();
|
||||
return mode == IrisStructureTerrainMode.VACUUM
|
||||
|| requiresFlattenTerrain(target)
|
||||
|| requiresSourceSurfaceTerrain(target);
|
||||
}
|
||||
|
||||
@@ -528,6 +546,268 @@ public final class NativeStructureSurfaceFitter {
|
||||
}
|
||||
}
|
||||
|
||||
static boolean requiresFlattenTerrain(NativeStructureTerrainIntegrator.TerrainTarget target) {
|
||||
if (target == null || target.terrain() == null
|
||||
|| target.terrain().resolvedMode() != IrisStructureTerrainMode.FLATTEN
|
||||
|| target.terrain().resolvedFlattenRange() == 0
|
||||
|| target.start() == null || !target.start().isValid()) {
|
||||
return false;
|
||||
}
|
||||
StructureStart start = target.start();
|
||||
return start.getStructure().step() == GenerationStep.Decoration.SURFACE_STRUCTURES
|
||||
&& start.getStructure().terrainAdaptation() != TerrainAdjustment.BURY
|
||||
&& start.getStructure().terrainAdaptation() != TerrainAdjustment.ENCAPSULATE
|
||||
&& !(start.getStructure() instanceof StrongholdStructure)
|
||||
&& !(start.getStructure() instanceof OceanMonumentStructure)
|
||||
&& !(start.getStructure() instanceof OceanRuinStructure)
|
||||
&& (!(start.getStructure() instanceof ShipwreckStructure)
|
||||
|| "minecraft:shipwreck_beached".equals(target.structureId()));
|
||||
}
|
||||
|
||||
private static FlattenFootprint collectFlattenFootprint(
|
||||
WorldGenLevel world, BoundingBox area,
|
||||
List<NativeStructureTerrainIntegrator.TerrainTarget> targets,
|
||||
IntBinaryOperator surfaceHeight, int maximumTemplateCells) {
|
||||
List<FlattenAnchor> anchors = new ArrayList<>();
|
||||
Map<Long, Integer> projectedSupport = new HashMap<>();
|
||||
Set<StructureStart> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
|
||||
if (!requiresFlattenTerrain(target) || !seen.add(target.start())) {
|
||||
continue;
|
||||
}
|
||||
int radius = Math.max(0, Math.min(128, target.terrain().getHorizontalPadding()));
|
||||
int range = target.terrain().resolvedFlattenRange();
|
||||
BoundingBox influenceArea = new BoundingBox(
|
||||
area.minX() - radius, area.minY(), area.minZ() - radius,
|
||||
area.maxX() + radius, area.maxY(), area.maxZ() + radius);
|
||||
StructureStart start = target.start();
|
||||
if (!hasExposedFlattenPiece(start, surfaceHeight, range)) {
|
||||
continue;
|
||||
}
|
||||
BoundingBox first = start.getPieces().getFirst().getBoundingBox();
|
||||
BlockPos reference = new BlockPos(first.getCenter().getX(), first.minY(), first.getCenter().getZ());
|
||||
TemplateCellBudget budget = new TemplateCellBudget(maximumTemplateCells);
|
||||
List<SurfaceAnchor> sources = new ArrayList<>();
|
||||
Set<Long> projectedColumns = new HashSet<>();
|
||||
try {
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
if (!intersectsHorizontally(bounds, influenceArea) || !flattenPieceAllowed(start, piece)) {
|
||||
continue;
|
||||
}
|
||||
if (piece instanceof PoolElementStructurePiece poolPiece) {
|
||||
if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) {
|
||||
addProcessedSurfaceAnchors(world, influenceArea, poolPiece, reference,
|
||||
StructureTemplatePool.Projection.RIGID, budget, sources);
|
||||
} else {
|
||||
addProjectedFlattenSupport(world, area, poolPiece, reference, budget, projectedColumns);
|
||||
}
|
||||
for (JigsawJunction junction : poolPiece.getJunctions()) {
|
||||
sources.add(new SurfaceAnchor(junction.getSourceX(), junction.getSourceX(),
|
||||
junction.getSourceZ(), junction.getSourceZ(), junction.getSourceGroundY() - 1, 1));
|
||||
}
|
||||
} else {
|
||||
sources.add(new SurfaceAnchor(bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
|
||||
flattenGroundY(start, piece, surfaceHeight), 2));
|
||||
}
|
||||
}
|
||||
} catch (TemplateBudgetExceeded ignored) {
|
||||
String structureId = target.structureId() == null
|
||||
? start.getStructure().getClass().getName() : target.structureId();
|
||||
if (WARNED_FLATTEN_BUDGET.add(structureId)) {
|
||||
IrisLogging.warn("Native structure FLATTEN fitting for '" + structureId
|
||||
+ "' exceeded its bounded template budget; skipping this start");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (SurfaceAnchor source : sources) {
|
||||
anchors.add(new FlattenAnchor(source, radius, range));
|
||||
}
|
||||
for (long column : projectedColumns) {
|
||||
projectedSupport.merge(column, range, Math::max);
|
||||
}
|
||||
}
|
||||
return new FlattenFootprint(List.copyOf(anchors), Map.copyOf(projectedSupport));
|
||||
}
|
||||
|
||||
private static void addProjectedFlattenSupport(
|
||||
WorldGenLevel world, BoundingBox area, PoolElementStructurePiece piece,
|
||||
BlockPos reference, TemplateCellBudget budget, Set<Long> columns) {
|
||||
NativeStructureTemplateOccupancy.OccupancyResult occupancy = NativeStructureTemplateOccupancy.resolve(
|
||||
world, piece, reference, area, () -> world.getLevel().getStructureManager(),
|
||||
area::isInside, budget::consume);
|
||||
for (Map.Entry<Long, NativeStructureTemplateOccupancy.OccupancyCell> entry : occupancy.cells().entrySet()) {
|
||||
NativeStructureTemplateOccupancy.OccupancyCell cell = entry.getValue();
|
||||
if (cell.blocker() || !NativeStructureTemplateOccupancy.isSolidBase(cell.state())) {
|
||||
continue;
|
||||
}
|
||||
BlockPos position = BlockPos.of(entry.getKey());
|
||||
columns.add(NativeStructureTemplateOccupancy.columnKey(position.getX(), position.getZ()));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean flattenPieceAllowed(StructureStart start, StructurePiece piece) {
|
||||
if (piece instanceof RuinedPortalPiece portal) {
|
||||
RuinedPortalPiece.VerticalPlacement placement = NativeStructureReflection.ruinedPortalVerticalPlacement(portal);
|
||||
return placement == RuinedPortalPiece.VerticalPlacement.ON_LAND_SURFACE
|
||||
|| placement == RuinedPortalPiece.VerticalPlacement.IN_NETHER;
|
||||
}
|
||||
if (piece instanceof IglooPieces.IglooPiece) {
|
||||
int highest = Integer.MIN_VALUE;
|
||||
for (StructurePiece candidate : start.getPieces()) {
|
||||
if (candidate instanceof IglooPieces.IglooPiece) {
|
||||
highest = Math.max(highest, candidate.getBoundingBox().minY());
|
||||
}
|
||||
}
|
||||
return piece.getBoundingBox().minY() == highest;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static boolean hasExposedFlattenPiece(
|
||||
StructureStart start, IntBinaryOperator surfaceHeight, int range) {
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (!flattenPieceAllowed(start, piece)) {
|
||||
continue;
|
||||
}
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
int groundY = flattenGroundY(start, piece, surfaceHeight);
|
||||
int roofY = piece instanceof IglooPieces.IglooPiece || piece instanceof SwampHutPiece
|
||||
? groundY + bounds.getYSpan() : bounds.maxY();
|
||||
for (int x = bounds.minX(); x <= bounds.maxX(); x++) {
|
||||
for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) {
|
||||
int surfaceY = surfaceHeight.applyAsInt(x, z);
|
||||
if (surfaceY <= roofY && (long) groundY - surfaceY <= range) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int flattenGroundY(
|
||||
StructureStart start, StructurePiece piece, IntBinaryOperator surfaceHeight) {
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
if (piece instanceof PoolElementStructurePiece poolPiece) {
|
||||
return bounds.minY() + poolPiece.getGroundLevelDelta() - 1;
|
||||
}
|
||||
if (piece instanceof IglooPieces.IglooPiece igloo) {
|
||||
BlockPos entrance = igloo.templatePosition().offset(
|
||||
StructureTemplate.calculateRelativePosition(igloo.placeSettings(), new BlockPos(3, 0, 0)));
|
||||
return surfaceHeight.applyAsInt(entrance.getX(), entrance.getZ());
|
||||
}
|
||||
if (piece instanceof SwampHutPiece || start.getStructure() instanceof ShipwreckStructure) {
|
||||
long sum = 0;
|
||||
int minimum = Integer.MAX_VALUE;
|
||||
for (int x = bounds.minX(); x <= bounds.maxX(); x++) {
|
||||
for (int z = bounds.minZ(); z <= bounds.maxZ(); z++) {
|
||||
int surfaceY = surfaceHeight.applyAsInt(x, z);
|
||||
sum += surfaceY;
|
||||
minimum = Math.min(minimum, surfaceY);
|
||||
}
|
||||
}
|
||||
return piece instanceof SwampHutPiece
|
||||
? (int) Math.floorDiv(sum, (long) bounds.getXSpan() * bounds.getZSpan()) : minimum;
|
||||
}
|
||||
if (piece instanceof WoodlandMansionPieces.WoodlandMansionPiece) {
|
||||
return start.getBoundingBox().minY() - 1;
|
||||
}
|
||||
return bounds.minY() - 1;
|
||||
}
|
||||
|
||||
static FlattenResolution resolveFlattenSurface(
|
||||
List<FlattenAnchor> anchors, int x, int z, int originalY) {
|
||||
FlattenAnchor selected = null;
|
||||
long weightedY = 0;
|
||||
long totalWeight = 0;
|
||||
long maximumInfluence = 0;
|
||||
int range = 0;
|
||||
for (FlattenAnchor anchor : anchors) {
|
||||
SurfaceAnchor source = anchor.source();
|
||||
int outX = IrisObjectVacuum.outset(x, source.minX(), source.maxX());
|
||||
int outZ = IrisObjectVacuum.outset(z, source.minZ(), source.maxZ());
|
||||
if (outX == 0 && outZ == 0) {
|
||||
if (selected == null || precedes(source, selected.source())) {
|
||||
selected = anchor;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
double distance = Math.sqrt((double) outX * outX + (double) outZ * outZ);
|
||||
if (anchor.radius() == 0 || distance >= anchor.radius()) {
|
||||
continue;
|
||||
}
|
||||
double progress = distance / anchor.radius();
|
||||
double factor = 1D - progress * progress * (3D - 2D * progress);
|
||||
long influence = Math.round(factor * SURFACE_TERRAIN_INFLUENCE_SCALE);
|
||||
long weight = influence * source.strength();
|
||||
int targetY = originalY + Math.max(-anchor.range(), Math.min(anchor.range(), source.meetY() - originalY));
|
||||
weightedY += weight * targetY;
|
||||
totalWeight += weight;
|
||||
maximumInfluence = Math.max(maximumInfluence, influence);
|
||||
range = Math.max(range, anchor.range());
|
||||
}
|
||||
if (selected != null) {
|
||||
int targetY = originalY + Math.max(-selected.range(),
|
||||
Math.min(selected.range(), selected.source().meetY() - originalY));
|
||||
return new FlattenResolution(targetY, selected.range(), selected.source().strength() > 1);
|
||||
}
|
||||
if (totalWeight == 0) {
|
||||
return new FlattenResolution(originalY, 0, false);
|
||||
}
|
||||
int targetY = blendSurfaceTarget(originalY, weightedY / (double) totalWeight,
|
||||
maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE);
|
||||
return new FlattenResolution(targetY, range, false);
|
||||
}
|
||||
|
||||
private static Map<Long, Integer> fitFlattenTerrain(
|
||||
WorldGenLevel world, BoundingBox area, FlattenFootprint footprint,
|
||||
IntBinaryOperator surfaceHeight) {
|
||||
Map<Long, Integer> heights = new HashMap<>();
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (int x = area.minX(); x <= area.maxX(); x++) {
|
||||
for (int z = area.minZ(); z <= area.maxZ(); z++) {
|
||||
int originalY = Math.max(area.minY(), Math.min(area.maxY(), surfaceHeight.applyAsInt(x, z)));
|
||||
long column = NativeStructureTemplateOccupancy.columnKey(x, z);
|
||||
FlattenResolution resolution = resolveFlattenSurface(footprint.anchors(), x, z, originalY);
|
||||
int projectedRange = footprint.projectedSupport().getOrDefault(column, 0);
|
||||
int range = Math.max(resolution.range(), projectedRange);
|
||||
if (range == 0 || originalY < area.maxY()
|
||||
&& !world.getBlockState(position.set(x, originalY + 1, z)).getFluidState().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
int targetY = Math.max(area.minY(), Math.min(area.maxY(), resolution.targetY()));
|
||||
int minimumY = Math.max(area.minY(), targetY - range);
|
||||
applySurfaceColumn(world, position, x, z, originalY, targetY,
|
||||
minimumY, Math.min(area.maxY(), originalY + range));
|
||||
if (resolution.foundation() || projectedRange > 0) {
|
||||
fillFlattenFoundation(world, position, x, z, targetY, minimumY);
|
||||
}
|
||||
heights.put(column, targetY + 1);
|
||||
}
|
||||
}
|
||||
return Map.copyOf(heights);
|
||||
}
|
||||
|
||||
private static void fillFlattenFoundation(
|
||||
WorldGenLevel world, BlockPos.MutableBlockPos position,
|
||||
int x, int z, int targetY, int minimumY) {
|
||||
SurfaceMaterials materials = resolveSurfaceMaterials(world, position, x, z, targetY, minimumY);
|
||||
if (!isTerrainBlock(world.getBlockState(position.set(x, targetY, z)))) {
|
||||
world.setBlock(position, materials.surface(), 2);
|
||||
}
|
||||
for (int y = targetY - 1; y >= minimumY; y--) {
|
||||
BlockState existing = world.getBlockState(position.set(x, y, z));
|
||||
if (!existing.getFluidState().isEmpty()
|
||||
|| NativeStructureVegetationClearer.isTreeBlock(existing)) {
|
||||
break;
|
||||
}
|
||||
if (!isTerrainBlock(existing)) {
|
||||
world.setBlock(position, materials.subsurface(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void fitVacuumTerrain(
|
||||
WorldGenLevel world, BoundingBox area, VacuumFootprint footprint,
|
||||
IntBinaryOperator surfaceHeight) {
|
||||
@@ -821,6 +1101,15 @@ public final class NativeStructureSurfaceFitter {
|
||||
record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) {
|
||||
}
|
||||
|
||||
record FlattenAnchor(SurfaceAnchor source, int radius, int range) {
|
||||
}
|
||||
|
||||
record FlattenResolution(int targetY, int range, boolean foundation) {
|
||||
}
|
||||
|
||||
private record FlattenFootprint(List<FlattenAnchor> anchors, Map<Long, Integer> projectedSupport) {
|
||||
}
|
||||
|
||||
private record SurfaceMaterials(BlockState surface, BlockState subsurface) {
|
||||
}
|
||||
|
||||
@@ -835,27 +1124,25 @@ public final class NativeStructureSurfaceFitter {
|
||||
private record VacuumAnchor(int surfaceY, int strength) {
|
||||
}
|
||||
|
||||
public static final class VacuumFoundationPlan {
|
||||
private static final VacuumFoundationPlan EMPTY =
|
||||
new VacuumFoundationPlan(List.of(), Set.of());
|
||||
public record SurfaceTerrainPlan(
|
||||
List<Long> foundationBases, Set<Long> occupiedCells, Map<Long, Integer> flattenedHeights) {
|
||||
private static final SurfaceTerrainPlan EMPTY =
|
||||
new SurfaceTerrainPlan(List.of(), Set.of(), Map.of());
|
||||
|
||||
private final List<Long> foundationBases;
|
||||
private final Set<Long> occupiedCells;
|
||||
|
||||
private VacuumFoundationPlan(
|
||||
List<Long> foundationBases, Set<Long> occupiedCells) {
|
||||
this.foundationBases = foundationBases;
|
||||
this.occupiedCells = occupiedCells;
|
||||
public SurfaceTerrainPlan {
|
||||
foundationBases = List.copyOf(foundationBases);
|
||||
occupiedCells = Set.copyOf(occupiedCells);
|
||||
flattenedHeights = Map.copyOf(flattenedHeights);
|
||||
}
|
||||
|
||||
private static VacuumFoundationPlan empty() {
|
||||
private static SurfaceTerrainPlan empty() {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
private static VacuumFoundationPlan create(
|
||||
Set<Long> packedBases, Set<Long> packedOccupancy,
|
||||
BoundingBox area) {
|
||||
if (packedBases.isEmpty()) {
|
||||
private static SurfaceTerrainPlan create(
|
||||
VacuumFootprint vacuum, BoundingBox area, Map<Long, Integer> flattenedHeights) {
|
||||
Set<Long> packedBases = vacuum.foundationBases();
|
||||
if (packedBases.isEmpty() && flattenedHeights.isEmpty()) {
|
||||
return EMPTY;
|
||||
}
|
||||
List<Long> bases = new ArrayList<>(packedBases.size());
|
||||
@@ -869,7 +1156,7 @@ public final class NativeStructureSurfaceFitter {
|
||||
columns.add(NativeStructureTemplateOccupancy.columnKey(
|
||||
base.getX(), base.getZ()));
|
||||
}
|
||||
if (bases.isEmpty()) {
|
||||
if (bases.isEmpty() && flattenedHeights.isEmpty()) {
|
||||
return EMPTY;
|
||||
}
|
||||
bases.sort((first, second) -> {
|
||||
@@ -878,15 +1165,28 @@ public final class NativeStructureSurfaceFitter {
|
||||
return yOrder == 0 ? Long.compare(first, second) : yOrder;
|
||||
});
|
||||
Set<Long> occupancy = new HashSet<>();
|
||||
for (long packedCell : packedOccupancy) {
|
||||
for (long packedCell : vacuum.occupiedCells()) {
|
||||
BlockPos cell = BlockPos.of(packedCell);
|
||||
if (columns.contains(NativeStructureTemplateOccupancy.columnKey(
|
||||
cell.getX(), cell.getZ()))) {
|
||||
occupancy.add(packedCell);
|
||||
}
|
||||
}
|
||||
return new VacuumFoundationPlan(
|
||||
List.copyOf(bases), Set.copyOf(occupancy));
|
||||
return new SurfaceTerrainPlan(
|
||||
bases, occupancy, flattenedHeights);
|
||||
}
|
||||
|
||||
public void primeHeightmaps(ChunkAccess chunk) {
|
||||
if (flattenedHeights.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Heightmap surface = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.WORLD_SURFACE_WG);
|
||||
Heightmap floor = chunk.getOrCreateHeightmapUnprimed(Heightmap.Types.OCEAN_FLOOR_WG);
|
||||
WorldgenTerrainHeightmaps.primeTerrain(chunk,
|
||||
(x, z) -> flattenedHeights.getOrDefault(NativeStructureTemplateOccupancy.columnKey(x, z),
|
||||
surface.getFirstAvailable(x & 15, z & 15)),
|
||||
(x, z) -> flattenedHeights.getOrDefault(NativeStructureTemplateOccupancy.columnKey(x, z),
|
||||
floor.getFirstAvailable(x & 15, z & 15)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -1,8 +1,10 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.StructurePiece;
|
||||
@@ -44,6 +46,10 @@ final class NativeStructureSurfaceSupportBuilder {
|
||||
for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
|
||||
if (target == null
|
||||
|| !NativeStructureSurfaceFitter.requiresSurfaceTerrain(target)
|
||||
|| target.terrain().resolvedMode() == IrisStructureTerrainMode.FLATTEN
|
||||
&& !NativeStructureSurfaceFitter.hasExposedFlattenPiece(
|
||||
target.start(), (x, z) -> world.getHeight(Heightmap.Types.WORLD_SURFACE_WG, x, z) - 1,
|
||||
target.terrain().resolvedFlattenRange())
|
||||
|| !seenStarts.add(target.start())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ public final class NativeStructureTerrainIntegrator {
|
||||
if (mode == IrisStructureTerrainMode.PRESERVE) {
|
||||
return;
|
||||
}
|
||||
if (mode == IrisStructureTerrainMode.VACUUM) {
|
||||
if (mode == IrisStructureTerrainMode.VACUUM || mode == IrisStructureTerrainMode.FLATTEN) {
|
||||
return;
|
||||
}
|
||||
if (mode == IrisStructureTerrainMode.ENCASE) {
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import it.unimi.dsi.fastutil.objects.Object2IntLinkedOpenHashMap;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
|
||||
import java.util.OptionalInt;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class NativeTerrainHeightCache {
|
||||
private static final int MAXIMUM_CACHED_QUERIES = 65_536;
|
||||
|
||||
private final Object2IntLinkedOpenHashMap<Query> heights = new Object2IntLinkedOpenHashMap<>();
|
||||
|
||||
public OptionalInt resolvedHeight(Query query, Supplier<OptionalInt> resolver) {
|
||||
synchronized (heights) {
|
||||
if (heights.containsKey(query)) {
|
||||
return OptionalInt.of(heights.getAndMoveToFirst(query));
|
||||
}
|
||||
}
|
||||
OptionalInt resolved = resolver.get();
|
||||
if (resolved.isEmpty()) {
|
||||
return resolved;
|
||||
}
|
||||
int height = resolved.getAsInt();
|
||||
synchronized (heights) {
|
||||
heights.putAndMoveToFirst(query, height);
|
||||
while (heights.size() > MAXIMUM_CACHED_QUERIES) {
|
||||
heights.removeLastInt();
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public void evictRuntime(int runtimeId) {
|
||||
synchronized (heights) {
|
||||
heights.keySet().removeIf(query -> query.runtimeId() == runtimeId);
|
||||
}
|
||||
}
|
||||
|
||||
public record Query(int runtimeId, int x, int z, Heightmap.Types type, int minimumY, int height) {
|
||||
}
|
||||
}
|
||||
+17
-3
@@ -19,6 +19,7 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.nativegen.NativeTransitionColumn;
|
||||
import art.arcane.iris.nativegen.NativeTerrainHeightCache;
|
||||
import art.arcane.iris.engine.history.TerrainBoundarySignature;
|
||||
import art.arcane.iris.engine.history.NativeBiomeSpawnSelection;
|
||||
import java.util.Optional;
|
||||
@@ -107,6 +108,7 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.CancellationException;
|
||||
@@ -145,6 +147,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
private final ModdedNativeStructureStage nativeStructures = new ModdedNativeStructureStage(this);
|
||||
private final ModdedSpawnTableMerger spawnTables = new ModdedSpawnTableMerger(this);
|
||||
private final ModdedImportedFeatureStage importedFeatures;
|
||||
private final NativeTerrainHeightCache terrainHeights = new NativeTerrainHeightCache();
|
||||
private final AtomicBoolean announced = new AtomicBoolean(false);
|
||||
private final IntConsumer generationRuntimeRetirementListener = this::retireGenerationRuntimeCaches;
|
||||
private volatile boolean unloading;
|
||||
@@ -1381,15 +1384,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
@Override
|
||||
public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor, RandomState randomState) {
|
||||
Engine current = requireDataQueryEngine("base height");
|
||||
boolean ignoreFluid = !type.isOpaque().test(Blocks.WATER.defaultBlockState());
|
||||
try (GenerationHistoryRuntimeRouter.CoordinateScope historyScope = openHistoryCoordinateScope(
|
||||
current, x, z, "modded_base_height");
|
||||
GenerationSessionLease lease = current.acquireGenerationLease("modded_base_height");
|
||||
IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) {
|
||||
Optional<TerrainBoundarySignature> resolved = current.getComplex().resolvedTerrainColumn(x, z);
|
||||
NativeTerrainHeightCache.Query query = new NativeTerrainHeightCache.Query(
|
||||
current.getCacheID(), x, z, type, heightAccessor.getMinY(), heightAccessor.getHeight());
|
||||
OptionalInt resolved = terrainHeights.resolvedHeight(query,
|
||||
() -> resolvedBaseHeight(current, x, z, type, heightAccessor));
|
||||
if (resolved.isPresent()) {
|
||||
return NativeTransitionColumn.height(resolved.get(), type, heightAccessor);
|
||||
return resolved.getAsInt();
|
||||
}
|
||||
boolean ignoreFluid = !type.isOpaque().test(Blocks.WATER.defaultBlockState());
|
||||
int height = current.getDimensionStackContext() == null
|
||||
? current.getHeight(x, z, ignoreFluid)
|
||||
: Engine.hostHeight(current, x, z, ignoreFluid);
|
||||
@@ -1399,6 +1405,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
private OptionalInt resolvedBaseHeight(Engine current, int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor) {
|
||||
Optional<TerrainBoundarySignature> resolved = current.getComplex().resolvedTerrainColumn(x, z);
|
||||
return resolved.isPresent()
|
||||
? OptionalInt.of(NativeTransitionColumn.height(resolved.get(), type, heightAccessor))
|
||||
: OptionalInt.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState randomState) {
|
||||
int minY = heightAccessor.getMinY();
|
||||
@@ -1482,6 +1495,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
}
|
||||
|
||||
private void retireGenerationRuntimeCaches(int runtimeIdentity) {
|
||||
terrainHeights.evictRuntime(runtimeIdentity);
|
||||
structureBiomeSource.evictRuntime(runtimeIdentity);
|
||||
importedFeatures.evictRuntime(runtimeIdentity);
|
||||
spawnTables.evictRuntime(runtimeIdentity);
|
||||
|
||||
+9
-1
@@ -46,6 +46,7 @@ import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.sounds.SoundEvent;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.Difficulty;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
@@ -120,13 +121,20 @@ public final class ModdedEntitySpawner {
|
||||
return null;
|
||||
}
|
||||
|
||||
Entity entity = type.spawn(level, BlockPos.containing(x, y, z), reasonFor(irisEntity.getReason()));
|
||||
Entity entity = spawnNative(type, level, BlockPos.containing(x, y, z), reasonFor(irisEntity.getReason()));
|
||||
if (entity != null) {
|
||||
entity.snapTo(x, y, z, 0F, 0F);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
static Entity spawnNative(EntityType<?> type, ServerLevel level, BlockPos position, EntitySpawnReason reason) {
|
||||
if (level.getDifficulty() == Difficulty.PEACEFUL && !type.isAllowedInPeaceful()) {
|
||||
return null;
|
||||
}
|
||||
return type.spawn(level, position, reason);
|
||||
}
|
||||
|
||||
private static void applyConfig(Engine engine, IrisEntity irisEntity, Entity entity, ServerLevel level, int blockX, int blockY, int blockZ, RNG rng, boolean riseEffectActive) {
|
||||
String customName = irisEntity.getCustomName();
|
||||
if (customName != null) {
|
||||
|
||||
+5
-7
@@ -398,10 +398,7 @@ final class ModdedNativeStructureStage {
|
||||
}
|
||||
IrisStaticObjectLayer staticObjects = current.getDimension().getStaticObjectLayer(current.getData());
|
||||
Predicate<BlockPos> protectedPosition = nativeStructureProtection(current, staticObjects);
|
||||
WorldGenLevel boundedWorld = staticObjects.isEmpty()
|
||||
&& current.getDimensionStackContext() == null
|
||||
? world
|
||||
: ModdedNativeStructureWorldgenAccess.create(
|
||||
WorldGenLevel boundedWorld = ModdedNativeStructureWorldgenAccess.create(
|
||||
world, chunkPos, worldgenSurfaceHeight(current, world.getMinY()), worldgenFloorHeight(current, world.getMinY()),
|
||||
current.getDimensionStackContext() != null,
|
||||
protectedPosition);
|
||||
@@ -413,11 +410,12 @@ final class ModdedNativeStructureStage {
|
||||
"vegetation cleanup", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
NativeStructureSurfaceFitter.VacuumFoundationPlan vacuumFoundationPlan;
|
||||
NativeStructureSurfaceFitter.SurfaceTerrainPlan surfaceTerrainPlan;
|
||||
try {
|
||||
vacuumFoundationPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
surfaceTerrainPlan = NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
boundedWorld, area, terrainTargets,
|
||||
(x, z) -> Engine.hostHeight(current, x, z, true) + current.getMinHeight());
|
||||
surfaceTerrainPlan.primeHeightmaps(chunk);
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"terrain integration", nativeStructureBatchContext(placementGroups),
|
||||
@@ -445,7 +443,7 @@ final class ModdedNativeStructureStage {
|
||||
}
|
||||
try {
|
||||
NativeStructureSurfaceFitter.repairVacuumFoundations(
|
||||
boundedWorld, area, vacuumFoundationPlan);
|
||||
boundedWorld, area, surfaceTerrainPlan);
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"foundation repair", nativeStructureBatchContext(placementGroups),
|
||||
|
||||
@@ -210,7 +210,7 @@ public final class ModdedPlatform implements IrisPlatform {
|
||||
}
|
||||
EntityType<?> type = (EntityType<?>) resolved.nativeHandle();
|
||||
BlockPos pos = BlockPos.containing(x, y, z);
|
||||
Entity entity = type.spawn(level, pos, EntitySpawnReason.COMMAND);
|
||||
Entity entity = ModdedEntitySpawner.spawnNative(type, level, pos, EntitySpawnReason.COMMAND);
|
||||
return entity != null;
|
||||
}
|
||||
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisEntity;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerChunkCache;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.Difficulty;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.EntitySpawnReason;
|
||||
import net.minecraft.world.entity.EntityType;
|
||||
import net.minecraft.world.level.chunk.LevelChunk;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
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.ArgumentMatchers.anyInt;
|
||||
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.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedEntitySpawnDifficultyTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sharedPlatformSpawnReturnsFalseForPeacefulHostiles() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL);
|
||||
ModdedPlatform platform = new ModdedPlatform(mock(ModdedLoader.class));
|
||||
|
||||
assertFalse(platform.spawnEntity(new ModdedPlatformWorld(level), "minecraft:zombie", 0.5, 80, 0.5));
|
||||
|
||||
verify(level, never()).addFreshEntity(any(Entity.class));
|
||||
verify(level, never()).enabledFeatures();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ambientSpawnReturnsNullForPeacefulHostilesBeforeApplyingConfiguration() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
ServerChunkCache chunks = mock(ServerChunkCache.class);
|
||||
when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL);
|
||||
when(level.getChunkSource()).thenReturn(chunks);
|
||||
when(chunks.getChunkNow(anyInt(), anyInt())).thenReturn(mock(LevelChunk.class));
|
||||
IrisEntity configured = new IrisEntity().setType("zombie").setSpawnEffectRiseOutOfGround(false);
|
||||
|
||||
assertNull(ModdedEntitySpawner.spawn(mock(Engine.class), configured, level, 0, 80, 0, new RNG(1L)));
|
||||
|
||||
verify(level, never()).addFreshEntity(any(Entity.class));
|
||||
verify(level, never()).enabledFeatures();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void peacefulRejectsEveryNativeForbiddenTypeBeforeCreation() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL);
|
||||
int rejected = 0;
|
||||
|
||||
for (EntityType<?> registered : BuiltInRegistries.ENTITY_TYPE) {
|
||||
if (registered.isAllowedInPeaceful()) {
|
||||
continue;
|
||||
}
|
||||
EntityType<?> type = spy(registered);
|
||||
assertNull(ModdedEntitySpawner.spawnNative(type, level, BlockPos.ZERO, EntitySpawnReason.NATURAL));
|
||||
verify(type, never()).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL);
|
||||
rejected++;
|
||||
}
|
||||
|
||||
assertTrue(rejected > 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void peacefulPreservesPassiveAndNativeMonsterCategoryExceptions() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL);
|
||||
Entity created = mock(Entity.class);
|
||||
|
||||
for (String key : List.of("cow", "villager", "wolf", "piglin", "shulker")) {
|
||||
EntityType<?> type = spy(BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse("minecraft:" + key)));
|
||||
doReturn(created).when(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL);
|
||||
|
||||
assertSame(key, created, ModdedEntitySpawner.spawnNative(type, level, BlockPos.ZERO, EntitySpawnReason.NATURAL));
|
||||
verify(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonPeacefulDifficultiesPreserveHostileSpawns() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
Entity created = mock(Entity.class);
|
||||
|
||||
for (Difficulty difficulty : List.of(Difficulty.EASY, Difficulty.NORMAL, Difficulty.HARD)) {
|
||||
EntityType<?> type = spy(BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse("minecraft:zombie")));
|
||||
when(level.getDifficulty()).thenReturn(difficulty);
|
||||
doReturn(created).when(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL);
|
||||
|
||||
assertSame(created, ModdedEntitySpawner.spawnNative(type, level, BlockPos.ZERO, EntitySpawnReason.NATURAL));
|
||||
verify(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedNativeSpawnFailuresStillPropagate() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
when(level.getDifficulty()).thenReturn(Difficulty.PEACEFUL);
|
||||
EntityType<?> type = spy(BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse("minecraft:cow")));
|
||||
IllegalStateException failure = new IllegalStateException("Spawn rejected by platform");
|
||||
doThrow(failure).when(type).spawn(level, BlockPos.ZERO, EntitySpawnReason.NATURAL);
|
||||
|
||||
assertSame(failure, assertThrows(IllegalStateException.class,
|
||||
() -> ModdedEntitySpawner.spawnNative(type, level, BlockPos.ZERO, EntitySpawnReason.NATURAL)));
|
||||
}
|
||||
}
|
||||
+91
@@ -1,9 +1,14 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.iris.nativegen.NativeStructurePostProcessor;
|
||||
import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
@@ -12,10 +17,25 @@ import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.entity.EntityTypeTest;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.GravityProcessor;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
|
||||
import net.minecraft.world.level.material.Fluid;
|
||||
import net.minecraft.world.level.material.FluidState;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
@@ -30,11 +50,13 @@ import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedNativeStructureWorldgenAccessTest {
|
||||
@@ -49,6 +71,69 @@ public class ModdedNativeStructureWorldgenAccessTest {
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ordinaryWorldFlattenPreparationHandlesReferencedVillagePiecesBeyondEightChunks() {
|
||||
int farX = (GENERATION_CENTER_X - 9) << 4;
|
||||
int farZ = (GENERATION_CENTER_Z + 4) << 4;
|
||||
BoundingBox bounds = new BoundingBox(farX, 64, farZ, farX + 4, 90, farZ + 4);
|
||||
PoolElementStructurePiece piece = new PoolElementStructurePiece(
|
||||
null, StructurePoolElement.single("minecraft:empty").apply(StructureTemplatePool.Projection.RIGID),
|
||||
new BlockPos(farX, 64, farZ), 1, Rotation.NONE, bounds, LiquidSettings.APPLY_WATERLOGGING);
|
||||
Structure structure = new DesertPyramidStructure(new Structure.StructureSettings(
|
||||
HolderSet.empty(), Map.of(), GenerationStep.Decoration.SURFACE_STRUCTURES, TerrainAdjustment.BEARD_BOX));
|
||||
StructureStart start = new StructureStart(structure, generationCenter(), 0, new PiecesContainer(List.of(piece)));
|
||||
List<NativeStructureTerrainIntegrator.TerrainTarget> targets = List.of(
|
||||
new NativeStructureTerrainIntegrator.TerrainTarget("minecraft:village_plains", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.FLATTEN)));
|
||||
BoundingBox area = new BoundingBox(generationCenter().getMinBlockX(), -64, generationCenter().getMinBlockZ(),
|
||||
generationCenter().getMaxBlockX(), 319, generationCenter().getMaxBlockZ());
|
||||
RecordingDelegate unbounded = new RecordingDelegate();
|
||||
unbounded.rejectDistantTerrainReads = true;
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> NativeStructurePostProcessor.prepareTerrain(
|
||||
unbounded.world(), area, targets, (palette, rng, x, y, z) -> Blocks.STONE.defaultBlockState()));
|
||||
|
||||
RecordingDelegate recording = new RecordingDelegate();
|
||||
recording.rejectDistantTerrainReads = true;
|
||||
ModdedNativeStructureWorldgenAccess access = access(recording);
|
||||
NativeStructurePostProcessor.prepareTerrain(
|
||||
access, area, targets, (palette, rng, x, y, z) -> Blocks.STONE.defaultBlockState());
|
||||
|
||||
assertEquals(0, recording.heightReads);
|
||||
assertEquals(0, recording.chunkReads);
|
||||
assertEquals(0, recording.terrainReads);
|
||||
assertEquals(0, recording.mutations);
|
||||
assertEquals(91, access.getHeight(Heightmap.Types.WORLD_SURFACE_WG,
|
||||
generationCenter().getMinBlockX(), generationCenter().getMinBlockZ()));
|
||||
assertEquals(1, recording.heightReads);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectedTemplatePrefiltersUseRemoteTerrainAndRetainLocalHeightmaps() {
|
||||
RecordingDelegate recording = new RecordingDelegate();
|
||||
recording.rejectDistantTerrainReads = true;
|
||||
ModdedNativeStructureWorldgenAccess access = access(recording);
|
||||
GravityProcessor gravity = new GravityProcessor(Heightmap.Types.WORLD_SURFACE_WG, -1);
|
||||
BlockPos remote = new BlockPos((GENERATION_CENTER_X - 9) << 4, 64, (GENERATION_CENTER_Z + 4) << 4);
|
||||
StructureTemplate.StructureBlockInfo raw = new StructureTemplate.StructureBlockInfo(
|
||||
new BlockPos(0, 4, 0), Blocks.DIRT_PATH.defaultBlockState(), null);
|
||||
StructurePlaceSettings settings = new StructurePlaceSettings().addProcessor(gravity);
|
||||
|
||||
StructureTemplate.StructureBlockInfo projected = StructureTemplate.processBlockInfos(
|
||||
access, remote, BlockPos.ZERO, settings, List.of(raw)).getFirst();
|
||||
|
||||
assertEquals(remote.atY(SURFACE_FIRST_FREE_Y + 3), projected.pos());
|
||||
assertEquals(0, recording.heightReads);
|
||||
BlockPos local = generationCenter().getMiddleBlockPosition(64);
|
||||
StructureTemplate.StructureBlockInfo localBlock = new StructureTemplate.StructureBlockInfo(
|
||||
BlockPos.ZERO, Blocks.DIRT_PATH.defaultBlockState(), null);
|
||||
StructureTemplate.StructureBlockInfo localProjection = StructureTemplate.processBlockInfos(
|
||||
access, local, BlockPos.ZERO, settings, List.of(localBlock)).getFirst();
|
||||
assertEquals(local.atY(90), localProjection.pos());
|
||||
assertEquals(1, recording.heightReads);
|
||||
assertEquals(0, recording.chunkReads);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void distanceTwoTerrainReadsNeverReachTheDelegate() {
|
||||
RecordingDelegate recording = new RecordingDelegate();
|
||||
@@ -253,6 +338,7 @@ public class ModdedNativeStructureWorldgenAccessTest {
|
||||
private int statusChunkReads;
|
||||
private int mutations;
|
||||
private int events;
|
||||
private boolean rejectDistantTerrainReads;
|
||||
|
||||
private RecordingDelegate() {
|
||||
this.biome = Holder.direct((Biome) null);
|
||||
@@ -293,6 +379,11 @@ public class ModdedNativeStructureWorldgenAccessTest {
|
||||
return 384;
|
||||
}
|
||||
if (name.equals("getHeight")) {
|
||||
if (rejectDistantTerrainReads
|
||||
&& (Math.abs(((int) arguments[1] >> 4) - GENERATION_CENTER_X) > 1
|
||||
|| Math.abs(((int) arguments[2] >> 4) - GENERATION_CENTER_Z) > 1)) {
|
||||
throw new IllegalStateException("Requested chunk unavailable during world generation");
|
||||
}
|
||||
heightReads++;
|
||||
return 91;
|
||||
}
|
||||
|
||||
@@ -1011,6 +1011,13 @@ final class PackHydrologyValidator {
|
||||
validateNullableEnum(path, policy, "placement", PLACEMENT_MODES, errors);
|
||||
validateNullableEnum(path, policy, "routing", ROUTING_MODES, errors);
|
||||
validateNullableBoolean(path, policy, "outletAdmission", errors);
|
||||
validateNullableDouble(path, policy, "surfaceSourceDensity", 0D, 64D, errors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceSourceSpacing", 0, 8192, errors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceTributaries", 0, 4, errors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceInlandOutlets", 0, 256, errors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceCoastalOutlets", 0, 64, errors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceMinimumCourseLength", 16, 4096, errors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path, policy, "surfaceMaximumIncision", 1, 32, errors);
|
||||
validatePolicyProfiles(path, policy, errors);
|
||||
validatePolicyPools(path, policy, errors);
|
||||
for (String biomeField : POLICY_BIOME_FIELDS) {
|
||||
|
||||
@@ -40,7 +40,7 @@ import java.util.Set;
|
||||
|
||||
final class PackStructurePlacementValidator {
|
||||
private static final Set<String> TERRAIN_ENVELOPE_MODES = Set.of(
|
||||
"BORE", "FORCE_CARVE", "VACUUM", "ENCASE");
|
||||
"BORE", "FORCE_CARVE", "VACUUM", "FLATTEN", "ENCASE");
|
||||
|
||||
private PackStructurePlacementValidator() {
|
||||
}
|
||||
@@ -633,11 +633,13 @@ final class PackStructurePlacementValidator {
|
||||
return;
|
||||
}
|
||||
PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "mode",
|
||||
Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), blockingErrors);
|
||||
Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "FLATTEN", "ENCASE"), blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "shape",
|
||||
Set.of("BOX", "ROUNDED", "ERODED"), blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
|
||||
"horizontalPadding", 0, 128, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
|
||||
"flattenRange", 0, 128, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
|
||||
"ceilingPadding", 0, 128, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
|
||||
@@ -663,7 +665,7 @@ final class PackStructurePlacementValidator {
|
||||
return;
|
||||
}
|
||||
String mode = terrain.optString("mode", "SOURCE");
|
||||
if ("VACUUM".equals(mode) || "ENCASE".equals(mode)) {
|
||||
if ("VACUUM".equals(mode) || "FLATTEN".equals(mode) || "ENCASE".equals(mode)) {
|
||||
blockingErrors.add(path + ".terrain.mode " + mode
|
||||
+ " cannot target editable Iris structures; use nativeStructures or "
|
||||
+ "importedStructures.adjustments for native terrain preparation.");
|
||||
|
||||
@@ -4,6 +4,8 @@ import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.compat.CompatAction;
|
||||
import art.arcane.iris.core.compat.CompatFinding;
|
||||
import art.arcane.iris.core.compat.CompatRegistry;
|
||||
import art.arcane.iris.engine.history.GenerationKernelRegistry;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
@@ -46,12 +48,20 @@ public final class PackValidationCache {
|
||||
if (!IrisPlatforms.isBound()) {
|
||||
return "";
|
||||
}
|
||||
return contextFingerprint(ImplementationIdentity.FINGERPRINT);
|
||||
}
|
||||
|
||||
static String contextFingerprint(String implementationFingerprint) {
|
||||
if (!IrisPlatforms.isBound() || implementationFingerprint == null || implementationFingerprint.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
IrisPlatform platform = IrisPlatforms.get();
|
||||
update(digest, platform.platformName());
|
||||
update(digest, platform.minecraftVersion());
|
||||
update(digest, Integer.toString(platform.irisVersionNumber()));
|
||||
update(digest, implementationFingerprint);
|
||||
update(digest, Boolean.toString(ContentKeyValidator.strictContent()));
|
||||
|
||||
PlatformRegistries registries = Objects.requireNonNull(
|
||||
@@ -230,6 +240,17 @@ public final class PackValidationCache {
|
||||
}
|
||||
}
|
||||
|
||||
private static String implementationFingerprint() {
|
||||
try {
|
||||
GenerationKernelRegistry kernels = GenerationKernelRegistry.standard();
|
||||
return kernels.requireSupported(kernels.current()).implementationFingerprint();
|
||||
} catch (IOException | RuntimeException | LinkageError failure) {
|
||||
IrisLogging.reportError(
|
||||
"Cannot identify the Iris validation implementation; persisted pack validation is disabled.", failure);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(MessageDigest digest, String value) {
|
||||
byte[] bytes = Objects.requireNonNullElse(value, "").getBytes(StandardCharsets.UTF_8);
|
||||
digest.update((byte) (bytes.length >>> 24));
|
||||
@@ -246,6 +267,10 @@ public final class PackValidationCache {
|
||||
private List<CachedResult> results;
|
||||
}
|
||||
|
||||
private static final class ImplementationIdentity {
|
||||
private static final String FINGERPRINT = implementationFingerprint();
|
||||
}
|
||||
|
||||
private static final class CachedResult {
|
||||
private String packName;
|
||||
private List<String> blockingErrors;
|
||||
|
||||
@@ -77,6 +77,7 @@ public class IrisPregenerator {
|
||||
private final AtomicLong failed;
|
||||
private final IrisPackBenchmarking benchmarking;
|
||||
private volatile PregenRates rates;
|
||||
private boolean progressClosed;
|
||||
|
||||
public IrisPregenerator(PregenTask task, PregeneratorMethod generator, PregenListener listener) {
|
||||
this.jobId = JOB_SEQUENCE.incrementAndGet();
|
||||
@@ -123,7 +124,10 @@ public class IrisPregenerator {
|
||||
return Double.isFinite(d) && d != INVALID ? (long) d : 0;
|
||||
}
|
||||
|
||||
private void publishProgress(long now, boolean logProgress) {
|
||||
private synchronized void publishProgress(long now, boolean logProgress) {
|
||||
if (progressClosed) {
|
||||
return;
|
||||
}
|
||||
long generatedCount = generated.get();
|
||||
long total = totalChunks.get();
|
||||
rates = rateTracker.sample(generatedCount, now);
|
||||
@@ -173,6 +177,14 @@ public class IrisPregenerator {
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void publishFinalProgress() {
|
||||
try {
|
||||
publishProgress(M.ms(), false);
|
||||
} finally {
|
||||
progressClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void sampleHistory(long now, long generatedCount, long cachedCount) {
|
||||
long previousTime = historyLastMillis.getAndSet(now);
|
||||
long previousGenerated = historyLastGenerated.getAndSet(generatedCount);
|
||||
@@ -297,6 +309,7 @@ public class IrisPregenerator {
|
||||
shutdownStep("generator", generator::close);
|
||||
Thread.interrupted();
|
||||
shutdownStep("ticker", ticker::interrupt);
|
||||
shutdownStep("progress", this::publishFinalProgress);
|
||||
shutdownStep("mantle", () -> {
|
||||
Mantle mantle = getMantle();
|
||||
if (mantle != null) {
|
||||
|
||||
+27
-17
@@ -29,6 +29,7 @@ import art.arcane.iris.core.pregenerator.PregenMantleBackpressure;
|
||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
@@ -68,6 +69,8 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
private static final int ADAPTIVE_RECOVERY_INTERVAL = 8;
|
||||
private static final long CLOSE_DRAIN_WARNING_SECONDS = 60L;
|
||||
private static final long FLUSH_TIMEOUT_SECONDS = 120L;
|
||||
private static final long MANTLE_CLEANUP_DRAIN_SECONDS = 60L;
|
||||
private static final long CHUNK_FLUSH_DRAIN_SECONDS = 30L;
|
||||
private static final long ADMISSION_WAIT_BOUND_MS = 500L;
|
||||
private final World world;
|
||||
private final IrisRuntimeSchedulerMode runtimeSchedulerMode;
|
||||
@@ -83,6 +86,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
private final String chunkAccessMode;
|
||||
private final ChunkRequestExecutor executor;
|
||||
private final Executor slowRequestExecutor;
|
||||
private final Executor chunkIoExecutor;
|
||||
private final PregenSerialWorker chunkFlush;
|
||||
private final PregenSerialWorker mantleCleanup;
|
||||
private final PregenAdmissionGate admission;
|
||||
private final int threads;
|
||||
private final int slowRequestWarningSeconds;
|
||||
@@ -164,6 +170,12 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
this.admission = new PregenAdmissionGate(this.threads, ADMISSION_WAIT_BOUND_MS, M::ms);
|
||||
this.slowRequestWarningSeconds = pregen.getChunkLoadTimeoutSeconds();
|
||||
this.slowRequestExecutor = CompletableFuture.delayedExecutor(this.slowRequestWarningSeconds, TimeUnit.SECONDS);
|
||||
// Never the shared Iris IO pool: the mantle loads tectonic plates on that pool, and a
|
||||
// Moonrise flush parks until the level's pending IO settles. Filling the pool with parked
|
||||
// flushes left generation waiting on plate loads that could never run.
|
||||
this.chunkFlush = new PregenSerialWorker("Iris Pregen Chunk Flush", world.getName());
|
||||
this.chunkIoExecutor = chunkFlush.executor();
|
||||
this.mantleCleanup = new PregenSerialWorker("Iris Pregen Mantle Cleanup", world.getName());
|
||||
this.slowRequestWarnIntervalMs = pregen.getTimeoutWarnIntervalMs();
|
||||
this.urgent = false;
|
||||
this.regionPending = new ConcurrentHashMap<>();
|
||||
@@ -383,41 +395,32 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
unloadChunkSafely(chunk.getX(), chunk.getZ());
|
||||
}
|
||||
}
|
||||
|
||||
INMS.get().flushChunkIO(world);
|
||||
});
|
||||
}).thenCompose(ignored -> flushChunkIOAsync());
|
||||
}
|
||||
return trackEviction(eviction);
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> evictFoliaRegion(Queue<Chunk> chunks) {
|
||||
List<CompletableFuture<Void>> unloads = new ArrayList<>(chunks.size());
|
||||
Chunk anchor = null;
|
||||
for (Chunk chunk : chunks) {
|
||||
if (chunk == null) {
|
||||
continue;
|
||||
}
|
||||
if (anchor == null) {
|
||||
anchor = chunk;
|
||||
}
|
||||
|
||||
int chunkX = chunk.getX();
|
||||
int chunkZ = chunk.getZ();
|
||||
unloads.add(J.runRegionFuture(world, chunkX, chunkZ, () -> unloadChunkSafely(chunkX, chunkZ)));
|
||||
}
|
||||
|
||||
CompletableFuture<Void> unloaded = CompletableFuture.allOf(unloads.toArray(CompletableFuture[]::new));
|
||||
if (anchor == null) {
|
||||
if (unloads.isEmpty()) {
|
||||
return unloaded;
|
||||
}
|
||||
return unloaded.thenCompose(ignored -> flushChunkIOAsync());
|
||||
}
|
||||
|
||||
int anchorX = anchor.getX();
|
||||
int anchorZ = anchor.getZ();
|
||||
return unloaded.thenCompose(ignored -> J.runRegionFuture(
|
||||
world,
|
||||
anchorX,
|
||||
anchorZ,
|
||||
() -> INMS.get().flushChunkIO(world)));
|
||||
private CompletableFuture<Void> flushChunkIOAsync() {
|
||||
INMSBinding binding = INMS.get();
|
||||
return CompletableFuture.runAsync(() -> binding.flushChunkIO(world), chunkIoExecutor);
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> trackEviction(CompletableFuture<Void> eviction) {
|
||||
@@ -466,7 +469,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
? settledEvictions
|
||||
: settledEvictions.thenCompose(ignored -> IrisToolbelt.isServerStopping()
|
||||
? CompletableFuture.completedFuture(null)
|
||||
: J.sfut(() -> INMS.get().flushChunkIO(world)));
|
||||
: flushChunkIOAsync());
|
||||
|
||||
long started = System.nanoTime();
|
||||
long timeoutNanos = TimeUnit.SECONDS.toNanos(FLUSH_TIMEOUT_SECONDS);
|
||||
@@ -520,7 +523,11 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
listener.onChunkFailed(x, z);
|
||||
} else {
|
||||
listener.onChunkGenerated(x, z);
|
||||
// Paper-like chunk futures complete on the server thread; the covered-neighbour scan
|
||||
// (and any plate it has to load back) belongs on the cleanup thread, not in the tick.
|
||||
if (!mantleCleanup.submit(() -> cleanupMantleChunksCoveredBy(x, z, listener))) {
|
||||
cleanupMantleChunksCoveredBy(x, z, listener);
|
||||
}
|
||||
onChunkCompleted(x, z, chunk);
|
||||
success = true;
|
||||
}
|
||||
@@ -691,6 +698,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
+ " submitted=" + submitted.get()
|
||||
+ " completed=" + completed.get()
|
||||
+ " failed=" + failed.get()
|
||||
+ " cleanupBacklog=" + mantleCleanup.pending()
|
||||
+ " stalledForMs=" + stalledFor;
|
||||
}
|
||||
|
||||
@@ -820,7 +828,9 @@ public class AsyncPregenMethod implements PregeneratorMethod {
|
||||
() -> IrisLogging.warn("Async pregen is still draining outstanding chunks. " + metricsSnapshot())
|
||||
);
|
||||
|
||||
mantleCleanup.close(MANTLE_CLEANUP_DRAIN_SECONDS, TimeUnit.SECONDS);
|
||||
flushAllRemainingChunks();
|
||||
chunkFlush.close(CHUNK_FLUSH_DRAIN_SECONDS, TimeUnit.SECONDS);
|
||||
executor.shutdown();
|
||||
if (holdsWorkerBoost.compareAndSet(true, false)) {
|
||||
releaseWorkerThreadBoost();
|
||||
|
||||
+11
-3
@@ -20,6 +20,7 @@ package art.arcane.iris.core.pregenerator.methods;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.core.pregenerator.PregenDiagnostics;
|
||||
import art.arcane.iris.core.pregenerator.PregenListener;
|
||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||
@@ -42,6 +43,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
@@ -58,11 +60,15 @@ public class MedievalPregenMethod implements PregeneratorMethod {
|
||||
private final AtomicBoolean directAsyncDisabled;
|
||||
private final AtomicBoolean prefetchDisabled;
|
||||
private final ExecutorService prefetchPool;
|
||||
private final Executor chunkIoExecutor;
|
||||
private final PregenSerialWorker chunkFlush;
|
||||
private volatile Engine cachedEngine;
|
||||
private volatile boolean engineResolutionAttempted;
|
||||
|
||||
public MedievalPregenMethod(World world) {
|
||||
this.world = world;
|
||||
this.chunkFlush = new PregenSerialWorker("Iris Pregen Chunk Flush", world == null ? "unknown" : world.getName());
|
||||
this.chunkIoExecutor = chunkFlush.executor();
|
||||
futures = new KList<>();
|
||||
this.lastUse = new ConcurrentHashMap<>();
|
||||
int configuredThreads = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism());
|
||||
@@ -145,10 +151,11 @@ public class MedievalPregenMethod implements PregeneratorMethod {
|
||||
lastUse.remove(i);
|
||||
}
|
||||
}
|
||||
if (flushChunkIo) {
|
||||
INMS.get().flushChunkIO(world);
|
||||
}
|
||||
});
|
||||
if (flushChunkIo && world != null) {
|
||||
INMSBinding binding = INMS.get();
|
||||
unload = unload.thenRunAsync(() -> binding.flushChunkIO(world), chunkIoExecutor);
|
||||
}
|
||||
|
||||
try {
|
||||
unload.get(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
@@ -176,6 +183,7 @@ public class MedievalPregenMethod implements PregeneratorMethod {
|
||||
prefetchPool.shutdownNow();
|
||||
}
|
||||
unloadAndSaveAllChunks(true);
|
||||
chunkFlush.close(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.core.pregenerator.methods;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Single-thread worker owned by one pregen run. Used for mantle cleanup (Paper-like chunk futures
|
||||
* complete on the server thread, so the covered-neighbour scan must leave the tick) and for native
|
||||
* region flushes (a Moonrise flush parks until the level's pending IO settles; parking the shared
|
||||
* Iris IO pool starved the mantle plate loads that generation was waiting on, wedging every worker).
|
||||
*/
|
||||
final class PregenSerialWorker {
|
||||
private final ExecutorService executor;
|
||||
private final AtomicInteger pending = new AtomicInteger();
|
||||
private final String role;
|
||||
private final String worldName;
|
||||
private final BiConsumer<String, Throwable> failureReporter;
|
||||
private final Consumer<String> warningReporter;
|
||||
|
||||
PregenSerialWorker(String role, String worldName) {
|
||||
this(role, worldName, IrisLogging::reportError, IrisLogging::warn);
|
||||
}
|
||||
|
||||
PregenSerialWorker(String role,
|
||||
String worldName,
|
||||
BiConsumer<String, Throwable> failureReporter,
|
||||
Consumer<String> warningReporter) {
|
||||
this.role = Objects.requireNonNull(role, "role");
|
||||
this.worldName = Objects.requireNonNull(worldName, "world name");
|
||||
this.failureReporter = Objects.requireNonNull(failureReporter, "failure reporter");
|
||||
this.warningReporter = Objects.requireNonNull(warningReporter, "warning reporter");
|
||||
this.executor = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, role + " " + worldName);
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
|
||||
/** The serial executor itself, for callers that need a completion future per task. */
|
||||
Executor executor() {
|
||||
return executor;
|
||||
}
|
||||
|
||||
boolean submit(Runnable cleanup) {
|
||||
Objects.requireNonNull(cleanup, "cleanup");
|
||||
pending.incrementAndGet();
|
||||
try {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
cleanup.run();
|
||||
} catch (Throwable failure) {
|
||||
failureReporter.accept(role + " failed in world " + worldName + ".", failure);
|
||||
} finally {
|
||||
pending.decrementAndGet();
|
||||
}
|
||||
});
|
||||
return true;
|
||||
} catch (RejectedExecutionException rejected) {
|
||||
pending.decrementAndGet();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int pending() {
|
||||
return pending.get();
|
||||
}
|
||||
|
||||
boolean close(long timeout, TimeUnit unit) {
|
||||
executor.shutdown();
|
||||
boolean drained = false;
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
drained = executor.awaitTermination(timeout, unit);
|
||||
} catch (InterruptedException interruption) {
|
||||
interrupted = true;
|
||||
}
|
||||
if (!drained) {
|
||||
int abandoned = executor.shutdownNow().size();
|
||||
warningReporter.accept(role + " for " + worldName + " did not drain in " + unit.toSeconds(timeout)
|
||||
+ "s; " + abandoned + " queued task(s) were abandoned.");
|
||||
}
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,14 @@ import art.arcane.iris.core.runtime.ObjectStudioLayout.GridCell;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator;
|
||||
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.ChunkTiles;
|
||||
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.PlacedTile;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.plugin.IrisService;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
@@ -42,10 +45,12 @@ import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.world.WorldUnloadEvent;
|
||||
import org.bukkit.event.world.ChunkLoadEvent;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -58,6 +63,7 @@ public class ObjectStudioSaveService implements IrisService {
|
||||
private static ObjectStudioSaveService INSTANCE;
|
||||
|
||||
private final Map<UUID, ActiveStudio> studios = new ConcurrentHashMap<>();
|
||||
private final Map<UUID, Map<Long, ChunkTiles>> pendingTiles = new ConcurrentHashMap<>();
|
||||
|
||||
public static ObjectStudioSaveService get() {
|
||||
ObjectStudioSaveService svc = INSTANCE;
|
||||
@@ -74,6 +80,7 @@ public class ObjectStudioSaveService implements IrisService {
|
||||
@Override
|
||||
public void onDisable() {
|
||||
studios.clear();
|
||||
pendingTiles.clear();
|
||||
INSTANCE = null;
|
||||
}
|
||||
|
||||
@@ -114,6 +121,7 @@ public class ObjectStudioSaveService implements IrisService {
|
||||
|
||||
public void unregister(World world) {
|
||||
if (world == null) return;
|
||||
pendingTiles.remove(world.getUID());
|
||||
ActiveStudio removed = studios.remove(world.getUID());
|
||||
if (removed != null) {
|
||||
if (removed.packKey != null) {
|
||||
@@ -128,6 +136,50 @@ public class ObjectStudioSaveService implements IrisService {
|
||||
unregister(event.getWorld());
|
||||
}
|
||||
|
||||
public void queueTiles(Engine engine, ChunkTiles tiles) {
|
||||
World world = BukkitWorldBinding.world(engine.getTarget().getWorld());
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Object Studio tile data has no target world.");
|
||||
}
|
||||
pendingTiles.computeIfAbsent(world.getUID(), ignored -> new ConcurrentHashMap<>())
|
||||
.put(chunkKey(tiles.chunkX(), tiles.chunkZ()), tiles);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onChunkLoad(ChunkLoadEvent event) {
|
||||
Chunk chunk = event.getChunk();
|
||||
Map<Long, ChunkTiles> pending = pendingTiles.get(chunk.getWorld().getUID());
|
||||
if (pending == null) {
|
||||
return;
|
||||
}
|
||||
long key = chunkKey(chunk.getX(), chunk.getZ());
|
||||
ChunkTiles tiles = pending.get(key);
|
||||
if (tiles == null) {
|
||||
return;
|
||||
}
|
||||
int restored = 0;
|
||||
try {
|
||||
for (PlacedTile tile : tiles.tiles()) {
|
||||
Block block = chunk.getBlock(tile.x(), tile.y(), tile.z());
|
||||
if (!tile.data().isApplicable(block.getBlockData()) || !tile.data().toBukkitTry(block)) {
|
||||
throw new IllegalStateException("Object Studio tile data could not be restored at "
|
||||
+ block.getX() + "," + block.getY() + "," + block.getZ() + ".");
|
||||
}
|
||||
restored++;
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to restore Object Studio tile data in " + chunk.getWorld().getName()
|
||||
+ " at chunk " + chunk.getX() + "," + chunk.getZ() + ".", failure);
|
||||
} finally {
|
||||
if (restored == tiles.tiles().size()) {
|
||||
pending.remove(key, tiles);
|
||||
} else if (restored > 0) {
|
||||
pending.replace(key, tiles, new ChunkTiles(tiles.chunkX(), tiles.chunkZ(),
|
||||
List.copyOf(tiles.tiles().subList(restored, tiles.tiles().size()))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
public void onPlayerInteract(PlayerInteractEvent event) {
|
||||
if (event.getHand() != EquipmentSlot.HAND) return;
|
||||
@@ -158,6 +210,10 @@ public class ObjectStudioSaveService implements IrisService {
|
||||
});
|
||||
}
|
||||
|
||||
private static long chunkKey(int x, int z) {
|
||||
return ((long) x << 32) | (z & 0xffffffffL);
|
||||
}
|
||||
|
||||
private static GridCell findCellNear(ActiveStudio studio, int x, int z) {
|
||||
GridCell inside = studio.layout.findAt(x, z);
|
||||
if (inside != null) return inside;
|
||||
@@ -218,6 +274,17 @@ public class ObjectStudioSaveService implements IrisService {
|
||||
if (!allChunksLoaded(world, cell)) {
|
||||
return;
|
||||
}
|
||||
Map<Long, ChunkTiles> pending = pendingTiles.get(world.getUID());
|
||||
if (pending != null) {
|
||||
for (int x = cell.chunkMinX(); x <= cell.chunkMaxX(); x++) {
|
||||
for (int z = cell.chunkMinZ(); z <= cell.chunkMaxZ(); z++) {
|
||||
if (pending.containsKey(chunkKey(x, z))) {
|
||||
throw new IllegalStateException("Object Studio tile data is not ready for "
|
||||
+ cell.pack() + "/" + cell.key() + ".");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IrisObject snapshot = studio.generator.createCapture(cell);
|
||||
int originX = cell.originX();
|
||||
@@ -271,6 +338,7 @@ public class ObjectStudioSaveService implements IrisService {
|
||||
parent.mkdirs();
|
||||
}
|
||||
snapshot.write(targetFile);
|
||||
IrisData.invalidateLoadedAuthoringResources(studio.objectsDirs.get(cell.pack()).getParentFile());
|
||||
IrisLogging.debug("Object Studio saved: %s/%s (%dx%dx%d)",
|
||||
cell.pack(), cell.key(), cell.w(), cell.h(), cell.d());
|
||||
if (notify != null) {
|
||||
|
||||
@@ -117,6 +117,13 @@ public class TreeSVC implements IrisService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!EngineLifecycleTasks.run(engine, "bukkit_tree_grow",
|
||||
() -> growTree(event, worldAccess, engine))) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void growTree(StructureGrowEvent event, PlatformChunkGenerator worldAccess, Engine engine) {
|
||||
IrisDimension dimension = engine.getDimension();
|
||||
|
||||
if (dimension == null) {
|
||||
|
||||
@@ -22,8 +22,6 @@ import art.arcane.iris.util.project.stream.interpolation.Interpolated;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
@@ -31,7 +29,6 @@ import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class DimensionTerrainContext implements DataProvider {
|
||||
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
|
||||
@@ -171,7 +168,7 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
: configuredFocusBiome.withInferredType(InferredType.LAND);
|
||||
IrisRegion focusRegion = focusBiome == null
|
||||
? loadFocusRegion(dimension, dimensionData)
|
||||
: findFocusRegion(focusBiome, dimension, dataProvider, dimensionData);
|
||||
: dimension.resolveFocusRegion(focusBiome, dataProvider);
|
||||
|
||||
Map<IrisInterpolator, Set<IrisGenerator>> generators = new HashMap<>();
|
||||
Set<IrisBiome> allBiomes = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
@@ -593,32 +590,6 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
return region == null || region.isCompatExcluded() ? null : region;
|
||||
}
|
||||
|
||||
private static IrisRegion findFocusRegion(
|
||||
IrisBiome focusBiome,
|
||||
IrisDimension dimension,
|
||||
DataProvider dataProvider,
|
||||
IrisData data
|
||||
) {
|
||||
for (IrisRegion region : dimension.getAllRegions(dataProvider)) {
|
||||
if (region.getAllBiomeIds().contains(focusBiome.getLoadKey())) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
String key = UUID.nameUUIDFromBytes((dimension.getLoadKey() + "\u0000" + focusBiome.getLoadKey())
|
||||
.getBytes(StandardCharsets.UTF_8)).toString();
|
||||
IrisRegion region = new IrisRegion();
|
||||
region.getLandBiomes().add(focusBiome.getLoadKey());
|
||||
region.getSeaBiomes().add(focusBiome.getLoadKey());
|
||||
region.getShoreBiomes().add(focusBiome.getLoadKey());
|
||||
region.setLoadKey(key);
|
||||
region.setLoader(data);
|
||||
region.setLoadFile(new File(
|
||||
data.getDataFolder(),
|
||||
data.getRegionLoader().getFolderName() + "/" + key + ".json"
|
||||
));
|
||||
return region;
|
||||
}
|
||||
|
||||
private static IrisBiome implode(
|
||||
IrisBiome biome,
|
||||
double x,
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionManager;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
@@ -31,7 +34,7 @@ import java.util.concurrent.ExecutionException;
|
||||
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 static art.arcane.iris.engine.EngineShutdownSequence.appendFailure;
|
||||
import static art.arcane.iris.engine.EngineShutdownSequence.propagate;
|
||||
@@ -47,19 +50,41 @@ final class EngineBackgroundTasks {
|
||||
private final Object backgroundTaskLock = new Object();
|
||||
private final List<TrackedBackgroundTask> backgroundTasks = new ArrayList<>();
|
||||
private boolean backgroundTaskAdmission;
|
||||
private int activeDrains;
|
||||
|
||||
boolean scheduleTrackedTask(Runnable task) {
|
||||
return scheduleTrackedTask(task, false);
|
||||
}
|
||||
|
||||
void scheduleAdmittedTask(Engine engine, Runnable task) {
|
||||
IrisContext context = IrisContext.get();
|
||||
GenerationSessionManager sessions = engine.getGenerationSessions();
|
||||
if (context == null || context.getEngine() != engine || context.getGenerationSessionId() == 0L
|
||||
|| context.getGenerationSessionId() != engine.getGenerationSessionId()
|
||||
|| sessions == null || sessions.activeLeases() == 0) {
|
||||
throw new IllegalStateException("Iris background continuation requires an active generation lease.");
|
||||
}
|
||||
scheduleTrackedTask(task, true);
|
||||
}
|
||||
|
||||
private boolean scheduleTrackedTask(Runnable task, boolean admitted) {
|
||||
synchronized (backgroundTaskLock) {
|
||||
// A finished task is not outstanding work, however it finished. Retaining failed
|
||||
// entries made the NEXT transition's drain re-report a long-settled failure and
|
||||
// blocked close() from ever marking the engine closed.
|
||||
if (activeDrains == 0) {
|
||||
backgroundTasks.removeIf(tracked -> tracked.completion.isDone());
|
||||
if (!backgroundTaskAdmission) {
|
||||
}
|
||||
if (!backgroundTaskAdmission && !admitted) {
|
||||
return false;
|
||||
}
|
||||
TrackedBackgroundTask tracked = new TrackedBackgroundTask();
|
||||
Future<Void> future = J.a(() -> {
|
||||
tracked.started.set(true);
|
||||
// The pool behind J.a cannot stop a callable it already dequeued, so the claim is
|
||||
// decided here: a cancellation that won the claim first means this body never runs.
|
||||
if (!tracked.claim.compareAndSet(TrackedBackgroundTask.QUEUED, TrackedBackgroundTask.RUNNING)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
task.run();
|
||||
tracked.completion.complete(null);
|
||||
@@ -83,21 +108,69 @@ final class EngineBackgroundTasks {
|
||||
}
|
||||
|
||||
BackgroundTaskDrain drainBackgroundTasks(String reason) {
|
||||
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS);
|
||||
Set<TrackedBackgroundTask> observed = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Set<TrackedBackgroundTask> settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
List<TrackedBackgroundTask> tasks;
|
||||
synchronized (backgroundTaskLock) {
|
||||
activeDrains++;
|
||||
tasks = List.copyOf(backgroundTasks);
|
||||
}
|
||||
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(BACKGROUND_TASK_TIMEOUT_MILLIS);
|
||||
Throwable failure = null;
|
||||
// Settled-set snapshot taken ONCE at drain entry: a per-iteration isDone() sample
|
||||
// would also suppress tasks that failed while the drain was blocked on an earlier
|
||||
// task, letting a real in-flight failure pass the transition unreported.
|
||||
Set<TrackedBackgroundTask> settledAtEntry = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
for (TrackedBackgroundTask task : tasks) {
|
||||
if (task.completion.isDone()) {
|
||||
settledAtEntry.add(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
Throwable failure = null;
|
||||
try {
|
||||
while (true) {
|
||||
failure = appendFailure(failure, drainTaskBatch(tasks, settledAtEntry, deadline, reason));
|
||||
observed.addAll(tasks);
|
||||
synchronized (backgroundTaskLock) {
|
||||
tasks = new ArrayList<>();
|
||||
for (TrackedBackgroundTask task : backgroundTasks) {
|
||||
if (!observed.contains(task)) {
|
||||
tasks.add(task);
|
||||
}
|
||||
}
|
||||
if (tasks.isEmpty()) {
|
||||
return new BackgroundTaskDrain(failure, allTasksComplete());
|
||||
}
|
||||
}
|
||||
if (System.nanoTime() >= deadline) {
|
||||
for (TrackedBackgroundTask task : tasks) {
|
||||
cancelBackgroundTask(task, reason);
|
||||
}
|
||||
failure = appendFailure(failure, new TimeoutException(
|
||||
"Timed out waiting for Iris background tasks during " + reason + "."));
|
||||
synchronized (backgroundTaskLock) {
|
||||
return new BackgroundTaskDrain(failure, allTasksComplete());
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
synchronized (backgroundTaskLock) {
|
||||
activeDrains--;
|
||||
if (activeDrains == 0) {
|
||||
backgroundTasks.removeIf(tracked -> tracked.completion.isDone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allTasksComplete() {
|
||||
for (TrackedBackgroundTask task : backgroundTasks) {
|
||||
if (!task.completion.isDone()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private Throwable drainTaskBatch(List<TrackedBackgroundTask> tasks,
|
||||
Set<TrackedBackgroundTask> settledAtEntry,
|
||||
long deadline, String reason) {
|
||||
Throwable failure = null;
|
||||
for (TrackedBackgroundTask task : tasks) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (remaining <= 0L) {
|
||||
@@ -123,17 +196,16 @@ final class EngineBackgroundTasks {
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean complete;
|
||||
synchronized (backgroundTaskLock) {
|
||||
backgroundTasks.removeIf(tracked -> tracked.completion.isDone());
|
||||
complete = backgroundTasks.isEmpty();
|
||||
}
|
||||
return new BackgroundTaskDrain(failure, complete);
|
||||
return failure;
|
||||
}
|
||||
|
||||
private void cancelBackgroundTask(TrackedBackgroundTask task, String reason) {
|
||||
boolean claimed = task.claim.compareAndSet(TrackedBackgroundTask.QUEUED, TrackedBackgroundTask.CANCELLED);
|
||||
Future<?> future = task.future;
|
||||
if (future != null && future.cancel(true) && !task.started.get()) {
|
||||
if (future != null) {
|
||||
future.cancel(true);
|
||||
}
|
||||
if (claimed) {
|
||||
task.completion.completeExceptionally(
|
||||
new IllegalStateException("Iris background task was cancelled before starting during " + reason + "."));
|
||||
}
|
||||
@@ -162,7 +234,11 @@ final class EngineBackgroundTasks {
|
||||
}
|
||||
|
||||
private static final class TrackedBackgroundTask {
|
||||
private final AtomicBoolean started = new AtomicBoolean();
|
||||
private static final int QUEUED = 0;
|
||||
private static final int RUNNING = 1;
|
||||
private static final int CANCELLED = 2;
|
||||
|
||||
private final AtomicInteger claim = new AtomicInteger(QUEUED);
|
||||
private final CompletableFuture<Void> completion = new CompletableFuture<>();
|
||||
private volatile Future<?> future;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ final class EngineHotloader {
|
||||
engine.runtimeAssembly.set(assembly);
|
||||
EngineRuntime next;
|
||||
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan);
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan, false);
|
||||
assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine);
|
||||
assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine);
|
||||
BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes();
|
||||
|
||||
@@ -108,7 +108,7 @@ final class EngineRuntimeBuilder {
|
||||
}
|
||||
IrisLogging.debug("[IrisEngine timing] new IrisEngineMantle=" + (M.ms() - started) + "ms");
|
||||
started = M.ms();
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan);
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan, false);
|
||||
IrisLogging.debug("[IrisEngine timing] complex=" + (M.ms() - started) + "ms");
|
||||
started = M.ms();
|
||||
assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine);
|
||||
@@ -179,7 +179,7 @@ final class EngineRuntimeBuilder {
|
||||
IrisLogging.debug("Setup Detached Generation Runtime " + assembly.cacheId);
|
||||
assembly.mantle = assembly.runtimeKernel.createMantle(engine, assembly.mantleStorageDirectory);
|
||||
assembly.ownsMantle = true;
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan);
|
||||
assembly.complex = assembly.runtimeKernel.createComplex(engine, assembly.transitionPlan, true);
|
||||
assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine);
|
||||
assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine);
|
||||
assembly.hash32 = CompletableFuture.completedFuture(computePackHash(runtimeTarget.getData()));
|
||||
|
||||
@@ -23,6 +23,7 @@ import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.IrisRegistrant;
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.platform.studio.BiomeBuffetLayout;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.history.GenerationBlend;
|
||||
import art.arcane.iris.engine.history.TransitionGenerationPlan;
|
||||
@@ -37,6 +38,7 @@ import art.arcane.iris.engine.image.IrisImageMapRuntime;
|
||||
import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView;
|
||||
import art.arcane.iris.engine.object.InferredType;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeGeneratorLink;
|
||||
import art.arcane.iris.engine.object.IrisRaritySelection;
|
||||
import art.arcane.iris.engine.object.IrisDecorationPart;
|
||||
import art.arcane.iris.engine.object.IrisDecorator;
|
||||
@@ -74,7 +76,6 @@ import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
@@ -181,6 +182,9 @@ public class IrisComplex implements DataProvider {
|
||||
private ProceduralStream<PlatformBlockState> rockStream;
|
||||
private ProceduralStream<PlatformBlockState> fluidStream;
|
||||
private Map<String, ProceduralStream<PlatformBlockState>> hydrologyFluidStreams;
|
||||
private final BiomeBuffetLayout biomeBuffet;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final Map<IrisBiome, GeneratorGroup[]> biomeBuffetGenerators;
|
||||
private IrisBiome focusBiome;
|
||||
private IrisRegion focusRegion;
|
||||
private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds;
|
||||
@@ -194,22 +198,22 @@ public class IrisComplex implements DataProvider {
|
||||
private final Object childSelectionPlanLock = new Object();
|
||||
|
||||
public IrisComplex(Engine engine) {
|
||||
this(engine, false, null);
|
||||
this(engine, false, null, false);
|
||||
}
|
||||
|
||||
public IrisComplex(Engine engine, boolean simple) {
|
||||
this(engine, simple, null);
|
||||
this(engine, simple, null, false);
|
||||
}
|
||||
|
||||
public IrisComplex(Engine engine, TransitionGenerationPlan transitionGenerationPlan) {
|
||||
this(engine, false, transitionGenerationPlan);
|
||||
public IrisComplex(Engine engine, TransitionGenerationPlan transitionGenerationPlan, boolean detached) {
|
||||
this(engine, false, transitionGenerationPlan, detached);
|
||||
}
|
||||
|
||||
IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan) {
|
||||
IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan, boolean detached) {
|
||||
terrainEngine = engine;
|
||||
this.transitionGenerationPlan = transitionGenerationPlan;
|
||||
this.resolvedTerrain = new ResolvedTerrainProvider(engine);
|
||||
int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize());
|
||||
int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize(), detached);
|
||||
IrisBiome emptyBiome = new IrisBiome().setInferredType(InferredType.CAVE);
|
||||
UUID focusUUID = UUID.nameUUIDFromBytes("focus".getBytes());
|
||||
this.rng = new RNG(engine.getSeedManager().getComplex());
|
||||
@@ -220,18 +224,39 @@ public class IrisComplex implements DataProvider {
|
||||
generators = new HashMap<>();
|
||||
generatorBiomes = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
// A registrant the version-content gate excluded never enters a pool, focus included.
|
||||
focusBiome = compatUsable(engine.getFocus());
|
||||
focusRegion = compatUsable(engine.getFocusRegion());
|
||||
biomeBuffet = engine.isStudio() && "bukkit".equals(IrisPlatforms.get().platformName())
|
||||
&& engine.getDimension().getStudioMode().biomeSizeChunks() > 0
|
||||
? new BiomeBuffetLayout(engine.getDimension(), engine) : null;
|
||||
boolean buffetActive = biomeBuffet != null && !biomeBuffet.cells().isEmpty();
|
||||
focusBiome = buffetActive ? null : compatUsable(engine.getFocus());
|
||||
focusRegion = buffetActive ? null : compatUsable(engine.getFocusRegion());
|
||||
Map<InferredType, ProceduralStream<IrisBiome>> inferredStreams = new HashMap<>();
|
||||
KList<IrisRegion> preparedRegions = new KList<>();
|
||||
|
||||
if (focusBiome != null) {
|
||||
focusBiome = focusBiome.withInferredType(InferredType.LAND);
|
||||
focusRegion = findRegion(focusBiome, engine);
|
||||
focusRegion = engine.getDimension().resolveFocusRegion(focusBiome, engine);
|
||||
}
|
||||
|
||||
ProceduralStream<IrisBiome> focusedBiomes = buffetActive
|
||||
? ProceduralStream.of((x, z) -> biomeBuffet.terrain(x, z).biome(),
|
||||
Interpolated.of(value -> 0D, value -> biomeBuffet.cells().getFirst().biome()))
|
||||
: focusBiome == null ? null : ProceduralStream.of((x, z) -> focusBiome,
|
||||
Interpolated.of(value -> 0D, value -> focusBiome));
|
||||
ProceduralStream<IrisRegion> focusedRegions = buffetActive
|
||||
? ProceduralStream.of((x, z) -> biomeBuffet.terrain(x, z).region(),
|
||||
Interpolated.of(value -> 0D, value -> biomeBuffet.cells().getFirst().region()))
|
||||
: focusRegion == null ? null : ProceduralStream.of((x, z) -> focusRegion,
|
||||
Interpolated.of(value -> 0D, value -> focusRegion));
|
||||
|
||||
//@builder
|
||||
if (focusRegion != null) {
|
||||
if (buffetActive) {
|
||||
for (BiomeBuffetLayout.Cell cell : biomeBuffet.cells()) {
|
||||
prepareInferredBiomes(cell.region(), preparedRegions);
|
||||
cell.region().getNaturalBiomes(this).forEach(this::registerGenerators);
|
||||
registerGenerators(cell.biome());
|
||||
}
|
||||
} else if (focusRegion != null) {
|
||||
prepareInferredBiomes(focusRegion, preparedRegions);
|
||||
focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators);
|
||||
} else {
|
||||
@@ -263,6 +288,7 @@ public class IrisComplex implements DataProvider {
|
||||
frozenGenerators[frozenIndex] = generatorGroups[frozenIndex].generators();
|
||||
}
|
||||
generatorBounds = buildGeneratorBounds(engine);
|
||||
biomeBuffetGenerators = compileBuffetGeneratorGroups();
|
||||
KList<IrisShapedGeneratorStyle> overlayNoise = engine.getDimension().getOverlayNoise();
|
||||
overlayStream = overlayNoise.isEmpty()
|
||||
? ProceduralStream.ofDouble((x, z) -> 0.0D)
|
||||
@@ -283,13 +309,11 @@ public class IrisComplex implements DataProvider {
|
||||
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
|
||||
.zoom(engine.getDimension().getRegionZoom());
|
||||
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE);
|
||||
ProceduralStream<IrisRegion> proceduralRegionStream = focusRegion != null ?
|
||||
ProceduralStream.of((x, z) -> focusRegion,
|
||||
Interpolated.of(a -> 0D, a -> focusRegion))
|
||||
ProceduralStream<IrisRegion> proceduralRegionStream = focusedRegions != null ? focusedRegions
|
||||
: regionStyleStream
|
||||
.selectRarity(compatRegionPool(engine))
|
||||
.cache2D("regionStream", engine, cacheSize);
|
||||
regionStream = focusRegion != null ? proceduralRegionStream : proceduralRegionStream
|
||||
regionStream = focusedRegions != null ? proceduralRegionStream : proceduralRegionStream
|
||||
.convertAware2D((region, x, z) -> {
|
||||
IrisRegion mapped = imageMapRuntime.sampleRegion(x, z);
|
||||
return mapped == null ? region : mapped;
|
||||
@@ -315,18 +339,16 @@ public class IrisComplex implements DataProvider {
|
||||
.convert((r) -> createInferredBiomeStream(r, InferredType.SHORE))
|
||||
.convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize);
|
||||
inferredStreams.put(InferredType.SHORE, shoreBiomeStream);
|
||||
bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(),
|
||||
Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) :
|
||||
bridgeStream = focusedBiomes != null ? focusedBiomes.convert(IrisBiome::getInferredType) :
|
||||
engine.getDimension().getContinentalStyle().create(rng.nextParallelRNG(234234565), getData())
|
||||
.bake().scale(1D / engine.getDimension().getContinentZoom()).bake().stream()
|
||||
.convert((v) -> v >= engine.getDimension().getLandChance() ? InferredType.SEA : InferredType.LAND)
|
||||
.cache2D("bridgeStream", engine, cacheSize);
|
||||
ProceduralStream<IrisBiome> proceduralBaseBiomeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome,
|
||||
Interpolated.of(a -> 0D, a -> focusBiome)) :
|
||||
ProceduralStream<IrisBiome> proceduralBaseBiomeStream = focusedBiomes != null ? focusedBiomes :
|
||||
bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z))
|
||||
.convertAware2D(this::implode)
|
||||
.cache2D("baseBiomeStream", engine, cacheSize);
|
||||
baseBiomeStream = focusBiome != null ? proceduralBaseBiomeStream : proceduralBaseBiomeStream
|
||||
baseBiomeStream = focusedBiomes != null ? proceduralBaseBiomeStream : proceduralBaseBiomeStream
|
||||
.convertAware2D((biome, x, z) -> {
|
||||
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
|
||||
return mapped == null ? biome : mapped;
|
||||
@@ -354,8 +376,7 @@ public class IrisComplex implements DataProvider {
|
||||
.cache2DDouble("unblendedNaturalHeightStream", engine, cacheSize)
|
||||
: baseTerrainHeightStream;
|
||||
naturalHeightStream = unblendedNaturalHeightStream;
|
||||
naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
|
||||
b -> focusBiome))
|
||||
naturalTrueBiomeStream = focusedBiomes != null ? focusedBiomes
|
||||
.cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream
|
||||
.convertAware2D((h, x, z) -> {
|
||||
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
|
||||
@@ -364,7 +385,7 @@ public class IrisComplex implements DataProvider {
|
||||
: mapped;
|
||||
})
|
||||
.cache2D("naturalTrueBiomeStream", engine, cacheSize);
|
||||
unblendedNaturalTrueBiomeStream = transitionGenerationPlan == null || focusBiome != null
|
||||
unblendedNaturalTrueBiomeStream = transitionGenerationPlan == null || focusedBiomes != null
|
||||
? naturalTrueBiomeStream
|
||||
: unblendedNaturalHeightStream.convertAware2D((h, x, z) -> {
|
||||
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
|
||||
@@ -396,8 +417,7 @@ public class IrisComplex implements DataProvider {
|
||||
slopeStream = placementHeightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
|
||||
.slope(3);
|
||||
naturalSlopeStream = naturalHeightStream.slope(3).cache2DDouble("naturalSlopeStream", engine, cacheSize);
|
||||
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
|
||||
b -> focusBiome))
|
||||
trueBiomeStream = focusedBiomes != null ? focusedBiomes
|
||||
.cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
|
||||
.convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z))
|
||||
.cache2D("trueBiomeStream", engine, cacheSize);
|
||||
@@ -441,8 +461,13 @@ public class IrisComplex implements DataProvider {
|
||||
//@done
|
||||
}
|
||||
|
||||
static int noiseCacheSize(Engine engine, int configuredSize) {
|
||||
return engine.isStudio() ? Math.max(configuredSize, STUDIO_NOISE_CACHE_SIZE) : configuredSize;
|
||||
/**
|
||||
* The Studio-wide cache exists for the runtime a player edits against. Detached runtimes serve
|
||||
* historical activations (transition bands, saved-biome queries) and keep the configured size, or
|
||||
* every hotload would add another full set of Studio caches to the resident set.
|
||||
*/
|
||||
static int noiseCacheSize(Engine engine, int configuredSize, boolean detached) {
|
||||
return engine.isStudio() && !detached ? Math.max(configuredSize, STUDIO_NOISE_CACHE_SIZE) : configuredSize;
|
||||
}
|
||||
|
||||
void enableStudioHydrologyCache(String runtimeIdentity, Path persistentRoot) {
|
||||
@@ -469,11 +494,12 @@ public class IrisComplex implements DataProvider {
|
||||
int z,
|
||||
double naturalHeight
|
||||
) {
|
||||
InferredType inferredType = resolveNaturalInferredType(bridgeStream, focusBiome, x, z);
|
||||
IrisBiome focused = focusedBiomeAt(x, z);
|
||||
InferredType inferredType = resolveNaturalInferredType(bridgeStream, focused, x, z);
|
||||
IrisRegion region = regionStream.get(x, z);
|
||||
IrisBiome biome = focusBiome == null
|
||||
IrisBiome biome = focused == null
|
||||
? sampleNaturalBiome(inferredType, region, naturalHeight, x, z)
|
||||
: focusBiome;
|
||||
: focused;
|
||||
return new IrisHydrologyNaturalSample(
|
||||
naturalHeight,
|
||||
inferredType == InferredType.SEA,
|
||||
@@ -526,9 +552,15 @@ public class IrisComplex implements DataProvider {
|
||||
return column.shaped() ? column.topY() : column.baseHeight();
|
||||
}
|
||||
|
||||
private IrisBiome focusedBiomeAt(double x, double z) {
|
||||
BiomeBuffetLayout.Cell cell = biomeBuffet == null ? null : biomeBuffet.terrain(x, z);
|
||||
return cell == null ? focusBiome : cell.biome();
|
||||
}
|
||||
|
||||
private IrisBiome sampleTerrain3DBiome(int x, int z) {
|
||||
if (focusBiome != null) {
|
||||
return focusBiome;
|
||||
IrisBiome focused = focusedBiomeAt(x, z);
|
||||
if (focused != null) {
|
||||
return focused;
|
||||
}
|
||||
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
|
||||
return mapped == null
|
||||
@@ -1111,24 +1143,6 @@ public class IrisComplex implements DataProvider {
|
||||
return getHeight(engine, worldX, worldZ, engine.getSeedManager().getHeight());
|
||||
}
|
||||
|
||||
private IrisRegion findRegion(IrisBiome focus, Engine engine) {
|
||||
for (IrisRegion i : engine.getDimension().getAllRegions(engine)) {
|
||||
if (i.getAllBiomeIds().contains(focus.getLoadKey())) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
String key = UUID.randomUUID().toString();
|
||||
IrisRegion region = new IrisRegion();
|
||||
region.getLandBiomes().add(focus.getLoadKey());
|
||||
region.getSeaBiomes().add(focus.getLoadKey());
|
||||
region.getShoreBiomes().add(focus.getLoadKey());
|
||||
region.setLoadKey(key);
|
||||
region.setLoader(data);
|
||||
region.setLoadFile(new File(data.getDataFolder(), data.getRegionLoader().getFolderName() + "/" + key + ".json"));
|
||||
return region;
|
||||
}
|
||||
|
||||
private IrisDecorator decorateFor(IrisBiome b, double x, double z, IrisDecorationPart part) {
|
||||
RNG rngc = new RNG(Cache.key(((int) x), ((int) z)));
|
||||
|
||||
@@ -1250,6 +1264,9 @@ public class IrisComplex implements DataProvider {
|
||||
}
|
||||
|
||||
private NoiseBounds gridSampleBounds(Engine engine, IrisInterpolator interpolator, int interpolatorIndex, IrisGenerator[] generators, double x, double z) {
|
||||
if (biomeBuffet != null && !biomeBuffet.cells().isEmpty()) {
|
||||
return generatorBounds.get(interpolator).get(focusedBiomeAt(x, z)).noiseBounds;
|
||||
}
|
||||
int grid = HEIGHT_BOUNDS_GRID;
|
||||
GridBoundsCache cache = gridBoundsCache.get();
|
||||
if (grid <= 1) {
|
||||
@@ -1365,6 +1382,14 @@ public class IrisComplex implements DataProvider {
|
||||
|
||||
private double getInterpolatedHeight(Engine engine, double x, double z, long seed) {
|
||||
double h = 0;
|
||||
if (!biomeBuffetGenerators.isEmpty()) {
|
||||
IrisBiome biome = focusedBiomeAt(x, z);
|
||||
for (GeneratorGroup group : biomeBuffetGenerators.get(biome)) {
|
||||
NoiseBounds bounds = generatorBounds.get(group.interpolator()).get(biome).noiseBounds;
|
||||
h += averageGeneratorHeights(group.generators(), bounds.min(), bounds.max(), x, z, seed + 239945);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) {
|
||||
h += interpolateGenerators(engine, frozenInterpolators[interpolatorIndex], interpolatorIndex, frozenGenerators[interpolatorIndex], x, z, seed);
|
||||
@@ -1482,6 +1507,26 @@ public class IrisComplex implements DataProvider {
|
||||
return registrant == null || registrant.isCompatExcluded() ? null : registrant;
|
||||
}
|
||||
|
||||
private Map<IrisBiome, GeneratorGroup[]> compileBuffetGeneratorGroups() {
|
||||
if (biomeBuffet == null || biomeBuffet.cells().isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<IrisBiome, GeneratorGroup[]> cells = new IdentityHashMap<>();
|
||||
for (BiomeBuffetLayout.Cell cell : biomeBuffet.cells()) {
|
||||
Map<IrisInterpolator, Set<IrisGenerator>> groups = new HashMap<>();
|
||||
KList<IrisBiome> biomes = new KList<>(cell.region().getNaturalBiomes(this));
|
||||
biomes.add(cell.biome());
|
||||
for (IrisBiome biome : biomes) {
|
||||
for (IrisBiomeGeneratorLink link : biome.getGenerators()) {
|
||||
IrisGenerator generator = link.getCachedGenerator(this);
|
||||
groups.computeIfAbsent(generator.getInterpolator(), ignored -> new HashSet<>()).add(generator);
|
||||
}
|
||||
}
|
||||
cells.put(cell.biome(), freezeGeneratorGroups(groups));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
private void registerGenerators(IrisBiome biome) {
|
||||
generatorBiomes.add(biome);
|
||||
biome.getGenerators().forEach(c -> registerGenerator(c.getCachedGenerator(this)));
|
||||
|
||||
@@ -34,6 +34,8 @@ import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.EngineWorldManager;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionManager;
|
||||
import art.arcane.iris.engine.history.GenerationBoundarySignatureSampler;
|
||||
import art.arcane.iris.engine.history.GenerationHistory;
|
||||
@@ -1547,6 +1549,23 @@ public class IrisEngine implements Engine {
|
||||
return generationSessions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestSave() {
|
||||
IrisContext context = IrisContext.get();
|
||||
if (context != null && context.getEngine() == this && context.getGenerationSessionId() != 0L) {
|
||||
scheduleWorldSave();
|
||||
return true;
|
||||
}
|
||||
return EngineLifecycleTasks.run(this, "world_save_request", this::scheduleWorldSave);
|
||||
}
|
||||
|
||||
private void scheduleWorldSave() {
|
||||
backgroundTasks.scheduleAdmittedTask(this, () -> NativeStructureOwnershipStore.flush(this));
|
||||
getMantle().save();
|
||||
getWorldManager().onSave();
|
||||
saveEngineData();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveEngineData() {
|
||||
engineDataStore.saveEngineData();
|
||||
|
||||
@@ -26,7 +26,7 @@ record StudioGenerationUpdate(
|
||||
GenerationRegistryContract registryContract
|
||||
) implements AutoCloseable {
|
||||
static StudioGenerationUpdate prepare(IrisEngine engine, GenerationHistory history) throws IOException {
|
||||
Path source = engine.getStudioGenerationSource();
|
||||
Path source = engine.getStudioGenerationSource().toRealPath();
|
||||
IrisData.invalidateLoadedAuthoringResources(source.toFile());
|
||||
String fingerprint = GenerationPackFingerprint.compute(source, GenerationPackFingerprint.CURRENT_VERSION);
|
||||
GenerationEpoch active = history.activeEpoch();
|
||||
|
||||
@@ -270,6 +270,8 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
|
||||
saveEngineData();
|
||||
}
|
||||
|
||||
boolean requestSave();
|
||||
|
||||
default void saveNow() {
|
||||
NativeStructureOwnershipStore.flush(this);
|
||||
getMantle().saveAllNow();
|
||||
|
||||
@@ -106,7 +106,7 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent
|
||||
public void on(WorldSaveEvent e) {
|
||||
runManagerTask("bukkit_world_manager_save", () -> {
|
||||
if (e.getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) {
|
||||
getEngine().save();
|
||||
getEngine().requestSave();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,6 +92,18 @@ public final class BoundaryColumnGeometry {
|
||||
return paletteIndices.clone();
|
||||
}
|
||||
|
||||
int runCount() {
|
||||
return runEnds.length;
|
||||
}
|
||||
|
||||
int runEnd(int run) {
|
||||
return runEnds[run];
|
||||
}
|
||||
|
||||
Voxel runVoxel(int run) {
|
||||
return palette.get(paletteIndices[run]);
|
||||
}
|
||||
|
||||
public Voxel voxelAt(int worldY) {
|
||||
long offset = (long) worldY - minimumY;
|
||||
if (offset < 0 || offset >= height()) {
|
||||
|
||||
@@ -164,7 +164,7 @@ public final class GenerationKernelRegistry {
|
||||
|
||||
@FunctionalInterface
|
||||
public interface RuntimeFactory {
|
||||
IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan);
|
||||
IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan, boolean detached);
|
||||
|
||||
default SeedManager createSeedManager(long worldSeed) {
|
||||
throw new UnsupportedOperationException("Generation kernel does not own seed derivation.");
|
||||
@@ -209,9 +209,9 @@ public final class GenerationKernelRegistry {
|
||||
Objects.requireNonNull(factory, "factory");
|
||||
}
|
||||
|
||||
public IrisComplex createComplex(IrisEngine engine, TransitionGenerationPlan transitionPlan) {
|
||||
public IrisComplex createComplex(IrisEngine engine, TransitionGenerationPlan transitionPlan, boolean detached) {
|
||||
return Objects.requireNonNull(
|
||||
factory.create(Objects.requireNonNull(engine, "engine"), transitionPlan),
|
||||
factory.create(Objects.requireNonNull(engine, "engine"), transitionPlan, detached),
|
||||
"generation kernel complex"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ final class GenerationKernelV1 implements GenerationKernelRegistry.RuntimeFactor
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan) {
|
||||
return new IrisComplex(engine, transitionPlan);
|
||||
public IrisComplex create(IrisEngine engine, TransitionGenerationPlan transitionPlan, boolean detached) {
|
||||
return new IrisComplex(engine, transitionPlan, detached);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -447,7 +447,8 @@ public final class SavedBiomeRuntime implements AutoCloseable {
|
||||
if (dimension == null) {
|
||||
throw new IOException("Historical dimension is no longer supported: " + epoch.dimensionContract().dimensionKey());
|
||||
}
|
||||
Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data));
|
||||
Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data),
|
||||
resolveFocusRegions(data, dimension));
|
||||
definitions.put(epochId, loaded);
|
||||
return loaded;
|
||||
} catch (Throwable failure) {
|
||||
@@ -464,6 +465,41 @@ public final class SavedBiomeRuntime implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, IrisRegion> resolveFocusRegions(IrisData data, IrisDimension dimension) {
|
||||
Map<String, IrisRegion> regions = new HashMap<>();
|
||||
Set<String> dimensions = new HashSet<>();
|
||||
dimensions.add(dimension.getLoadKey());
|
||||
if (dimension.hasUpperDimension()) {
|
||||
dimensions.add(dimension.getUpperDimension());
|
||||
}
|
||||
if (dimension.hasDimensionStack()) {
|
||||
dimensions.addAll(dimension.getDimensionStack().getDimensions());
|
||||
}
|
||||
for (String key : dimensions) {
|
||||
IrisDimension source = key.equals(dimension.getLoadKey()) ? dimension : data.getDimensionLoader().load(key);
|
||||
if (source == null || source.isCompatExcluded()) {
|
||||
continue;
|
||||
}
|
||||
if (source.getStudioMode().biomeSizeChunks() > 0) {
|
||||
for (IrisBiome biome : source.getAllBiomes(() -> data)) {
|
||||
if (!biome.isCompatExcluded()) {
|
||||
IrisRegion region = source.resolveFocusRegion(biome, () -> data);
|
||||
regions.put(region.getLoadKey(), region);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (source.getFocus() == null || source.getFocus().isBlank()) {
|
||||
continue;
|
||||
}
|
||||
IrisBiome focus = data.getBiomeLoader().load(source.getFocus());
|
||||
if (focus != null && !focus.isCompatExcluded()) {
|
||||
IrisRegion region = source.resolveFocusRegion(focus, () -> data);
|
||||
regions.put(region.getLoadKey(), region);
|
||||
}
|
||||
}
|
||||
return Map.copyOf(regions);
|
||||
}
|
||||
|
||||
private static long key(int chunkX, int chunkZ) {
|
||||
return ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL);
|
||||
}
|
||||
@@ -483,10 +519,14 @@ public final class SavedBiomeRuntime implements AutoCloseable {
|
||||
SURFACE, CAVE_BASE, VOLUME
|
||||
}
|
||||
|
||||
private record Definitions(IrisData data, IrisDimension dimension, Map<String, String> nativeDerivatives) {
|
||||
private record Definitions(IrisData data, IrisDimension dimension, Map<String, String> nativeDerivatives,
|
||||
Map<String, IrisRegion> focusRegions) {
|
||||
private BiomeEnvironment environment(SavedBiomeChunk.Cell cell) {
|
||||
IrisBiome biome = data.getBiomeLoader().load(cell.biomeKey());
|
||||
IrisRegion region = data.getRegionLoader().load(cell.regionKey());
|
||||
if (region == null) {
|
||||
region = focusRegions.get(cell.regionKey());
|
||||
}
|
||||
if (biome == null || region == null) {
|
||||
throw new SavedBiomeUnavailableException("Historical biome " + cell.biomeKey() + " in region "
|
||||
+ cell.regionKey() + " is no longer supported for generation " + cell.activationId()
|
||||
|
||||
@@ -52,7 +52,7 @@ public final class TransitionGeometryBlender {
|
||||
influence.newTerrainWeight(), influence.openingWeight(), GenerationBlend.usesHistoricalMaterial(blockX,
|
||||
currentGeometry.minimumY() + offset, blockZ, influence.newTerrainWeight()));
|
||||
result.add(value);
|
||||
changed |= !value.equals(current.voxels.get(offset));
|
||||
changed |= !value.equals(current.voxelAt(offset));
|
||||
}
|
||||
return changed ? BoundaryColumnGeometry.fromVoxels(currentGeometry.minimumY(), result) : currentGeometry;
|
||||
}
|
||||
@@ -65,7 +65,7 @@ public final class TransitionGeometryBlender {
|
||||
double openingWeight,
|
||||
boolean historicalMaterial
|
||||
) {
|
||||
BoundaryColumnGeometry.Voxel currentVoxel = current.voxels.get(offset);
|
||||
BoundaryColumnGeometry.Voxel currentVoxel = current.voxelAt(offset);
|
||||
if (currentVoxel.protectedContent()) {
|
||||
return currentVoxel;
|
||||
}
|
||||
@@ -79,20 +79,20 @@ public final class TransitionGeometryBlender {
|
||||
BoundaryColumnGeometry.Voxel oldFluid = null;
|
||||
for (WeightedProfile contribution : historical) {
|
||||
ColumnProfile profile = contribution.profile();
|
||||
if (profile.voxels.get(offset).protectedContent()) {
|
||||
if (profile.voxelAt(offset).protectedContent()) {
|
||||
continue;
|
||||
}
|
||||
historicalOpening |= profile.enclosedOpenings[offset];
|
||||
historicalOpening |= profile.enclosedOpeningAt(offset);
|
||||
double weight = contribution.weight();
|
||||
total += weight;
|
||||
solid += profile.solidDistances[offset] * weight;
|
||||
fluid += profile.fluidDistances[offset] * weight;
|
||||
if (profile.solidMaterials[offset] != null && weight > solidMaterialWeight) {
|
||||
oldSolid = profile.solidMaterials[offset];
|
||||
solid += profile.solid.distanceAt(offset) * weight;
|
||||
fluid += profile.fluid.distanceAt(offset) * weight;
|
||||
if (profile.solid.materialAt(offset) != null && weight > solidMaterialWeight) {
|
||||
oldSolid = profile.solid.materialAt(offset);
|
||||
solidMaterialWeight = weight;
|
||||
}
|
||||
if (profile.fluidMaterials[offset] != null && weight > fluidMaterialWeight) {
|
||||
oldFluid = profile.fluidMaterials[offset];
|
||||
if (profile.fluid.materialAt(offset) != null && weight > fluidMaterialWeight) {
|
||||
oldFluid = profile.fluid.materialAt(offset);
|
||||
fluidMaterialWeight = weight;
|
||||
}
|
||||
}
|
||||
@@ -100,14 +100,14 @@ public final class TransitionGeometryBlender {
|
||||
return currentVoxel;
|
||||
}
|
||||
double blendedSolid = GenerationBlend.interpolate(solid / total,
|
||||
current.solidDistances[offset], historicalOpening ? openingWeight : currentWeight);
|
||||
current.solid.distanceAt(offset), historicalOpening ? openingWeight : currentWeight);
|
||||
if (blendedSolid > 0D) {
|
||||
return selectMaterial(oldSolid, current.solidMaterials[offset], historicalMaterial);
|
||||
return selectMaterial(oldSolid, current.solid.materialAt(offset), historicalMaterial);
|
||||
}
|
||||
double blendedFluid = GenerationBlend.interpolate(fluid / total,
|
||||
current.fluidDistances[offset], historicalOpening ? openingWeight : currentWeight);
|
||||
current.fluid.distanceAt(offset), historicalOpening ? openingWeight : currentWeight);
|
||||
if (blendedFluid > 0D) {
|
||||
BoundaryColumnGeometry.Voxel newFluid = current.fluidMaterials[offset];
|
||||
BoundaryColumnGeometry.Voxel newFluid = current.fluid.materialAt(offset);
|
||||
if (oldFluid != null && newFluid != null && !fluidFamily(oldFluid).equals(fluidFamily(newFluid))) {
|
||||
return FLUID_BARRIER;
|
||||
}
|
||||
@@ -133,70 +133,87 @@ public final class TransitionGeometryBlender {
|
||||
return current == null || historicalMaterial ? historical : current;
|
||||
}
|
||||
|
||||
private static BoundaryColumnGeometry.Voxel[] nearestMaterials(
|
||||
List<BoundaryColumnGeometry.Voxel> voxels,
|
||||
BoundaryColumnGeometry.Phase phase
|
||||
) {
|
||||
BoundaryColumnGeometry.Voxel[] materials = new BoundaryColumnGeometry.Voxel[voxels.size()];
|
||||
int[] distances = new int[voxels.size()];
|
||||
int last = -voxels.size() - 1;
|
||||
BoundaryColumnGeometry.Voxel nearest = null;
|
||||
for (int index = 0; index < voxels.size(); index++) {
|
||||
BoundaryColumnGeometry.Voxel voxel = voxels.get(index);
|
||||
if (voxel.phase() == phase && !voxel.protectedContent()) {
|
||||
nearest = voxel;
|
||||
last = index;
|
||||
}
|
||||
materials[index] = nearest;
|
||||
distances[index] = index - last;
|
||||
}
|
||||
last = voxels.size() * 2;
|
||||
nearest = null;
|
||||
for (int index = voxels.size() - 1; index >= 0; index--) {
|
||||
BoundaryColumnGeometry.Voxel voxel = voxels.get(index);
|
||||
if (voxel.phase() == phase && !voxel.protectedContent()) {
|
||||
nearest = voxel;
|
||||
last = index;
|
||||
}
|
||||
if (nearest != null && last - index < distances[index]) {
|
||||
materials[index] = nearest;
|
||||
}
|
||||
}
|
||||
return materials;
|
||||
}
|
||||
|
||||
private static boolean[] enclosedOpenings(List<BoundaryColumnGeometry.Voxel> voxels) {
|
||||
boolean[] openings = new boolean[voxels.size()];
|
||||
int lowerSolid = -1;
|
||||
for (int index = 0; index < voxels.size(); index++) {
|
||||
BoundaryColumnGeometry.Voxel voxel = voxels.get(index);
|
||||
if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) {
|
||||
if (lowerSolid >= 0 && index - lowerSolid - 1 <= MAXIMUM_OPENING_HEIGHT) {
|
||||
for (int opening = lowerSolid + 1; opening < index; opening++) {
|
||||
openings[opening] = !voxels.get(opening).protectedContent();
|
||||
}
|
||||
}
|
||||
lowerSolid = index;
|
||||
}
|
||||
}
|
||||
return openings;
|
||||
}
|
||||
|
||||
private static final class ColumnProfile {
|
||||
private final List<BoundaryColumnGeometry.Voxel> voxels;
|
||||
private final boolean[] enclosedOpenings;
|
||||
private final double[] solidDistances;
|
||||
private final double[] fluidDistances;
|
||||
private final BoundaryColumnGeometry.Voxel[] solidMaterials;
|
||||
private final BoundaryColumnGeometry.Voxel[] fluidMaterials;
|
||||
private final BoundaryColumnGeometry geometry;
|
||||
private final PhaseCursor solid;
|
||||
private final PhaseCursor fluid;
|
||||
private int run;
|
||||
|
||||
private ColumnProfile(BoundaryColumnGeometry geometry) {
|
||||
voxels = geometry.voxels();
|
||||
enclosedOpenings = enclosedOpenings(voxels);
|
||||
solidDistances = geometry.solidDistances();
|
||||
fluidDistances = geometry.fluidDistances();
|
||||
solidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.SOLID);
|
||||
fluidMaterials = nearestMaterials(voxels, BoundaryColumnGeometry.Phase.FLUID);
|
||||
this.geometry = geometry;
|
||||
solid = new PhaseCursor(geometry, BoundaryColumnGeometry.Phase.SOLID);
|
||||
fluid = new PhaseCursor(geometry, BoundaryColumnGeometry.Phase.FLUID);
|
||||
}
|
||||
|
||||
private BoundaryColumnGeometry.Voxel voxelAt(int offset) {
|
||||
while (offset >= geometry.runEnd(run)) {
|
||||
run++;
|
||||
}
|
||||
return geometry.runVoxel(run);
|
||||
}
|
||||
|
||||
private boolean enclosedOpeningAt(int offset) {
|
||||
solid.advanceTo(offset);
|
||||
return !solid.occupied && solid.start > 0 && solid.end < geometry.height()
|
||||
&& solid.end - solid.start <= MAXIMUM_OPENING_HEIGHT
|
||||
&& !voxelAt(offset).protectedContent();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PhaseCursor {
|
||||
private final BoundaryColumnGeometry geometry;
|
||||
private final BoundaryColumnGeometry.Phase phase;
|
||||
private int start;
|
||||
private int end;
|
||||
private int nextRun;
|
||||
private int materialRun;
|
||||
private boolean occupied;
|
||||
private BoundaryColumnGeometry.Voxel lowerMaterial;
|
||||
private BoundaryColumnGeometry.Voxel upperMaterial;
|
||||
|
||||
private PhaseCursor(BoundaryColumnGeometry geometry, BoundaryColumnGeometry.Phase phase) {
|
||||
this.geometry = geometry;
|
||||
this.phase = phase;
|
||||
advanceTo(0);
|
||||
}
|
||||
|
||||
private void advanceTo(int offset) {
|
||||
while (offset >= end) {
|
||||
start = end;
|
||||
occupied = matches(geometry.runVoxel(nextRun));
|
||||
lowerMaterial = start == 0 ? null : geometry.runVoxel(nextRun - 1);
|
||||
do {
|
||||
end = geometry.runEnd(nextRun++);
|
||||
} while (nextRun < geometry.runCount() && matches(geometry.runVoxel(nextRun)) == occupied);
|
||||
upperMaterial = nextRun < geometry.runCount() ? geometry.runVoxel(nextRun) : null;
|
||||
}
|
||||
}
|
||||
|
||||
private double distanceAt(int offset) {
|
||||
advanceTo(offset);
|
||||
double lowerDistance = start == 0 ? geometry.height() + offset + 1.5D : offset - start + 0.5D;
|
||||
double upperDistance = end == geometry.height()
|
||||
? geometry.height() * 2D - offset + 0.5D : end - offset - 0.5D;
|
||||
return Math.min(lowerDistance, upperDistance) * (occupied ? 1D : -1D);
|
||||
}
|
||||
|
||||
private BoundaryColumnGeometry.Voxel materialAt(int offset) {
|
||||
advanceTo(offset);
|
||||
if (occupied) {
|
||||
while (offset >= geometry.runEnd(materialRun)) {
|
||||
materialRun++;
|
||||
}
|
||||
return geometry.runVoxel(materialRun);
|
||||
}
|
||||
if (lowerMaterial == null) {
|
||||
return upperMaterial;
|
||||
}
|
||||
return upperMaterial != null && end - offset < offset - start + 1
|
||||
? upperMaterial : lowerMaterial;
|
||||
}
|
||||
|
||||
private boolean matches(BoundaryColumnGeometry.Voxel voxel) {
|
||||
return voxel.phase() == phase && !voxel.protectedContent();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-4
@@ -517,7 +517,7 @@ final class HydrologyCrossTileResolver {
|
||||
}
|
||||
}
|
||||
}
|
||||
int sourceReach = Math.max(0, planner.settings.surface().sources().minimumSpacing() - 1);
|
||||
int sourceReach = Math.max(0, planner.settings.maximumSurfaceSourceSpacing() - 1);
|
||||
for (HydrologyCrossTileSurfaceAdmission.Claim claim : surfaceClaims(result)) {
|
||||
HydrologyPoint source = claim.centerline().getFirst();
|
||||
int minimumTileX = ownerCoordinate((long) source.x() - sourceReach, tileSize);
|
||||
@@ -605,12 +605,26 @@ final class HydrologyCrossTileResolver {
|
||||
centerline.getLast(),
|
||||
reachesOutlet,
|
||||
maximumWidth,
|
||||
centerline
|
||||
centerline,
|
||||
sourceSpacing(course, result.nodes())
|
||||
));
|
||||
}
|
||||
return List.copyOf(claims);
|
||||
}
|
||||
|
||||
private int sourceSpacing(RiverCourse course, List<DrainageNode> nodes) {
|
||||
int fallback = planner.settings.surface().sources().minimumSpacing();
|
||||
if (course.sourceNodeId().isPresent()) {
|
||||
long sourceId = course.sourceNodeId().getAsLong();
|
||||
for (DrainageNode node : nodes) {
|
||||
if (node.id() == sourceId) {
|
||||
return node.terrain().surfacePolicy().sourceSpacing(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
Map<Long, RiverCourse> coursesById(List<RiverCourse> courses) {
|
||||
LinkedHashMap<Long, RiverCourse> indexed = new LinkedHashMap<>(courses.size());
|
||||
for (RiverCourse course : courses) {
|
||||
@@ -711,7 +725,8 @@ final class HydrologyCrossTileResolver {
|
||||
surfaceRouting,
|
||||
surfaceContributions,
|
||||
undergroundContributions,
|
||||
refinedEdges
|
||||
refinedEdges,
|
||||
true
|
||||
);
|
||||
if (planner.settings.surface().enabled()) {
|
||||
planner.surfaceCourses.compileSurfaceCourses(
|
||||
@@ -769,6 +784,7 @@ final class HydrologyCrossTileResolver {
|
||||
graph = planner.sourcePlanner.mergeGraphs(surfaceGraph, undergroundGraph);
|
||||
}
|
||||
}
|
||||
HydrologySurfaceProfiles.rejectExcludedWetFootprints(footprintCompiler, courses, diagnostics);
|
||||
List<RiverCourse> normalizedTrunkCourses = planner.tributaries.normalizeSharedTrunks(courses, diagnostics);
|
||||
List<RiverCourse> normalizedOutletCourses = planner.tributaries.normalizeOutletContinuations(normalizedTrunkCourses);
|
||||
courses.clear();
|
||||
@@ -845,7 +861,8 @@ final class HydrologyCrossTileResolver {
|
||||
routing,
|
||||
surface ? contributions : new int[contributions.length],
|
||||
surface ? new int[contributions.length] : contributions,
|
||||
refinedEdges
|
||||
refinedEdges,
|
||||
surface
|
||||
);
|
||||
ArrayList<RiverCourse> courses = new ArrayList<>();
|
||||
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
|
||||
|
||||
+7
-6
@@ -10,14 +10,13 @@ final class HydrologyCrossTileSurfaceAdmission {
|
||||
|
||||
static Result admit(
|
||||
List<Claim> currentClaims,
|
||||
List<RankedClaim> blockers,
|
||||
int minimumSeparation
|
||||
List<RankedClaim> blockers
|
||||
) {
|
||||
ArrayList<Rejection> rejections = new ArrayList<>();
|
||||
for (Claim current : currentClaims) {
|
||||
for (RankedClaim ranked : blockers) {
|
||||
Claim blocker = ranked.claim();
|
||||
if (!conflicts(current, blocker, minimumSeparation)) {
|
||||
if (!conflicts(current, blocker)) {
|
||||
continue;
|
||||
}
|
||||
rejections.add(new Rejection(current, blocker.courseId()));
|
||||
@@ -27,10 +26,11 @@ final class HydrologyCrossTileSurfaceAdmission {
|
||||
return new Result(List.copyOf(rejections));
|
||||
}
|
||||
|
||||
private static boolean conflicts(Claim first, Claim second, int minimumSeparation) {
|
||||
private static boolean conflicts(Claim first, Claim second) {
|
||||
if (first.outletId() == second.outletId()) {
|
||||
return true;
|
||||
}
|
||||
int minimumSeparation = Math.max(first.sourceSpacing(), second.sourceSpacing());
|
||||
long minimumSeparationSquared = (long) minimumSeparation * minimumSeparation;
|
||||
if (first.centerline().getFirst().distanceSquared2D(second.centerline().getFirst())
|
||||
< minimumSeparationSquared) {
|
||||
@@ -131,12 +131,13 @@ final class HydrologyCrossTileSurfaceAdmission {
|
||||
HydrologyPoint terminal,
|
||||
boolean reachesOutlet,
|
||||
int maximumWidth,
|
||||
List<HydrologyPoint> centerline
|
||||
List<HydrologyPoint> centerline,
|
||||
int sourceSpacing
|
||||
) {
|
||||
Claim {
|
||||
Objects.requireNonNull(terminal, "terminal");
|
||||
centerline = List.copyOf(Objects.requireNonNull(centerline, "centerline"));
|
||||
if (maximumWidth < 1 || centerline.size() < 2) {
|
||||
if (maximumWidth < 1 || centerline.size() < 2 || sourceSpacing < 0 || sourceSpacing > 8192) {
|
||||
throw new IllegalArgumentException("Surface claims require a positive width and centerline.");
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -253,7 +253,7 @@ final class HydrologyFeatureSitePlanner {
|
||||
|| !terrain.outletAllowed()) {
|
||||
continue;
|
||||
}
|
||||
HydrologyGridNode ocean = planner.outletPlanner.firstOceanNeighbor(grid, land);
|
||||
HydrologyGridNode ocean = planner.outletPlanner.firstOceanNeighbor(grid, land, false);
|
||||
if (ocean == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
|
||||
|
||||
final class HydrologyOutletPlanner {
|
||||
private final HydrologyPlanner planner;
|
||||
@@ -98,20 +101,26 @@ final class HydrologyOutletPlanner {
|
||||
sortOutletCandidates(key, grid, surface, oceanCandidates);
|
||||
// Sea outlets and inland outlets are budgeted separately: a coast never starves the
|
||||
// sinkholes behind it and a sinkhole never displaces a mouth.
|
||||
List<OutletCandidate> selectedOcean = limitOutletsByType(
|
||||
oceanCandidates,
|
||||
planner.settings.outlets().maximumCoastalPerTile()
|
||||
);
|
||||
List<OutletCandidate> selectedOcean = surface
|
||||
? limitSurfaceOutlets(key, grid, oceanCandidates, true)
|
||||
: limitOutletsByType(oceanCandidates, planner.settings.outlets().maximumCoastalPerTile());
|
||||
addRejectedOutlets(oceanCandidates, selectedOcean, diagnostics);
|
||||
if (!planner.settings.outlets().inlandGrotto().enabled()) {
|
||||
return selectedOcean;
|
||||
}
|
||||
if (surface) {
|
||||
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources());
|
||||
if (budgets.overridden()) {
|
||||
oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true));
|
||||
}
|
||||
}
|
||||
boolean[] oceanReachable = outletReachable(grid, oceanCandidates, surface);
|
||||
ArrayList<OutletCandidate> inlandCandidates = inlandOutletCandidates(grid, oceanReachable);
|
||||
sortInlandOutletCandidates(grid, inlandCandidates);
|
||||
List<OutletCandidate> selectedInland = styledInlandOutlets(
|
||||
grid,
|
||||
limitOutlets(inlandCandidates, planner.settings.outlets().maximumPerTile()),
|
||||
surface ? limitSurfaceOutlets(key, grid, inlandCandidates, false)
|
||||
: limitOutlets(inlandCandidates, planner.settings.outlets().maximumPerTile()),
|
||||
surface
|
||||
);
|
||||
addRejectedOutlets(inlandCandidates, selectedInland, diagnostics);
|
||||
@@ -127,7 +136,7 @@ final class HydrologyOutletPlanner {
|
||||
if (land.terrain().ocean() || !land.terrain().transitAllowed() || !land.terrain().outletAllowed()) {
|
||||
continue;
|
||||
}
|
||||
HydrologyGridNode ocean = firstOceanNeighbor(grid, land);
|
||||
HydrologyGridNode ocean = firstOceanNeighbor(grid, land, surface);
|
||||
if (ocean == null || !land.terrain().drainsInto(ocean.terrain())) {
|
||||
// A confined shore only reaches a sea that belongs to its own area.
|
||||
continue;
|
||||
@@ -135,7 +144,11 @@ final class HydrologyOutletPlanner {
|
||||
HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean);
|
||||
if (boundary == null
|
||||
|| !boundary.landwardTerrain().transitAllowed()
|
||||
|| !boundary.landwardTerrain().outletAllowed()) {
|
||||
|| !boundary.landwardTerrain().outletAllowed()
|
||||
|| surface && !HydrologySurfaceProfiles.sharesProfile(
|
||||
boundary.landwardTerrain(),
|
||||
planner.sampleBasisWithoutSlope(boundary.oceanPoint().x(), boundary.oceanPoint().z())
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
HydrologyFeatureType type = coastalOutletType(boundary.landwardTerrain());
|
||||
@@ -174,6 +187,12 @@ final class HydrologyOutletPlanner {
|
||||
}
|
||||
sortSurfaceFallbackOutletCandidates(grid, oceanCandidates);
|
||||
sortSurfaceFallbackOutletCandidates(grid, inlandCandidates);
|
||||
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources());
|
||||
if (budgets.overridden()) {
|
||||
HydrologyTileKey key = HydrologyTileKey.fromBlock(grid.ownerMinimumX(), grid.ownerMinimumZ(), grid.ownerSize());
|
||||
oceanCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, true));
|
||||
inlandCandidates.removeIf(candidate -> !surfaceOutletHasBudget(key, grid, budgets, candidate, false));
|
||||
}
|
||||
// Trials alternate sea and inland outlets, sea first: a coast with more mouths than trials
|
||||
// still gives a sinkhole its turn, and a rejected mouth is retried at the next coast.
|
||||
ArrayList<OutletCandidate> candidates = new ArrayList<>(oceanCandidates.size() + inlandCandidates.size());
|
||||
@@ -196,6 +215,41 @@ final class HydrologyOutletPlanner {
|
||||
return limitOutlets(candidates, maximumTrials);
|
||||
}
|
||||
|
||||
private boolean surfaceOutletHasBudget(HydrologyTileKey key, HydrologySampledGrid grid,
|
||||
HydrologySurfaceBudgets budgets, OutletCandidate candidate, boolean coastal) {
|
||||
HydrologySurfaceBudgets.Area area = budgets.area(grid.node(candidate.landIndex()).terrain().surfacePolicy());
|
||||
return area != null && budgets.outletTarget(area, coastal, planner, key) > 0;
|
||||
}
|
||||
|
||||
List<OutletCandidate> limitSurfaceOutlets(HydrologyTileKey key, HydrologySampledGrid grid,
|
||||
List<OutletCandidate> candidates, boolean coastal) {
|
||||
HydrologySurfaceBudgets budgets = HydrologySurfaceBudgets.sample(grid, planner.settings.surface().sources());
|
||||
if (!budgets.overridden()) {
|
||||
return coastal ? limitOutletsByType(candidates, planner.settings.outlets().maximumCoastalPerTile())
|
||||
: limitOutlets(candidates, planner.settings.outlets().maximumPerTile());
|
||||
}
|
||||
LinkedHashMap<SurfaceRiverPolicy.Budget, ArrayList<OutletCandidate>> grouped = new LinkedHashMap<>();
|
||||
for (OutletCandidate candidate : candidates) {
|
||||
SurfaceRiverPolicy policy = grid.node(candidate.landIndex()).terrain().surfacePolicy();
|
||||
if (budgets.area(policy) != null) {
|
||||
grouped.computeIfAbsent(policy.budget(), ignored -> new ArrayList<>()).add(candidate);
|
||||
}
|
||||
}
|
||||
ArrayList<OutletCandidate> selected = new ArrayList<>();
|
||||
long spacingSquared = outletSpacingSquared();
|
||||
for (Map.Entry<SurfaceRiverPolicy.Budget, ArrayList<OutletCandidate>> entry : grouped.entrySet()) {
|
||||
int target = budgets.outletTarget(budgets.area(entry.getKey()), coastal, planner, key);
|
||||
List<OutletCandidate> local = coastal ? limitOutletsByType(entry.getValue(), target)
|
||||
: limitOutlets(entry.getValue(), target);
|
||||
for (OutletCandidate candidate : local) {
|
||||
if (!withinOutletSpacing(candidate, selected, spacingSquared)) {
|
||||
selected.add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return List.copyOf(selected);
|
||||
}
|
||||
|
||||
ArrayList<OutletCandidate> inlandOutletCandidates(
|
||||
HydrologySampledGrid grid,
|
||||
boolean[] oceanReachable
|
||||
@@ -287,7 +341,6 @@ final class HydrologyOutletPlanner {
|
||||
}
|
||||
|
||||
boolean[] outletReachable(HydrologySampledGrid grid, List<OutletCandidate> outlets, boolean surface) {
|
||||
int maximumRise = surface ? planner.sourcePlanner.maximumSurfaceEdgeRise() : Integer.MAX_VALUE;
|
||||
boolean[] reachable = new boolean[grid.nodes().size()];
|
||||
int[] queue = new int[reachable.length];
|
||||
int readIndex = 0;
|
||||
@@ -305,7 +358,9 @@ final class HydrologyOutletPlanner {
|
||||
HydrologyGridNode neighbor = grid.nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z());
|
||||
if (neighbor == null || reachable[neighbor.index()] || neighbor.terrain().ocean()
|
||||
|| !neighbor.terrain().transitAllowed()
|
||||
|| node.terrain().naturalHeight() - neighbor.terrain().naturalHeight() > maximumRise) {
|
||||
|| surface && !HydrologySurfaceProfiles.sharesProfile(neighbor.terrain(), node.terrain())
|
||||
|| surface && node.terrain().naturalHeight() - neighbor.terrain().naturalHeight()
|
||||
> planner.sourcePlanner.maximumSurfaceEdgeRise(node.terrain())) {
|
||||
continue;
|
||||
}
|
||||
reachable[neighbor.index()] = true;
|
||||
@@ -324,11 +379,15 @@ final class HydrologyOutletPlanner {
|
||||
for (OutletCandidate candidate : candidates) {
|
||||
HydrologyGridNode node = grid.node(candidate.landIndex());
|
||||
RiverOutlet outlet = candidate.outlet();
|
||||
HydrologyPoint landward = planner.routeGeometry.routeAnchor(node);
|
||||
HydrologyPoint landward = planner.routeGeometry.routeAnchor(node, surface);
|
||||
HydrologyTerrainSample landwardTerrain = Objects.requireNonNull(
|
||||
planner.sampleDetailed(landward.x(), landward.z()),
|
||||
"Hydrology inland outlet anchor left sampled terrain"
|
||||
);
|
||||
if (surface && (!HydrologySurfaceProfiles.sharesProfile(node.terrain(), landwardTerrain)
|
||||
|| !node.terrain().surfacePolicy().areaKey().equals(landwardTerrain.surfacePolicy().areaKey()))) {
|
||||
continue;
|
||||
}
|
||||
int connectionOffsetX = Integer.compare(outlet.connectionPoint().x(), outlet.landwardPoint().x());
|
||||
int connectionOffsetZ = Integer.compare(outlet.connectionPoint().z(), outlet.landwardPoint().z());
|
||||
HydrologyPoint connection = new HydrologyPoint(
|
||||
@@ -404,11 +463,12 @@ final class HydrologyOutletPlanner {
|
||||
);
|
||||
}
|
||||
|
||||
HydrologyGridNode firstOceanNeighbor(HydrologySampledGrid grid, HydrologyGridNode land) {
|
||||
HydrologyGridNode firstOceanNeighbor(HydrologySampledGrid grid, HydrologyGridNode land, boolean surface) {
|
||||
ArrayList<HydrologyGridNode> oceans = new ArrayList<>();
|
||||
for (HydrologyGridOffset offset : CARDINAL_OFFSETS) {
|
||||
HydrologyGridNode neighbor = grid.nodeAt(land.gridX() + offset.x(), land.gridZ() + offset.z());
|
||||
if (neighbor != null && neighbor.terrain().ocean()) {
|
||||
if (neighbor != null && neighbor.terrain().ocean()
|
||||
&& (!surface || HydrologySurfaceProfiles.sharesProfile(land.terrain(), neighbor.terrain()))) {
|
||||
oceans.add(neighbor);
|
||||
}
|
||||
}
|
||||
@@ -500,7 +560,7 @@ final class HydrologyOutletPlanner {
|
||||
&& downstream.terrain().surfaceSourceAllowed()
|
||||
&& downstream.terrain().naturalHeight() >= planner.settings.surface().sources().minimumElevation()
|
||||
&& distances[downstream.index()]
|
||||
>= planner.settings.routing().minimumSurfaceCourseLength()) {
|
||||
>= planner.sourcePlanner.minimumCourseLength(downstream.terrain(), true)) {
|
||||
capacity = Math.addExact(
|
||||
capacity,
|
||||
1_000_000L
|
||||
@@ -516,7 +576,8 @@ final class HydrologyOutletPlanner {
|
||||
if (upstream == null
|
||||
|| visited[upstream.index()]
|
||||
|| upstream.terrain().ocean()
|
||||
|| !upstream.terrain().transitAllowed()) {
|
||||
|| !upstream.terrain().transitAllowed()
|
||||
|| !HydrologySurfaceProfiles.sharesProfile(upstream.terrain(), downstream.terrain())) {
|
||||
continue;
|
||||
}
|
||||
visited[upstream.index()] = true;
|
||||
|
||||
@@ -51,7 +51,6 @@ public final class HydrologyPlanner {
|
||||
final SimplexNoise routeWormPrimary;
|
||||
final SimplexNoise routeWormDetail;
|
||||
final SurfaceCourseBuilder surfaceCourseBuilder;
|
||||
final SurfaceCourseBuilder tributaryCourseBuilder;
|
||||
final HydrologyCrossTileResolver crossTile;
|
||||
final HydrologyOutletPlanner outletPlanner;
|
||||
final HydrologySourcePlanner sourcePlanner;
|
||||
@@ -182,17 +181,9 @@ public final class HydrologyPlanner {
|
||||
settings.surface(),
|
||||
this::sampleBasisWithoutSlope,
|
||||
geometrySampler,
|
||||
settings.seaLevel(),
|
||||
settings.routing().minimumSurfaceCourseLength()
|
||||
settings.seaLevel()
|
||||
);
|
||||
// A tributary only has to be a real reach before it joins its stem: half the course minimum.
|
||||
this.tributaryCourseBuilder = new SurfaceCourseBuilder(
|
||||
settings.surface(),
|
||||
this::sampleBasisWithoutSlope,
|
||||
geometrySampler,
|
||||
settings.seaLevel(),
|
||||
settings.routing().minimumSurfaceCourseLength() / 2
|
||||
); this.crossTile = new HydrologyCrossTileResolver(this);
|
||||
this.crossTile = new HydrologyCrossTileResolver(this);
|
||||
this.outletPlanner = new HydrologyOutletPlanner(this);
|
||||
this.sourcePlanner = new HydrologySourcePlanner(this);
|
||||
this.routeGeometry = new HydrologyRouteGeometry(this);
|
||||
@@ -638,6 +629,7 @@ public final class HydrologyPlanner {
|
||||
final HashMap<Integer, List<HydrologyGridOffset>> radialOffsets;
|
||||
final HashMap<UndergroundSegmentCapKey, Integer> undergroundSegmentCaps;
|
||||
final HashMap<Long, HydrologyPoint> routeAnchors;
|
||||
final HashMap<Long, HydrologyPoint> surfaceRouteAnchors;
|
||||
final IdentityHashMap<HydrologySampledGrid, HashMap<SurfaceRouteKey, List<HydrologyPoint>>> surfaceRoutes;
|
||||
final HydrologyCaveCourseFilter.CandidateCache caveCandidates;
|
||||
final HydrologyCaveContainmentPlanner.ValidationCache caveValidations;
|
||||
@@ -652,6 +644,7 @@ public final class HydrologyPlanner {
|
||||
this.radialOffsets = new HashMap<>();
|
||||
this.undergroundSegmentCaps = new HashMap<>();
|
||||
this.routeAnchors = new HashMap<>();
|
||||
this.surfaceRouteAnchors = new HashMap<>();
|
||||
this.surfaceRoutes = new IdentityHashMap<>();
|
||||
this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache();
|
||||
this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache();
|
||||
@@ -769,8 +762,7 @@ public final class HydrologyPlanner {
|
||||
HydrologyCrossTileSurfaceAdmission.Result surfaceAdmission =
|
||||
HydrologyCrossTileSurfaceAdmission.admit(
|
||||
crossTile.surfaceClaims(current),
|
||||
blockers.surfaceClaims(),
|
||||
settings.surface().sources().minimumSpacing()
|
||||
blockers.surfaceClaims()
|
||||
);
|
||||
Map<Long, RiverCourse> currentCourses = crossTile.coursesById(current.courses());
|
||||
HashSet<Long> rejectedSurfaceOutlets = new HashSet<>();
|
||||
|
||||
@@ -17,7 +17,8 @@ public record HydrologyPlannerSettings(
|
||||
List<DeepFluid> deepFluids,
|
||||
List<SurfacePool> surfacePools,
|
||||
double widestShoreBiomeWidth,
|
||||
SeaCaves seaCaves
|
||||
SeaCaves seaCaves,
|
||||
SurfacePolicyBounds surfacePolicyBounds
|
||||
) {
|
||||
private static final long PLAN_FORMAT_REVISION = 6L;
|
||||
private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4;
|
||||
@@ -38,7 +39,9 @@ public record HydrologyPlannerSettings(
|
||||
if (seaCaves == null) {
|
||||
throw new IllegalArgumentException("Sea cave settings are required.");
|
||||
}
|
||||
int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves);
|
||||
surfacePolicyBounds = Objects.requireNonNull(surfacePolicyBounds);
|
||||
int publicationRadius = Math.max(surfacePolicyBounds.maximumSourceSpacing(),
|
||||
publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves));
|
||||
if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) {
|
||||
throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period.");
|
||||
}
|
||||
@@ -68,7 +71,8 @@ public record HydrologyPlannerSettings(
|
||||
List.of(),
|
||||
List.of(),
|
||||
0D,
|
||||
SeaCaves.disabled()
|
||||
SeaCaves.disabled(),
|
||||
HydrologyPlannerSettings.SurfacePolicyBounds.NONE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,11 +95,29 @@ public record HydrologyPlannerSettings(
|
||||
for (SurfacePool pool : surfacePools) {
|
||||
hash = HydrologyHash.mix(hash, pool.hashCode(), HydrologyHash.text(pool.id()));
|
||||
}
|
||||
if (surfacePolicyBounds.fingerprint() != 0L) {
|
||||
hash = HydrologyHash.mix(hash, surfacePolicyBounds.fingerprint(), surfacePolicyBounds.maximumSourceSpacing());
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
public int publicationRadius() {
|
||||
return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves);
|
||||
return Math.max(surfacePolicyBounds.maximumSourceSpacing(),
|
||||
publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools, widestShoreBiomeWidth, seaCaves));
|
||||
}
|
||||
|
||||
public int maximumSurfaceSourceSpacing() {
|
||||
return Math.max(surface.sources().minimumSpacing(), surfacePolicyBounds.maximumSourceSpacing());
|
||||
}
|
||||
|
||||
public record SurfacePolicyBounds(long fingerprint, int maximumSourceSpacing) {
|
||||
public static final SurfacePolicyBounds NONE = new SurfacePolicyBounds(0L, 0);
|
||||
|
||||
public SurfacePolicyBounds {
|
||||
if (maximumSourceSpacing < 0 || maximumSourceSpacing > 8192) {
|
||||
throw new IllegalArgumentException("Maximum surface policy source spacing must be between 0 and 8192.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int crossTileColorPeriod() {
|
||||
|
||||
@@ -2,6 +2,7 @@ package art.arcane.iris.engine.hydrology;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -33,11 +34,12 @@ final class HydrologyRouteGeometry {
|
||||
HydrologySampledGrid grid,
|
||||
HydrologyRoutingPlan routing,
|
||||
HydrologyGridNode downstream,
|
||||
RiverOutlet outlet
|
||||
RiverOutlet outlet,
|
||||
boolean surface
|
||||
) {
|
||||
int continuationIndex = routing.parent()[downstream.index()];
|
||||
if (continuationIndex >= 0) {
|
||||
return routeAnchor(grid.node(continuationIndex));
|
||||
return routeAnchor(grid.node(continuationIndex), surface);
|
||||
}
|
||||
HydrologyPoint landward = outlet.landwardPoint();
|
||||
if (landward.x() != downstream.x() || landward.z() != downstream.z()) {
|
||||
@@ -46,10 +48,12 @@ final class HydrologyRouteGeometry {
|
||||
return outlet.connectionPoint();
|
||||
}
|
||||
|
||||
HydrologyPoint routeAnchor(HydrologyGridNode node) {
|
||||
HydrologyPoint routeAnchor(HydrologyGridNode node, boolean surface) {
|
||||
HydrologyPlanner.PlanningSamples samples = planner.planningSamples.get();
|
||||
HashMap<Long, HydrologyPoint> anchors = samples == null ? null
|
||||
: surface ? samples.surfaceRouteAnchors : samples.routeAnchors;
|
||||
if (samples != null) {
|
||||
HydrologyPoint cached = samples.routeAnchors.get(node.id());
|
||||
HydrologyPoint cached = anchors.get(node.id());
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
@@ -112,12 +116,13 @@ final class HydrologyRouteGeometry {
|
||||
selected.z(),
|
||||
desiredX,
|
||||
desiredZ,
|
||||
maximumOffsetRatio
|
||||
maximumOffsetRatio,
|
||||
surface
|
||||
);
|
||||
for (int[] offset : offsets) {
|
||||
int x = desiredX + offset[0];
|
||||
int z = desiredZ + offset[1];
|
||||
double score = anchorScore(node, x, z, desiredX, desiredZ, maximumOffsetRatio);
|
||||
double score = anchorScore(node, x, z, desiredX, desiredZ, maximumOffsetRatio, surface);
|
||||
if (score < selectedScore) {
|
||||
HydrologyTerrainSample terrain = planner.sampleLandBasis(x, z);
|
||||
selected = new HydrologyPoint(x, terrain.naturalHeight(), z);
|
||||
@@ -125,7 +130,7 @@ final class HydrologyRouteGeometry {
|
||||
}
|
||||
}
|
||||
if (samples != null) {
|
||||
samples.routeAnchors.put(node.id(), selected);
|
||||
anchors.put(node.id(), selected);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
@@ -136,7 +141,8 @@ final class HydrologyRouteGeometry {
|
||||
int z,
|
||||
int desiredX,
|
||||
int desiredZ,
|
||||
double maximumOffsetRatio
|
||||
double maximumOffsetRatio,
|
||||
boolean surface
|
||||
) {
|
||||
double distanceFromNode = StrictMath.hypot(x - node.x(), z - node.z());
|
||||
if (distanceFromNode > planner.settings.routing().sampleSpacing() * maximumOffsetRatio) {
|
||||
@@ -146,6 +152,17 @@ final class HydrologyRouteGeometry {
|
||||
if (terrain == null || !terrain.transitAllowed() || !withinConfines(terrain, node.terrain().confinesKey())) {
|
||||
return Double.POSITIVE_INFINITY;
|
||||
}
|
||||
if (surface) {
|
||||
int maximumDrop = Math.max(0, Math.min(planner.sourcePlanner.permittedSurfaceIncision(node.terrain()),
|
||||
planner.sourcePlanner.permittedSurfaceIncision(terrain))
|
||||
- planner.settings.surface().banks().sink() - planner.settings.surface().minimumDepth());
|
||||
int drop = node.terrain().naturalHeight() - terrain.naturalHeight();
|
||||
if (drop < 0 || drop > maximumDrop
|
||||
|| !HydrologySurfaceProfiles.sharesProfile(node.terrain(), terrain)
|
||||
|| !node.terrain().surfacePolicy().areaKey().equals(terrain.surfacePolicy().areaKey())) {
|
||||
return Double.POSITIVE_INFINITY;
|
||||
}
|
||||
}
|
||||
if (!planner.routePaths.traversableHop(node.naturalPoint(), new HydrologyPoint(x, terrain.naturalHeight(), z))) {
|
||||
return Double.POSITIVE_INFINITY;
|
||||
}
|
||||
@@ -166,7 +183,8 @@ final class HydrologyRouteGeometry {
|
||||
HydrologyPoint upstream,
|
||||
HydrologyPoint downstream,
|
||||
HydrologyPoint continuation,
|
||||
int transverseCandidates
|
||||
int transverseCandidates,
|
||||
boolean surface
|
||||
) {
|
||||
int refinement = planner.settings.routing().refinementSpacing();
|
||||
int effectiveTransverseCandidates = transverseCandidates;
|
||||
@@ -222,7 +240,7 @@ final class HydrologyRouteGeometry {
|
||||
nominal,
|
||||
progress,
|
||||
refinement,
|
||||
elevationTransition ? 0 : effectiveTransverseCandidates,
|
||||
elevationTransition && !surface ? 0 : effectiveTransverseCandidates,
|
||||
upstreamTerrain == null ? null : upstreamTerrain.confinesKey()
|
||||
);
|
||||
if (candidates.isEmpty()) {
|
||||
@@ -331,6 +349,8 @@ final class HydrologyRouteGeometry {
|
||||
if (terrain == null || !terrain.transitAllowed()) {
|
||||
return new RouteCandidate(
|
||||
point,
|
||||
point.x(),
|
||||
point.z(),
|
||||
0D,
|
||||
Double.POSITIVE_INFINITY,
|
||||
Double.POSITIVE_INFINITY,
|
||||
@@ -341,6 +361,8 @@ final class HydrologyRouteGeometry {
|
||||
double terrainScore = routeTerrainScore(terrain);
|
||||
return new RouteCandidate(
|
||||
new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()),
|
||||
point.x(),
|
||||
point.z(),
|
||||
0D,
|
||||
terrainScore,
|
||||
terrainScore,
|
||||
@@ -457,6 +479,8 @@ final class HydrologyRouteGeometry {
|
||||
+ HydrologyHash.unit(HydrologyHash.mix(planner.worldSeed, upstreamId, downstreamId, x, z)) * 1.0E-6D;
|
||||
RouteCandidate candidate = new RouteCandidate(
|
||||
new HydrologyPoint(x, terrain.naturalHeight(), z),
|
||||
x,
|
||||
z,
|
||||
offset,
|
||||
localScore,
|
||||
routeTerrainScore(terrain),
|
||||
@@ -491,6 +515,8 @@ final class HydrologyRouteGeometry {
|
||||
)) * 1.0E-6D;
|
||||
RouteCandidate fallback = new RouteCandidate(
|
||||
new HydrologyPoint(fallbackX, fallbackTerrain.naturalHeight(), fallbackZ),
|
||||
fallbackX,
|
||||
fallbackZ,
|
||||
fallbackOffset,
|
||||
localScore,
|
||||
routeTerrainScore(fallbackTerrain),
|
||||
@@ -535,6 +561,8 @@ final class HydrologyRouteGeometry {
|
||||
)) * 1.0E-6D;
|
||||
RouteCandidate candidate = new RouteCandidate(
|
||||
new HydrologyPoint(x, estimate.height(), z),
|
||||
nominal.x() - nominal.tangent().z() * offset,
|
||||
nominal.z() + nominal.tangent().x() * offset,
|
||||
offset,
|
||||
localScore,
|
||||
estimate.terrainScore(),
|
||||
@@ -590,6 +618,8 @@ final class HydrologyRouteGeometry {
|
||||
if (terrain == null || !terrain.transitAllowed()) {
|
||||
return new RouteCandidate(
|
||||
point,
|
||||
candidate.continuousX(),
|
||||
candidate.continuousZ(),
|
||||
candidate.offset(),
|
||||
Double.POSITIVE_INFINITY,
|
||||
Double.POSITIVE_INFINITY,
|
||||
@@ -601,6 +631,8 @@ final class HydrologyRouteGeometry {
|
||||
double exactLocalScore = candidate.localScore() - candidate.terrainScore() + terrainScore;
|
||||
return new RouteCandidate(
|
||||
new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()),
|
||||
candidate.continuousX(),
|
||||
candidate.continuousZ(),
|
||||
candidate.offset(),
|
||||
exactLocalScore,
|
||||
terrainScore,
|
||||
@@ -887,10 +919,10 @@ final class HydrologyRouteGeometry {
|
||||
}
|
||||
double turnPenalty = candidateTurns[beforeIndex];
|
||||
if (Double.isNaN(turnPenalty)) {
|
||||
double turn = HydrologyRoutePath.routeTurnDegrees(
|
||||
beforeLayer.get(beforeIndex).point(),
|
||||
previous.point(),
|
||||
current.point()
|
||||
double turn = continuousTurnDegrees(
|
||||
beforeLayer.get(beforeIndex),
|
||||
previous,
|
||||
current
|
||||
);
|
||||
turnPenalty = turn > maximumTurn
|
||||
? Double.POSITIVE_INFINITY
|
||||
@@ -1028,16 +1060,25 @@ final class HydrologyRouteGeometry {
|
||||
return excess * excess * turnCost / maximumTurn;
|
||||
}
|
||||
|
||||
double continuousTurnDegrees(RouteCandidate before, RouteCandidate center, RouteCandidate after) {
|
||||
double incomingX = center.continuousX() - before.continuousX();
|
||||
double incomingZ = center.continuousZ() - before.continuousZ();
|
||||
double outgoingX = after.continuousX() - center.continuousX();
|
||||
double outgoingZ = after.continuousZ() - center.continuousZ();
|
||||
double length = StrictMath.hypot(incomingX, incomingZ) * StrictMath.hypot(outgoingX, outgoingZ);
|
||||
if (length <= 0D) {
|
||||
return 0D;
|
||||
}
|
||||
double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) / length;
|
||||
return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine))));
|
||||
}
|
||||
|
||||
double surfaceRouteTransitionPenalty(
|
||||
RouteCandidate start,
|
||||
RouteCandidate end
|
||||
) {
|
||||
int minimumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().minimumWidth());
|
||||
int maximumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().maximumWidth());
|
||||
int maximumBankRise = Math.addExact(
|
||||
planner.settings.surface().maximumIncision(),
|
||||
(int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D)
|
||||
);
|
||||
RouteDirection tangent = planner.routePaths.direction(start.point().x(), start.point().z(), end.point().x(), end.point().z());
|
||||
int endpointCeiling = Math.max(start.point().y(), end.point().y());
|
||||
double penalty = 0D;
|
||||
@@ -1068,7 +1109,7 @@ final class HydrologyRouteGeometry {
|
||||
minimumBankDistance,
|
||||
maximumBankDistance,
|
||||
head,
|
||||
maximumBankRise
|
||||
planner.surfaceCourses.maximumSurfaceBankRise(terrain)
|
||||
);
|
||||
}
|
||||
if ((raster.size() - 1) % sampleStride != 0) {
|
||||
@@ -1094,7 +1135,7 @@ final class HydrologyRouteGeometry {
|
||||
minimumBankDistance,
|
||||
maximumBankDistance,
|
||||
head,
|
||||
maximumBankRise
|
||||
planner.surfaceCourses.maximumSurfaceBankRise(terrain)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.function.IntPredicate;
|
||||
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
|
||||
|
||||
final class HydrologySourcePlanner {
|
||||
private final HydrologyPlanner planner;
|
||||
@@ -64,7 +65,6 @@ final class HydrologySourcePlanner {
|
||||
) {
|
||||
// A surface river never climbs: any rise along its route becomes a cut of at least that rise,
|
||||
// so lattice edges that rise more than a small saddle are not drainage at all.
|
||||
int maximumRise = surface ? maximumSurfaceEdgeRise() : Integer.MAX_VALUE;
|
||||
int nodeCount = grid.nodes().size();
|
||||
double[] potential = new double[nodeCount];
|
||||
int[] parent = new int[nodeCount];
|
||||
@@ -93,12 +93,14 @@ final class HydrologySourcePlanner {
|
||||
for (HydrologyGridOffset offset : ROUTING_OFFSETS) {
|
||||
HydrologyGridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z());
|
||||
if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed()
|
||||
|| !upstream.terrain().drainsInto(downstream.terrain())) {
|
||||
|| !upstream.terrain().drainsInto(downstream.terrain())
|
||||
|| surface && !HydrologySurfaceProfiles.sharesProfile(upstream.terrain(), downstream.terrain())) {
|
||||
// Confined ground only drains into its own area, so every course that starts or
|
||||
// arrives there keeps to that area up to and including its outlet.
|
||||
continue;
|
||||
}
|
||||
if (downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight() > maximumRise) {
|
||||
if (surface && downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight()
|
||||
> maximumSurfaceEdgeRise(downstream.terrain())) {
|
||||
continue;
|
||||
}
|
||||
double edgeCost = routeCost(
|
||||
@@ -140,11 +142,16 @@ final class HydrologySourcePlanner {
|
||||
* Largest rise a surface drainage edge may take. Heads only fall, so a rise along a route becomes a cut
|
||||
* of at least that rise; the lattice may climb no more than the cut the valley solver still accepts.
|
||||
*/
|
||||
int maximumSurfaceEdgeRise() {
|
||||
int permitted = planner.settings.surface().maximumIncision() - planner.settings.surface().banks().sink() - planner.settings.surface().minimumDepth();
|
||||
int maximumSurfaceEdgeRise(HydrologyTerrainSample terrain) {
|
||||
int permitted = permittedSurfaceIncision(terrain) - planner.settings.surface().banks().sink() - planner.settings.surface().minimumDepth();
|
||||
return Math.max(1, permitted);
|
||||
}
|
||||
|
||||
int minimumCourseLength(HydrologyTerrainSample source, boolean surface) {
|
||||
int fallback = planner.settings.routing().minimumCourseLength(surface);
|
||||
return surface ? source.surfacePolicy().minimumCourseLength(fallback) : fallback;
|
||||
}
|
||||
|
||||
HydrologyRoutingPlan requireOrganicSurface(HydrologyRoutingPlan routing) {
|
||||
return new HydrologyRoutingPlan(
|
||||
routing.potential(),
|
||||
@@ -158,7 +165,7 @@ final class HydrologySourcePlanner {
|
||||
|
||||
int permittedSurfaceIncision(HydrologyTerrainSample terrain) {
|
||||
return permittedSurfaceIncision(
|
||||
planner.settings.surface().maximumIncision(),
|
||||
terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()),
|
||||
terrain.incisionMultiplier()
|
||||
);
|
||||
}
|
||||
@@ -219,6 +226,7 @@ final class HydrologySourcePlanner {
|
||||
return SourceSelection.empty(surface);
|
||||
}
|
||||
long sourceSalt = surface ? SURFACE_SOURCE_SALT : UNDERGROUND_SOURCE_SALT;
|
||||
HydrologySurfaceBudgets surfaceBudgets = surface ? HydrologySurfaceBudgets.sample(grid, sourceSettings) : null;
|
||||
ArrayList<SourceCandidate> candidates = new ArrayList<>();
|
||||
boolean hasRequiredCandidate = false;
|
||||
for (HydrologyGridNode node : grid.nodes()) {
|
||||
@@ -228,7 +236,8 @@ final class HydrologySourcePlanner {
|
||||
HydrologyTerrainSample terrain = node.terrain();
|
||||
boolean allowed = surface ? terrain.surfaceSourceAllowed() : terrain.undergroundSourceAllowed();
|
||||
boolean required = surface ? terrain.surfaceSourceRequired() : terrain.undergroundSourceRequired();
|
||||
if (!allowed || surface && terrain.naturalHeight() < sourceSettings.minimumElevation()) {
|
||||
if (!allowed || surface && (terrain.naturalHeight() < sourceSettings.minimumElevation()
|
||||
|| terrain.surfacePolicy().sourceDensity() != null && terrain.surfacePolicy().sourceDensity() == 0D)) {
|
||||
continue;
|
||||
}
|
||||
double weight = surface ? terrain.surfaceSourceWeight() : terrain.undergroundSourceWeight();
|
||||
@@ -246,7 +255,7 @@ final class HydrologySourcePlanner {
|
||||
: HydrologyCandidateRejection.NO_DRAINAGE_PATH, diagnostics);
|
||||
continue;
|
||||
}
|
||||
if (!sourceOutletAllowed(node, routing, surface)) {
|
||||
if (!sourceOutletAllowed(node, grid, routing, surface)) {
|
||||
addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.NO_LEGAL_OUTLET, diagnostics);
|
||||
continue;
|
||||
}
|
||||
@@ -259,7 +268,7 @@ final class HydrologySourcePlanner {
|
||||
addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.ROUTE_LIMIT, diagnostics);
|
||||
continue;
|
||||
}
|
||||
if (routeLength < planner.settings.routing().minimumCourseLength(surface)) {
|
||||
if (routeLength < minimumCourseLength(terrain, surface)) {
|
||||
addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.COURSE_TOO_SHORT, diagnostics);
|
||||
continue;
|
||||
}
|
||||
@@ -272,6 +281,9 @@ final class HydrologySourcePlanner {
|
||||
candidates.add(new SourceCandidate(node.index(), stable, score, required));
|
||||
hasRequiredCandidate |= required;
|
||||
}
|
||||
if (surfaceBudgets != null && surfaceBudgets.overridden()) {
|
||||
return selectLocalSurfaceSources(key, grid, routing, candidates, surfaceBudgets, enforceGlobalSpacing, routingContexts);
|
||||
}
|
||||
int target = expectedCount(sourceSettings.density(), HydrologyHash.mix(
|
||||
planner.worldSeed,
|
||||
sourceSalt,
|
||||
@@ -334,11 +346,98 @@ final class HydrologySourcePlanner {
|
||||
surface,
|
||||
candidates,
|
||||
admission,
|
||||
admittedGuaranteed,
|
||||
new int[]{admittedGuaranteed},
|
||||
maximumOptionalRejections
|
||||
);
|
||||
}
|
||||
|
||||
private SourceSelection selectLocalSurfaceSources(
|
||||
HydrologyTileKey key,
|
||||
HydrologySampledGrid grid,
|
||||
HydrologyRoutingPlan routing,
|
||||
List<SourceCandidate> candidates,
|
||||
HydrologySurfaceBudgets budgets,
|
||||
boolean enforceGlobalSpacing,
|
||||
Map<HydrologyTileKey, SourceRoutingContext> routingContexts
|
||||
) {
|
||||
candidates.sort(Comparator.comparing(SourceCandidate::required).reversed()
|
||||
.thenComparing(Comparator.comparingDouble(SourceCandidate::score).reversed())
|
||||
.thenComparingLong(SourceCandidate::stableId));
|
||||
prioritizeInlandSource(candidates, routing);
|
||||
List<HydrologySurfaceBudgets.Area> areas = budgets.areas();
|
||||
Map<SurfaceRiverPolicy.Budget, Integer> areaIndices = new HashMap<>();
|
||||
ArrayList<HashSet<Integer>> areaOutlets = new ArrayList<>(areas.size());
|
||||
for (int index = 0; index < areas.size(); index++) {
|
||||
areaIndices.put(areas.get(index).policy.budget(), index);
|
||||
areaOutlets.add(new HashSet<>());
|
||||
}
|
||||
int count = candidates.size();
|
||||
int[] candidateAreas = new int[count];
|
||||
int[] candidateOutlets = new int[count];
|
||||
int[] outletLimits = new int[count];
|
||||
int[] requiredCandidates = new int[areas.size()];
|
||||
for (int index = 0; index < count; index++) {
|
||||
HydrologyGridNode node = grid.node(candidates.get(index).nodeIndex());
|
||||
SurfaceRiverPolicy policy = node.terrain().surfacePolicy();
|
||||
int area = areaIndices.get(policy.budget());
|
||||
int outlet = routing.outletIndex()[node.index()];
|
||||
candidateAreas[index] = area;
|
||||
candidateOutlets[index] = outlet;
|
||||
outletLimits[index] = 1 + policy.tributaries(planner.settings.routing().tributaries());
|
||||
areaOutlets.get(area).add(outlet);
|
||||
if (candidates.get(index).required()) {
|
||||
requiredCandidates[area]++;
|
||||
}
|
||||
}
|
||||
int[] areaLimits = new int[areas.size()];
|
||||
int[] requiredMinimums = new int[areas.size()];
|
||||
int target = 0;
|
||||
for (int index = 0; index < areas.size(); index++) {
|
||||
requiredMinimums[index] = Math.min(requiredCandidates[index], Math.max(1,
|
||||
planner.settings.surface().sources().minimumPerTile()));
|
||||
areaLimits[index] = Math.max(requiredMinimums[index],
|
||||
budgets.sourceTarget(areas.get(index), areaOutlets.get(index).size(), planner, key));
|
||||
target = Math.addExact(target, areaLimits[index]);
|
||||
}
|
||||
boolean[] selected = new boolean[count];
|
||||
boolean[] spacingRejected = new boolean[count];
|
||||
boolean[] evaluated = new boolean[count];
|
||||
IntPredicate admitted = candidateIndex -> !enforceGlobalSpacing || globallyAdmittedSource(
|
||||
grid.node(candidates.get(candidateIndex).nodeIndex()), planner.settings.surface().sources(),
|
||||
SURFACE_SOURCE_SALT, true, routingContexts);
|
||||
ArrayList<Integer> selectedIndices = new ArrayList<>(Math.min(count, target));
|
||||
int[] selectedByArea = new int[areas.size()];
|
||||
int[] selectedRequiredByArea = new int[areas.size()];
|
||||
int[] selectedByOutlet = new int[routing.outlets().size()];
|
||||
for (int index = 0; index < count && selectedIndices.size() < target; index++) {
|
||||
int area = candidateAreas[index];
|
||||
int outlet = candidateOutlets[index];
|
||||
if (selectedByArea[area] >= areaLimits[area] || selectedByOutlet[outlet] >= outletLimits[index]) {
|
||||
continue;
|
||||
}
|
||||
evaluated[index] = true;
|
||||
spacingRejected[index] = !admitted.test(index);
|
||||
if (spacingRejected[index]) {
|
||||
continue;
|
||||
}
|
||||
selected[index] = true;
|
||||
selectedIndices.add(index);
|
||||
selectedByArea[area]++;
|
||||
selectedByOutlet[outlet]++;
|
||||
if (candidates.get(index).required()) {
|
||||
selectedRequiredByArea[area]++;
|
||||
}
|
||||
}
|
||||
for (int area = 0; area < areas.size(); area++) {
|
||||
requiredMinimums[area] = Math.min(requiredMinimums[area], selectedRequiredByArea[area]);
|
||||
}
|
||||
SourceAdmissionSelection admission = new SourceAdmissionSelection(target, selectedIndices, selected,
|
||||
spacingRejected, evaluated, admitted,
|
||||
new SourceAdmissionSelection.Quotas(candidateOutlets, outletLimits, candidateAreas, areaLimits));
|
||||
return new SourceSelection(true, candidates, admission, requiredMinimums,
|
||||
Math.max(SURFACE_OPTIONAL_SOURCE_REJECTIONS_PER_TARGET, target * SURFACE_OPTIONAL_SOURCE_REJECTIONS_PER_TARGET));
|
||||
}
|
||||
|
||||
static int effectiveSourceTarget(boolean outletBounded, int requestedTarget, int outletCount) {
|
||||
return effectiveSourceTarget(outletBounded, requestedTarget, outletCount, 1);
|
||||
}
|
||||
@@ -407,8 +506,7 @@ final class HydrologySourcePlanner {
|
||||
spacingRejectedCandidates,
|
||||
evaluatedCandidates,
|
||||
globallyAdmitted,
|
||||
outletIndices,
|
||||
maximumCoursesPerOutlet
|
||||
SourceAdmissionSelection.Quotas.uniform(outletIndices, target, maximumCoursesPerOutlet)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -483,8 +581,7 @@ final class HydrologySourcePlanner {
|
||||
spacingRejectedCandidates,
|
||||
evaluatedCandidates,
|
||||
globallyAdmitted,
|
||||
new int[candidateCount],
|
||||
Integer.MAX_VALUE
|
||||
SourceAdmissionSelection.Quotas.uniform(new int[candidateCount], target, Integer.MAX_VALUE)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -495,16 +592,19 @@ final class HydrologySourcePlanner {
|
||||
boolean surface,
|
||||
Map<HydrologyTileKey, SourceRoutingContext> routingContexts
|
||||
) {
|
||||
int minimumSpacing = sourceSettings.minimumSpacing();
|
||||
int minimumSpacing = surface
|
||||
? candidate.terrain().surfacePolicy().sourceSpacing(sourceSettings.minimumSpacing())
|
||||
: sourceSettings.minimumSpacing();
|
||||
boolean required = surface
|
||||
? candidate.terrain().surfaceSourceRequired()
|
||||
: candidate.terrain().undergroundSourceRequired();
|
||||
if (minimumSpacing <= 0 || required) {
|
||||
if ((!surface && minimumSpacing <= 0) || required) {
|
||||
return true;
|
||||
}
|
||||
SourcePriority candidatePriority = sourcePriority(candidate.x(), candidate.z(), candidate.terrain(), sourceSalt, surface);
|
||||
int sampleSpacing = planner.settings.routing().sampleSpacing();
|
||||
int latticeRadius = (int) StrictMath.ceil(minimumSpacing / (double) sampleSpacing);
|
||||
int searchSpacing = surface ? Math.max(minimumSpacing, planner.settings.maximumSurfaceSourceSpacing()) : minimumSpacing;
|
||||
int latticeRadius = (int) StrictMath.ceil(searchSpacing / (double) sampleSpacing);
|
||||
for (int offsetZ = -latticeRadius; offsetZ <= latticeRadius; offsetZ++) {
|
||||
for (int offsetX = -latticeRadius; offsetX <= latticeRadius; offsetX++) {
|
||||
if (offsetX == 0 && offsetZ == 0) {
|
||||
@@ -512,7 +612,8 @@ final class HydrologySourcePlanner {
|
||||
}
|
||||
long deltaX = (long) offsetX * sampleSpacing;
|
||||
long deltaZ = (long) offsetZ * sampleSpacing;
|
||||
if (StrictMath.hypot(deltaX, deltaZ) >= minimumSpacing) {
|
||||
double separation = StrictMath.hypot(deltaX, deltaZ);
|
||||
if (separation >= searchSpacing) {
|
||||
continue;
|
||||
}
|
||||
long neighborX = (long) candidate.x() + deltaX;
|
||||
@@ -530,6 +631,12 @@ final class HydrologySourcePlanner {
|
||||
throw new IllegalStateException("Source coordinate is absent from its owner routing lattice.");
|
||||
}
|
||||
HydrologyTerrainSample terrain = neighbor.terrain();
|
||||
int pairSpacing = surface
|
||||
? Math.max(minimumSpacing, terrain.surfacePolicy().sourceSpacing(sourceSettings.minimumSpacing()))
|
||||
: minimumSpacing;
|
||||
if (separation >= pairSpacing) {
|
||||
continue;
|
||||
}
|
||||
if (!rawSourceEligible(terrain, sourceSettings, surface)) {
|
||||
continue;
|
||||
}
|
||||
@@ -569,20 +676,24 @@ final class HydrologySourcePlanner {
|
||||
if (routing.outlets().isEmpty()
|
||||
|| !Double.isFinite(routing.potential()[node.index()])
|
||||
|| routing.parent()[node.index()] < 0
|
||||
|| !sourceOutletAllowed(node, routing, surface)) {
|
||||
|| !sourceOutletAllowed(node, context.grid(), routing, surface)) {
|
||||
return false;
|
||||
}
|
||||
int routeLength = routing.routeLengths()[node.index()];
|
||||
return routeLength >= planner.settings.routing().minimumCourseLength(surface)
|
||||
return routeLength >= minimumCourseLength(node.terrain(), surface)
|
||||
&& routeLength <= planner.settings.routing().maximumRouteLength();
|
||||
}
|
||||
|
||||
boolean sourceOutletAllowed(HydrologyGridNode node, HydrologyRoutingPlan routing, boolean surface) {
|
||||
boolean sourceOutletAllowed(HydrologyGridNode node, HydrologySampledGrid grid, HydrologyRoutingPlan routing, boolean surface) {
|
||||
int outletIndex = routing.outletIndex()[node.index()];
|
||||
if (outletIndex < 0 || outletIndex >= routing.outlets().size()) {
|
||||
return false;
|
||||
}
|
||||
RiverOutlet outlet = routing.outlets().get(outletIndex).outlet();
|
||||
OutletCandidate candidate = routing.outlets().get(outletIndex);
|
||||
if (surface && !HydrologySurfaceProfiles.sharesProfile(node.terrain(), grid.node(candidate.landIndex()).terrain())) {
|
||||
return false;
|
||||
}
|
||||
RiverOutlet outlet = candidate.outlet();
|
||||
return !surface
|
||||
|| outlet.type() != HydrologyFeatureType.INLAND_GROTTO
|
||||
|| planner.settings.outlets().surfaceSinkholesEnabled();
|
||||
@@ -598,7 +709,8 @@ final class HydrologySourcePlanner {
|
||||
}
|
||||
boolean allowed = surface ? terrain.surfaceSourceAllowed() : terrain.undergroundSourceAllowed();
|
||||
boolean required = surface ? terrain.surfaceSourceRequired() : terrain.undergroundSourceRequired();
|
||||
if (!allowed || surface && terrain.naturalHeight() < sourceSettings.minimumElevation()) {
|
||||
if (!allowed || surface && (terrain.naturalHeight() < sourceSettings.minimumElevation()
|
||||
|| terrain.surfacePolicy().sourceDensity() != null && terrain.surfacePolicy().sourceDensity() == 0D)) {
|
||||
return false;
|
||||
}
|
||||
if (!surface && planner.settings.underground().connectToExistingCaves() && !terrain.caveAvailable()) {
|
||||
@@ -729,7 +841,8 @@ final class HydrologySourcePlanner {
|
||||
HydrologyRoutingPlan routing,
|
||||
int[] surfaceContributions,
|
||||
int[] undergroundContributions,
|
||||
Map<Long, List<HydrologyPoint>> refinedEdges
|
||||
Map<Long, List<HydrologyPoint>> refinedEdges,
|
||||
boolean surface
|
||||
) {
|
||||
HashSet<Integer> includedNodeIndices = new HashSet<>();
|
||||
ArrayList<DrainageEdge> edges = new ArrayList<>();
|
||||
@@ -747,12 +860,13 @@ final class HydrologySourcePlanner {
|
||||
HydrologyGridNode downstream = grid.node(downstreamIndex);
|
||||
RiverOutlet outlet = routing.outlets().get(routing.outletIndex()[upstream.index()]).outlet();
|
||||
long edgeId = HydrologyHash.mix(planner.worldSeed, EDGE_SALT, upstream.id(), downstream.id(), outlet.id());
|
||||
List<HydrologyPoint> centerline = refinedEdges.get(edgeId);
|
||||
long refinementId = surface ? HydrologyHash.mix(edgeId, SURFACE_SOURCE_SALT) : edgeId;
|
||||
List<HydrologyPoint> centerline = refinedEdges.get(refinementId);
|
||||
int transverseCandidates = HydrologyRouteGeometry.ROUTE_TRANSVERSE_CANDIDATES;
|
||||
if (centerline == null) {
|
||||
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream);
|
||||
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream);
|
||||
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet);
|
||||
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface);
|
||||
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface);
|
||||
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface);
|
||||
RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey(
|
||||
upstream.id(),
|
||||
downstream.id(),
|
||||
@@ -762,7 +876,8 @@ final class HydrologySourcePlanner {
|
||||
downstreamAnchor.z(),
|
||||
continuation.x(),
|
||||
continuation.z(),
|
||||
transverseCandidates
|
||||
transverseCandidates,
|
||||
surface
|
||||
);
|
||||
centerline = planner.refinedEdgeCache.get(
|
||||
refinedEdgeKey,
|
||||
@@ -772,15 +887,16 @@ final class HydrologySourcePlanner {
|
||||
upstreamAnchor,
|
||||
downstreamAnchor,
|
||||
continuation,
|
||||
transverseCandidates
|
||||
transverseCandidates,
|
||||
surface
|
||||
)
|
||||
);
|
||||
refinedEdges.put(edgeId, centerline);
|
||||
refinedEdges.put(refinementId, centerline);
|
||||
}
|
||||
if (centerline.isEmpty()) {
|
||||
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream);
|
||||
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream);
|
||||
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet);
|
||||
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface);
|
||||
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface);
|
||||
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface);
|
||||
RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey(
|
||||
upstream.id(),
|
||||
downstream.id(),
|
||||
@@ -790,7 +906,8 @@ final class HydrologySourcePlanner {
|
||||
downstreamAnchor.z(),
|
||||
continuation.x(),
|
||||
continuation.z(),
|
||||
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES
|
||||
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES,
|
||||
surface
|
||||
);
|
||||
centerline = planner.refinedEdgeCache.get(
|
||||
refinedEdgeKey,
|
||||
@@ -800,17 +917,18 @@ final class HydrologySourcePlanner {
|
||||
upstreamAnchor,
|
||||
downstreamAnchor,
|
||||
continuation,
|
||||
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES
|
||||
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES,
|
||||
surface
|
||||
)
|
||||
);
|
||||
refinedEdges.put(edgeId, centerline);
|
||||
refinedEdges.put(refinementId, centerline);
|
||||
}
|
||||
if (centerline.isEmpty()) {
|
||||
centerline = planner.routeGeometry.constrainedTerrainFallbackEdge(
|
||||
planner.routeGeometry.routeAnchor(upstream),
|
||||
planner.routeGeometry.routeAnchor(downstream)
|
||||
planner.routeGeometry.routeAnchor(upstream, surface),
|
||||
planner.routeGeometry.routeAnchor(downstream, surface)
|
||||
);
|
||||
refinedEdges.put(edgeId, centerline);
|
||||
refinedEdges.put(refinementId, centerline);
|
||||
}
|
||||
if (centerline.isEmpty()) {
|
||||
continue;
|
||||
@@ -838,7 +956,7 @@ final class HydrologySourcePlanner {
|
||||
}
|
||||
int outletIndex = routing.outletIndex()[node.index()];
|
||||
RiverOutlet outlet = routing.outlets().get(outletIndex).outlet();
|
||||
HydrologyPoint anchor = planner.routeGeometry.routeAnchor(node);
|
||||
HydrologyPoint anchor = planner.routeGeometry.routeAnchor(node, surface);
|
||||
HydrologyTerrainSample terrain = Objects.requireNonNull(
|
||||
planner.sampleLandBasis(anchor.x(), anchor.z()),
|
||||
"Hydrology route anchor left natural land"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package art.arcane.iris.engine.hydrology;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class HydrologySurfaceBudgets {
|
||||
private static final long SOURCE_SALT = 0x41524541534f5552L;
|
||||
private static final long INLAND_SALT = 0x41524541494e4c44L;
|
||||
private static final long COASTAL_SALT = 0x41524541434f4153L;
|
||||
|
||||
private final int ownedCells;
|
||||
private final Map<SurfaceRiverPolicy.Budget, Area> areas;
|
||||
private final boolean overridden;
|
||||
|
||||
private HydrologySurfaceBudgets(Sampling sampling) {
|
||||
ownedCells = sampling.ownedCells();
|
||||
areas = sampling.areas();
|
||||
overridden = sampling.overridden();
|
||||
}
|
||||
|
||||
static HydrologySurfaceBudgets sample(HydrologySampledGrid grid, HydrologyPlannerSettings.Source sources) {
|
||||
LinkedHashMap<SurfaceRiverPolicy.Budget, Area> areas = new LinkedHashMap<>();
|
||||
int ownedCells = 0;
|
||||
boolean overridden = false;
|
||||
for (HydrologyGridNode node : grid.nodes()) {
|
||||
if (!grid.owns(node.x(), node.z())) {
|
||||
continue;
|
||||
}
|
||||
ownedCells++;
|
||||
HydrologyTerrainSample terrain = node.terrain();
|
||||
if (terrain.ocean() || !terrain.transitAllowed()) {
|
||||
continue;
|
||||
}
|
||||
SurfaceRiverPolicy policy = terrain.surfacePolicy();
|
||||
overridden |= policy.overridden();
|
||||
Area area = areas.computeIfAbsent(policy.budget(), ignored -> new Area(policy));
|
||||
area.landCells++;
|
||||
if (terrain.surfaceSourceAllowed() && terrain.naturalHeight() >= sources.minimumElevation()
|
||||
&& (terrain.surfaceSourceWeight() > 0D || terrain.surfaceSourceRequired())) {
|
||||
area.sourceCells++;
|
||||
}
|
||||
}
|
||||
return new HydrologySurfaceBudgets(new Sampling(ownedCells, areas, overridden));
|
||||
}
|
||||
|
||||
boolean overridden() {
|
||||
return overridden;
|
||||
}
|
||||
|
||||
List<Area> areas() {
|
||||
return List.copyOf(areas.values());
|
||||
}
|
||||
|
||||
Area area(SurfaceRiverPolicy policy) {
|
||||
return area(policy.budget());
|
||||
}
|
||||
|
||||
Area area(SurfaceRiverPolicy.Budget budget) {
|
||||
return areas.get(budget);
|
||||
}
|
||||
|
||||
int sourceTarget(Area area, int outlets, HydrologyPlanner planner, HydrologyTileKey tile) {
|
||||
double density = area.policy.sourceDensity(planner.settings.surface().sources().density());
|
||||
if (density <= 0D || area.sourceCells == 0) {
|
||||
return 0;
|
||||
}
|
||||
int tributaries = area.policy.tributaries(planner.settings.routing().tributaries());
|
||||
double expected = (Math.min(64D, density) + outlets * tributaries) * fraction(area.sourceCells);
|
||||
return count(expected, planner.worldSeed, tile, area.policy, SOURCE_SALT);
|
||||
}
|
||||
|
||||
int outletTarget(Area area, boolean coastal, HydrologyPlanner planner, HydrologyTileKey tile) {
|
||||
int maximum = coastal
|
||||
? area.policy.coastalOutlets(planner.settings.outlets().maximumCoastalPerTile())
|
||||
: area.policy.inlandOutlets(planner.settings.outlets().maximumPerTile());
|
||||
return count(maximum * fraction(area.landCells), planner.worldSeed, tile, area.policy,
|
||||
coastal ? COASTAL_SALT : INLAND_SALT);
|
||||
}
|
||||
|
||||
private double fraction(int cells) {
|
||||
return ownedCells == 0 ? 0D : cells / (double) ownedCells;
|
||||
}
|
||||
|
||||
private static int count(double expected, long seed, HydrologyTileKey tile, SurfaceRiverPolicy policy, long salt) {
|
||||
int whole = (int) StrictMath.floor(expected);
|
||||
long stable = HydrologyHash.mix(seed, salt, tile.tileX(), tile.tileZ(),
|
||||
HydrologyHash.text(policy.areaKey()), policy.budget().hashCode());
|
||||
return whole + (HydrologyHash.unit(stable) < expected - whole ? 1 : 0);
|
||||
}
|
||||
|
||||
static final class Area {
|
||||
final SurfaceRiverPolicy policy;
|
||||
int landCells;
|
||||
int sourceCells;
|
||||
|
||||
private Area(SurfaceRiverPolicy policy) {
|
||||
this.policy = policy;
|
||||
}
|
||||
}
|
||||
|
||||
private record Sampling(int ownedCells, Map<SurfaceRiverPolicy.Budget, Area> areas, boolean overridden) {
|
||||
}
|
||||
}
|
||||
+21
-14
@@ -59,12 +59,12 @@ final class HydrologySurfaceCoursePlanner {
|
||||
continue;
|
||||
}
|
||||
long courseId = HydrologyHash.mix(sourceCourseId, path.outlet().id());
|
||||
HydrologyPoint trunkPoint = path.points().getFirst();
|
||||
HydrologyTerrainSample trunkTerrain = Objects.requireNonNull(
|
||||
planner.sampleDetailed(trunkPoint.x(), trunkPoint.z()),
|
||||
"Hydrology surface trunk left sampled terrain"
|
||||
);
|
||||
String profileKey = planner.segments.chooseProfile(trunkTerrain, path.outlet().id());
|
||||
String profileKey = HydrologySurfaceProfiles.chooseProfile(planner, path, sourceNode.terrain());
|
||||
if (profileKey == null) {
|
||||
planner.undergroundCourses.addCompiledSourceDiagnostic(sourceNode, true, sourceCourseId,
|
||||
HydrologyCandidateRejection.POLICY_EXCLUDED, diagnostics);
|
||||
continue;
|
||||
}
|
||||
draftsByOutlet.computeIfAbsent(path.outlet().id(), (Long ignored) -> new ArrayList<>())
|
||||
.add(new SurfaceCourseDraft(sourceNode, courseId, profileKey, path));
|
||||
}
|
||||
@@ -80,8 +80,9 @@ final class HydrologySurfaceCoursePlanner {
|
||||
HashMap<Long, SurfaceCourseBuild> mainRejections = new HashMap<>();
|
||||
int tributaries = 0;
|
||||
for (SurfaceCourseDraft draft : outletDrafts) {
|
||||
if (mainCourse != null && tributaries >= planner.settings.routing().tributaries()) {
|
||||
break;
|
||||
int tributaryLimit = draft.source().terrain().surfacePolicy().tributaries(planner.settings.routing().tributaries());
|
||||
if (mainCourse != null && tributaries >= tributaryLimit) {
|
||||
continue;
|
||||
}
|
||||
if (mainCourse == null) {
|
||||
SurfaceCourseBuild candidate = buildSurfaceCourse(
|
||||
@@ -156,7 +157,8 @@ final class HydrologySurfaceCoursePlanner {
|
||||
profileKey,
|
||||
path.points(),
|
||||
terminal,
|
||||
terminalHead
|
||||
terminalHead,
|
||||
planner.sourcePlanner.minimumCourseLength(source.terrain(), true)
|
||||
);
|
||||
if (!result.accepted()) {
|
||||
return SurfaceCourseBuild.rejected(result.rejection(), result.rejectionDetail());
|
||||
@@ -597,6 +599,8 @@ final class HydrologySurfaceCoursePlanner {
|
||||
ArrayList<RouteCandidate> expanded = new ArrayList<>(layer);
|
||||
expanded.add(new RouteCandidate(
|
||||
new HydrologyPoint(fallbackX, estimate.height(), fallbackZ),
|
||||
basePoint.x(),
|
||||
basePoint.z(),
|
||||
offset,
|
||||
localScore,
|
||||
estimate.terrainScore(),
|
||||
@@ -646,10 +650,6 @@ final class HydrologySurfaceCoursePlanner {
|
||||
double surfaceRouteCandidateBankPenalty(RouteCandidate candidate) {
|
||||
int minimumBankDistance = surfaceBankDistance(planner.settings.surface().minimumWidth());
|
||||
int maximumBankDistance = surfaceBankDistance(planner.settings.surface().maximumWidth());
|
||||
int maximumBankRise = Math.addExact(
|
||||
planner.settings.surface().maximumIncision(),
|
||||
(int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D)
|
||||
);
|
||||
HydrologyPoint point = candidate.point();
|
||||
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
|
||||
if (terrain == null) {
|
||||
@@ -668,7 +668,14 @@ final class HydrologySurfaceCoursePlanner {
|
||||
minimumBankDistance,
|
||||
maximumBankDistance,
|
||||
head,
|
||||
maximumBankRise
|
||||
maximumSurfaceBankRise(terrain)
|
||||
);
|
||||
}
|
||||
|
||||
int maximumSurfaceBankRise(HydrologyTerrainSample terrain) {
|
||||
return Math.addExact(
|
||||
terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()),
|
||||
(int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package art.arcane.iris.engine.hydrology;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.surface.SurfaceFootprint;
|
||||
import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
final class HydrologySurfaceProfiles {
|
||||
private HydrologySurfaceProfiles() {
|
||||
}
|
||||
|
||||
static boolean sharesProfile(HydrologyTerrainSample first, HydrologyTerrainSample second) {
|
||||
if (first == null || second == null) {
|
||||
return false;
|
||||
}
|
||||
for (String profile : first.preferredProfileKeys()) {
|
||||
if (second.preferredProfileKeys().contains(profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static String chooseProfile(
|
||||
HydrologyPlanner planner,
|
||||
HydrologyCoursePath path,
|
||||
HydrologyTerrainSample source
|
||||
) {
|
||||
if (path.points().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
HydrologyPoint anchor = path.points().getFirst();
|
||||
HydrologyTerrainSample terrain = planner.sampleBasisWithoutSlope(anchor.x(), anchor.z());
|
||||
if (terrain == null) {
|
||||
return null;
|
||||
}
|
||||
ArrayList<String> profiles = new ArrayList<>(terrain.preferredProfileKeys());
|
||||
profiles.retainAll(source.preferredProfileKeys());
|
||||
if (!retainPathProfiles(planner, path.points(), profiles)
|
||||
|| !retainOutletProfiles(planner, path, profiles)) {
|
||||
return null;
|
||||
}
|
||||
int index = HydrologyHash.between(
|
||||
HydrologyHash.mix(planner.worldSeed, path.outlet().id(), HydrologyHash.text("profile")),
|
||||
0,
|
||||
profiles.size() - 1
|
||||
);
|
||||
return profiles.get(index);
|
||||
}
|
||||
|
||||
static boolean allowsProfile(HydrologyPlanner planner, HydrologyCoursePath path, String profile) {
|
||||
ArrayList<String> profiles = new ArrayList<>(List.of(profile));
|
||||
return retainPathProfiles(planner, path.points(), profiles)
|
||||
&& retainOutletProfiles(planner, path, profiles);
|
||||
}
|
||||
|
||||
static boolean allowsProfile(HydrologyPlanner planner, List<HydrologyPoint> points, String profile) {
|
||||
return retainPathProfiles(planner, points, new ArrayList<>(List.of(profile)));
|
||||
}
|
||||
|
||||
static void rejectExcludedWetFootprints(
|
||||
HydrologyFootprintCompiler compiler,
|
||||
List<RiverCourse> courses,
|
||||
List<HydrologyDiagnosticCandidate> diagnostics
|
||||
) {
|
||||
HashSet<Long> rejectedOutlets = new HashSet<>();
|
||||
Iterator<RiverCourse> iterator = courses.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
RiverCourse course = iterator.next();
|
||||
if (course.type() != RiverCourseType.SURFACE) {
|
||||
continue;
|
||||
}
|
||||
SurfaceFootprint footprint = compiler.surfaceFootprint(course);
|
||||
int excluded = 0;
|
||||
for (SurfaceLayerColumn column : footprint.columns()) {
|
||||
HydrologyColumnLayer layer = column.layer();
|
||||
if (layer.fluidOwned() && layer.bedY() < layer.fluidHeadY()
|
||||
&& !column.terrain().preferredProfileKeys().contains(course.profileKey())) {
|
||||
excluded++;
|
||||
}
|
||||
}
|
||||
if (excluded == 0) {
|
||||
continue;
|
||||
}
|
||||
iterator.remove();
|
||||
HydrologyFeatureType terminal = course.segments().getLast().type();
|
||||
if (terminal == HydrologyFeatureType.MOUTH || terminal == HydrologyFeatureType.COASTAL_GROTTO
|
||||
|| terminal == HydrologyFeatureType.INLAND_GROTTO) {
|
||||
rejectedOutlets.add(course.outletId().orElseThrow());
|
||||
}
|
||||
diagnostics.add(new HydrologyDiagnosticCandidate(
|
||||
HydrologyHash.mix(course.id(), HydrologySourcePlanner.DIAGNOSTIC_SALT,
|
||||
HydrologyCandidateRejection.POLICY_EXCLUDED.ordinal()),
|
||||
HydrologyCandidateKind.SOURCE,
|
||||
HydrologyFeatureType.SURFACE_POOL,
|
||||
course.segments().getFirst().start(),
|
||||
HydrologyCandidateRejection.POLICY_EXCLUDED,
|
||||
excluded
|
||||
));
|
||||
}
|
||||
if (rejectedOutlets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
iterator = courses.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
RiverCourse course = iterator.next();
|
||||
if (course.type() != RiverCourseType.SURFACE
|
||||
|| !rejectedOutlets.contains(course.outletId().orElseThrow())) {
|
||||
continue;
|
||||
}
|
||||
iterator.remove();
|
||||
diagnostics.add(new HydrologyDiagnosticCandidate(
|
||||
HydrologyHash.mix(course.id(), HydrologySourcePlanner.DIAGNOSTIC_SALT,
|
||||
HydrologyCandidateRejection.NO_DRAINAGE_PATH.ordinal()),
|
||||
HydrologyCandidateKind.SOURCE,
|
||||
HydrologyFeatureType.SURFACE_POOL,
|
||||
course.segments().getFirst().start(),
|
||||
HydrologyCandidateRejection.NO_DRAINAGE_PATH,
|
||||
0
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean retainPathProfiles(
|
||||
HydrologyPlanner planner,
|
||||
List<HydrologyPoint> points,
|
||||
ArrayList<String> profiles
|
||||
) {
|
||||
if (points.isEmpty() || !retainPointProfiles(planner, points.getFirst(), profiles)) {
|
||||
return false;
|
||||
}
|
||||
for (int pointIndex = 1; pointIndex < points.size(); pointIndex++) {
|
||||
for (HydrologyPoint point : planner.segments.rasterLine(points.get(pointIndex - 1), points.get(pointIndex))) {
|
||||
if (!retainPointProfiles(planner, point, profiles)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean retainOutletProfiles(
|
||||
HydrologyPlanner planner,
|
||||
HydrologyCoursePath path,
|
||||
ArrayList<String> profiles
|
||||
) {
|
||||
if (!path.reachesOutlet()) {
|
||||
return true;
|
||||
}
|
||||
RiverOutlet outlet = path.outlet();
|
||||
return retainPointProfiles(planner, outlet.landwardPoint(), profiles)
|
||||
&& (!outlet.directOcean() || retainPointProfiles(planner, outlet.connectionPoint(), profiles));
|
||||
}
|
||||
|
||||
private static boolean retainPointProfiles(
|
||||
HydrologyPlanner planner,
|
||||
HydrologyPoint point,
|
||||
ArrayList<String> profiles
|
||||
) {
|
||||
HydrologyTerrainSample terrain = planner.sampleBasisWithoutSlope(point.x(), point.z());
|
||||
if (terrain == null) {
|
||||
return false;
|
||||
}
|
||||
profiles.retainAll(terrain.preferredProfileKeys());
|
||||
return !profiles.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package art.arcane.iris.engine.hydrology;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public record HydrologyTerrainSample(
|
||||
int naturalHeight,
|
||||
@@ -34,7 +37,8 @@ public record HydrologyTerrainSample(
|
||||
double shoreBiomeWidth,
|
||||
String confinesKey,
|
||||
double shoreWidth,
|
||||
boolean erosion
|
||||
boolean erosion,
|
||||
SurfaceRiverPolicy surfacePolicy
|
||||
) {
|
||||
/**
|
||||
* {@code shoreBiomeWidth} is the width in blocks of the shore biome band beside a surface river at this
|
||||
@@ -45,6 +49,7 @@ public record HydrologyTerrainSample(
|
||||
* shore width applies; {@code erosion} is whether the ground beyond that bench is eroded into a valley here.
|
||||
*/
|
||||
public HydrologyTerrainSample {
|
||||
surfacePolicy = Objects.requireNonNull(surfacePolicy);
|
||||
requireFiniteNonNegative(slope, "slope");
|
||||
requireFiniteNonNegative(routingCost, "routingCost");
|
||||
requireFiniteNonNegative(surfaceSourceWeight, "surfaceSourceWeight");
|
||||
@@ -122,7 +127,8 @@ public record HydrologyTerrainSample(
|
||||
Double.NaN,
|
||||
null,
|
||||
Double.NaN,
|
||||
true
|
||||
true,
|
||||
SurfaceRiverPolicy.INHERIT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,7 +165,8 @@ public record HydrologyTerrainSample(
|
||||
Double.NaN,
|
||||
null,
|
||||
Double.NaN,
|
||||
true
|
||||
true,
|
||||
SurfaceRiverPolicy.INHERIT
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,7 +203,8 @@ public record HydrologyTerrainSample(
|
||||
shoreBiomeWidth,
|
||||
confinesKey,
|
||||
shoreWidth,
|
||||
erosion
|
||||
erosion,
|
||||
surfacePolicy
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,7 +241,8 @@ public record HydrologyTerrainSample(
|
||||
shoreBiomeWidth,
|
||||
confinesKey,
|
||||
shoreWidth,
|
||||
erosion
|
||||
erosion,
|
||||
surfacePolicy
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,7 +279,8 @@ public record HydrologyTerrainSample(
|
||||
shoreBiomeWidth,
|
||||
confinesKey,
|
||||
replacementShoreWidth,
|
||||
erosion
|
||||
erosion,
|
||||
surfacePolicy
|
||||
);
|
||||
}
|
||||
|
||||
@@ -307,7 +317,46 @@ public record HydrologyTerrainSample(
|
||||
shoreBiomeWidth,
|
||||
confinesKey,
|
||||
shoreWidth,
|
||||
replacementErosion
|
||||
replacementErosion,
|
||||
surfacePolicy
|
||||
);
|
||||
}
|
||||
|
||||
public HydrologyTerrainSample withSurfacePolicy(SurfaceRiverPolicy replacementPolicy) {
|
||||
return new HydrologyTerrainSample(
|
||||
naturalHeight,
|
||||
slope,
|
||||
ocean,
|
||||
caveAvailable,
|
||||
caveFloorY,
|
||||
caveFluidY,
|
||||
transitAllowed,
|
||||
outletAllowed,
|
||||
surfaceSourceAllowed,
|
||||
surfaceSourceRequired,
|
||||
undergroundSourceAllowed,
|
||||
undergroundSourceRequired,
|
||||
routingCost,
|
||||
surfaceSourceWeight,
|
||||
undergroundSourceWeight,
|
||||
widthMultiplier,
|
||||
depthMultiplier,
|
||||
incisionMultiplier,
|
||||
routingMultiplier,
|
||||
bankMultiplier,
|
||||
parentBiomeKey,
|
||||
surfaceBiomeKey,
|
||||
mouthBiomeKey,
|
||||
shoreBiomeKey,
|
||||
bankBiomeKey,
|
||||
floodedCaveBiomeKey,
|
||||
preferredProfileKeys,
|
||||
surfacePoolKeys,
|
||||
shoreBiomeWidth,
|
||||
confinesKey,
|
||||
shoreWidth,
|
||||
erosion,
|
||||
replacementPolicy
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ final class HydrologyTributaryPlanner {
|
||||
}
|
||||
OutletCourseKey key = new OutletCourseKey(course.outletId().getAsLong());
|
||||
RiverCourse canonicalCourse = canonicalCourses.get(key);
|
||||
if (canonicalCourse == null || canonicalCourse.id() == course.id()) {
|
||||
if (canonicalCourse == null || canonicalCourse.id() == course.id()
|
||||
|| (course.type() == RiverCourseType.SURFACE || canonicalCourse.type() == RiverCourseType.SURFACE)
|
||||
&& !course.profileKey().equals(canonicalCourse.profileKey())) {
|
||||
normalized.add(course);
|
||||
continue;
|
||||
}
|
||||
@@ -197,6 +199,13 @@ final class HydrologyTributaryPlanner {
|
||||
) {
|
||||
HydrologyCoursePath path = draft.path();
|
||||
HydrologyPoint sourcePoint = path.points().getFirst();
|
||||
if (!draft.source().terrain().preferredProfileKeys().contains(stem.profileKey())
|
||||
|| !HydrologySurfaceProfiles.allowsProfile(planner, path, stem.profileKey())) {
|
||||
addTributaryDiagnostic(draft.courseId(), sourcePoint,
|
||||
HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0, diagnostics);
|
||||
return null;
|
||||
}
|
||||
draft = new SurfaceCourseDraft(draft.source(), draft.courseId(), stem.profileKey(), path);
|
||||
HashSet<Long> stemEdges = new HashSet<>();
|
||||
for (DrainageEdge edge : stem.drainageEdges()) {
|
||||
stemEdges.add(edge.id());
|
||||
@@ -349,7 +358,6 @@ final class HydrologyTributaryPlanner {
|
||||
*/
|
||||
void levelApproach(List<HydraulicSegment> segments, int level) {
|
||||
int run = Math.max(1, planner.settings.surface().banks().cascadeRun());
|
||||
int maximumIncision = planner.settings.surface().maximumIncision();
|
||||
double distance = 0D;
|
||||
HydrologyPoint previous = null;
|
||||
int downstreamHead = Integer.MIN_VALUE;
|
||||
@@ -369,7 +377,8 @@ final class HydrologyTributaryPlanner {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
int floor = planner.sampleBasisWithoutSlope(point.x(), point.z()).naturalHeight() - maximumIncision;
|
||||
HydrologyTerrainSample terrain = planner.sampleBasisWithoutSlope(point.x(), point.z());
|
||||
int floor = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain);
|
||||
int lowered = Math.min(point.y(), Math.max(Math.max(allowed, floor), downstreamHead));
|
||||
if (lowered < point.y()) {
|
||||
centerline.set(pointIndex, new HydrologyPoint(point.x(), lowered, point.z()));
|
||||
@@ -444,6 +453,9 @@ final class HydrologyTributaryPlanner {
|
||||
if (last.x() != joinStation.x() || last.z() != joinStation.z()) {
|
||||
points.add(new HydrologyPoint(joinStation.x(), last.y(), joinStation.z()));
|
||||
}
|
||||
if (!HydrologySurfaceProfiles.allowsProfile(planner, points, draft.profileKey())) {
|
||||
return TributaryBuild.rejected(HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0);
|
||||
}
|
||||
LinkedHashSet<DrainageEdge> ownedEdges = new LinkedHashSet<>();
|
||||
for (DrainageEdge edge : path.pairEdges().subList(0, joinIndex)) {
|
||||
if (!stemEdges.contains(edge.id())) {
|
||||
@@ -454,13 +466,14 @@ final class HydrologyTributaryPlanner {
|
||||
return TributaryBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, joinIndex);
|
||||
}
|
||||
int stemHead = joinStation.y();
|
||||
SurfaceCourseResult result = planner.tributaryCourseBuilder.build(
|
||||
SurfaceCourseResult result = planner.surfaceCourseBuilder.build(
|
||||
planner.worldSeed,
|
||||
draft.courseId(),
|
||||
draft.profileKey(),
|
||||
List.copyOf(points),
|
||||
SurfaceTerminal.TRIBUTARY,
|
||||
stemHead
|
||||
stemHead,
|
||||
planner.sourcePlanner.minimumCourseLength(draft.source().terrain(), true) / 2
|
||||
);
|
||||
if (!result.accepted()) {
|
||||
return TributaryBuild.rejected(result.rejection(), result.rejectionDetail());
|
||||
|
||||
@@ -9,6 +9,7 @@ record RefinedEdgeKey(
|
||||
int downstreamZ,
|
||||
int continuationX,
|
||||
int continuationZ,
|
||||
int transverseCandidates
|
||||
int transverseCandidates,
|
||||
boolean surface
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package art.arcane.iris.engine.hydrology;
|
||||
|
||||
record RouteCandidate(
|
||||
HydrologyPoint point,
|
||||
double continuousX,
|
||||
double continuousZ,
|
||||
double offset,
|
||||
double localScore,
|
||||
double terrainScore,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.engine.hydrology;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Arrays;
|
||||
import java.util.function.IntPredicate;
|
||||
|
||||
final class SourceAdmissionSelection {
|
||||
@@ -10,8 +11,7 @@ final class SourceAdmissionSelection {
|
||||
final boolean[] spacingRejectedCandidates;
|
||||
final boolean[] evaluatedCandidates;
|
||||
final IntPredicate globallyAdmitted;
|
||||
final int[] outletIndices;
|
||||
final int maximumCoursesPerOutlet;
|
||||
final Quotas quotas;
|
||||
|
||||
SourceAdmissionSelection(
|
||||
int targetCount,
|
||||
@@ -20,10 +20,9 @@ final class SourceAdmissionSelection {
|
||||
boolean[] spacingRejectedCandidates,
|
||||
boolean[] evaluatedCandidates,
|
||||
IntPredicate globallyAdmitted,
|
||||
int[] outletIndices,
|
||||
int maximumCoursesPerOutlet
|
||||
Quotas quotas
|
||||
) {
|
||||
if (outletIndices.length != selectedCandidates.length || maximumCoursesPerOutlet < 1) {
|
||||
if (quotas.outletIndices().length != selectedCandidates.length) {
|
||||
throw new IllegalArgumentException("Source outlet admission bounds are invalid.");
|
||||
}
|
||||
this.targetCount = targetCount;
|
||||
@@ -32,8 +31,7 @@ final class SourceAdmissionSelection {
|
||||
this.spacingRejectedCandidates = spacingRejectedCandidates;
|
||||
this.evaluatedCandidates = evaluatedCandidates;
|
||||
this.globallyAdmitted = globallyAdmitted;
|
||||
this.outletIndices = outletIndices;
|
||||
this.maximumCoursesPerOutlet = maximumCoursesPerOutlet;
|
||||
this.quotas = quotas;
|
||||
}
|
||||
|
||||
List<Integer> selectedCandidateIndices() {
|
||||
@@ -61,13 +59,33 @@ final class SourceAdmissionSelection {
|
||||
}
|
||||
|
||||
boolean outletQuotaAvailable(int candidateIndex, List<Integer> selectedIndices) {
|
||||
int outletIndex = outletIndices[candidateIndex];
|
||||
int outletIndex = quotas.outletIndices()[candidateIndex];
|
||||
int areaIndex = quotas.areaIndices()[candidateIndex];
|
||||
int selectedForOutlet = 0;
|
||||
int selectedForArea = 0;
|
||||
for (int selectedIndex : selectedIndices) {
|
||||
if (outletIndices[selectedIndex] == outletIndex) {
|
||||
if (quotas.outletIndices()[selectedIndex] == outletIndex) {
|
||||
selectedForOutlet++;
|
||||
}
|
||||
if (quotas.areaIndices()[selectedIndex] == areaIndex) {
|
||||
selectedForArea++;
|
||||
}
|
||||
return selectedForOutlet < maximumCoursesPerOutlet;
|
||||
}
|
||||
return selectedForOutlet < quotas.outletLimits()[candidateIndex]
|
||||
&& selectedForArea < quotas.areaLimits()[areaIndex];
|
||||
}
|
||||
|
||||
record Quotas(int[] outletIndices, int[] outletLimits, int[] areaIndices, int[] areaLimits) {
|
||||
Quotas {
|
||||
if (outletIndices.length != outletLimits.length || outletIndices.length != areaIndices.length) {
|
||||
throw new IllegalArgumentException("Source quota arrays must match candidate count.");
|
||||
}
|
||||
}
|
||||
|
||||
static Quotas uniform(int[] outlets, int target, int maximumCoursesPerOutlet) {
|
||||
int[] limits = new int[outlets.length];
|
||||
Arrays.fill(limits, maximumCoursesPerOutlet);
|
||||
return new Quotas(outlets, limits, new int[outlets.length], new int[]{target});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ final class SourceSelection {
|
||||
final boolean surface;
|
||||
final List<SourceCandidate> candidates;
|
||||
final SourceAdmissionSelection admission;
|
||||
private final int guaranteedMinimum;
|
||||
private final int[] requiredMinimums;
|
||||
final int maximumOptionalRejections;
|
||||
final int targetCount;
|
||||
final ArrayList<Integer> selectedCandidateIndices;
|
||||
@@ -21,11 +21,16 @@ final class SourceSelection {
|
||||
boolean surface,
|
||||
List<SourceCandidate> candidates,
|
||||
SourceAdmissionSelection admission,
|
||||
int guaranteedMinimum,
|
||||
int[] requiredMinimums,
|
||||
int maximumOptionalRejections
|
||||
) {
|
||||
if (guaranteedMinimum < 0 || guaranteedMinimum > admission.selectedCandidateIndices().size()) {
|
||||
throw new IllegalArgumentException("Guaranteed source minimum is outside the admission bounds.");
|
||||
if (requiredMinimums.length != admission.quotas.areaLimits().length) {
|
||||
throw new IllegalArgumentException("Required source areas must match the admission bounds.");
|
||||
}
|
||||
for (int area = 0; area < requiredMinimums.length; area++) {
|
||||
if (requiredMinimums[area] < 0 || requiredMinimums[area] > admission.quotas.areaLimits()[area]) {
|
||||
throw new IllegalArgumentException("Required source minimum is outside the area bounds.");
|
||||
}
|
||||
}
|
||||
if (maximumOptionalRejections < 0) {
|
||||
throw new IllegalArgumentException("Maximum optional source rejections cannot be negative.");
|
||||
@@ -33,7 +38,7 @@ final class SourceSelection {
|
||||
this.surface = surface;
|
||||
this.candidates = List.copyOf(candidates);
|
||||
this.admission = admission;
|
||||
this.guaranteedMinimum = guaranteedMinimum;
|
||||
this.requiredMinimums = requiredMinimums.clone();
|
||||
this.maximumOptionalRejections = maximumOptionalRejections;
|
||||
this.selectedCandidateIndices = new ArrayList<>(admission.selectedCandidateIndices());
|
||||
this.targetCount = admission.targetCount();
|
||||
@@ -55,10 +60,9 @@ final class SourceSelection {
|
||||
new boolean[0],
|
||||
new boolean[0],
|
||||
(int candidateIndex) -> false,
|
||||
new int[0],
|
||||
Integer.MAX_VALUE
|
||||
SourceAdmissionSelection.Quotas.uniform(new int[0], 0, Integer.MAX_VALUE)
|
||||
),
|
||||
0,
|
||||
new int[]{0},
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -85,7 +89,6 @@ final class SourceSelection {
|
||||
|
||||
boolean advanceAfterPublication(List<RiverCourse> acceptedCourses, HydrologySampledGrid grid) {
|
||||
Set<Long> acceptedSourceNodeIds = acceptedSourceNodeIds(acceptedCourses);
|
||||
int acceptedRequired = 0;
|
||||
boolean changed = false;
|
||||
int selectedPosition = 0;
|
||||
while (selectedPosition < selectedCandidateIndices.size()) {
|
||||
@@ -95,7 +98,6 @@ final class SourceSelection {
|
||||
if (acceptedSourceNodeIds.contains(sourceNodeId)) {
|
||||
if (candidate.required()) {
|
||||
attemptedCandidates[candidateIndex] = true;
|
||||
acceptedRequired++;
|
||||
}
|
||||
selectedPosition++;
|
||||
continue;
|
||||
@@ -108,8 +110,7 @@ final class SourceSelection {
|
||||
selectedCandidates[candidateIndex] = false;
|
||||
changed = true;
|
||||
}
|
||||
int requiredVacancies = Math.max(0, guaranteedMinimum - acceptedRequired);
|
||||
for (int vacancy = 0; vacancy < requiredVacancies; vacancy++) {
|
||||
while (true) {
|
||||
int replacement = nextRequiredCandidate();
|
||||
if (replacement < 0) {
|
||||
break;
|
||||
@@ -142,9 +143,17 @@ final class SourceSelection {
|
||||
}
|
||||
|
||||
private int nextRequiredCandidate() {
|
||||
int[] selectedRequired = new int[requiredMinimums.length];
|
||||
for (int candidateIndex : selectedCandidateIndices) {
|
||||
if (candidates.get(candidateIndex).required()) {
|
||||
selectedRequired[admission.quotas.areaIndices()[candidateIndex]]++;
|
||||
}
|
||||
}
|
||||
for (int candidateIndex = 0; candidateIndex < candidates.size(); candidateIndex++) {
|
||||
SourceCandidate candidate = candidates.get(candidateIndex);
|
||||
int area = admission.quotas.areaIndices()[candidateIndex];
|
||||
if (candidate.required()
|
||||
&& selectedRequired[area] < requiredMinimums[area]
|
||||
&& !selectedCandidates[candidateIndex]
|
||||
&& !attemptedCandidates[candidateIndex]
|
||||
&& admission.outletQuotaAvailable(candidateIndex, selectedCandidateIndices)) {
|
||||
|
||||
@@ -41,7 +41,7 @@ import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
final class StudioHydrologyTileStore {
|
||||
private static final int SCHEMA_VERSION = 1;
|
||||
private static final int SCHEMA_VERSION = 2;
|
||||
private static final long MAXIMUM_COMPRESSED_BYTES = 128L * 1024L * 1024L;
|
||||
private static final long MAXIMUM_DECOMPRESSED_BYTES = 512L * 1024L * 1024L;
|
||||
private static final Gson GSON = new GsonBuilder()
|
||||
|
||||
+3
-1
@@ -25,12 +25,14 @@ public record EffectiveRiverPolicy(
|
||||
Double shoreBiomeWidth,
|
||||
RiverConfinement confinement,
|
||||
Double shoreWidth,
|
||||
Boolean erosion
|
||||
Boolean erosion,
|
||||
SurfaceRiverPolicy surfacePolicy
|
||||
) {
|
||||
public EffectiveRiverPolicy {
|
||||
placement = Objects.requireNonNull(placement);
|
||||
routing = Objects.requireNonNull(routing);
|
||||
confinement = Objects.requireNonNull(confinement);
|
||||
surfacePolicy = Objects.requireNonNull(surfacePolicy);
|
||||
if (shoreBiomeWidth != null && (!Double.isFinite(shoreBiomeWidth) || shoreBiomeWidth < 0D)) {
|
||||
throw new IllegalArgumentException("shoreBiomeWidth must be finite and non-negative.");
|
||||
}
|
||||
|
||||
+33
-10
@@ -16,15 +16,17 @@ public final class RiverPolicyResolver {
|
||||
}
|
||||
|
||||
public static EffectiveRiverPolicy resolve(IrisDimension dimension, IrisRegion region, IrisBiome biome) {
|
||||
return resolve(dimension == null ? null : dimension.getRiverPolicy(),
|
||||
return resolveState(dimension == null ? null : dimension.getRiverPolicy(),
|
||||
region == null ? null : region.getRiverPolicy(),
|
||||
biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension));
|
||||
biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension), false,
|
||||
AreaKeys.of(region, biome)).build();
|
||||
}
|
||||
|
||||
public static Resolution resolveWithStatus(IrisDimension dimension, IrisRegion region, IrisBiome biome) {
|
||||
State state = resolveState(dimension == null ? null : dimension.getRiverPolicy(),
|
||||
region == null ? null : region.getRiverPolicy(),
|
||||
biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension), true);
|
||||
biome == null ? null : biome.getRiverPolicy(), loaderOf(biome, region, dimension), true,
|
||||
AreaKeys.of(region, biome));
|
||||
return new Resolution(state.build(), state.complete);
|
||||
}
|
||||
|
||||
@@ -46,15 +48,16 @@ public final class RiverPolicyResolver {
|
||||
IrisRiverPolicy biomePolicy,
|
||||
IrisData data
|
||||
) {
|
||||
return resolveState(dimensionPolicy, regionPolicy, biomePolicy, data, false).build();
|
||||
return resolveState(dimensionPolicy, regionPolicy, biomePolicy, data, false,
|
||||
new AreaKeys("region", "biome")).build();
|
||||
}
|
||||
|
||||
private static State resolveState(IrisRiverPolicy dimensionPolicy, IrisRiverPolicy regionPolicy,
|
||||
IrisRiverPolicy biomePolicy, IrisData data, boolean trackCompleteness) {
|
||||
IrisRiverPolicy biomePolicy, IrisData data, boolean trackCompleteness, AreaKeys areas) {
|
||||
State state = new State(trackCompleteness);
|
||||
state.apply(dimensionPolicy, RiverConfinement.REGION, data);
|
||||
state.apply(regionPolicy, RiverConfinement.REGION, data);
|
||||
state.apply(biomePolicy, RiverConfinement.BIOME, data);
|
||||
state.apply(dimensionPolicy, RiverConfinement.REGION, data, "");
|
||||
state.apply(regionPolicy, RiverConfinement.REGION, data, areas.region());
|
||||
state.apply(biomePolicy, RiverConfinement.BIOME, data, areas.biome());
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -71,6 +74,13 @@ public final class RiverPolicyResolver {
|
||||
public record Resolution(EffectiveRiverPolicy policy, boolean complete) {
|
||||
}
|
||||
|
||||
private record AreaKeys(String region, String biome) {
|
||||
static AreaKeys of(IrisRegion region, IrisBiome biome) {
|
||||
return new AreaKeys("region:" + (region == null ? "" : region.getLoadKey()),
|
||||
"biome:" + (biome == null ? "" : biome.getLoadKey()));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class State {
|
||||
private final Runnable unresolvedReference;
|
||||
private boolean complete = true;
|
||||
@@ -93,6 +103,7 @@ public final class RiverPolicyResolver {
|
||||
private RiverConfinement confinement = RiverConfinement.NONE;
|
||||
private Double shoreWidth = null;
|
||||
private Boolean erosion = null;
|
||||
private SurfaceRiverPolicy surfacePolicy = SurfaceRiverPolicy.INHERIT;
|
||||
|
||||
private State(boolean trackCompleteness) {
|
||||
unresolvedReference = trackCompleteness ? this::unresolvedReference : null;
|
||||
@@ -102,10 +113,21 @@ public final class RiverPolicyResolver {
|
||||
* {@code scope} is the confinement a {@code confined: true} at this level binds courses to; {@code data} is the
|
||||
* pack whose version-content gate filters the biome selections (null skips filtering).
|
||||
*/
|
||||
private void apply(IrisRiverPolicy policy, RiverConfinement scope, IrisData data) {
|
||||
private void apply(IrisRiverPolicy policy, RiverConfinement scope, IrisData data, String areaKey) {
|
||||
if (policy == null) {
|
||||
return;
|
||||
}
|
||||
SurfaceRiverPolicy declared = policy.surfacePolicy(areaKey);
|
||||
if (declared.configured()) {
|
||||
surfacePolicy = new SurfaceRiverPolicy(declared.overridden() ? areaKey : surfacePolicy.areaKey(),
|
||||
declared.sourceDensity() == null ? surfacePolicy.sourceDensity() : declared.sourceDensity(),
|
||||
declared.sourceSpacing() == null ? surfacePolicy.sourceSpacing() : declared.sourceSpacing(),
|
||||
declared.tributaries() == null ? surfacePolicy.tributaries() : declared.tributaries(),
|
||||
declared.inlandOutlets() == null ? surfacePolicy.inlandOutlets() : declared.inlandOutlets(),
|
||||
declared.coastalOutlets() == null ? surfacePolicy.coastalOutlets() : declared.coastalOutlets(),
|
||||
declared.minimumCourseLength() == null ? surfacePolicy.minimumCourseLength() : declared.minimumCourseLength(),
|
||||
declared.maximumIncision() == null ? surfacePolicy.maximumIncision() : declared.maximumIncision());
|
||||
}
|
||||
if (policy.getShoreBiomeWidth() != null) {
|
||||
shoreBiomeWidth = policy.getShoreBiomeWidth();
|
||||
}
|
||||
@@ -189,7 +211,8 @@ public final class RiverPolicyResolver {
|
||||
shoreBiomeWidth,
|
||||
confinement,
|
||||
shoreWidth,
|
||||
erosion
|
||||
erosion,
|
||||
surfacePolicy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package art.arcane.iris.engine.hydrology.policy;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record SurfaceRiverPolicy(
|
||||
String areaKey,
|
||||
Double sourceDensity,
|
||||
Integer sourceSpacing,
|
||||
Integer tributaries,
|
||||
Integer inlandOutlets,
|
||||
Integer coastalOutlets,
|
||||
Integer minimumCourseLength,
|
||||
Integer maximumIncision
|
||||
) {
|
||||
public static final SurfaceRiverPolicy INHERIT = new SurfaceRiverPolicy("", null, null, null, null, null, null, null);
|
||||
|
||||
public SurfaceRiverPolicy {
|
||||
areaKey = Objects.requireNonNull(areaKey);
|
||||
if (sourceDensity != null && (!Double.isFinite(sourceDensity) || sourceDensity < 0D || sourceDensity > 64D)) {
|
||||
throw new IllegalArgumentException("surfaceSourceDensity must be finite and between 0 and 64.");
|
||||
}
|
||||
requireRange(sourceSpacing, 0, 8192, "surfaceSourceSpacing");
|
||||
requireRange(tributaries, 0, 4, "surfaceTributaries");
|
||||
requireRange(inlandOutlets, 0, 256, "surfaceInlandOutlets");
|
||||
requireRange(coastalOutlets, 0, 64, "surfaceCoastalOutlets");
|
||||
requireRange(minimumCourseLength, 16, 4096, "surfaceMinimumCourseLength");
|
||||
requireRange(maximumIncision, 1, 32, "surfaceMaximumIncision");
|
||||
}
|
||||
|
||||
public boolean overridden() {
|
||||
return sourceDensity != null || sourceSpacing != null || tributaries != null
|
||||
|| inlandOutlets != null || coastalOutlets != null;
|
||||
}
|
||||
|
||||
public boolean configured() {
|
||||
return overridden() || minimumCourseLength != null || maximumIncision != null;
|
||||
}
|
||||
|
||||
public Budget budget() {
|
||||
return new Budget(areaKey, sourceDensity, sourceSpacing, tributaries, inlandOutlets, coastalOutlets);
|
||||
}
|
||||
|
||||
public int minimumCourseLength(int fallback) {
|
||||
return minimumCourseLength == null ? fallback : minimumCourseLength;
|
||||
}
|
||||
|
||||
public int maximumIncision(int fallback) {
|
||||
return maximumIncision == null ? fallback : maximumIncision;
|
||||
}
|
||||
|
||||
public double sourceDensity(double fallback) {
|
||||
return sourceDensity == null ? fallback : sourceDensity;
|
||||
}
|
||||
|
||||
public int sourceSpacing(int fallback) {
|
||||
return sourceSpacing == null ? fallback : sourceSpacing;
|
||||
}
|
||||
|
||||
public int tributaries(int fallback) {
|
||||
return tributaries == null ? fallback : tributaries;
|
||||
}
|
||||
|
||||
public int inlandOutlets(int fallback) {
|
||||
return inlandOutlets == null ? fallback : inlandOutlets;
|
||||
}
|
||||
|
||||
public int coastalOutlets(int fallback) {
|
||||
return coastalOutlets == null ? fallback : coastalOutlets;
|
||||
}
|
||||
|
||||
private static void requireRange(Integer value, int minimum, int maximum, String name) {
|
||||
if (value != null && (value < minimum || value > maximum)) {
|
||||
throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum + ".");
|
||||
}
|
||||
}
|
||||
|
||||
public record Budget(String areaKey, Double sourceDensity, Integer sourceSpacing, Integer tributaries,
|
||||
Integer inlandOutlets, Integer coastalOutlets) {
|
||||
}
|
||||
}
|
||||
+36
-2
@@ -1,5 +1,8 @@
|
||||
package art.arcane.iris.engine.hydrology.runtime;
|
||||
|
||||
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyHash;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
|
||||
@@ -73,6 +76,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.Predicate;
|
||||
@@ -645,7 +649,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
policy.shoreBiomeWidth() == null ? Double.NaN : policy.shoreBiomeWidth(),
|
||||
confinesKey(policy.confinement(), region, biome),
|
||||
policy.shoreWidth() == null ? Double.NaN : policy.shoreWidth(),
|
||||
policy.erosion() == null || policy.erosion()
|
||||
policy.erosion() == null || policy.erosion(),
|
||||
policy.surfacePolicy()
|
||||
);
|
||||
return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(resolvedNaturalHeight, terrain);
|
||||
}
|
||||
@@ -1005,10 +1010,39 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()),
|
||||
surfacePools(hydrology.getSurfacePools()),
|
||||
widestShoreBiomeWidth(dimension, data, banks.getShoreWidth()),
|
||||
seaCaves(coastal)
|
||||
seaCaves(coastal),
|
||||
surfacePolicyBounds(dimension, data)
|
||||
);
|
||||
}
|
||||
|
||||
static HydrologyPlannerSettings.SurfacePolicyBounds surfacePolicyBounds(IrisDimension dimension, DataProvider data) {
|
||||
TreeMap<String, SurfaceRiverPolicy> policies = new TreeMap<>();
|
||||
addSurfacePolicy(policies, "dimension", dimension.getRiverPolicy());
|
||||
for (IrisRegion region : dimension.getAllRegions(data)) {
|
||||
addSurfacePolicy(policies, "region:" + region.getLoadKey(), region.getRiverPolicy());
|
||||
}
|
||||
for (IrisBiome biome : dimension.getAllBiomes(data)) {
|
||||
addSurfacePolicy(policies, "biome:" + biome.getLoadKey(), biome.getRiverPolicy());
|
||||
}
|
||||
long fingerprint = 0L;
|
||||
int maximumSpacing = 0;
|
||||
for (SurfaceRiverPolicy policy : policies.values()) {
|
||||
fingerprint = HydrologyHash.mix(fingerprint, policy.hashCode(), HydrologyHash.text(policy.areaKey()));
|
||||
maximumSpacing = Math.max(maximumSpacing, policy.sourceSpacing(0));
|
||||
}
|
||||
return new HydrologyPlannerSettings.SurfacePolicyBounds(fingerprint, maximumSpacing);
|
||||
}
|
||||
|
||||
private static void addSurfacePolicy(Map<String, SurfaceRiverPolicy> policies, String key, IrisRiverPolicy policy) {
|
||||
if (policy == null) {
|
||||
return;
|
||||
}
|
||||
SurfaceRiverPolicy surface = policy.surfacePolicy(key);
|
||||
if (surface.configured()) {
|
||||
policies.put(key, surface);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest shore band any policy of the dimension asks for, whether as a shore biome band or as a
|
||||
* geometric shore width, never below the dimension's geometric shore width, so the publication
|
||||
|
||||
+1
-1
@@ -118,7 +118,7 @@ public final class ChannelProfileBuilder {
|
||||
}
|
||||
}
|
||||
double drop = center.naturalHeight() - lowest;
|
||||
double allowance = Math.max(1D, surface.maximumIncision() / 2D);
|
||||
double allowance = Math.max(1D, center.surfacePolicy().maximumIncision(surface.maximumIncision()) / 2D);
|
||||
return Math.max(0D, Math.min(1D, 1D - drop / allowance));
|
||||
}
|
||||
|
||||
|
||||
+3
-5
@@ -15,20 +15,17 @@ public final class SurfaceCourseBuilder {
|
||||
private final HydrologyTerrainSampler sampler;
|
||||
private final HydrologyGeometrySampler geometry;
|
||||
private final int seaLevel;
|
||||
private final int minimumCourseLength;
|
||||
|
||||
public SurfaceCourseBuilder(
|
||||
HydrologyPlannerSettings.Surface surface,
|
||||
HydrologyTerrainSampler sampler,
|
||||
HydrologyGeometrySampler geometry,
|
||||
int seaLevel,
|
||||
int minimumCourseLength
|
||||
int seaLevel
|
||||
) {
|
||||
this.surface = Objects.requireNonNull(surface, "surface");
|
||||
this.sampler = Objects.requireNonNull(sampler, "sampler");
|
||||
this.geometry = Objects.requireNonNull(geometry, "geometry");
|
||||
this.seaLevel = seaLevel;
|
||||
this.minimumCourseLength = minimumCourseLength;
|
||||
}
|
||||
|
||||
public SurfaceCourseResult build(
|
||||
@@ -37,7 +34,8 @@ public final class SurfaceCourseBuilder {
|
||||
String profileKey,
|
||||
List<HydrologyPoint> path,
|
||||
SurfaceTerminal terminal,
|
||||
int terminalHead
|
||||
int terminalHead,
|
||||
int minimumCourseLength
|
||||
) {
|
||||
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
|
||||
boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH;
|
||||
|
||||
+8
-5
@@ -36,6 +36,7 @@ public final class ValleyProfileSolver {
|
||||
int[] crossMin = new int[count];
|
||||
int[] crossMax = new int[count];
|
||||
int[] centerNatural = new int[count];
|
||||
int[] channelIncision = new int[count];
|
||||
double[] incisionMultiplier = new double[count];
|
||||
double roughness = surface.banks().roughness();
|
||||
int exposed = count;
|
||||
@@ -52,6 +53,7 @@ public final class ValleyProfileSolver {
|
||||
break;
|
||||
}
|
||||
centerNatural[station] = center.naturalHeight();
|
||||
channelIncision[station] = center.surfacePolicy().maximumIncision(surface.maximumIncision());
|
||||
incisionMultiplier[station] = center.incisionMultiplier();
|
||||
double normalX = centerline.normalX(station);
|
||||
double normalZ = centerline.normalZ(station);
|
||||
@@ -127,14 +129,13 @@ public final class ValleyProfileSolver {
|
||||
// headwater keeps its natural head.
|
||||
// Every head set here is at or below the terrain-supported head, so the course stays non-rising.
|
||||
HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet();
|
||||
int channelIncision = surface.maximumIncision();
|
||||
int inletIncision = Math.max(channelIncision, inlet.maximumIncision());
|
||||
int rampStart = exposed;
|
||||
if (terminal == SurfaceTerminal.OCEAN_MOUTH && inlet.length() > 0) {
|
||||
int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposed * inlet.courseFraction()));
|
||||
int inletStart = exposed;
|
||||
while (inletStart > 0 && exposed - inletStart < reach
|
||||
&& cutFits(inletStart - 1, seaLevel, channel, centerNatural, incisionMultiplier, inletIncision)) {
|
||||
&& cutFits(inletStart - 1, seaLevel, channel, centerNatural, incisionMultiplier,
|
||||
Math.max(channelIncision[inletStart - 1], inlet.maximumIncision()))) {
|
||||
inletStart--;
|
||||
}
|
||||
for (int station = inletStart; station < exposed; station++) {
|
||||
@@ -148,7 +149,8 @@ public final class ValleyProfileSolver {
|
||||
if (head[station] <= target) {
|
||||
continue;
|
||||
}
|
||||
if (!cutFits(station, target, channel, centerNatural, incisionMultiplier, inletIncision)) {
|
||||
if (!cutFits(station, target, channel, centerNatural, incisionMultiplier,
|
||||
Math.max(channelIncision[station], inlet.maximumIncision()))) {
|
||||
break;
|
||||
}
|
||||
head[station] = target;
|
||||
@@ -163,7 +165,8 @@ public final class ValleyProfileSolver {
|
||||
int deepestCut = 0;
|
||||
boolean rejected = false;
|
||||
for (int station = 0; station < exposed; station++) {
|
||||
int maximumIncision = station >= rampStart ? inletIncision : channelIncision;
|
||||
int maximumIncision = station >= rampStart
|
||||
? Math.max(channelIncision[station], inlet.maximumIncision()) : channelIncision[station];
|
||||
int cut = cut(station, head[station], channel, centerNatural);
|
||||
deepestCut = Math.max(deepestCut, cut);
|
||||
rejected |= cut > permitted(station, incisionMultiplier, maximumIncision);
|
||||
|
||||
@@ -29,6 +29,7 @@ import java.util.concurrent.TimeoutException;
|
||||
public interface MatterGenerator {
|
||||
MultiBurst DISPATCHER = MultiBurst.burst;
|
||||
ConcurrentHashMap<MatterTaskKey, MatterComponentTask> IN_FLIGHT_COMPONENTS = new ConcurrentHashMap<>();
|
||||
int COMPONENT_SUBMISSION_BATCH_SIZE = Math.max(1, Math.min(8, Runtime.getRuntime().availableProcessors()));
|
||||
long COMPONENT_TASK_POLL_MS = 1000L;
|
||||
long COMPONENT_TASK_TIMEOUT_MS = Long.getLong("iris.mantle.componentTimeout", 120000L);
|
||||
|
||||
@@ -103,6 +104,9 @@ public interface MatterGenerator {
|
||||
// close() releases the cached chunks, even when a pass throws, or detached pool threads
|
||||
// write into released chunks.
|
||||
List<MatterComponentTask> outstandingTasks = null;
|
||||
List<MatterComponentTask> submittedTasks = multicore && !DISPATCHER.ownsCurrentThread()
|
||||
? new ArrayList<>(COMPONENT_SUBMISSION_BATCH_SIZE)
|
||||
: null;
|
||||
|
||||
try {
|
||||
for (MatterPassPlan passPlan : passPlans) {
|
||||
@@ -223,7 +227,11 @@ public interface MatterGenerator {
|
||||
if (asyncComponents) {
|
||||
for (int componentIndex = 0; componentIndex < eligibleComponentCount; componentIndex++) {
|
||||
MantleComponent component = eligibleComponents[componentIndex];
|
||||
outstandingTasks.add(runComponentAsync(chunk, component, writer, finalPassX, finalPassZ, context));
|
||||
outstandingTasks.add(runComponentAsync(
|
||||
chunk, component, writer, finalPassX, finalPassZ, context, submittedTasks));
|
||||
if (submittedTasks != null && submittedTasks.size() >= COMPONENT_SUBMISSION_BATCH_SIZE) {
|
||||
awaitComponentTasks(submittedTasks);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (int componentIndex = 0; componentIndex < eligibleComponentCount; componentIndex++) {
|
||||
@@ -236,6 +244,9 @@ public interface MatterGenerator {
|
||||
|
||||
if (asyncComponents) {
|
||||
awaitComponentTasks(outstandingTasks);
|
||||
if (submittedTasks != null) {
|
||||
submittedTasks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,7 +376,8 @@ public interface MatterGenerator {
|
||||
MantleWriter writer,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
ChunkContext context
|
||||
ChunkContext context,
|
||||
List<MatterComponentTask> submittedTasks
|
||||
) {
|
||||
MantleFlag flag = component.getFlag();
|
||||
MatterTaskKey key = new MatterTaskKey(getMantle(), chunkX, chunkZ, flag);
|
||||
@@ -397,6 +409,7 @@ public interface MatterGenerator {
|
||||
chunkZ,
|
||||
context,
|
||||
callerContext)));
|
||||
submittedTasks.add(task);
|
||||
return task;
|
||||
} catch (Throwable throwable) {
|
||||
task.cancel(throwable);
|
||||
|
||||
@@ -76,6 +76,7 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Accessors(chain = true)
|
||||
@@ -499,6 +500,35 @@ public class IrisDimension extends IrisRegistrant {
|
||||
return focusRegion;
|
||||
}
|
||||
|
||||
public IrisRegion resolveFocusRegion(IrisBiome biome, DataProvider provider) {
|
||||
String biomeKey = Objects.requireNonNull(biome, "focus biome").getLoadKey();
|
||||
KList<IrisRegion> available = getAllRegions(provider);
|
||||
for (IrisRegion region : available) {
|
||||
if (region.getAllBiomeIds().contains(biomeKey)) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
for (IrisRegion region : available) {
|
||||
for (IrisBiome member : region.getAllBiomes(provider)) {
|
||||
if (biomeKey.equals(member.getLoadKey())) {
|
||||
return region;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String key = "iris-focus/" + UUID.nameUUIDFromBytes((getLoadKey() + "\u0000" + biomeKey)
|
||||
.getBytes(StandardCharsets.UTF_8));
|
||||
IrisData data = provider.getData();
|
||||
IrisRegion region = new IrisRegion();
|
||||
region.getLandBiomes().add(biomeKey);
|
||||
region.getSeaBiomes().add(biomeKey);
|
||||
region.getShoreBiomes().add(biomeKey);
|
||||
region.setLoadKey(key);
|
||||
region.setLoader(data);
|
||||
region.setLoadFile(new File(data.getDataFolder(), data.getRegionLoader().getFolderName() + "/" + key + ".json"));
|
||||
return region;
|
||||
}
|
||||
|
||||
public double sinRotate() {
|
||||
return sinr.aquire(() -> Math.sin(getDimensionAngle()));
|
||||
}
|
||||
|
||||
@@ -301,20 +301,20 @@ public class IrisRegion extends IrisRegistrant implements IRare {
|
||||
}
|
||||
|
||||
public KList<IrisBiome> getAllBiomes(DataProvider g) {
|
||||
return resolveBiomes(g, getAllBiomeIds());
|
||||
return resolveBiomes(g, getAllBiomeIds(), true);
|
||||
}
|
||||
|
||||
public KList<IrisBiome> getNaturalBiomes(DataProvider g) {
|
||||
return resolveBiomes(g, getNaturalBiomeIds());
|
||||
return resolveBiomes(g, getNaturalBiomeIds(), false);
|
||||
}
|
||||
|
||||
private KList<IrisBiome> resolveBiomes(DataProvider g, KSet<String> biomeIds) {
|
||||
private KList<IrisBiome> resolveBiomes(DataProvider g, KSet<String> biomeIds, boolean includeRiverBiomes) {
|
||||
KMap<String, IrisBiome> b = new KMap<>();
|
||||
KSet<String> names = biomeIds.copy();
|
||||
|
||||
while (!names.isEmpty()) {
|
||||
for (String i : new KList<>(names)) {
|
||||
if (b.containsKey(i)) {
|
||||
if (i == null || i.isBlank() || b.containsKey(i)) {
|
||||
names.remove(i);
|
||||
continue;
|
||||
}
|
||||
@@ -334,6 +334,9 @@ public class IrisRegion extends IrisRegistrant implements IRare {
|
||||
names.add(biome.getCarvingBiome());
|
||||
b.put(biome.getLoadKey(), biome);
|
||||
names.addAll(biome.getChildren());
|
||||
if (includeRiverBiomes && biome.getRiverPolicy() != null) {
|
||||
names.addAll(biome.getRiverPolicy().getAllBiomeIds());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.iris.engine.object.annotations.MaxNumber;
|
||||
import art.arcane.iris.engine.object.annotations.MinNumber;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.hydrology.policy.SurfaceRiverPolicy;
|
||||
import art.arcane.iris.engine.object.annotations.RegistryListResource;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
@@ -17,6 +18,41 @@ import lombok.experimental.Accessors;
|
||||
@Desc("Nullable river policy values inherited from dimension to region to biome.")
|
||||
@Data
|
||||
public class IrisRiverPolicy {
|
||||
@MinNumber(0)
|
||||
@MaxNumber(64)
|
||||
@Desc("Expected surface headwaters per full tile of eligible policy area. Null inherits the dimension source density.")
|
||||
private Double surfaceSourceDensity = null;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(8192)
|
||||
@Desc("Minimum surface headwater separation in blocks. Two areas use the larger separation at their boundary.")
|
||||
private Integer surfaceSourceSpacing = null;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(4)
|
||||
@Desc("Extra surface tributaries admitted to one outlet from this policy area.")
|
||||
private Integer surfaceTributaries = null;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(256)
|
||||
@Desc("Maximum inland surface outlets per full tile of eligible policy area. Null inherits the dimension outlet limit.")
|
||||
private Integer surfaceInlandOutlets = null;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(64)
|
||||
@Desc("Maximum coastal surface outlets per full tile of eligible policy area. Null inherits the dimension coastal outlet limit.")
|
||||
private Integer surfaceCoastalOutlets = null;
|
||||
|
||||
@MinNumber(16)
|
||||
@MaxNumber(4096)
|
||||
@Desc("Minimum surface river course length in blocks for headwaters in this area. Null inherits the dimension routing minimum.")
|
||||
private Integer surfaceMinimumCourseLength = null;
|
||||
|
||||
@MinNumber(1)
|
||||
@MaxNumber(32)
|
||||
@Desc("Maximum surface river incision in blocks in this area, before the incision multiplier. Null inherits the dimension channel limit.")
|
||||
private Integer surfaceMaximumIncision = null;
|
||||
|
||||
@Desc("Controls source and transit admission in this area.")
|
||||
private IrisRiverPlacementMode placement = null;
|
||||
|
||||
@@ -27,7 +63,7 @@ public class IrisRiverPolicy {
|
||||
private Boolean outletAdmission = null;
|
||||
|
||||
@ArrayType(type = String.class)
|
||||
@Desc("Preferred dimension-owned river profile identifiers. An empty list explicitly clears inherited profiles.")
|
||||
@Desc("Permitted dimension-owned profiles along surface routes and preferred profiles for underground rivers. An empty list clears inheritance and resolves to the first configured dimension profile.")
|
||||
private KList<String> profiles = null;
|
||||
|
||||
@RegistryListResource(IrisBiome.class)
|
||||
@@ -100,6 +136,11 @@ public class IrisRiverPolicy {
|
||||
@Desc("Rivers that start in this area stay inside it: their whole course and their outlet lie in this region (when set on a region) or this biome (when set on a biome), and a river that flows into the area never leaves it. A source with no reachable outlet inside the area is rejected. Null inherits, false releases the area.")
|
||||
private Boolean confined = null;
|
||||
|
||||
public SurfaceRiverPolicy surfacePolicy(String areaKey) {
|
||||
return new SurfaceRiverPolicy(areaKey, surfaceSourceDensity, surfaceSourceSpacing,
|
||||
surfaceTributaries, surfaceInlandOutlets, surfaceCoastalOutlets, surfaceMinimumCourseLength, surfaceMaximumIncision);
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared river biome selection with biomes the version-content gate excluded removed. A declared-but-now-empty
|
||||
* selection keeps its "explicitly clears the inherited selection" meaning; null (undeclared) stays null.
|
||||
|
||||
@@ -22,14 +22,19 @@ public class IrisStructureTerrain {
|
||||
private static final double MAX_EROSION_FREQUENCY = 1D;
|
||||
private static final double MAX_LOBE_FREQUENCY = 1D;
|
||||
|
||||
@Desc("Terrain operation. SOURCE applies the registered native structure's authored terrain adaptation and is a no-op for editable Iris structures. PRESERVE disables terrain integration. VACUUM raises terrain from processed rigid-template foundations at or below each authored ground plane with a 12-block falloff without lowering existing ground. BORE and FORCE_CARVE clear the requested envelope, while ENCASE fills it before placement so native shells are not lost to pre-carved air.")
|
||||
@Desc("Terrain operation. SOURCE applies the registered native structure's authored terrain adaptation and is a no-op for editable Iris structures. PRESERVE disables terrain integration. FLATTEN cuts and fills exposed native foundations within flattenRange, blending across horizontalPadding blocks. VACUUM raises terrain from processed rigid-template foundations with a fixed 12-block falloff without lowering ground. BORE and FORCE_CARVE clear the requested envelope, while ENCASE fills it before placement.")
|
||||
private IrisStructureTerrainMode mode = IrisStructureTerrainMode.SOURCE;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(128)
|
||||
@Desc("Horizontal clearance around the assembled pieces for BORE, FORCE_CARVE, and ENCASE. VACUUM uses its fixed 12-block terrain falloff.")
|
||||
@Desc("Horizontal clearance for BORE, FORCE_CARVE, and ENCASE, or the terrain blend distance for FLATTEN, in blocks. VACUUM uses its fixed 12-block terrain falloff.")
|
||||
private int horizontalPadding = 0;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(128)
|
||||
@Desc("Maximum vertical cut, fill, and foundation support depth for FLATTEN, in blocks. Other terrain modes ignore this setting.")
|
||||
private int flattenRange = 64;
|
||||
|
||||
@MinNumber(0)
|
||||
@MaxNumber(128)
|
||||
@Desc("Air clearance above the assembled pieces.")
|
||||
@@ -74,6 +79,10 @@ public class IrisStructureTerrain {
|
||||
return shape == null ? IrisStructureCarveShape.BOX : shape;
|
||||
}
|
||||
|
||||
public int resolvedFlattenRange() {
|
||||
return Math.max(0, Math.min(128, flattenRange));
|
||||
}
|
||||
|
||||
public double resolvedErosionStrength() {
|
||||
if (!Double.isFinite(erosionStrength)) {
|
||||
return DEFAULT_EROSION_STRENGTH;
|
||||
|
||||
@@ -19,6 +19,9 @@ public enum IrisStructureTerrainMode {
|
||||
@Desc("Raises surface terrain from processed solid rigid-template foundations at or below each authored ground plane with a 12-block falloff, even when the registered structure has no terrain adaptation. Existing higher terrain and authored air remain untouched.")
|
||||
VACUUM,
|
||||
|
||||
@Desc("Cuts and fills exposed native structure foundations within flattenRange, blending into surrounding terrain across horizontalPadding blocks. Buried and submerged pieces retain their terrain.")
|
||||
FLATTEN,
|
||||
|
||||
@Desc("Fills the padded piece volume with solid blocks before any piece is placed so shells, walls, and floors land in solid ground instead of pre-carved air. Only air and liquid cells are filled; existing terrain and structures are never overwritten. Native pieces then carve their own interiors.")
|
||||
ENCASE
|
||||
}
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ public class IrisVanillaStructureAdjustment {
|
||||
@Desc("Optional foundation columns placed beneath the native structure piece bases after placement.")
|
||||
private IrisStructureStiltSettings stilt = null;
|
||||
|
||||
@Desc("Optional terrain integration override. VACUUM raises surface terrain from processed solid rigid-template foundations at or below each authored ground plane with a 12-block falloff without lowering existing ground. BORE and FORCE_CARVE clear every intersecting chunk before native pieces are placed, while ENCASE fills it with solid blocks instead. Left unset, SOURCE replays the registered structure's authored terrain adaptation, including surface fitting, burial, and encapsulation.")
|
||||
@Desc("Optional terrain integration override. FLATTEN cuts and fills exposed surface foundations within flattenRange, blending across horizontalPadding blocks while preserving buried and submerged structures. VACUUM raises processed rigid-template foundations with a fixed 12-block falloff without lowering ground. BORE and FORCE_CARVE clear every intersecting chunk before native placement, while ENCASE fills it. Left unset, SOURCE replays the registered structure's authored terrain adaptation.")
|
||||
private IrisStructureTerrain terrain = null;
|
||||
|
||||
public boolean matches(String key) {
|
||||
|
||||
@@ -52,15 +52,24 @@ public enum StudioMode {
|
||||
@Desc("Replaces terrain with the object studio: a flat polished-deepslate floor laying every pack object out on framed, end-rod-marked grid plinths. Bukkit studio worlds only.")
|
||||
OBJECT_BUFFET;
|
||||
|
||||
public int biomeSizeChunks() {
|
||||
return switch (this) {
|
||||
case BIOME_BUFFET_1x1 -> 1;
|
||||
case BIOME_BUFFET_3x3 -> 3;
|
||||
case BIOME_BUFFET_5x5 -> 5;
|
||||
case BIOME_BUFFET_9x9 -> 9;
|
||||
case BIOME_BUFFET_18x18 -> 18;
|
||||
case BIOME_BUFFET_36x36 -> 36;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
|
||||
public void inject(BukkitChunkGenerator c) {
|
||||
switch (this) {
|
||||
case NORMAL, REGION_BUFFET -> c.setStudioGenerator(null);
|
||||
case BIOME_BUFFET_1x1 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 1));
|
||||
case BIOME_BUFFET_3x3 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 3));
|
||||
case BIOME_BUFFET_5x5 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 5));
|
||||
case BIOME_BUFFET_9x9 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 9));
|
||||
case BIOME_BUFFET_18x18 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 18));
|
||||
case BIOME_BUFFET_36x36 -> c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine(), 36));
|
||||
case BIOME_BUFFET_1x1, BIOME_BUFFET_3x3, BIOME_BUFFET_5x5,
|
||||
BIOME_BUFFET_9x9, BIOME_BUFFET_18x18, BIOME_BUFFET_36x36 ->
|
||||
c.setStudioGenerator(new BiomeBuffetGenerator(c.getEngine()));
|
||||
case OBJECT_BUFFET -> c.setStudioGenerator(new ObjectStudioGenerator(c.getEngine()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,9 @@ import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -111,7 +113,6 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Data
|
||||
@@ -136,6 +137,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
private final CompletableFuture<Void> initialSpawnReady = new CompletableFuture<>();
|
||||
private final AtomicCache<EngineTarget> targetCache = new AtomicCache<>();
|
||||
private final AtomicReference<CompletableFuture<Void>> closeFuture = new AtomicReference<>();
|
||||
private final AtomicReference<CompletableFuture<Void>> watcherHotload = new AtomicReference<>();
|
||||
private volatile Engine engine;
|
||||
private volatile Looper hotloader;
|
||||
private volatile StudioMode lastMode;
|
||||
@@ -749,6 +751,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
|
||||
return HOTLOAD_LOOP_DELAY_MS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onError(Throwable failure) {
|
||||
if (failure instanceof CancellationException && (closing || isInterrupted())) {
|
||||
return;
|
||||
}
|
||||
super.onError(failure);
|
||||
}
|
||||
} : null;
|
||||
|
||||
if (hotloader != null) {
|
||||
@@ -822,6 +832,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
|
||||
boolean alreadyClosing = closing;
|
||||
closing = true;
|
||||
cancelWatcherHotload();
|
||||
CompletableFuture<Void> operation;
|
||||
try {
|
||||
operation = withExclusiveControlFuture(() -> {
|
||||
@@ -873,6 +884,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
|
||||
@Override
|
||||
public void quiesceForServerShutdown() {
|
||||
cancelWatcherHotload();
|
||||
Looper activeHotloader = hotloader;
|
||||
hotloader = null;
|
||||
if (activeHotloader != null) {
|
||||
@@ -944,7 +956,36 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
|
||||
return;
|
||||
}
|
||||
withExclusiveControlFuture(() -> getEngine().hotload(), 30L, TimeUnit.SECONDS).join();
|
||||
CompletableFuture<Void> pending = new CompletableFuture<>();
|
||||
watcherHotload.set(pending);
|
||||
try {
|
||||
if (!shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)
|
||||
|| Thread.currentThread().isInterrupted()) {
|
||||
pending.cancel(true);
|
||||
} else {
|
||||
J.a(() -> completeExclusiveControlFuture(loadLock, () -> {
|
||||
if (shouldRunStudioHotload(isStudio(), closing, jigsawStudioActive)) {
|
||||
getEngine().hotload();
|
||||
}
|
||||
}, pending));
|
||||
}
|
||||
pending.get();
|
||||
} catch (InterruptedException failure) {
|
||||
pending.cancel(true);
|
||||
Thread.currentThread().interrupt();
|
||||
throw new CancellationException("Studio hotload watcher interrupted.");
|
||||
} catch (ExecutionException failure) {
|
||||
throw new CompletionException(failure.getCause());
|
||||
} finally {
|
||||
watcherHotload.compareAndSet(pending, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelWatcherHotload() {
|
||||
CompletableFuture<Void> pending = watcherHotload.getAndSet(null);
|
||||
if (pending != null) {
|
||||
pending.cancel(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -964,80 +1005,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
return loadLock.acquireStage(operation);
|
||||
}
|
||||
|
||||
public GenerationStagePermit acquireNoiseGenerationStage(
|
||||
Engine expectedEngine,
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
String operation
|
||||
) {
|
||||
Engine activeEngine = Objects.requireNonNull(expectedEngine, "Noise generation engine");
|
||||
return acquirePreparedGenerationStage(
|
||||
loadLock,
|
||||
operation,
|
||||
() -> resolveStudioGeneratorForNoise(activeEngine),
|
||||
activeEngine,
|
||||
chunkX,
|
||||
chunkZ);
|
||||
}
|
||||
|
||||
static GenerationStagePermit acquirePreparedGenerationStage(
|
||||
GenerationStageGate gate,
|
||||
String operation,
|
||||
Supplier<StudioGenerator> resolver,
|
||||
Engine engine,
|
||||
int chunkX,
|
||||
int chunkZ
|
||||
) {
|
||||
GenerationStageGate activeGate = Objects.requireNonNull(gate, "Generation stage gate");
|
||||
Supplier<StudioGenerator> activeResolver = Objects.requireNonNull(resolver, "Studio generator resolver");
|
||||
Engine activeEngine = Objects.requireNonNull(engine, "Prepared generation engine");
|
||||
while (true) {
|
||||
GenerationStagePermit stage = activeGate.acquireStage(operation);
|
||||
StudioGenerator selected;
|
||||
boolean retained = false;
|
||||
try {
|
||||
selected = activeResolver.get();
|
||||
if (selected == null || !selected.requiresPreSessionPreparation()) {
|
||||
if (activeResolver.get() == selected) {
|
||||
retained = true;
|
||||
return stage;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} finally {
|
||||
if (!retained) {
|
||||
stage.close();
|
||||
}
|
||||
}
|
||||
|
||||
GenerationStageExclusivePermit exclusive = activeGate.acquireExclusiveStage(operation);
|
||||
try {
|
||||
StudioGenerator active = activeResolver.get();
|
||||
if (active != selected) {
|
||||
continue;
|
||||
}
|
||||
active.prepareChunkBeforeSession(activeEngine, chunkX, chunkZ);
|
||||
return exclusive.downgradeToStage();
|
||||
} catch (WrongEngineBroException e) {
|
||||
throw new IllegalStateException("Iris generation stage " + operation
|
||||
+ " could not prepare its Studio generator.", e);
|
||||
} finally {
|
||||
exclusive.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StudioGenerator resolveStudioGeneratorForNoise(Engine expectedEngine) {
|
||||
if (engine != expectedEngine) {
|
||||
throw new IllegalStateException("Iris noise generation belongs to a replaced engine runtime.");
|
||||
}
|
||||
StudioGenerator selected = computeStudioGenerator();
|
||||
if (engine != expectedEngine) {
|
||||
throw new IllegalStateException("Iris noise generation changed engine runtime during Studio resolution.");
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
public void withExclusiveControl(Runnable r) {
|
||||
J.a(() -> {
|
||||
boolean acquired = false;
|
||||
@@ -1087,15 +1054,34 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
GenerationStageGate activeGate = Objects.requireNonNull(gate, "Exclusive control gate");
|
||||
Runnable activeOperation = Objects.requireNonNull(operation, "Exclusive control operation");
|
||||
CompletableFuture<Void> outward = Objects.requireNonNull(future, "Exclusive control future");
|
||||
if (outward.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
ExclusiveControlAcquisition acquisition = new ExclusiveControlAcquisition(Thread.currentThread());
|
||||
outward.whenComplete((ignored, completionFailure) -> {
|
||||
if (outward.isCancelled()) {
|
||||
acquisition.cancel();
|
||||
}
|
||||
});
|
||||
boolean acquired = false;
|
||||
Throwable failure = null;
|
||||
try {
|
||||
try {
|
||||
activeGate.acquireExclusive();
|
||||
acquired = true;
|
||||
} finally {
|
||||
acquisition.stopWaiting();
|
||||
}
|
||||
if (!outward.isCancelled()) {
|
||||
activeOperation.run();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
if (!acquisition.interruptedByCancellation()) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
if (!outward.isCancelled()) {
|
||||
failure = e;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
} finally {
|
||||
@@ -1106,8 +1092,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
|
||||
if (failure == null) {
|
||||
outward.complete(null);
|
||||
} else {
|
||||
outward.completeExceptionally(failure);
|
||||
} else if (!outward.completeExceptionally(failure)) {
|
||||
IrisLogging.reportError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1436,25 +1422,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
return new GenerationStagePermit(permits);
|
||||
}
|
||||
|
||||
GenerationStageExclusivePermit acquireExclusiveStage(String operation) {
|
||||
String activeOperation = Objects.requireNonNull(operation, "Generation stage operation");
|
||||
if (closing.getAsBoolean()) {
|
||||
throw rejected(activeOperation);
|
||||
}
|
||||
try {
|
||||
permits.acquire(permitCount);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Iris generation stage " + activeOperation
|
||||
+ " was interrupted while waiting for exclusive engine access.", e);
|
||||
}
|
||||
if (closing.getAsBoolean()) {
|
||||
permits.release(permitCount);
|
||||
throw rejected(activeOperation);
|
||||
}
|
||||
return new GenerationStageExclusivePermit(permits, permitCount);
|
||||
}
|
||||
|
||||
void acquireExclusive() throws InterruptedException {
|
||||
permits.acquire(permitCount);
|
||||
}
|
||||
@@ -1513,36 +1480,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
}
|
||||
}
|
||||
|
||||
static final class GenerationStageExclusivePermit implements AutoCloseable {
|
||||
private final Semaphore permits;
|
||||
private final int permitCount;
|
||||
private final AtomicBoolean released;
|
||||
|
||||
private GenerationStageExclusivePermit(Semaphore permits, int permitCount) {
|
||||
this.permits = permits;
|
||||
this.permitCount = permitCount;
|
||||
this.released = new AtomicBoolean(false);
|
||||
}
|
||||
|
||||
GenerationStagePermit downgradeToStage() {
|
||||
if (!released.compareAndSet(false, true)) {
|
||||
throw new IllegalStateException("Exclusive Iris generation stage was already released.");
|
||||
}
|
||||
GenerationStagePermit stage = new GenerationStagePermit(permits);
|
||||
if (permitCount > 1) {
|
||||
permits.release(permitCount - 1);
|
||||
}
|
||||
return stage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (released.compareAndSet(false, true)) {
|
||||
permits.release(permitCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GenerationStagePermit implements AutoCloseable {
|
||||
private static final GenerationStagePermit NOOP = new GenerationStagePermit(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package art.arcane.iris.engine.platform.studio;
|
||||
|
||||
import art.arcane.iris.engine.object.InferredType;
|
||||
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.util.common.data.DataProvider;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
public final class BiomeBuffetLayout {
|
||||
private final List<Cell> cells;
|
||||
private final int width;
|
||||
private final int biomeSizeChunks;
|
||||
|
||||
public BiomeBuffetLayout(IrisDimension dimension, DataProvider provider) {
|
||||
biomeSizeChunks = dimension.getStudioMode().biomeSizeChunks();
|
||||
if (biomeSizeChunks == 0) {
|
||||
throw new IllegalArgumentException("The dimension does not select a biome buffet.");
|
||||
}
|
||||
List<IrisBiome> biomes = new ArrayList<>(dimension.getAllBiomes(provider));
|
||||
biomes.removeIf(IrisBiome::isCompatExcluded);
|
||||
biomes.sort(Comparator.comparing(IrisBiome::getLoadKey));
|
||||
List<Cell> prepared = new ArrayList<>(biomes.size());
|
||||
for (IrisBiome biome : biomes) {
|
||||
IrisBiome focused = biome.withInferredType(InferredType.LAND);
|
||||
prepared.add(new Cell(focused, dimension.resolveFocusRegion(focused, provider)));
|
||||
}
|
||||
cells = List.copyOf(prepared);
|
||||
width = Math.max((int) Math.sqrt(cells.size()), 1);
|
||||
}
|
||||
|
||||
public List<Cell> cells() {
|
||||
return cells;
|
||||
}
|
||||
|
||||
public Cell chunk(int chunkX, int chunkZ) {
|
||||
int x = Math.floorDiv(chunkX, biomeSizeChunks);
|
||||
int z = Math.floorDiv(chunkZ, biomeSizeChunks);
|
||||
if (x < 0 || x >= width || z < 0) {
|
||||
return null;
|
||||
}
|
||||
long index = (long) z * width + x;
|
||||
return index < cells.size() ? cells.get((int) index) : null;
|
||||
}
|
||||
|
||||
public Cell terrain(double blockX, double blockZ) {
|
||||
Cell cell = chunk(Math.floorDiv((int) Math.floor(blockX), 16),
|
||||
Math.floorDiv((int) Math.floor(blockZ), 16));
|
||||
return cell == null && !cells.isEmpty() ? cells.getFirst() : cell;
|
||||
}
|
||||
|
||||
public record Cell(IrisBiome biome, IrisRegion region) {
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,5 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.WrongEngineBroException;
|
||||
|
||||
public interface StudioGenerator {
|
||||
default boolean requiresPreSessionPreparation() {
|
||||
return false;
|
||||
}
|
||||
|
||||
default void prepareChunkBeforeSession(Engine engine, int x, int z) throws WrongEngineBroException {
|
||||
}
|
||||
|
||||
void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException;
|
||||
}
|
||||
|
||||
+6
-37
@@ -18,52 +18,27 @@
|
||||
|
||||
package art.arcane.iris.engine.platform.studio.generators;
|
||||
|
||||
import art.arcane.iris.engine.data.cache.Cache;
|
||||
import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.WrongEngineBroException;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.platform.studio.BiomeBuffetLayout;
|
||||
import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.BoundBlockState;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class BiomeBuffetGenerator extends EnginedStudioGenerator {
|
||||
private static final BoundBlockState FLOOR = BoundBlockState.of("BARRIER");
|
||||
private final IrisBiome[] biomes;
|
||||
private final int width;
|
||||
private final int biomeSize;
|
||||
private final BiomeBuffetLayout layout;
|
||||
|
||||
public BiomeBuffetGenerator(Engine engine, int biomeSize) {
|
||||
public BiomeBuffetGenerator(Engine engine) {
|
||||
super(engine);
|
||||
this.biomeSize = biomeSize;
|
||||
biomes = engine.getDimension().getAllBiomes(engine).toArray(new IrisBiome[0]);
|
||||
width = Math.max((int) Math.sqrt(biomes.length), 1);
|
||||
layout = new BiomeBuffetLayout(engine.getDimension(), engine);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresPreSessionPreparation() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void prepareChunkBeforeSession(Engine engine, int x, int z) {
|
||||
IrisBiome biome = biomeAt(x, z);
|
||||
if (biome == null || Objects.equals(engine.getDimension().getFocus(), biome.getLoadKey())) {
|
||||
return;
|
||||
}
|
||||
|
||||
engine.getDimension().setFocus(biome.getLoadKey());
|
||||
engine.hotloadComplex();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException {
|
||||
IrisBiome biome = biomeAt(x, z);
|
||||
if (biome == null) {
|
||||
public void generateChunk(Engine engine, TerrainChunk tc, int x, int z) throws WrongEngineBroException {
|
||||
if (layout.chunk(x, z) == null) {
|
||||
try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_biome_buffet_stage");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
tc.setRegion(0, 0, 0, 16, 1, 16, FLOOR.get());
|
||||
@@ -71,16 +46,10 @@ public class BiomeBuffetGenerator extends EnginedStudioGenerator {
|
||||
return;
|
||||
}
|
||||
|
||||
prepareChunkBeforeSession(engine, x, z);
|
||||
|
||||
try (GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_biome_buffet_stage");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
engine.generate(x << 4, z << 4, tc, true);
|
||||
}
|
||||
}
|
||||
|
||||
private IrisBiome biomeAt(int x, int z) {
|
||||
int id = Cache.to1D(x / biomeSize, 0, z / biomeSize, width, 1);
|
||||
return id < 0 || id >= biomes.length ? null : biomes[id];
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -28,6 +28,7 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.WrongEngineBroException;
|
||||
import art.arcane.iris.engine.object.IrisObject;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.engine.platform.studio.EnginedStudioGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBiome;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockState;
|
||||
@@ -42,7 +43,9 @@ import art.arcane.iris.util.common.math.IrisBlockVector;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -118,6 +121,7 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
|
||||
int maxHeight = engine.getMaxHeight();
|
||||
|
||||
int plinthY = floorY + 1;
|
||||
List<PlacedTile> tiles = new ArrayList<>();
|
||||
|
||||
for (GridCell cell : currentLayout.cells()) {
|
||||
int frameMinX = cell.originX() - 1;
|
||||
@@ -132,9 +136,12 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
|
||||
|
||||
DisplayObject object = loadObject(cell);
|
||||
if (object != null) {
|
||||
placeSlice(object, cell, tc, chunkWorldX, chunkWorldZ, minHeight, maxHeight);
|
||||
placeSlice(object, cell, tc, chunkWorldX, chunkWorldZ, minHeight, maxHeight, tiles);
|
||||
}
|
||||
}
|
||||
if (!tiles.isEmpty()) {
|
||||
ObjectStudioSaveService.get().queueTiles(engine, new ChunkTiles(x, z, List.copyOf(tiles)));
|
||||
}
|
||||
}
|
||||
|
||||
private void paintFrame(GridCell cell, TerrainChunk tc, int plinthY, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) {
|
||||
@@ -196,7 +203,7 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
private void placeSlice(DisplayObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight) {
|
||||
private void placeSlice(DisplayObject object, GridCell cell, TerrainChunk tc, int chunkWorldX, int chunkWorldZ, int minHeight, int maxHeight, List<PlacedTile> tiles) {
|
||||
VectorMap<PlatformBlockState> blocks = object.source().getBlocks();
|
||||
if (blocks == null || blocks.isEmpty()) return;
|
||||
|
||||
@@ -218,6 +225,10 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
|
||||
if (data == null) continue;
|
||||
|
||||
tc.setBlock(worldX - chunkWorldX, worldY, worldZ - chunkWorldZ, data);
|
||||
TileData tile = object.source().getStates().get(signed);
|
||||
if (tile != null) {
|
||||
tiles.add(new PlacedTile(worldX - chunkWorldX, worldY, worldZ - chunkWorldZ, tile.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +322,12 @@ public class ObjectStudioGenerator extends EnginedStudioGenerator {
|
||||
return new IrisBlockVector(maxX, 0, maxZ);
|
||||
}
|
||||
|
||||
public record PlacedTile(int x, int y, int z, TileData data) {
|
||||
}
|
||||
|
||||
public record ChunkTiles(int chunkX, int chunkZ, List<PlacedTile> tiles) {
|
||||
}
|
||||
|
||||
private record DisplayObject(IrisObject source, int minimumX, int minimumY, int minimumZ) {
|
||||
private static DisplayObject of(IrisObject object) {
|
||||
int minimumX = Integer.MAX_VALUE;
|
||||
|
||||
@@ -13,8 +13,11 @@ final class Terrain3DFragmentFilter {
|
||||
private static final int KEEP = 1;
|
||||
private static final int REMOVE = 2;
|
||||
|
||||
// Static on purpose: a per-instance ThreadLocal whose value was an inner class kept every
|
||||
// retired filter (and the runtime and stream caches behind it) reachable from each worker
|
||||
// thread that had ever classified for it. The scratch holds primitives only between calls.
|
||||
private static final ThreadLocal<Traversal> TRAVERSALS = ThreadLocal.withInitial(Traversal::new);
|
||||
private final ColumnSource source;
|
||||
private final ThreadLocal<Traversal> traversals = ThreadLocal.withInitial(Traversal::new);
|
||||
|
||||
Terrain3DFragmentFilter(ColumnSource source) {
|
||||
this.source = Objects.requireNonNull(source, "Raw density columns");
|
||||
@@ -30,7 +33,7 @@ final class Terrain3DFragmentFilter {
|
||||
int removed = 0;
|
||||
for (int span = 1; span < raw.spanCount(); span++) {
|
||||
if (density.state(span) == UNKNOWN) {
|
||||
traversals.get().classify(x, z, density, span);
|
||||
TRAVERSALS.get().classify(this, x, z, density, span);
|
||||
}
|
||||
if (density.state(span) == REMOVE) {
|
||||
removed++;
|
||||
@@ -42,7 +45,7 @@ final class Terrain3DFragmentFilter {
|
||||
}
|
||||
|
||||
void clear() {
|
||||
traversals.remove();
|
||||
TRAVERSALS.remove();
|
||||
}
|
||||
|
||||
private Terrain3DColumn retainSpans(DensityColumn density, int removed) {
|
||||
@@ -79,7 +82,7 @@ final class Terrain3DFragmentFilter {
|
||||
}
|
||||
}
|
||||
|
||||
private final class Traversal {
|
||||
private static final class Traversal {
|
||||
private final int[] xs = new int[MAXIMUM_FRAGMENT_VOLUME + 1];
|
||||
private final int[] zs = new int[MAXIMUM_FRAGMENT_VOLUME + 1];
|
||||
private final int[] spans = new int[MAXIMUM_FRAGMENT_VOLUME + 1];
|
||||
@@ -90,17 +93,17 @@ final class Terrain3DFragmentFilter {
|
||||
private int volume;
|
||||
private int decision;
|
||||
|
||||
private void classify(int x, int z, DensityColumn density, int span) {
|
||||
private void classify(Terrain3DFragmentFilter filter, int x, int z, DensityColumn density, int span) {
|
||||
try {
|
||||
visit(x, z, density, span);
|
||||
for (int cursor = 0; cursor < count && decision == UNKNOWN; cursor++) {
|
||||
Terrain3DColumn raw = columns[cursor].raw;
|
||||
int bottom = raw.ceiling(spans[cursor]);
|
||||
int top = raw.floor(spans[cursor]);
|
||||
inspect((long) xs[cursor] - 1, zs[cursor], bottom, top);
|
||||
inspect((long) xs[cursor] + 1, zs[cursor], bottom, top);
|
||||
inspect(xs[cursor], (long) zs[cursor] - 1, bottom, top);
|
||||
inspect(xs[cursor], (long) zs[cursor] + 1, bottom, top);
|
||||
inspect(filter, (long) xs[cursor] - 1, zs[cursor], bottom, top);
|
||||
inspect(filter, (long) xs[cursor] + 1, zs[cursor], bottom, top);
|
||||
inspect(filter, xs[cursor], (long) zs[cursor] - 1, bottom, top);
|
||||
inspect(filter, xs[cursor], (long) zs[cursor] + 1, bottom, top);
|
||||
}
|
||||
int result = decision == UNKNOWN ? REMOVE : decision;
|
||||
for (int index = 0; index < count; index++) {
|
||||
@@ -117,7 +120,7 @@ final class Terrain3DFragmentFilter {
|
||||
}
|
||||
}
|
||||
|
||||
private void inspect(long x, long z, int bottom, int top) {
|
||||
private void inspect(Terrain3DFragmentFilter filter, long x, long z, int bottom, int top) {
|
||||
if (decision != UNKNOWN) {
|
||||
return;
|
||||
}
|
||||
@@ -126,7 +129,7 @@ final class Terrain3DFragmentFilter {
|
||||
decision = KEEP;
|
||||
return;
|
||||
}
|
||||
DensityColumn density = source.column((int) x, (int) z);
|
||||
DensityColumn density = filter.source.column((int) x, (int) z);
|
||||
Terrain3DColumn raw = density.raw;
|
||||
for (int span = 0; span < raw.spanCount(); span++) {
|
||||
if (raw.ceiling(span) > top || decision != UNKNOWN) {
|
||||
|
||||
@@ -105,11 +105,14 @@ public final class Terrain3DRuntime {
|
||||
if (amplitude + crackDepth <= 0D) {
|
||||
return Terrain3DColumn.unshaped(baseHeight, options.height());
|
||||
}
|
||||
double densityHeight = baseHeight + interpolate(northWest.heightOffset(baseHeight),
|
||||
northEast.heightOffset(baseHeight), southWest.heightOffset(baseHeight),
|
||||
southEast.heightOffset(baseHeight), dx, dz);
|
||||
double crackWidth = interpolate(northWest.profile.crackWidth, northEast.profile.crackWidth,
|
||||
southWest.profile.crackWidth, southEast.profile.crackWidth, dx, dz);
|
||||
int minimum = Math.max(Math.max(1, (int) Math.floor(options.fluidHeight()) + 1),
|
||||
(int) Math.floor(baseHeight - amplitude - crackDepth));
|
||||
int maximum = Math.min(options.height() - 1, (int) Math.ceil(baseHeight + amplitude));
|
||||
(int) Math.floor(densityHeight - amplitude - crackDepth));
|
||||
int maximum = Math.min(options.height() - 1, (int) Math.ceil(densityHeight + amplitude));
|
||||
if (minimum > maximum) {
|
||||
return Terrain3DColumn.unshaped(baseHeight, options.height());
|
||||
}
|
||||
@@ -148,7 +151,7 @@ public final class Terrain3DRuntime {
|
||||
double ridge = Math.max(0D, 1D - distance / crackWidth);
|
||||
fissure = lerp(lowerDepth, upperDepth, dy) * ridge * ridge;
|
||||
}
|
||||
boolean solid = baseHeight + 0.5D - y + displacement - fissure >= 0D;
|
||||
boolean solid = densityHeight + 0.5D - y + displacement - fissure >= 0D;
|
||||
if (solid != previousSolid) {
|
||||
if (count == boundaries.length) {
|
||||
boundaries = Arrays.copyOf(boundaries, count * 2);
|
||||
@@ -177,13 +180,13 @@ public final class Terrain3DRuntime {
|
||||
IrisBiome biome = sources.biomes().sample(x, z);
|
||||
CompiledProfile profile = profile(biome == null ? null : biome.getTerrain3D());
|
||||
if (profile == DISABLED) {
|
||||
return new Anchor(x, z, profile, 0D);
|
||||
return new Anchor(x, z, profile, 0D, 0D);
|
||||
}
|
||||
double height = baseHeight(x, z);
|
||||
double elevationStrength = smooth((height - options.fluidHeight() - profile.fluidClearance)
|
||||
/ profile.fluidFade);
|
||||
if (elevationStrength <= 0D) {
|
||||
return new Anchor(x, z, profile, 0D);
|
||||
return new Anchor(x, z, profile, height, 0D);
|
||||
}
|
||||
double slopeStrength = 1D;
|
||||
if (profile.minimumSlope > 0D) {
|
||||
@@ -192,7 +195,7 @@ public final class Terrain3DRuntime {
|
||||
double slope = StrictMath.sqrt(east * east + south * south);
|
||||
slopeStrength = smooth((slope - profile.minimumSlope) / profile.slopeFade);
|
||||
}
|
||||
return new Anchor(x, z, profile, elevationStrength * slopeStrength);
|
||||
return new Anchor(x, z, profile, height, elevationStrength * slopeStrength);
|
||||
}
|
||||
|
||||
private CompiledProfile profile(IrisTerrain3D config) {
|
||||
@@ -322,13 +325,15 @@ public final class Terrain3DRuntime {
|
||||
private final int x;
|
||||
private final int z;
|
||||
private final CompiledProfile profile;
|
||||
private final double height;
|
||||
private final double strength;
|
||||
private final AtomicReferenceArray<NodeSample> samples;
|
||||
|
||||
private Anchor(int x, int z, CompiledProfile profile, double strength) {
|
||||
private Anchor(int x, int z, CompiledProfile profile, double height, double strength) {
|
||||
this.x = x;
|
||||
this.z = z;
|
||||
this.profile = profile;
|
||||
this.height = height;
|
||||
this.strength = strength;
|
||||
samples = strength == 0D ? null
|
||||
: new AtomicReferenceArray<>(Math.ceilDiv(options.height(), STEP) + 1);
|
||||
@@ -342,6 +347,10 @@ public final class Terrain3DRuntime {
|
||||
return profile.crackDepth * strength;
|
||||
}
|
||||
|
||||
private double heightOffset(double baseHeight) {
|
||||
return (height - baseHeight) * strength;
|
||||
}
|
||||
|
||||
private NodeSample sample(int y) {
|
||||
if (samples == null) {
|
||||
return EMPTY_SAMPLE;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package art.arcane.iris.core.gui;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.pregenerator.PregenApiPhase;
|
||||
import art.arcane.iris.core.pregenerator.PregenApiSink;
|
||||
import art.arcane.iris.core.pregenerator.PregenListener;
|
||||
import art.arcane.iris.core.pregenerator.PregenTask;
|
||||
import art.arcane.iris.core.pregenerator.PregeneratorMethod;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.volmlib.util.math.Position2;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class PregeneratorJobTerminalProgressTest {
|
||||
@Test
|
||||
public void finalApiEventIncludesEveryChunkCompletedDuringClose() throws Exception {
|
||||
List<ProgressEvent> events = run(new Completion(false, false, false));
|
||||
|
||||
assertCompleted(events);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cachedCompletionsDrainedDuringCloseReachTheSameTerminalTotal() throws Exception {
|
||||
List<ProgressEvent> events = run(new Completion(true, false, false));
|
||||
|
||||
assertCompleted(events);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedFinalRequestRemainsIncompleteWithCurrentFailureCount() throws Exception {
|
||||
List<ProgressEvent> events = run(new Completion(false, true, false));
|
||||
ProgressEvent terminal = events.getLast();
|
||||
|
||||
assertEquals(PregenApiPhase.CANCELLED, terminal.phase());
|
||||
assertEquals(8L, terminal.progress().generated());
|
||||
assertEquals(9L, terminal.progress().totalChunks());
|
||||
assertEquals(1L, terminal.progress().chunksRemaining());
|
||||
assertEquals(1L, terminal.progress().failed());
|
||||
assertFalse(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.COMPLETED));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitCancellationPublishesItsDrainedPartialCount() throws Exception {
|
||||
List<ProgressEvent> events = run(new Completion(false, false, true));
|
||||
ProgressEvent terminal = events.getLast();
|
||||
|
||||
assertEquals(PregenApiPhase.CANCELLED, terminal.phase());
|
||||
assertEquals(1L, terminal.progress().generated());
|
||||
assertEquals(9L, terminal.progress().totalChunks());
|
||||
assertEquals(8L, terminal.progress().chunksRemaining());
|
||||
assertEquals(0L, terminal.progress().failed());
|
||||
}
|
||||
|
||||
private static void assertCompleted(List<ProgressEvent> events) {
|
||||
ProgressEvent terminal = events.getLast();
|
||||
assertEquals(PregenApiPhase.COMPLETED, terminal.phase());
|
||||
assertEquals(9L, terminal.progress().generated());
|
||||
assertEquals(9L, terminal.progress().totalChunks());
|
||||
assertEquals(0L, terminal.progress().chunksRemaining());
|
||||
assertEquals(0L, terminal.progress().failed());
|
||||
assertEquals(100D, terminal.progress().percent(), 0D);
|
||||
assertTrue(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.SAVING
|
||||
&& event.progress().generated() == 0L));
|
||||
assertFalse(events.stream().anyMatch(event -> event.phase() == PregenApiPhase.CANCELLED));
|
||||
assertEquals(1L, events.stream().filter(event -> event.phase() == PregenApiPhase.COMPLETED).count());
|
||||
}
|
||||
|
||||
private static List<ProgressEvent> run(Completion completion) throws Exception {
|
||||
IrisSettings previousSettings = IrisSettings.settings;
|
||||
PregenApiSink previousSink = IrisServices.getOrNull(PregenApiSink.class);
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
CopyOnWriteArrayList<ProgressEvent> events = new CopyOnWriteArrayList<>();
|
||||
IrisServices.register(PregenApiSink.class,
|
||||
(PregenApiSink) (phase, progress) -> events.add(new ProgressEvent(phase, progress)));
|
||||
PregeneratorMethod method = mock(PregeneratorMethod.class);
|
||||
when(method.getMethod(anyInt(), anyInt())).thenReturn("terminal-progress-test");
|
||||
ArrayList<PregenListener> pending = new ArrayList<>();
|
||||
doAnswer(invocation -> {
|
||||
pending.add(invocation.getArgument(2));
|
||||
if (completion.cancel()) {
|
||||
PregeneratorJob.shutdownInstance();
|
||||
}
|
||||
return null;
|
||||
}).when(method).generateChunk(anyInt(), anyInt(), any(PregenListener.class));
|
||||
doAnswer(invocation -> {
|
||||
for (int index = 0; index < pending.size(); index++) {
|
||||
PregenListener listener = pending.get(index);
|
||||
if (completion.failLast() && index == pending.size() - 1) {
|
||||
listener.onChunkFailed(0, 0);
|
||||
} else {
|
||||
listener.onChunkGenerated(0, 0, completion.cached());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}).when(method).close();
|
||||
PregenTask task = PregenTask.builder().center(new Position2(0, 0))
|
||||
.radiusX(1).radiusZ(1).gui(false).build();
|
||||
Thread worker = null;
|
||||
try {
|
||||
assertNull(PregeneratorJob.getInstance());
|
||||
PregeneratorJob job = new PregeneratorJob(new PregeneratorJob.Configuration(task, method, null, () -> {}));
|
||||
Field workerField = PregeneratorJob.class.getDeclaredField("worker");
|
||||
workerField.setAccessible(true);
|
||||
worker = (Thread) workerField.get(job);
|
||||
worker.join(10_000L);
|
||||
assertFalse(worker.isAlive());
|
||||
assertNull(PregeneratorJob.getInstance());
|
||||
return List.copyOf(events);
|
||||
} finally {
|
||||
if (worker != null && worker.isAlive()) {
|
||||
PregeneratorJob.shutdownInstance();
|
||||
worker.join(5_000L);
|
||||
}
|
||||
IrisSettings.settings = previousSettings;
|
||||
if (previousSink == null) {
|
||||
IrisServices.remove(PregenApiSink.class);
|
||||
} else {
|
||||
IrisServices.register(PregenApiSink.class, previousSink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record Completion(boolean cached, boolean failLast, boolean cancel) {
|
||||
}
|
||||
|
||||
private record ProgressEvent(PregenApiPhase phase, PregeneratorJob.PregenProgress progress) {
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,53 @@ public class PackHydrologyValidatorTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void acceptsNullableRegionalSurfaceControlsAtTheirBounds() throws Exception {
|
||||
File pack = pack(riversDimension("{}", """
|
||||
"surfaceSourceDensity": null,
|
||||
"surfaceSourceSpacing": null,
|
||||
"surfaceTributaries": null,
|
||||
"surfaceInlandOutlets": null,
|
||||
"surfaceCoastalOutlets": null,
|
||||
"surfaceMinimumCourseLength": null,
|
||||
"surfaceMaximumIncision": null
|
||||
"""));
|
||||
write(pack, "regions/region.json", """
|
||||
{"landBiomes":["biome"],"riverPolicy":{
|
||||
"surfaceSourceDensity":64,"surfaceSourceSpacing":8192,"surfaceTributaries":4,
|
||||
"surfaceInlandOutlets":256,"surfaceCoastalOutlets":64,
|
||||
"surfaceMinimumCourseLength":4096,"surfaceMaximumIncision":32}}
|
||||
""");
|
||||
write(pack, "biomes/biome.json", """
|
||||
{"name":"Biome","derivative":"minecraft:plains","riverPolicy":{
|
||||
"surfaceSourceDensity":0,"surfaceSourceSpacing":0,"surfaceTributaries":0,
|
||||
"surfaceInlandOutlets":0,"surfaceCoastalOutlets":0,
|
||||
"surfaceMinimumCourseLength":16,"surfaceMaximumIncision":1}}
|
||||
""");
|
||||
|
||||
PackHydrologyValidator.Validation validation = validate(pack);
|
||||
assertTrue(validation.errors().toString(), validation.errors().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsInvalidRegionalSurfaceControlNumbers() throws Exception {
|
||||
List<String> invalidEntries = List.of(
|
||||
"\"surfaceSourceDensity\":-1", "\"surfaceSourceDensity\":65", "\"surfaceSourceDensity\":\"NaN\"",
|
||||
"\"surfaceSourceDensity\":true", "\"surfaceSourceSpacing\":8193", "\"surfaceSourceSpacing\":1.5",
|
||||
"\"surfaceTributaries\":5", "\"surfaceTributaries\":1.5", "\"surfaceInlandOutlets\":-1",
|
||||
"\"surfaceInlandOutlets\":257", "\"surfaceCoastalOutlets\":65", "\"surfaceCoastalOutlets\":\"4\"",
|
||||
"\"surfaceMinimumCourseLength\":15", "\"surfaceMinimumCourseLength\":4097",
|
||||
"\"surfaceMinimumCourseLength\":128.5", "\"surfaceMinimumCourseLength\":true",
|
||||
"\"surfaceMaximumIncision\":0", "\"surfaceMaximumIncision\":33",
|
||||
"\"surfaceMaximumIncision\":2.5", "\"surfaceMaximumIncision\":\"NaN\"");
|
||||
for (String entry : invalidEntries) {
|
||||
File pack = pack(riversDimension("{}"));
|
||||
write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"],\"riverPolicy\":{" + entry + "}}");
|
||||
PackHydrologyValidator.Validation validation = validate(pack);
|
||||
assertContains(validation.errors(), entry.substring(1, entry.indexOf('"', 1)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsCanonicalHydrologyAndRecursivePolicyClosure() throws Exception {
|
||||
File pack = pack(canonicalDimension());
|
||||
|
||||
@@ -249,4 +249,71 @@ public class PackValidationCacheTest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameVersionImplementationChangeInvalidatesSuccessfulAndFailedResults() throws Exception {
|
||||
bindValidationPlatform();
|
||||
try {
|
||||
String original = PackValidationCache.contextFingerprint("a".repeat(64));
|
||||
String changed = PackValidationCache.contextFingerprint("b".repeat(64));
|
||||
Path cache = temporaryFolder.newFolder("implementation-change").toPath().resolve("validation.json");
|
||||
PackValidationCache.save(cache, "content", original, List.of(
|
||||
new PackValidationResult("valid", List.of(), List.of(), 1L),
|
||||
new PackValidationResult("invalid", List.of("unsupported enum"), List.of(), 1L)));
|
||||
|
||||
assertNotEquals(original, changed);
|
||||
assertTrue(PackValidationCache.load(cache, "content", original, List.of("valid", "invalid")).isPresent());
|
||||
assertTrue(PackValidationCache.load(cache, "content", changed, List.of("valid", "invalid")).isEmpty());
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentBuildIdentityPreservesCacheHits() throws Exception {
|
||||
bindValidationPlatform();
|
||||
try {
|
||||
String context = PackValidationCache.contextFingerprint();
|
||||
Path cache = temporaryFolder.newFolder("current-implementation").toPath().resolve("validation.json");
|
||||
PackValidationCache.save(cache, "content", context, List.of(
|
||||
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
|
||||
|
||||
assertFalse(context.isBlank());
|
||||
assertEquals(context, PackValidationCache.contextFingerprint());
|
||||
assertTrue(PackValidationCache.load(
|
||||
cache, "content", PackValidationCache.contextFingerprint(), List.of("overworld")).isPresent());
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unavailableImplementationIdentityDisablesCacheReuseAndPersistence() throws Exception {
|
||||
bindValidationPlatform();
|
||||
try {
|
||||
Path cache = temporaryFolder.newFolder("missing-implementation").toPath().resolve("validation.json");
|
||||
String missing = PackValidationCache.contextFingerprint("");
|
||||
assertEquals("", missing);
|
||||
assertEquals("", PackValidationCache.contextFingerprint(null));
|
||||
PackValidationCache.save(cache, "content", missing, List.of(
|
||||
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
|
||||
assertFalse(Files.exists(cache));
|
||||
|
||||
PackValidationCache.save(cache, "content", "previous", List.of(
|
||||
new PackValidationResult("overworld", List.of(), List.of(), 1L)));
|
||||
assertTrue(PackValidationCache.load(cache, "content", missing, List.of("overworld")).isEmpty());
|
||||
} finally {
|
||||
IrisPlatforms.unbind();
|
||||
}
|
||||
}
|
||||
|
||||
private static void bindValidationPlatform() {
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.platformName()).thenReturn("Paper");
|
||||
when(platform.minecraftVersion()).thenReturn("26.2");
|
||||
when(platform.irisVersionNumber()).thenReturn(40100);
|
||||
when(platform.registries()).thenReturn(mock(PlatformRegistries.class));
|
||||
when(platform.structureHooks()).thenReturn(mock(PlatformStructureHooks.class));
|
||||
IrisPlatforms.bind(platform);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-3
@@ -112,7 +112,7 @@ public class PackValidatorStructureTerrainBackendTest {
|
||||
|
||||
@Test
|
||||
public void rejectsNativeOnlyTerrainModesForEditablePlacementsAcrossEveryHost() throws Exception {
|
||||
for (String mode : List.of("VACUUM", "ENCASE")) {
|
||||
for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) {
|
||||
String folderName = "editable-" + mode.toLowerCase(Locale.ROOT);
|
||||
File pack = temporaryFolder.newFolder(folderName);
|
||||
writePlacement(pack, "dimensions/main.json", "dimension-" + folderName,
|
||||
@@ -138,7 +138,7 @@ public class PackValidatorStructureTerrainBackendTest {
|
||||
|
||||
@Test
|
||||
public void acceptsNativeOnlyTerrainModesForNativeStructurePlacements() throws Exception {
|
||||
for (String mode : List.of("VACUUM", "ENCASE")) {
|
||||
for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) {
|
||||
File pack = temporaryFolder.newFolder("native-" + mode.toLowerCase(Locale.ROOT));
|
||||
writePlacement(pack, "dimensions/main.json", "native-" + mode,
|
||||
"minecraft:village_plains", true, mode);
|
||||
@@ -167,7 +167,7 @@ public class PackValidatorStructureTerrainBackendTest {
|
||||
|
||||
@Test
|
||||
public void acceptsNativeOnlyTerrainModesForImportedStructureAdjustments() {
|
||||
for (String mode : List.of("VACUUM", "ENCASE")) {
|
||||
for (String mode : List.of("VACUUM", "FLATTEN", "ENCASE")) {
|
||||
JSONObject policy = new JSONObject().put("adjustments", new JSONArray().put(
|
||||
new JSONObject()
|
||||
.put("match", new JSONArray().put("towns_and_towers:"))
|
||||
@@ -182,6 +182,26 @@ public class PackValidatorStructureTerrainBackendTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void flattenRequiresIntegralBoundedRangeAndBlendSettings() {
|
||||
for (String field : List.of("flattenRange", "horizontalPadding")) {
|
||||
for (Object value : List.of(0, 128, -1, 129, 1.5D, "64")) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
JSONObject terrain = new JSONObject().put("mode", "FLATTEN").put(field, value);
|
||||
|
||||
PackStructurePlacementValidator.validateNativeTerrain("placement",
|
||||
new JSONObject().put("terrain", terrain), errors);
|
||||
|
||||
if (value.equals(0) || value.equals(128)) {
|
||||
assertTrue(value + ": " + errors, errors.isEmpty());
|
||||
} else {
|
||||
assertEquals(value.toString(), 1, errors.size());
|
||||
assertTrue(errors.getFirst(), errors.getFirst().contains("terrain." + field));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writePlacement(File pack, String relativePath, String placementId,
|
||||
String structureKey, boolean nativeStructure,
|
||||
String terrainMode) throws Exception {
|
||||
|
||||
+137
@@ -4,6 +4,7 @@ import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.collection.KSet;
|
||||
import org.bukkit.Chunk;
|
||||
@@ -16,24 +17,34 @@ import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Queue;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
@@ -42,6 +53,104 @@ import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class AsyncPregenServerSaveOwnershipTest {
|
||||
@Test
|
||||
public void ownerReturnsAfterUnloadWhileEvictionTracksTheOffOwnerIoDrain() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
ExecutorService io = Executors.newSingleThreadExecutor();
|
||||
fixture.set("chunkIoExecutor", io);
|
||||
Thread ownerThread = Thread.currentThread();
|
||||
CountDownLatch draining = new CountDownLatch(1);
|
||||
CountDownLatch finishDrain = new CountDownLatch(1);
|
||||
AtomicReference<Thread> ioThread = new AtomicReference<>();
|
||||
doAnswer(invocation -> {
|
||||
assertSame(ownerThread, Thread.currentThread());
|
||||
return true;
|
||||
}).when(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
|
||||
doAnswer(invocation -> {
|
||||
ioThread.set(Thread.currentThread());
|
||||
draining.countDown();
|
||||
assertTrue(finishDrain.await(5L, TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(fixture.binding).flushChunkIO(fixture.world);
|
||||
try (SchedulingContext context = new SchedulingContext(fixture)) {
|
||||
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
|
||||
assertTrue(draining.await(5L, TimeUnit.SECONDS));
|
||||
assertNotSame(ownerThread, ioThread.get());
|
||||
assertFalse(eviction.isDone());
|
||||
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
|
||||
finishDrain.countDown();
|
||||
eviction.get(5L, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
finishDrain.countDown();
|
||||
io.shutdownNow();
|
||||
assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void foliaFlushWaitsForEveryOwningRegionAndRunsOffOwner() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.set("foliaRuntime", true);
|
||||
ExecutorService io = Executors.newSingleThreadExecutor();
|
||||
fixture.set("chunkIoExecutor", io);
|
||||
Chunk second = mock(Chunk.class);
|
||||
when(second.getX()).thenReturn(17);
|
||||
when(second.getZ()).thenReturn(3);
|
||||
fixture.chunks.get(0L).add(second);
|
||||
when(fixture.binding.saveAndUnloadChunk(fixture.world, 17, 3)).thenReturn(true);
|
||||
List<OwnedTask> owners = new ArrayList<>();
|
||||
Thread ownerThread = Thread.currentThread();
|
||||
CountDownLatch draining = new CountDownLatch(1);
|
||||
CountDownLatch finishDrain = new CountDownLatch(1);
|
||||
doAnswer(invocation -> {
|
||||
assertNotSame(ownerThread, Thread.currentThread());
|
||||
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 2, 3);
|
||||
verify(fixture.binding).saveAndUnloadChunk(fixture.world, 17, 3);
|
||||
draining.countDown();
|
||||
assertTrue(finishDrain.await(5L, TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(fixture.binding).flushChunkIO(fixture.world);
|
||||
try (SchedulingContext context = new SchedulingContext(fixture)) {
|
||||
context.scheduling.when(() -> J.runRegionFuture(eq(fixture.world), anyInt(), anyInt(), any(Runnable.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
CompletableFuture<Void> completion = new CompletableFuture<>();
|
||||
owners.add(new OwnedTask(invocation.getArgument(1), invocation.getArgument(2),
|
||||
invocation.getArgument(3), completion));
|
||||
return completion;
|
||||
});
|
||||
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
|
||||
assertEquals(2, owners.size());
|
||||
assertEquals(2, owners.get(0).chunkX());
|
||||
assertEquals(17, owners.get(1).chunkX());
|
||||
owners.get(0).run();
|
||||
assertEquals(1L, draining.getCount());
|
||||
assertFalse(eviction.isDone());
|
||||
owners.get(1).run();
|
||||
assertTrue(draining.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(eviction.isDone());
|
||||
finishDrain.countDown();
|
||||
eviction.get(5L, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
finishDrain.countDown();
|
||||
io.shutdownNow();
|
||||
assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedIoDrainFailsTheEvictionReceiptAndReportsTheFailure() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
IllegalStateException failure = new IllegalStateException("native IO failure");
|
||||
doThrow(failure).when(fixture.binding).flushChunkIO(fixture.world);
|
||||
try (SchedulingContext context = new SchedulingContext(fixture);
|
||||
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
|
||||
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
|
||||
assertTrue(eviction.isCompletedExceptionally());
|
||||
assertSame(failure, assertThrows(CompletionException.class, eviction::join).getCause());
|
||||
logging.verify(() -> IrisLogging.reportError(any(Throwable.class)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serverStopRetainsChunksForNativeSaveWithoutSchedulingEviction() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
@@ -151,6 +260,26 @@ public class AsyncPregenServerSaveOwnershipTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeFlushRunsOnThePregenFlushWorkerNotTheSharedIoPool() throws Exception {
|
||||
Fixture fixture = new Fixture();
|
||||
PregenSerialWorker flush = new PregenSerialWorker("Iris Pregen Chunk Flush", "world");
|
||||
fixture.set("chunkIoExecutor", flush.executor());
|
||||
AtomicReference<Thread> ioThread = new AtomicReference<>();
|
||||
doAnswer(invocation -> {
|
||||
ioThread.set(Thread.currentThread());
|
||||
return null;
|
||||
}).when(fixture.binding).flushChunkIO(fixture.world);
|
||||
try (SchedulingContext context = new SchedulingContext(fixture)) {
|
||||
CompletableFuture<?> eviction = (CompletableFuture<?>) invoke(fixture.method, "evictRegion", 0L);
|
||||
eviction.get(5L, TimeUnit.SECONDS);
|
||||
assertTrue(ioThread.get().getName().startsWith("Iris Pregen Chunk Flush world"));
|
||||
assertFalse(ioThread.get().getName().startsWith("Iris IO"));
|
||||
} finally {
|
||||
assertTrue(flush.close(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static Object invoke(AsyncPregenMethod target, String name, Object... arguments) throws Exception {
|
||||
Method method = arguments.length == 0
|
||||
? AsyncPregenMethod.class.getDeclaredMethod(name)
|
||||
@@ -182,6 +311,7 @@ public class AsyncPregenServerSaveOwnershipTest {
|
||||
set("regionPending", new ConcurrentHashMap<>());
|
||||
set("pendingEvictions", evictions);
|
||||
set("evictedRegions", new KSet<>());
|
||||
set("chunkIoExecutor", (Executor) Runnable::run);
|
||||
when(chunk.getX()).thenReturn(2);
|
||||
when(chunk.getZ()).thenReturn(3);
|
||||
when(binding.saveAndUnloadChunk(world, 2, 3)).thenReturn(true);
|
||||
@@ -218,4 +348,11 @@ public class AsyncPregenServerSaveOwnershipTest {
|
||||
toolbelt.close();
|
||||
}
|
||||
}
|
||||
|
||||
private record OwnedTask(int chunkX, int chunkZ, Runnable action, CompletableFuture<Void> completion) {
|
||||
private void run() {
|
||||
action.run();
|
||||
completion.complete(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package art.arcane.iris.core.pregenerator.methods;
|
||||
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
public class MedievalPregenFlushOwnershipTest {
|
||||
@Test
|
||||
public void closeWaitsForOffOwnerIoAfterTheOwnedUnloadReturns() throws Exception {
|
||||
MedievalPregenMethod method = mock(MedievalPregenMethod.class, CALLS_REAL_METHODS);
|
||||
World world = mock(World.class);
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
INMSBinding binding = mock(INMSBinding.class);
|
||||
Map<Chunk, Long> lastUse = new ConcurrentHashMap<>();
|
||||
lastUse.put(chunk, 0L);
|
||||
ExecutorService io = Executors.newSingleThreadExecutor();
|
||||
ExecutorService closing = Executors.newSingleThreadExecutor();
|
||||
set(method, "world", world);
|
||||
set(method, "lastUse", lastUse);
|
||||
set(method, "chunkIoExecutor", io);
|
||||
CountDownLatch scheduled = new CountDownLatch(1);
|
||||
CountDownLatch draining = new CountDownLatch(1);
|
||||
CountDownLatch finishDrain = new CountDownLatch(1);
|
||||
AtomicReference<Runnable> ownerAction = new AtomicReference<>();
|
||||
CompletableFuture<Void> unloaded = new CompletableFuture<>();
|
||||
Thread owner = Thread.currentThread();
|
||||
doAnswer(invocation -> {
|
||||
assertSame(owner, Thread.currentThread());
|
||||
return true;
|
||||
}).when(chunk).unload(true);
|
||||
doAnswer(invocation -> {
|
||||
assertNotSame(owner, Thread.currentThread());
|
||||
verify(chunk).unload(true);
|
||||
draining.countDown();
|
||||
assertTrue(finishDrain.await(5L, TimeUnit.SECONDS));
|
||||
return null;
|
||||
}).when(binding).flushChunkIO(world);
|
||||
try {
|
||||
Future<?> closed = closing.submit(() -> {
|
||||
try (MockedStatic<J> scheduling = mockStatic(J.class);
|
||||
MockedStatic<INMS> nativeAccess = mockStatic(INMS.class)) {
|
||||
nativeAccess.when(INMS::get).thenReturn(binding);
|
||||
scheduling.when(() -> J.sfut(any(Runnable.class))).thenAnswer(invocation -> {
|
||||
ownerAction.set(invocation.getArgument(0));
|
||||
scheduled.countDown();
|
||||
return unloaded;
|
||||
});
|
||||
Method flush = MedievalPregenMethod.class.getDeclaredMethod("unloadAndSaveAllChunks", boolean.class);
|
||||
flush.setAccessible(true);
|
||||
flush.invoke(method, true);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
assertTrue(scheduled.await(5L, TimeUnit.SECONDS));
|
||||
verifyNoInteractions(binding);
|
||||
ownerAction.get().run();
|
||||
unloaded.complete(null);
|
||||
assertTrue(draining.await(5L, TimeUnit.SECONDS));
|
||||
assertFalse(closed.isDone());
|
||||
assertTrue(lastUse.isEmpty());
|
||||
finishDrain.countDown();
|
||||
closed.get(5L, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
unloaded.complete(null);
|
||||
finishDrain.countDown();
|
||||
io.shutdownNow();
|
||||
closing.shutdownNow();
|
||||
assertTrue(io.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
assertTrue(closing.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static void set(MedievalPregenMethod method, String name, Object value) throws Exception {
|
||||
Field field = MedievalPregenMethod.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(method, value);
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package art.arcane.iris.core.pregenerator.methods;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PregenSerialWorkerTest {
|
||||
@Test
|
||||
public void submitRunsOffTheCallerThreadAndCloseDrainsTheBacklog() throws Exception {
|
||||
Reports reports = new Reports();
|
||||
PregenSerialWorker queue = reports.queue();
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicReference<Thread> runner = new AtomicReference<>();
|
||||
AtomicBoolean second = new AtomicBoolean();
|
||||
try {
|
||||
assertTrue(queue.submit(() -> {
|
||||
runner.set(Thread.currentThread());
|
||||
entered.countDown();
|
||||
await(release);
|
||||
}));
|
||||
assertTrue(queue.submit(() -> second.set(true)));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
assertNotSame(Thread.currentThread(), runner.get());
|
||||
assertTrue(runner.get().getName().startsWith("Iris Pregen Mantle Cleanup world"));
|
||||
assertEquals(2, queue.pending());
|
||||
assertFalse(second.get());
|
||||
release.countDown();
|
||||
assertTrue(queue.close(5, TimeUnit.SECONDS));
|
||||
assertTrue(second.get());
|
||||
assertEquals(0, queue.pending());
|
||||
assertTrue(reports.failures.isEmpty());
|
||||
assertTrue(reports.warnings.isEmpty());
|
||||
} finally {
|
||||
release.countDown();
|
||||
queue.close(1, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeAbandonsABlockedBacklogAfterItsTimeout() throws Exception {
|
||||
Reports reports = new Reports();
|
||||
PregenSerialWorker queue = reports.queue();
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicBoolean second = new AtomicBoolean();
|
||||
try {
|
||||
assertTrue(queue.submit(() -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
}));
|
||||
assertTrue(queue.submit(() -> second.set(true)));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(queue.close(50, TimeUnit.MILLISECONDS));
|
||||
assertFalse(queue.submit(() -> {}));
|
||||
assertEquals(1, reports.warnings.size());
|
||||
assertTrue(reports.warnings.get(0).contains("1 queued task(s)"));
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
assertFalse(second.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aFailingCleanupIsReportedAndDoesNotStopLaterCleanups() throws Exception {
|
||||
Reports reports = new Reports();
|
||||
PregenSerialWorker queue = reports.queue();
|
||||
AtomicBoolean second = new AtomicBoolean();
|
||||
IllegalStateException failure = new IllegalStateException("plate read failed");
|
||||
assertTrue(queue.submit(() -> {
|
||||
throw failure;
|
||||
}));
|
||||
assertTrue(queue.submit(() -> second.set(true)));
|
||||
assertTrue(queue.close(5, TimeUnit.SECONDS));
|
||||
assertTrue(second.get());
|
||||
assertEquals(1, reports.failures.size());
|
||||
assertSame(failure, reports.failures.get(0));
|
||||
assertEquals(0, queue.pending());
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(30, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException interruption) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(interruption);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Reports {
|
||||
private final List<Throwable> failures = new CopyOnWriteArrayList<>();
|
||||
private final List<String> warnings = new CopyOnWriteArrayList<>();
|
||||
|
||||
private PregenSerialWorker queue() {
|
||||
return new PregenSerialWorker("Iris Pregen Mantle Cleanup", "world",
|
||||
(message, failure) -> failures.add(failure),
|
||||
warnings::add);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ public class SchemaBuilderIdentityTest {
|
||||
@ClassRule
|
||||
public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
|
||||
|
||||
private static final String EXPECTED_SCHEMA_DIGEST = "ddfc51ba19c02dd50737a0d1ba30ee24cdb6cbbe5fed40c875adac69960ad81d";
|
||||
private static final String EXPECTED_SCHEMA_DIGEST = "b205ce33025fa36115f9ddb38c704803875043950967e31449e2b08ae364a7fe";
|
||||
|
||||
private static final List<Class<?>> SCHEMA_ROOTS = List.of(
|
||||
IrisDimension.class,
|
||||
|
||||
@@ -28,6 +28,8 @@ import art.arcane.iris.engine.object.IrisJigsawPiece;
|
||||
import art.arcane.iris.engine.object.IrisObjectPlacement;
|
||||
import art.arcane.iris.engine.object.IrisStructure;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.iris.engine.object.IrisVanillaStructureAdjustment;
|
||||
import art.arcane.iris.engine.object.annotations.ArrayType;
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
@@ -162,6 +164,8 @@ public class SchemaBuilderParityTest {
|
||||
.getJSONObject(terrain.getString("$ref").substring("#/definitions/".length()));
|
||||
JSONObject terrainProperties = terrainDefinition.getJSONObject("properties");
|
||||
JSONObject terrainMode = terrainProperties.getJSONObject("mode");
|
||||
JSONObject flattenRange = terrainProperties.getJSONObject("flattenRange");
|
||||
JSONObject horizontalPadding = terrainProperties.getJSONObject("horizontalPadding");
|
||||
JSONObject carveShape = terrainProperties.getJSONObject("shape");
|
||||
JSONObject erosionStrength = terrainProperties.getJSONObject("erosionStrength");
|
||||
JSONObject erosionFrequency = terrainProperties.getJSONObject("erosionFrequency");
|
||||
@@ -178,8 +182,18 @@ public class SchemaBuilderParityTest {
|
||||
assertTrue(terrain.getString("description").contains(
|
||||
"The editable structures backend supports SOURCE, PRESERVE, BORE, and FORCE_CARVE. "
|
||||
+ "The nativeStructures backend supports every terrain mode."));
|
||||
assertEquals(List.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"),
|
||||
assertEquals(List.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "FLATTEN", "ENCASE"),
|
||||
oneOfValues(schema.getJSONObject("definitions"), terrainModeDefinition));
|
||||
assertEquals("integer", flattenRange.getString("type"));
|
||||
assertEquals(0, flattenRange.getInt("minimum"));
|
||||
assertEquals(128, flattenRange.getInt("maximum"));
|
||||
assertEquals("integer", horizontalPadding.getString("type"));
|
||||
assertEquals(0, horizontalPadding.getInt("minimum"));
|
||||
assertEquals(128, horizontalPadding.getInt("maximum"));
|
||||
IrisStructureTerrain terrainDefaults = new IrisStructureTerrain();
|
||||
assertEquals(IrisStructureTerrainMode.SOURCE, terrainDefaults.resolvedMode());
|
||||
assertEquals(64, terrainDefaults.getFlattenRange());
|
||||
assertEquals(0, terrainDefaults.getHorizontalPadding());
|
||||
assertEquals(List.of("BOX", "ROUNDED", "ERODED"), oneOfValues(
|
||||
schema.getJSONObject("definitions"), carveShapeDefinition));
|
||||
assertEquals(0D, erosionStrength.getDouble("minimum"), 0D);
|
||||
@@ -226,6 +240,10 @@ public class SchemaBuilderParityTest {
|
||||
assertTrue(terrainProperties.has("lobeFrequency"));
|
||||
assertTrue(terrainProperties.has("lobeStrength"));
|
||||
assertTrue(terrainProperties.has("encasePalette"));
|
||||
assertTrue(oneOfValues(definitions, definitionKey(terrainProperties.getJSONObject("mode")))
|
||||
.contains("FLATTEN"));
|
||||
assertEquals(0, terrainProperties.getJSONObject("flattenRange").getInt("minimum"));
|
||||
assertEquals(128, terrainProperties.getJSONObject("flattenRange").getInt("maximum"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.engine.object.TileData;
|
||||
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.ChunkTiles;
|
||||
import art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator.PlacedTile;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.event.world.ChunkLoadEvent;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ObjectStudioTileRestorationTest {
|
||||
@Test
|
||||
public void restoresGeneratedTileOnlyAfterChunkLoadAndKeepsLaterEdits() {
|
||||
Fixture fixture = fixture();
|
||||
queue(fixture);
|
||||
|
||||
verifyNoInteractions(fixture.tile());
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true));
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
|
||||
|
||||
verify(fixture.tile(), times(1)).toBukkitTry(fixture.block());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedRestorationRemainsPendingForTheNextOwnedLoad() {
|
||||
Fixture fixture = fixture();
|
||||
when(fixture.tile().toBukkitTry(fixture.block())).thenReturn(false, true);
|
||||
queue(fixture);
|
||||
|
||||
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true));
|
||||
}
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
|
||||
|
||||
verify(fixture.tile(), times(2)).toBukkitTry(fixture.block());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retriesOnlyUnrestoredTilesAfterPartialFailure() {
|
||||
Fixture fixture = fixture();
|
||||
TileData second = mock(TileData.class);
|
||||
Block secondBlock = mock(Block.class);
|
||||
BlockData secondState = mock(BlockData.class);
|
||||
when(fixture.chunk().getBlock(15, 161, 1)).thenReturn(secondBlock);
|
||||
when(secondBlock.getBlockData()).thenReturn(secondState);
|
||||
when(second.isApplicable(secondState)).thenReturn(true);
|
||||
when(second.toBukkitTry(secondBlock)).thenReturn(false, true);
|
||||
try (MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class)) {
|
||||
binding.when(() -> BukkitWorldBinding.world(fixture.irisWorld())).thenReturn(fixture.world());
|
||||
fixture.service().queueTiles(fixture.engine(), new ChunkTiles(-2, 3, List.of(
|
||||
new PlacedTile(14, 161, 1, fixture.tile()), new PlacedTile(15, 161, 1, second))));
|
||||
}
|
||||
|
||||
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true));
|
||||
}
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), false));
|
||||
|
||||
verify(fixture.tile(), times(1)).toBukkitTry(fixture.block());
|
||||
verify(second, times(2)).toBukkitTry(secondBlock);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void worldUnregistrationDiscardsPendingTiles() {
|
||||
Fixture fixture = fixture();
|
||||
queue(fixture);
|
||||
|
||||
fixture.service().unregister(fixture.world());
|
||||
fixture.service().onChunkLoad(new ChunkLoadEvent(fixture.chunk(), true));
|
||||
|
||||
verifyNoInteractions(fixture.tile());
|
||||
}
|
||||
|
||||
private static void queue(Fixture fixture) {
|
||||
try (MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class)) {
|
||||
binding.when(() -> BukkitWorldBinding.world(fixture.irisWorld())).thenReturn(fixture.world());
|
||||
fixture.service().queueTiles(fixture.engine(), new ChunkTiles(-2, 3,
|
||||
List.of(new PlacedTile(14, 161, 1, fixture.tile()))));
|
||||
}
|
||||
}
|
||||
|
||||
private static Fixture fixture() {
|
||||
ObjectStudioSaveService service = new ObjectStudioSaveService();
|
||||
Engine engine = mock(Engine.class);
|
||||
EngineTarget target = mock(EngineTarget.class);
|
||||
IrisWorld irisWorld = mock(IrisWorld.class);
|
||||
when(engine.getTarget()).thenReturn(target);
|
||||
when(target.getWorld()).thenReturn(irisWorld);
|
||||
World world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.getName()).thenReturn("object-studio");
|
||||
Chunk chunk = mock(Chunk.class);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(chunk.getX()).thenReturn(-2);
|
||||
when(chunk.getZ()).thenReturn(3);
|
||||
Block block = mock(Block.class);
|
||||
BlockData state = mock(BlockData.class);
|
||||
when(chunk.getBlock(14, 161, 1)).thenReturn(block);
|
||||
when(block.getBlockData()).thenReturn(state);
|
||||
TileData tile = mock(TileData.class);
|
||||
when(tile.isApplicable(state)).thenReturn(true);
|
||||
when(tile.toBukkitTry(block)).thenReturn(true);
|
||||
return new Fixture(service, engine, irisWorld, world, chunk, block, tile);
|
||||
}
|
||||
|
||||
private record Fixture(ObjectStudioSaveService service, Engine engine, IrisWorld irisWorld, World world,
|
||||
Chunk chunk, Block block, TileData tile) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package art.arcane.iris.core.service;
|
||||
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineTarget;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionManager;
|
||||
import art.arcane.iris.engine.framework.GenerationTransitionGate;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisRegion;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.data.Cuboid;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.TreeType;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.data.BlockData;
|
||||
import org.bukkit.event.world.StructureGrowEvent;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class TreeSVCLifecycleTest {
|
||||
@Test
|
||||
public void growthDuringCutoverLeavesOwnerFreeForCheckpointAndRetriesAfterward() throws Exception {
|
||||
ExecutorService owner = Executors.newSingleThreadExecutor();
|
||||
ExecutorService reloader = Executors.newSingleThreadExecutor();
|
||||
Fixture fixture = new Fixture();
|
||||
AtomicInteger checkpoints = new AtomicInteger();
|
||||
try {
|
||||
StructureGrowEvent first = fixture.event();
|
||||
owner.submit(() -> fixture.grow(first)).get(2L, TimeUnit.SECONDS);
|
||||
assertFalse(first.isCancelled());
|
||||
assertEquals(1, fixture.biomeReads.get());
|
||||
|
||||
StructureGrowEvent during = fixture.event();
|
||||
CompletableFuture<Void> cutover = CompletableFuture.runAsync(() -> {
|
||||
try (GenerationTransitionGate.Transition ignored = fixture.sessions.transitionGate().beginTransition(2_000L)) {
|
||||
fixture.sessions.sealAndAwait("Studio generation cutover", 2_000L);
|
||||
Future<?> growth = owner.submit(() -> fixture.grow(during));
|
||||
owner.submit(checkpoints::incrementAndGet).get(2L, TimeUnit.SECONDS);
|
||||
growth.get(2L, TimeUnit.SECONDS);
|
||||
assertTrue(during.isCancelled());
|
||||
assertEquals(1, fixture.biomeReads.get());
|
||||
fixture.sessions.activateNextSession();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}, reloader);
|
||||
cutover.get(4L, TimeUnit.SECONDS);
|
||||
|
||||
StructureGrowEvent after = fixture.event();
|
||||
owner.submit(() -> fixture.grow(after)).get(2L, TimeUnit.SECONDS);
|
||||
assertFalse(after.isCancelled());
|
||||
assertEquals(2, fixture.biomeReads.get());
|
||||
assertEquals(1, checkpoints.get());
|
||||
assertEquals(0, fixture.sessions.activeLeases());
|
||||
} finally {
|
||||
owner.shutdownNow();
|
||||
reloader.shutdownNow();
|
||||
assertTrue(owner.awaitTermination(3L, TimeUnit.SECONDS));
|
||||
assertTrue(reloader.awaitTermination(3L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disabledTreeOverridesLeaveNormalGrowthUntouched() {
|
||||
Fixture fixture = new Fixture();
|
||||
fixture.dimension.getTreeSettings().setEnabled(false);
|
||||
StructureGrowEvent event = fixture.event();
|
||||
|
||||
fixture.grow(event);
|
||||
|
||||
assertFalse(event.isCancelled());
|
||||
assertEquals(0, fixture.biomeReads.get());
|
||||
assertEquals(0, fixture.sessions.activeLeases());
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final GenerationSessionManager sessions = new GenerationSessionManager(true);
|
||||
private final Engine engine = mock(Engine.class);
|
||||
private final World world = mock(World.class);
|
||||
private final IrisDimension dimension = new IrisDimension();
|
||||
private final PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
|
||||
private final TreeSVC service = spy(new TreeSVC());
|
||||
private final AtomicInteger biomeReads = new AtomicInteger();
|
||||
|
||||
private Fixture() {
|
||||
dimension.getTreeSettings().setEnabled(true);
|
||||
EngineTarget target = mock(EngineTarget.class);
|
||||
IrisWorld irisWorld = mock(IrisWorld.class);
|
||||
Block block = mock(Block.class);
|
||||
BlockData data = mock(BlockData.class);
|
||||
when(engine.getGenerationSessions()).thenReturn(sessions);
|
||||
when(engine.getDimension()).thenReturn(dimension);
|
||||
when(generator.getEngine()).thenReturn(engine);
|
||||
when(generator.getTarget()).thenReturn(target);
|
||||
when(target.getWorld()).thenReturn(irisWorld);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(block);
|
||||
when(world.getBlockAt(any(Location.class))).thenReturn(block);
|
||||
when(block.getBlockData()).thenReturn(data);
|
||||
when(data.clone()).thenReturn(data);
|
||||
doReturn(mock(Cuboid.class)).when(service).getSaplings(any(), any(), any());
|
||||
when(engine.getBiome(8, 80, 8)).thenAnswer(invocation -> {
|
||||
try (GenerationTransitionGate.Participation ignored = sessions.transitionGate().enter()) {
|
||||
biomeReads.incrementAndGet();
|
||||
return new IrisBiome();
|
||||
}
|
||||
});
|
||||
when(engine.getRegion(8, 80, 8)).thenReturn(new IrisRegion());
|
||||
}
|
||||
|
||||
private StructureGrowEvent event() {
|
||||
return new StructureGrowEvent(new Location(world, 8, 80, 8), TreeType.TREE, false, null, List.of());
|
||||
}
|
||||
|
||||
private void grow(StructureGrowEvent event) {
|
||||
try (MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
|
||||
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
|
||||
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
|
||||
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
|
||||
service.on(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EngineLifecycleTasks;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionManager;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class EngineBackgroundTasksAdmissionTest {
|
||||
@Test
|
||||
public void preliminaryDrainWaitsForSaveAdmittedAfterItsInitialSnapshot() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
CountDownLatch firstEntered = new CountDownLatch(1);
|
||||
CountDownLatch firstRelease = new CountDownLatch(1);
|
||||
CountDownLatch saveEntered = new CountDownLatch(1);
|
||||
CountDownLatch saveRelease = new CountDownLatch(1);
|
||||
try (ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
ExecutorService closer = Executors.newSingleThreadExecutor();
|
||||
MockedStatic<J> scheduler = scheduler(workers, new AtomicReference<>())) {
|
||||
try {
|
||||
fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease));
|
||||
assertTrue(firstEntered.await(5, TimeUnit.SECONDS));
|
||||
fixture.tasks().closeBackgroundTaskAdmission();
|
||||
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = startDrain(closer, fixture.tasks());
|
||||
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () ->
|
||||
fixture.tasks().scheduleAdmittedTask(fixture.engine(),
|
||||
() -> waitForRelease(saveEntered, saveRelease))));
|
||||
assertTrue(saveEntered.await(5, TimeUnit.SECONDS));
|
||||
firstRelease.countDown();
|
||||
assertThrows(TimeoutException.class, () -> drain.get(100, TimeUnit.MILLISECONDS));
|
||||
saveRelease.countDown();
|
||||
drain.get(5, TimeUnit.SECONDS).requireComplete("Studio cutover");
|
||||
} finally {
|
||||
firstRelease.countDown();
|
||||
saveRelease.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void newlyAdmittedFailureSurvivesCompletionAndLaterTaskSubmission() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
CountDownLatch firstEntered = new CountDownLatch(1);
|
||||
CountDownLatch firstRelease = new CountDownLatch(1);
|
||||
CountDownLatch failed = new CountDownLatch(1);
|
||||
IllegalStateException failure = new IllegalStateException("ownership write failed");
|
||||
AtomicReference<Throwable> logged = new AtomicReference<>();
|
||||
try (ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
ExecutorService closer = Executors.newSingleThreadExecutor();
|
||||
MockedStatic<J> scheduler = scheduler(workers, logged)) {
|
||||
try {
|
||||
fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease));
|
||||
assertTrue(firstEntered.await(5, TimeUnit.SECONDS));
|
||||
fixture.tasks().closeBackgroundTaskAdmission();
|
||||
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = startDrain(closer, fixture.tasks());
|
||||
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () ->
|
||||
fixture.tasks().scheduleAdmittedTask(fixture.engine(), () -> {
|
||||
failed.countDown();
|
||||
throw failure;
|
||||
})));
|
||||
assertTrue(failed.await(5, TimeUnit.SECONDS));
|
||||
awaitLogged(logged, failure);
|
||||
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () ->
|
||||
fixture.tasks().scheduleAdmittedTask(fixture.engine(), () -> {})));
|
||||
firstRelease.countDown();
|
||||
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(result.complete());
|
||||
assertNotNull(result.failure());
|
||||
assertSame(failure, result.failure().getCause());
|
||||
assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover"));
|
||||
} finally {
|
||||
firstRelease.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failureSettledBeforeDrainDoesNotPoisonTheNextTransition() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
IllegalStateException failure = new IllegalStateException("previous storage failure");
|
||||
AtomicReference<Throwable> logged = new AtomicReference<>();
|
||||
try (ExecutorService workers = Executors.newSingleThreadExecutor();
|
||||
MockedStatic<J> scheduler = scheduler(workers, logged)) {
|
||||
fixture.tasks().scheduleTrackedTask(() -> {
|
||||
throw failure;
|
||||
});
|
||||
workers.submit(() -> {}).get(5, TimeUnit.SECONDS);
|
||||
assertSame(failure, logged.get());
|
||||
fixture.tasks().closeBackgroundTaskAdmission();
|
||||
fixture.tasks().drainBackgroundTasks("next transition").requireComplete("next transition");
|
||||
assertFalse(fixture.tasks().scheduleTrackedTask(() -> {}));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void continuationsShareTheOriginalDrainDeadline() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
CountDownLatch firstEntered = new CountDownLatch(1);
|
||||
CountDownLatch firstRelease = new CountDownLatch(1);
|
||||
CountDownLatch saveEntered = new CountDownLatch(1);
|
||||
CountDownLatch saveRelease = new CountDownLatch(1);
|
||||
try (ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
ExecutorService closer = Executors.newSingleThreadExecutor();
|
||||
MockedStatic<J> scheduler = scheduler(workers, new AtomicReference<>())) {
|
||||
try {
|
||||
fixture.tasks().scheduleTrackedTask(() -> waitForRelease(firstEntered, firstRelease));
|
||||
assertTrue(firstEntered.await(5, TimeUnit.SECONDS));
|
||||
fixture.tasks().closeBackgroundTaskAdmission();
|
||||
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = startDrain(closer, fixture.tasks());
|
||||
assertTrue(EngineLifecycleTasks.run(fixture.engine(), "world_save", () ->
|
||||
fixture.tasks().scheduleAdmittedTask(fixture.engine(),
|
||||
() -> waitForRelease(saveEntered, saveRelease))));
|
||||
assertTrue(saveEntered.await(5, TimeUnit.SECONDS));
|
||||
TimeUnit.SECONDS.sleep(7);
|
||||
firstRelease.countDown();
|
||||
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(10, TimeUnit.SECONDS);
|
||||
assertTrue(result.failure() instanceof TimeoutException);
|
||||
assertThrows(IllegalStateException.class, () -> result.requireComplete("Studio cutover"));
|
||||
} finally {
|
||||
firstRelease.countDown();
|
||||
saveRelease.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancellationClaimsAQueuedTaskBeforeItCanStart() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
CountDownLatch gate = new CountDownLatch(1);
|
||||
AtomicBoolean ran = new AtomicBoolean();
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
|
||||
// Mirrors the ForkJoinPool behind J.a: cancel(true) reports success yet cannot stop a
|
||||
// callable the pool has already dequeued, so the claim must be decided by the task itself.
|
||||
scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> {
|
||||
Callable<Void> task = invocation.getArgument(0);
|
||||
CompletableFuture<Void> handle = new CompletableFuture<>();
|
||||
worker.execute(() -> {
|
||||
try {
|
||||
assertTrue(gate.await(5, TimeUnit.SECONDS));
|
||||
task.call();
|
||||
handle.complete(null);
|
||||
} catch (Throwable failure) {
|
||||
handle.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
return handle;
|
||||
});
|
||||
assertTrue(fixture.tasks().scheduleTrackedTask(() -> ran.set(true)));
|
||||
fixture.tasks().cancelBackgroundTasks("close");
|
||||
gate.countDown();
|
||||
worker.submit(() -> {}).get(5, TimeUnit.SECONDS);
|
||||
assertFalse(ran.get());
|
||||
fixture.tasks().closeBackgroundTaskAdmission();
|
||||
EngineBackgroundTasks.BackgroundTaskDrain drain = fixture.tasks().drainBackgroundTasks("close");
|
||||
assertTrue(drain.complete());
|
||||
} finally {
|
||||
worker.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cancellationOfARunningTaskLeavesItsCompletionToTheTask() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
try (ExecutorService workers = Executors.newSingleThreadExecutor();
|
||||
MockedStatic<J> scheduler = scheduler(workers, new AtomicReference<>())) {
|
||||
assertTrue(fixture.tasks().scheduleTrackedTask(() -> waitForReleaseIgnoringInterrupts(entered, release)));
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS));
|
||||
fixture.tasks().cancelBackgroundTasks("close");
|
||||
fixture.tasks().closeBackgroundTaskAdmission();
|
||||
ExecutorService closer = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = closer.submit(
|
||||
() -> fixture.tasks().drainBackgroundTasks("close"));
|
||||
assertThrows(TimeoutException.class, () -> drain.get(200, TimeUnit.MILLISECONDS));
|
||||
release.countDown();
|
||||
EngineBackgroundTasks.BackgroundTaskDrain result = drain.get(5, TimeUnit.SECONDS);
|
||||
assertTrue(result.complete());
|
||||
result.requireComplete("close");
|
||||
} finally {
|
||||
release.countDown();
|
||||
closer.shutdownNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Future<EngineBackgroundTasks.BackgroundTaskDrain> startDrain(
|
||||
ExecutorService closer, EngineBackgroundTasks tasks) {
|
||||
AtomicReference<Thread> drainingThread = new AtomicReference<>();
|
||||
Future<EngineBackgroundTasks.BackgroundTaskDrain> drain = closer.submit(() -> {
|
||||
drainingThread.set(Thread.currentThread());
|
||||
return tasks.drainBackgroundTasks("Studio cutover");
|
||||
});
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (System.nanoTime() < deadline) {
|
||||
Thread thread = drainingThread.get();
|
||||
if (thread != null && thread.getState() == Thread.State.TIMED_WAITING) {
|
||||
for (StackTraceElement frame : thread.getStackTrace()) {
|
||||
if (frame.getClassName().equals(EngineBackgroundTasks.class.getName())
|
||||
&& frame.getMethodName().equals("drainBackgroundTasks")) {
|
||||
return drain;
|
||||
}
|
||||
}
|
||||
}
|
||||
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
|
||||
}
|
||||
throw new AssertionError("Background drain did not await the initial task");
|
||||
}
|
||||
|
||||
private static void awaitLogged(AtomicReference<Throwable> logged, Throwable failure) {
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (logged.get() == failure) {
|
||||
return;
|
||||
}
|
||||
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1));
|
||||
}
|
||||
throw new AssertionError("Background task did not log its failure");
|
||||
}
|
||||
|
||||
private static void waitForReleaseIgnoringInterrupts(CountDownLatch entered, CountDownLatch release) {
|
||||
entered.countDown();
|
||||
boolean interrupted = false;
|
||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
|
||||
while (true) {
|
||||
try {
|
||||
assertTrue(release.await(Math.max(1L, deadline - System.nanoTime()), TimeUnit.NANOSECONDS));
|
||||
break;
|
||||
} catch (InterruptedException interruption) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
if (interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private static void waitForRelease(CountDownLatch entered, CountDownLatch release) {
|
||||
entered.countDown();
|
||||
try {
|
||||
assertTrue(release.await(30, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException interruption) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(interruption);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static MockedStatic<J> scheduler(ExecutorService workers, AtomicReference<Throwable> logged) {
|
||||
MockedStatic<J> scheduler = mockStatic(J.class);
|
||||
scheduler.when(() -> J.a(any(Callable.class))).thenAnswer(invocation -> {
|
||||
Callable<Void> task = invocation.getArgument(0);
|
||||
return workers.submit(() -> {
|
||||
try (MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class)) {
|
||||
logging.when(() -> IrisLogging.reportError(any(Throwable.class))).thenAnswer(call -> {
|
||||
logged.set(call.getArgument(0));
|
||||
return null;
|
||||
});
|
||||
return task.call();
|
||||
}
|
||||
});
|
||||
});
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
private static Fixture fixture() {
|
||||
EngineBackgroundTasks tasks = new EngineBackgroundTasks();
|
||||
tasks.openBackgroundTaskAdmission();
|
||||
GenerationSessionManager sessions = new GenerationSessionManager(true);
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.getGenerationSessions()).thenReturn(sessions);
|
||||
when(engine.getGenerationSessionId()).thenAnswer(invocation -> sessions.currentSessionId());
|
||||
return new Fixture(tasks, engine);
|
||||
}
|
||||
|
||||
private record Fixture(EngineBackgroundTasks tasks, Engine engine) {
|
||||
}
|
||||
}
|
||||
@@ -641,7 +641,7 @@ public class EngineShutdownDrainTest {
|
||||
|
||||
private GenerationKernelRegistry.RuntimeKernel prepareComplexHotload(IrisComplex replacement) throws Exception {
|
||||
GenerationKernelRegistry.RuntimeKernel kernel = mock(GenerationKernelRegistry.RuntimeKernel.class);
|
||||
when(kernel.createComplex(engine, null)).thenReturn(replacement);
|
||||
when(kernel.createComplex(engine, null, false)).thenReturn(replacement);
|
||||
when(generation.runtimeKernel()).thenReturn(kernel);
|
||||
GenerationRuntime nextGeneration = generation(target, mantle);
|
||||
when(nextGeneration.complex()).thenReturn(replacement);
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package art.arcane.iris.engine;
|
||||
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.EnginePlatformHooks;
|
||||
import art.arcane.iris.engine.framework.PreservationRegistry;
|
||||
import art.arcane.iris.engine.framework.SeedManager;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.StudioMode;
|
||||
import art.arcane.iris.engine.platform.studio.BiomeBuffetLayout;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.testsupport.PlatformLeakGuard;
|
||||
import art.arcane.iris.util.common.data.B;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Before;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisComplexBiomeBuffetTest {
|
||||
@ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean();
|
||||
@Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
private IrisSettings previous;
|
||||
|
||||
@Before
|
||||
public void prepareRuntime() {
|
||||
previous = IrisSettings.settings;
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
IrisServices.register(PreservationRegistry.class, mock(PreservationRegistry.class));
|
||||
}
|
||||
|
||||
@After
|
||||
public void releaseRuntime() {
|
||||
IrisPlatforms.unbind();
|
||||
IrisServices.remove(PreservationRegistry.class);
|
||||
IrisSettings.settings = previous;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void neighboringCellsUseTheirOwnRealBiomeStreamsWithoutChangingAuthoredFocus() throws Exception {
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.platformName()).thenReturn("bukkit");
|
||||
IrisPlatforms.bind(platform);
|
||||
Path pack = fixture();
|
||||
IrisData data = IrisData.openRuntime(pack.toFile());
|
||||
try (MockedStatic<B> blocks = mockStatic(B.class)) {
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
when(block.isFluid()).thenReturn(true);
|
||||
blocks.when(() -> B.getState(anyString())).thenReturn(block);
|
||||
blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block);
|
||||
IrisDimension dimension = data.getDimensionLoader().load("main");
|
||||
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
doReturn(data).when(engine).getData();
|
||||
doReturn(dimension).when(engine).getDimension();
|
||||
doReturn(data.getBiomeLoader().load("alpha")).when(engine).getFocus();
|
||||
doReturn(true).when(engine).isStudio();
|
||||
doReturn(384).when(engine).getHeight();
|
||||
doReturn(-64).when(engine).getMinHeight();
|
||||
doReturn(320).when(engine).getMaxHeight();
|
||||
doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks();
|
||||
doReturn(new SeedManager(1337L)).when(engine).getSeedManager();
|
||||
IrisComplex complex = new IrisComplex(engine);
|
||||
doReturn(complex).when(engine).getComplex();
|
||||
try {
|
||||
assertEquals("alpha", complex.getTrueBiomeStream().get(15, 0).getLoadKey());
|
||||
assertEquals("beta", complex.getTrueBiomeStream().get(16, 0).getLoadKey());
|
||||
assertEquals("gamma", complex.getTrueBiomeStream().get(0, 16).getLoadKey());
|
||||
assertEquals(137D, complex.getBaseTerrainHeightStream().getDouble(15, 0), 0D);
|
||||
assertEquals(147D, complex.getBaseTerrainHeightStream().getDouble(16, 0), 0D);
|
||||
assertEquals(157D, complex.getBaseTerrainHeightStream().getDouble(0, 16), 0D);
|
||||
assertEquals("owned", complex.getRegionStream().get(16, 0).getLoadKey());
|
||||
assertEquals("iris-focus/", complex.getRegionStream().get(0, 16).getLoadKey().substring(0, 11));
|
||||
assertEquals("alpha", dimension.getFocus());
|
||||
try (ExecutorService readers = Executors.newFixedThreadPool(2)) {
|
||||
List<Callable<String>> queries = List.of(
|
||||
() -> complex.getTrueBiomeStream().get(15, 0).getLoadKey(),
|
||||
() -> complex.getTrueBiomeStream().get(16, 0).getLoadKey());
|
||||
List<Future<String>> values = readers.invokeAll(queries);
|
||||
assertEquals("alpha", values.get(0).get());
|
||||
assertEquals("beta", values.get(1).get());
|
||||
}
|
||||
doReturn(false).when(engine).isStudio();
|
||||
IrisComplex production = new IrisComplex(engine);
|
||||
try {
|
||||
assertNull(production.getBiomeBuffet());
|
||||
assertEquals("alpha", production.getTrueBiomeStream().get(16, 0).getLoadKey());
|
||||
} finally {
|
||||
production.close();
|
||||
}
|
||||
} finally {
|
||||
complex.close();
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyBuffetSizeHasStableCellsAndNoWrappedOrNegativeCells() throws Exception {
|
||||
IrisData data = IrisData.openRuntime(fixture().toFile());
|
||||
try {
|
||||
IrisDimension dimension = data.getDimensionLoader().load("main");
|
||||
for (StudioMode mode : StudioMode.values()) {
|
||||
int size = mode.biomeSizeChunks();
|
||||
if (size == 0) {
|
||||
continue;
|
||||
}
|
||||
dimension.setStudioMode(mode);
|
||||
BiomeBuffetLayout layout = new BiomeBuffetLayout(dimension, () -> data);
|
||||
assertEquals("alpha", layout.chunk(size - 1, size - 1).biome().getLoadKey());
|
||||
assertEquals("beta", layout.chunk(size, 0).biome().getLoadKey());
|
||||
assertEquals("gamma", layout.chunk(0, size).biome().getLoadKey());
|
||||
assertNotNull(layout.chunk(size, size));
|
||||
assertNull(layout.chunk(size * 2, 0));
|
||||
assertNull(layout.chunk(0, size * 2));
|
||||
assertNull(layout.chunk(-1, 0));
|
||||
assertNull(layout.chunk(0, -1));
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedBuffetBiomesDoNotChangeTheFocusedBiomesGeneratorNoise() throws Exception {
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.platformName()).thenReturn("bukkit");
|
||||
IrisPlatforms.bind(platform);
|
||||
Path pack = fixture();
|
||||
Files.writeString(pack.resolve("biomes/alpha.json"),
|
||||
"{\"children\":[\"beta\"],\"generators\":[{\"generator\":\"flat\",\"min\":0,\"max\":40}]}");
|
||||
Files.writeString(pack.resolve("biomes/gamma.json"),
|
||||
"{\"generators\":[{\"generator\":\"unrelated\",\"min\":0,\"max\":40}]}");
|
||||
Files.writeString(pack.resolve("generators/unrelated.json"),
|
||||
"{\"composite\":[{\"enabled\":false,\"offsetY\":1}]}");
|
||||
IrisData data = IrisData.openRuntime(pack.toFile());
|
||||
try (MockedStatic<B> blocks = mockStatic(B.class)) {
|
||||
PlatformBlockState block = mock(PlatformBlockState.class);
|
||||
when(block.isFluid()).thenReturn(true);
|
||||
blocks.when(() -> B.getStateOrNull(anyString(), eq(false))).thenReturn(block);
|
||||
IrisDimension dimension = data.getDimensionLoader().load("main");
|
||||
Engine engine = mock(Engine.class, CALLS_REAL_METHODS);
|
||||
doReturn(data).when(engine).getData();
|
||||
doReturn(dimension).when(engine).getDimension();
|
||||
doReturn(data.getBiomeLoader().load("alpha")).when(engine).getFocus();
|
||||
doReturn(true).when(engine).isStudio();
|
||||
doReturn(384).when(engine).getHeight();
|
||||
doReturn(-64).when(engine).getMinHeight();
|
||||
doReturn(320).when(engine).getMaxHeight();
|
||||
doReturn(mock(EnginePlatformHooks.class)).when(engine).getPlatformHooks();
|
||||
doReturn(new SeedManager(1337L)).when(engine).getSeedManager();
|
||||
IrisComplex buffet = new IrisComplex(engine);
|
||||
doReturn(false).when(engine).isStudio();
|
||||
IrisComplex focused = new IrisComplex(engine);
|
||||
try {
|
||||
assertEquals(127D, focused.getBaseTerrainHeightStream().getDouble(0, 0), 0D);
|
||||
assertEquals(focused.getBaseTerrainHeightStream().getDouble(0, 0),
|
||||
buffet.getBaseTerrainHeightStream().getDouble(0, 0), 0D);
|
||||
assertEquals(167D, buffet.getBaseTerrainHeightStream().getDouble(0, 16), 0D);
|
||||
} finally {
|
||||
focused.close();
|
||||
buffet.close();
|
||||
}
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
}
|
||||
|
||||
private Path fixture() throws Exception {
|
||||
Path pack = temporaryFolder.newFolder().toPath();
|
||||
Files.createDirectories(pack.resolve("dimensions"));
|
||||
Files.createDirectories(pack.resolve("regions"));
|
||||
Files.createDirectories(pack.resolve("biomes"));
|
||||
Files.createDirectories(pack.resolve("generators"));
|
||||
Files.writeString(pack.resolve("dimensions/main.json"), """
|
||||
{"focus":"alpha","regions":["owned"],"studioMode":"BIOME_BUFFET_1x1",
|
||||
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
|
||||
""");
|
||||
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"alpha\"]}");
|
||||
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
|
||||
String[] names = {"alpha", "beta", "gamma", "zeta"};
|
||||
for (int index = 0; index < names.length; index++) {
|
||||
int height = (index + 1) * 10;
|
||||
Files.writeString(pack.resolve("biomes/" + names[index] + ".json"),
|
||||
"{\"generators\":[{\"generator\":\"flat\",\"min\":" + height + ",\"max\":" + height + "}]"
|
||||
+ (index == 0 ? ",\"children\":[\"beta\"]" : "") + "}");
|
||||
}
|
||||
return pack;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,22 @@ public class IrisComplexStudioCacheTest {
|
||||
@Test
|
||||
public void studioUsesWideNoiseCacheWithoutChangingConfiguredLiveWorldSize() {
|
||||
Engine engine = mock(Engine.class);
|
||||
|
||||
when(engine.isStudio()).thenReturn(false);
|
||||
assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024));
|
||||
|
||||
assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, false));
|
||||
when(engine.isStudio()).thenReturn(true);
|
||||
assertEquals(32_768, IrisComplex.noiseCacheSize(engine, 1_024));
|
||||
assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536));
|
||||
assertEquals(32_768, IrisComplex.noiseCacheSize(engine, 1_024, false));
|
||||
assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedRuntimesKeepTheConfiguredCacheSizeInStudio() {
|
||||
// Historical activation runtimes only answer transition-band and saved-biome queries; every
|
||||
// one of them carrying the Studio-wide cache multiplied resident memory per hotload.
|
||||
Engine engine = mock(Engine.class);
|
||||
when(engine.isStudio()).thenReturn(true);
|
||||
assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, true));
|
||||
assertEquals(65_536, IrisComplex.noiseCacheSize(engine, 65_536, true));
|
||||
when(engine.isStudio()).thenReturn(false);
|
||||
assertEquals(1_024, IrisComplex.noiseCacheSize(engine, 1_024, true));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user