mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
Vwop
This commit is contained in:
+111
-56
@@ -4,7 +4,7 @@ import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
@@ -14,11 +14,15 @@ import art.arcane.iris.nativegen.NativeStructureGenerationException;
|
||||
import art.arcane.iris.nativegen.NativeStructureStartInjector;
|
||||
import art.arcane.iris.nativegen.NativeStructureReferenceEnvelope;
|
||||
import art.arcane.iris.nativegen.NativeStructureLocateResults;
|
||||
import art.arcane.iris.nativegen.NativeStructureLocatePersistence;
|
||||
import art.arcane.iris.nativegen.NativeStructureOwnershipRecovery;
|
||||
import art.arcane.iris.nativegen.NativeStructurePostProcessor;
|
||||
import art.arcane.iris.nativegen.NativeStructureReferenceRepair;
|
||||
import art.arcane.iris.nativegen.NativeStructureSurfaceFitter;
|
||||
import art.arcane.iris.nativegen.NativeStructureTerrainIntegrator;
|
||||
import art.arcane.iris.nativegen.NativeStructureVegetationClearer;
|
||||
import art.arcane.iris.nativegen.NativeStructureVerticalPlacer;
|
||||
import art.arcane.iris.nativegen.NativeStructureVanillaLocator;
|
||||
import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.util.common.data.IrisCustomData;
|
||||
@@ -87,6 +91,7 @@ import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -131,55 +136,99 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
public @Nullable Pair<BlockPos, Holder<Structure>> findNearestMapStructure(ServerLevel level, HolderSet<Structure> holders, BlockPos pos, int radius, boolean findUnexplored) {
|
||||
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_structure_locate");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
Pair<BlockPos, Holder<Structure>> irisPlaced = findNearestIrisStructure(
|
||||
level, holders, pos, Math.max(1, radius), findUnexplored);
|
||||
HolderSet<Structure> reachable = filterReachableStructures(level, holders);
|
||||
Pair<BlockPos, Holder<Structure>> nativeLocated = reachable == null || reachable.size() == 0
|
||||
? null
|
||||
: delegate.findNearestMapStructure(level, reachable, pos, radius, findUnexplored);
|
||||
return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated);
|
||||
NativeStructureVanillaLocator.Candidate nativeCandidate =
|
||||
reachable == null || reachable.size() == 0 ? null
|
||||
: NativeStructureVanillaLocator.predict(
|
||||
level, reachable, pos, radius, findUnexplored);
|
||||
return findNearestIrisStructure(
|
||||
level, holders, pos, Math.max(0, radius),
|
||||
findUnexplored, nativeCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
private Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(ServerLevel level,
|
||||
HolderSet<Structure> holders,
|
||||
BlockPos pos, int radius,
|
||||
boolean findUnexplored) {
|
||||
if (findUnexplored) {
|
||||
return null;
|
||||
}
|
||||
boolean findUnexplored,
|
||||
NativeStructureVanillaLocator.Candidate nativeCandidate) {
|
||||
Pair<BlockPos, Holder<Structure>> nativeLocated =
|
||||
nativeCandidate == null ? null : nativeCandidate.result();
|
||||
Runnable nativeReference = () -> {
|
||||
if (nativeCandidate != null) {
|
||||
nativeCandidate.reference(level.structureManager());
|
||||
}
|
||||
};
|
||||
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||
BlockPos best = null;
|
||||
Holder<Structure> bestHolder = null;
|
||||
long bestDist = Long.MAX_VALUE;
|
||||
List<IrisNativeLocateSearch> searches = new ArrayList<>(holders.size());
|
||||
NativeStructureLocatePersistence.ProbeBudget budget = NativeStructureLocatePersistence.probeBudget();
|
||||
for (Holder<Structure> holder : holders) {
|
||||
Object id = registry.getKey(holder.value());
|
||||
if (id == null) {
|
||||
throw new IllegalStateException("Native structure locate received an unregistered structure holder");
|
||||
}
|
||||
String structureId = id.toString();
|
||||
if (!IrisStructureLocator.isPlaced(engine, structureId)) {
|
||||
if (!IrisStructureLocator.hasNativePlacement(engine, structureId)) {
|
||||
continue;
|
||||
}
|
||||
IrisStructureLocator.LocateResult result = IrisStructureLocator.locate(
|
||||
engine, structureId, pos.getX(), pos.getZ(), radius);
|
||||
if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
|
||||
throw new IllegalStateException("Iris structure locate reached its safety limit for "
|
||||
+ structureId + " within " + radius + " chunks");
|
||||
}
|
||||
if (!result.found()) {
|
||||
continue;
|
||||
}
|
||||
long dx = (long) result.originX() - pos.getX();
|
||||
long dz = (long) result.originZ() - pos.getZ();
|
||||
long d = dx * dx + dz * dz;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
best = new BlockPos(result.originX(), result.baseY(), result.originZ());
|
||||
bestHolder = holder;
|
||||
}
|
||||
NativeStructureLocatePersistence.Probe probe = NativeStructureLocatePersistence.probe(
|
||||
level, holder.value(), findUnexplored, budget);
|
||||
searches.add(new IrisNativeLocateSearch(
|
||||
holder, structureId, NativeStructureLocatePersistence.search(
|
||||
engine, structureId, pos.getX(), pos.getZ(), radius, probe)));
|
||||
}
|
||||
return best == null ? null : Pair.of(best, bestHolder);
|
||||
searches.sort(Comparator.comparing(IrisNativeLocateSearch::structureId));
|
||||
for (int attempt = 0; attempt < NativeStructureLocatePersistence.MAX_SELECTED_CANDIDATE_RETRIES; attempt++) {
|
||||
IrisNativeLocateSearch bestSearch = null;
|
||||
IrisStructureLocator.LocateResult bestResult = null;
|
||||
long bestDistance = Long.MAX_VALUE;
|
||||
for (IrisNativeLocateSearch search : searches) {
|
||||
IrisStructureLocator.LocateResult result = search.search().predict();
|
||||
if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
|
||||
throw new IllegalStateException("Iris structure locate reached its safety limit for "
|
||||
+ search.structureId() + " within " + radius + " placement rings");
|
||||
}
|
||||
if (!result.found()) {
|
||||
continue;
|
||||
}
|
||||
long dx = (long) result.originX() - pos.getX();
|
||||
long dz = (long) result.originZ() - pos.getZ();
|
||||
long distance = dx * dx + dz * dz;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
bestSearch = search;
|
||||
bestResult = result;
|
||||
}
|
||||
}
|
||||
if (bestSearch == null) {
|
||||
return NativeStructureLocateResults.selectAndReference(
|
||||
pos, null, () -> { }, nativeLocated, nativeReference);
|
||||
}
|
||||
Pair<BlockPos, Holder<Structure>> predicted = Pair.of(
|
||||
new BlockPos(bestResult.originX(), bestResult.baseY(), bestResult.originZ()),
|
||||
bestSearch.holder());
|
||||
if (NativeStructureLocateResults.nearest(pos, predicted, nativeLocated) != predicted) {
|
||||
return NativeStructureLocateResults.selectAndReference(
|
||||
pos, predicted, () -> { }, nativeLocated, nativeReference);
|
||||
}
|
||||
NativeStructureLocatePersistence.VerifiedStart verified =
|
||||
bestSearch.search().verify(bestResult);
|
||||
if (verified == null) {
|
||||
bestSearch.search().reject(bestResult);
|
||||
continue;
|
||||
}
|
||||
BlockPos located = new BlockPos(
|
||||
bestResult.originX(), verified.ownership().locatorY(),
|
||||
bestResult.originZ());
|
||||
Pair<BlockPos, Holder<Structure>> irisLocated = Pair.of(located, bestSearch.holder());
|
||||
IrisNativeLocateSearch selectedSearch = bestSearch;
|
||||
NativeStructureLocatePersistence.VerifiedStart selectedStart = verified;
|
||||
return NativeStructureLocateResults.selectAndReference(
|
||||
pos, irisLocated, () -> selectedSearch.search().reference(selectedStart),
|
||||
nativeLocated, nativeReference);
|
||||
}
|
||||
throw new IllegalStateException("Iris structure locate rejected too many selected candidates within "
|
||||
+ radius + " placement rings");
|
||||
}
|
||||
|
||||
private HolderSet<Structure> filterReachableStructures(ServerLevel level, HolderSet<Structure> holders) {
|
||||
@@ -323,9 +372,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
decision.preserveSourceY(),
|
||||
decision.yBand(),
|
||||
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
|
||||
start, structure, start.getReferences(), templateManager,
|
||||
NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()));
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrapForPublication(
|
||||
start, structure, start.getReferences(),
|
||||
NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()),
|
||||
structureId);
|
||||
access.setStartForStructure(structure, wrapped);
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
@@ -341,7 +391,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
|
||||
@Override
|
||||
public void createReferences(WorldGenLevel generatoraccessseed, StructureManager structuremanager, ChunkAccess ichunkaccess) {
|
||||
delegate.createReferences(generatoraccessseed, structuremanager, ichunkaccess);
|
||||
try (GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_references");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
NativeStructureReferenceRepair.createReferences(
|
||||
engine, generatoraccessseed, structuremanager, ichunkaccess);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -474,7 +528,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
int steps = GenerationStep.Decoration.values().length;
|
||||
List<NativePlacementGroup> placementGroups = new ArrayList<>();
|
||||
List<StructureStart> heightmapStarts = new ArrayList<>();
|
||||
List<StructureStart> nativeStarts = new ArrayList<>();
|
||||
List<NativeStructureVegetationClearer.VegetationTarget> vegetationTargets = new ArrayList<>();
|
||||
List<NativeStructureTerrainIntegrator.TerrainTarget> terrainTargets = new ArrayList<>();
|
||||
for (int step = 0; step < steps; step++) {
|
||||
@@ -492,10 +545,11 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
List<StructureStart> starts = structureManager.startsForStructure(sectionPos, structure);
|
||||
List<NativePlacement> resolvedPlacements = new ArrayList<>(starts.size());
|
||||
for (StructureStart start : starts) {
|
||||
NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan(
|
||||
engine, structureId, start.getChunkPos().x(), start.getChunkPos().z());
|
||||
IrisNativeStructureDecision decision = plan == null
|
||||
? sourceDecision : NativeStructurePlacementPlanner.decisionFor(plan);
|
||||
NativeStructureOwnershipRecord ownership =
|
||||
NativeStructureOwnershipRecovery.resolve(
|
||||
engine, world.getLevel(), structureId, structure, start);
|
||||
IrisNativeStructureDecision decision =
|
||||
ownership == null ? sourceDecision : ownership.restoredDecision();
|
||||
if (!decision.generate()) {
|
||||
continue;
|
||||
}
|
||||
@@ -505,9 +559,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
structureId, start,
|
||||
NativeStructureTerrainIntegrator.resolveNativeTerrain(
|
||||
start, decision.terrain())));
|
||||
if (plan == null || !plan.placement().isUnderground()) {
|
||||
nativeStarts.add(start);
|
||||
}
|
||||
boolean clearEntireFootprint = NativeStructureVegetationClearer
|
||||
.shouldClearEntireVegetationFootprint(
|
||||
structure.step(), decision.clearVegetation());
|
||||
@@ -533,15 +584,6 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
"heightmap priming", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
try {
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world, area, nativeStarts,
|
||||
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"terrain integration", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
try {
|
||||
NativeStructureVegetationClearer.clearIntersectingVegetation(
|
||||
world, chunk, area, vegetationTargets);
|
||||
@@ -550,6 +592,15 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
"vegetation cleanup", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
try {
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world, area, terrainTargets,
|
||||
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight());
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"terrain integration", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
try {
|
||||
NativeStructurePostProcessor.prepareTerrain(
|
||||
world, area, terrainTargets, this::resolvePaletteBlock);
|
||||
@@ -641,8 +692,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
ChunkPos cp = chunk.getPos();
|
||||
int i = cp.getMinBlockX();
|
||||
int j = cp.getMinBlockZ();
|
||||
int minY = chunk.getMinY();
|
||||
int maxY = minY + chunk.getHeight() - 1;
|
||||
int minY = chunk.getMinY() + 1;
|
||||
int maxY = chunk.getMinY() + chunk.getHeight() - 1;
|
||||
return new BoundingBox(i, minY, j, i + 15, maxY, j + 15);
|
||||
}
|
||||
|
||||
@@ -827,4 +878,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
|
||||
private record NativeLocateCandidate(Holder<Structure> holder, String key) {
|
||||
}
|
||||
|
||||
private record IrisNativeLocateSearch(Holder<Structure> holder, String structureId,
|
||||
NativeStructureLocatePersistence.Search search) {
|
||||
}
|
||||
}
|
||||
|
||||
+61
-2
@@ -15,7 +15,9 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisDimensionRuntimeContract;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.nativegen.NativeStructureFactory;
|
||||
import art.arcane.iris.nativegen.NativeStructureGenerationException;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import art.arcane.iris.util.project.agent.Agent;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
@@ -102,6 +104,7 @@ import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.levelgen.XoroshiroRandomSource;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureCheck;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
|
||||
import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature;
|
||||
@@ -557,6 +560,62 @@ public class NMSBinding implements INMSBinding {
|
||||
return keys;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JigsawSourceMetadata getJigsawSourceMetadata(String structureKey) {
|
||||
try {
|
||||
Identifier identifier = Identifier.tryParse(structureKey);
|
||||
if (identifier == null) {
|
||||
throw new IllegalArgumentException("Invalid registered structure key: " + structureKey);
|
||||
}
|
||||
Registry<Structure> structures = registry().lookupOrThrow(Registries.STRUCTURE);
|
||||
Structure structure = structures.getValue(identifier);
|
||||
if (structure == null) {
|
||||
throw new IllegalArgumentException("Registered structure does not exist: " + structureKey);
|
||||
}
|
||||
if (!(structure instanceof JigsawStructure jigsaw)) {
|
||||
throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey);
|
||||
}
|
||||
MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer();
|
||||
return NativeStructureFactory.sourceMetadata(
|
||||
registry(), server.getStructureManager(), jigsaw);
|
||||
} catch (RuntimeException error) {
|
||||
throw new IllegalStateException("Iris failed to resolve live jigsaw metadata for registered structure '"
|
||||
+ structureKey + "'", error);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTemplatePoolHorizontalSpan(String templatePoolKey) {
|
||||
try {
|
||||
MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer();
|
||||
return NativeStructureFactory.templatePoolHorizontalSpan(
|
||||
registry(), server.getStructureManager(), templatePoolKey);
|
||||
} catch (RuntimeException error) {
|
||||
throw new IllegalStateException("Iris failed to resolve the live horizontal span for registered "
|
||||
+ "template pool '" + templatePoolKey + "'", error);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getJigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) {
|
||||
try {
|
||||
Identifier identifier = Identifier.tryParse(structureKey);
|
||||
if (identifier == null) {
|
||||
throw new IllegalArgumentException("Invalid registered structure key: " + structureKey);
|
||||
}
|
||||
Structure structure = registry().lookupOrThrow(Registries.STRUCTURE).getValue(identifier);
|
||||
if (!(structure instanceof JigsawStructure jigsaw)) {
|
||||
throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey);
|
||||
}
|
||||
MinecraftServer server = ((CraftServer) Bukkit.getServer()).getHandle().getServer();
|
||||
return NativeStructureFactory.jigsawStartPoolHorizontalSpan(
|
||||
registry(), server.getStructureManager(), jigsaw, templatePoolKey);
|
||||
} catch (RuntimeException error) {
|
||||
throw new IllegalStateException("Iris failed to resolve the effective start-pool span for registered "
|
||||
+ "jigsaw structure '" + structureKey + "' and pool '" + templatePoolKey + "'", error);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public KList<String> getStructureSetKeys() {
|
||||
KList<String> keys = new KList<>();
|
||||
@@ -700,7 +759,7 @@ public class NMSBinding implements INMSBinding {
|
||||
return null;
|
||||
}
|
||||
|
||||
net.minecraft.world.level.levelgen.structure.BoundingBox box = start.getBoundingBox();
|
||||
BoundingBox box = start.getBoundingBox();
|
||||
int spanX = box.maxX() - box.minX() + 1;
|
||||
int spanY = box.maxY() - box.minY() + 1;
|
||||
int spanZ = box.maxZ() - box.minZ() + 1;
|
||||
@@ -717,7 +776,7 @@ public class NMSBinding implements INMSBinding {
|
||||
for (int cz = minCZ; cz <= maxCZ; cz++) {
|
||||
level.getChunk(cx, cz);
|
||||
net.minecraft.world.level.ChunkPos cp = new net.minecraft.world.level.ChunkPos(cx, cz);
|
||||
net.minecraft.world.level.levelgen.structure.BoundingBox chunkBox = new net.minecraft.world.level.levelgen.structure.BoundingBox(
|
||||
BoundingBox chunkBox = new BoundingBox(
|
||||
cp.getMinBlockX(), box.minY(), cp.getMinBlockZ(),
|
||||
cp.getMaxBlockX(), box.maxY(), cp.getMaxBlockZ());
|
||||
start.placeInChunk(level, structureManager, generator, random, chunkBox, cp);
|
||||
|
||||
+49
-2
@@ -49,9 +49,9 @@ public class IrisChunkGeneratorFailureContractTest {
|
||||
assertTrue(placement.contains("because structure generation is disabled outside the pack"));
|
||||
assertTrue(placement.contains("prepareSurfaceStructures"));
|
||||
assertTrue(placement.contains("clearIntersectingVegetation"));
|
||||
assertTrue(placement.indexOf("prepareSurfaceStructures")
|
||||
< placement.indexOf("clearIntersectingVegetation"));
|
||||
assertTrue(placement.indexOf("clearIntersectingVegetation")
|
||||
< placement.indexOf("prepareSurfaceStructures"));
|
||||
assertTrue(placement.indexOf("prepareSurfaceStructures")
|
||||
< placement.indexOf("for (NativePlacementGroup group"));
|
||||
assertFalse(placement.contains("IrisLogging.reportError"));
|
||||
}
|
||||
@@ -66,6 +66,20 @@ public class IrisChunkGeneratorFailureContractTest {
|
||||
assertTrue(source.contains("catch (GenerationSessionException e)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureReferenceRepairIsGenerationLeased() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
|
||||
int referencesStart = source.indexOf("public void createReferences");
|
||||
int referencesEnd = source.indexOf("public CompletableFuture<ChunkAccess> createBiomes", referencesStart);
|
||||
String references = source.substring(referencesStart, referencesEnd);
|
||||
|
||||
assertTrue(references.contains("requireGenerationLease(\"bukkit_nms_create_references\")"));
|
||||
assertTrue(references.contains("IrisContext.open(engine, lease.sessionId(), null)"));
|
||||
assertTrue(references.contains("NativeStructureReferenceRepair.createReferences("));
|
||||
assertFalse(references.contains("delegate.createReferences("));
|
||||
assertFalse(references.contains("catch ("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terrainWritesPrimeTheWorldgenHeightmapsForEveryChunk() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
|
||||
@@ -89,6 +103,7 @@ public class IrisChunkGeneratorFailureContractTest {
|
||||
assertTrue(placement.indexOf("WorldgenTerrainHeightmaps.primeStructurePlacement(")
|
||||
< placement.indexOf("prepareSurfaceStructures"));
|
||||
assertTrue(source.contains("engine.acquireGenerationLease(\"bukkit_nms_worldgen_heightmaps\")"));
|
||||
assertTrue(source.contains("int minY = chunk.getMinY() + 1;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -112,6 +127,38 @@ public class IrisChunkGeneratorFailureContractTest {
|
||||
assertTrue(source.contains("import art.arcane.iris.nativegen.WorldgenTerrainHeightmaps;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onlySidecarOwnedInjectedStartsUsePersistedPlacementPolicy() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource")));
|
||||
int placementStart = source.indexOf("private void placeVanillaStructures");
|
||||
int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart);
|
||||
String placement = source.substring(placementStart, placementEnd);
|
||||
int ownershipResolution = placement.indexOf(
|
||||
"NativeStructureOwnershipRecovery.resolve(");
|
||||
int persistedDecision = placement.indexOf("ownership.restoredDecision()", ownershipResolution);
|
||||
int adjustmentStart = source.indexOf("private void adjustGeneratedStructures");
|
||||
int adjustmentEnd = source.indexOf("public ChunkGeneratorStructureState createState", adjustmentStart);
|
||||
String adjustment = source.substring(adjustmentStart, adjustmentEnd);
|
||||
Path nativegen = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")).getParent();
|
||||
String factory = Files.readString(nativegen.resolve("NativeStructureFactory.java"));
|
||||
String injector = Files.readString(nativegen.resolve("NativeStructureStartInjector.java"));
|
||||
String recovery = Files.readString(nativegen.resolve("NativeStructureOwnershipRecovery.java"));
|
||||
int ownershipRecord = injector.indexOf("NativeStructureOwnershipStore.record(");
|
||||
int startPublication = injector.indexOf(
|
||||
"context.structureManager().setStartForStructure(", ownershipRecord);
|
||||
|
||||
assertTrue(ownershipResolution >= 0);
|
||||
assertTrue(persistedDecision > ownershipResolution);
|
||||
assertTrue(recovery.contains("NativeStructureOwnershipStore.findPersisted("));
|
||||
assertTrue(recovery.contains("NativeStructureOwnershipStore.record("));
|
||||
assertTrue(ownershipRecord >= 0);
|
||||
assertTrue(startPublication > ownershipRecord);
|
||||
assertTrue(factory.contains("NativeStructureReferenceEnvelope.wrapForPublication("));
|
||||
assertTrue(adjustment.contains("NativeStructureReferenceEnvelope.wrapForPublication("));
|
||||
assertFalse(source.contains("wrapManaged("));
|
||||
assertFalse(source.contains("isIrisManagedStart("));
|
||||
}
|
||||
|
||||
private static int occurrences(String source, String needle) {
|
||||
int count = 0;
|
||||
int index = source.indexOf(needle);
|
||||
|
||||
+67
-13
@@ -10,7 +10,9 @@ import org.junit.Test;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
@@ -27,17 +29,19 @@ public class IrisChunkGeneratorMonumentLocateContractTest {
|
||||
assertTrue(filterStart > irisHelperStart);
|
||||
String outerMethod = source.substring(findStart, irisHelperStart);
|
||||
String irisHelper = source.substring(irisHelperStart, filterStart);
|
||||
int unexploredGuard = irisHelper.indexOf("if (findUnexplored)");
|
||||
int registryLookup = irisHelper.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)");
|
||||
int placedCheck = irisHelper.indexOf(
|
||||
"if (!IrisStructureLocator.isPlaced(engine, structureId))");
|
||||
int irisLocate = irisHelper.indexOf("IrisStructureLocator.locate(", placedCheck);
|
||||
"if (!IrisStructureLocator.hasNativePlacement(engine, structureId))");
|
||||
int irisLocate = irisHelper.indexOf("NativeStructureLocatePersistence.search(", placedCheck);
|
||||
int searchLimit = irisHelper.indexOf("LocateStatus.SEARCH_LIMIT_REACHED", irisLocate);
|
||||
int limitSkip = irisHelper.indexOf("continue;", searchLimit);
|
||||
int nativeFilter = outerMethod.indexOf("filterReachableStructures(level, holders)");
|
||||
int delegateLocate = outerMethod.indexOf("delegate.findNearestMapStructure(level, reachable");
|
||||
int nearestSelection = outerMethod.indexOf(
|
||||
"NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated)");
|
||||
int nativePrediction = outerMethod.indexOf("NativeStructureVanillaLocator.predict(", nativeFilter);
|
||||
int irisResolution = outerMethod.indexOf("return findNearestIrisStructure(", nativePrediction);
|
||||
int nearestSelection = irisHelper.indexOf(
|
||||
"NativeStructureLocateResults.nearest(pos, predicted, nativeLocated)");
|
||||
int selectedVerification = irisHelper.indexOf("bestSearch.search().verify(bestResult)", nearestSelection);
|
||||
int selectedReference = irisHelper.indexOf(
|
||||
"selectedSearch.search().reference(selectedStart)", selectedVerification);
|
||||
int reachabilityStart = source.indexOf("private Set<String> reachableStructureKeys", filterStart);
|
||||
assertTrue(reachabilityStart > filterStart);
|
||||
String filterMethod = source.substring(filterStart, reachabilityStart);
|
||||
@@ -46,22 +50,28 @@ public class IrisChunkGeneratorMonumentLocateContractTest {
|
||||
int emptyNativePartition = filterMethod.indexOf("if (candidates.isEmpty())", filterContinue);
|
||||
int reachabilityLookup = filterMethod.indexOf("reachableStructureKeys(level)", emptyNativePartition);
|
||||
|
||||
assertTrue(unexploredGuard >= 0);
|
||||
assertTrue(registryLookup > unexploredGuard);
|
||||
assertTrue(registryLookup >= 0);
|
||||
assertTrue(placedCheck > registryLookup);
|
||||
assertTrue(irisLocate > placedCheck);
|
||||
assertTrue(searchLimit > irisLocate);
|
||||
assertTrue(limitSkip > searchLimit);
|
||||
assertTrue(nativeFilter >= 0);
|
||||
assertTrue(delegateLocate > nativeFilter);
|
||||
assertTrue(nearestSelection > delegateLocate);
|
||||
assertTrue(nativePrediction > nativeFilter);
|
||||
assertTrue(irisResolution > nativePrediction);
|
||||
assertTrue(nearestSelection > irisLocate);
|
||||
assertTrue(selectedVerification > nearestSelection);
|
||||
assertTrue(selectedReference > selectedVerification);
|
||||
assertTrue(policyFilter >= 0);
|
||||
assertTrue(filterContinue > policyFilter);
|
||||
assertTrue(emptyNativePartition > filterContinue);
|
||||
assertTrue(reachabilityLookup > emptyNativePartition);
|
||||
assertFalse(filterMethod.contains("NativeStructureLocateCapability"));
|
||||
assertFalse(irisHelper.contains("NativeStructureLocateCapability.isPaperUnavailable(structureId)"));
|
||||
assertTrue(irisHelper.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())"));
|
||||
assertTrue(irisHelper.contains("NativeStructureLocatePersistence.probe("));
|
||||
assertTrue(irisHelper.contains("NativeStructureLocateResults.selectAndReference("));
|
||||
assertTrue(irisHelper.contains("selectedSearch.search().reference(selectedStart)"));
|
||||
assertTrue(irisHelper.contains("findUnexplored"));
|
||||
assertTrue(irisHelper.contains("verified.ownership().locatorY()"));
|
||||
assertFalse(outerMethod.contains("delegate.findNearestMapStructure("));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -77,6 +87,50 @@ public class IrisChunkGeneratorMonumentLocateContractTest {
|
||||
assertSame(nativeTie, NativeStructureLocateResults.nearest(origin, irisNear, nativeTie));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mixedUnexploredLocateReferencesOnlyTheSelectedProvider() {
|
||||
BlockPos origin = BlockPos.ZERO;
|
||||
Pair<BlockPos, Holder<Structure>> irisNear = Pair.of(new BlockPos(4, 70, 0), null);
|
||||
Pair<BlockPos, Holder<Structure>> nativeFar = Pair.of(new BlockPos(8, 70, 0), null);
|
||||
AtomicInteger irisReferences = new AtomicInteger();
|
||||
AtomicInteger nativeReferences = new AtomicInteger();
|
||||
|
||||
Pair<BlockPos, Holder<Structure>> irisSelected =
|
||||
NativeStructureLocateResults.selectAndReference(
|
||||
origin,
|
||||
irisNear, () -> irisReferences.incrementAndGet(),
|
||||
nativeFar, () -> nativeReferences.incrementAndGet());
|
||||
|
||||
assertSame(irisNear, irisSelected);
|
||||
assertEquals(1, irisReferences.get());
|
||||
assertEquals(0, nativeReferences.get());
|
||||
|
||||
Pair<BlockPos, Holder<Structure>> nativeNear = Pair.of(new BlockPos(2, 70, 0), null);
|
||||
Pair<BlockPos, Holder<Structure>> nativeSelected =
|
||||
NativeStructureLocateResults.selectAndReference(
|
||||
origin,
|
||||
irisNear, () -> irisReferences.incrementAndGet(),
|
||||
nativeNear, () -> nativeReferences.incrementAndGet());
|
||||
|
||||
assertSame(nativeNear, nativeSelected);
|
||||
assertEquals(1, irisReferences.get());
|
||||
assertEquals(1, nativeReferences.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativePredictionIsReadOnlyUntilTheWinnerIsCommitted() throws IOException {
|
||||
Path nativegen = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource")).getParent();
|
||||
String source = Files.readString(nativegen.resolve("NativeStructureVanillaLocator.java"));
|
||||
int predictionStart = source.indexOf("private static Candidate predictAt(");
|
||||
int candidateStart = source.indexOf("public static final class Candidate", predictionStart);
|
||||
String prediction = source.substring(predictionStart, candidateStart);
|
||||
String candidate = source.substring(candidateStart);
|
||||
|
||||
assertFalse(prediction.contains("addReference("));
|
||||
assertTrue(candidate.contains("structureManager.addReference(referenceStart)"));
|
||||
assertTrue(candidate.contains("committed.compareAndSet(false, true)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stiltSupportUsesPlacedSolidOccupancyWithoutSnapshotDifferenceRequirement() throws IOException {
|
||||
Path processor = Path.of(System.getProperty("iris.nativeStructurePostProcessorSource"));
|
||||
|
||||
+339
-5
@@ -1,11 +1,23 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisJigsawConfiguration;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import com.mojang.datafixers.util.Either;
|
||||
import com.mojang.datafixers.util.Pair;
|
||||
import com.mojang.serialization.MapCodec;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderOwner;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.IntTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.WorldGenRegion;
|
||||
import net.minecraft.util.RandomSource;
|
||||
@@ -13,10 +25,12 @@ import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.NoiseColumn;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeManager;
|
||||
import net.minecraft.world.level.biome.FixedBiomeSource;
|
||||
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.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
@@ -25,19 +39,40 @@ import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.blending.Blender;
|
||||
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.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.EmptyPoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructurePoolElementType;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.alias.PoolAliasBinding;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructurePlaceSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
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;
|
||||
|
||||
public class NativeStructureFactoryTest {
|
||||
@BeforeClass
|
||||
@@ -67,20 +102,142 @@ public class NativeStructureFactoryTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forcedGeneratorSuppliesDeterministicPermissiveEnvironment() {
|
||||
public void sourceMetadataReadsScalarAndAxisSpecificJigsawRanges() {
|
||||
CompoundTag scalar = new CompoundTag();
|
||||
scalar.putInt("max_distance_from_center", 80);
|
||||
CompoundTag axisSpecific = new CompoundTag();
|
||||
CompoundTag distance = new CompoundTag();
|
||||
distance.putInt("horizontal", 96);
|
||||
distance.putInt("vertical", 64);
|
||||
axisSpecific.put("max_distance_from_center", distance);
|
||||
|
||||
assertEquals(80, NativeStructureFactory.sourceDistance(scalar, "horizontal"));
|
||||
assertEquals(80, NativeStructureFactory.sourceDistance(scalar, "vertical"));
|
||||
assertEquals(96, NativeStructureFactory.sourceDistance(axisSpecific, "horizontal"));
|
||||
assertEquals(64, NativeStructureFactory.sourceDistance(axisSpecific, "vertical"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceMetadataIncludesAdjustBoundingBoxExpansion() {
|
||||
Structure none = new SwampHutStructure(new Structure.StructureSettings(HolderSet.empty()));
|
||||
Structure beardBox = new SwampHutStructure(new Structure.StructureSettings.Builder(HolderSet.empty())
|
||||
.terrainAdapation(TerrainAdjustment.BEARD_BOX)
|
||||
.build());
|
||||
|
||||
assertEquals(0, NativeStructureFactory.horizontalReferenceExpansion(none));
|
||||
assertEquals(12, NativeStructureFactory.horizontalReferenceExpansion(beardBox));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void templatePoolSpanIncludesWeightedListsFallbacksAndRotations() {
|
||||
StructurePoolElement narrow = inlineElement(30, 8, 70);
|
||||
StructurePoolElement wide = inlineElement(96, 8, 12);
|
||||
ListPoolElement list = new ListPoolElement(
|
||||
List.of(narrow, wide, EmptyPoolElement.INSTANCE),
|
||||
StructureTemplatePool.Projection.RIGID);
|
||||
BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:fallback");
|
||||
StructureTemplatePool fallback = new StructureTemplatePool(
|
||||
fallbackHolder,
|
||||
List.of(Pair.of(inlineElement(110, 8, 20), 1)));
|
||||
fallbackHolder.bind(fallback);
|
||||
BoundPoolHolder startHolder = new BoundPoolHolder("test:start");
|
||||
StructureTemplatePool start = new StructureTemplatePool(
|
||||
fallbackHolder,
|
||||
List.of(Pair.of(list, 5), Pair.of(EmptyPoolElement.INSTANCE, 1)));
|
||||
startHolder.bind(start);
|
||||
|
||||
assertEquals(110, NativeStructureTemplatePoolBounds.horizontalSpan(start, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oversizedInlineStartTemplateReportsItsFullSpan() {
|
||||
BoundPoolHolder holder = new BoundPoolHolder("test:oversized");
|
||||
StructureTemplatePool pool = new StructureTemplatePool(
|
||||
holder,
|
||||
List.of(Pair.of(inlineElement(160, 8, 9), 1)));
|
||||
holder.bind(pool);
|
||||
|
||||
assertEquals(160, NativeStructureTemplatePoolBounds.horizontalSpan(pool, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceSpanIncludesOnlyAliasesThatCanReplaceTheStartPool() {
|
||||
BoundPoolHolder emptyHolder = new BoundPoolHolder("test:empty");
|
||||
StructureTemplatePool empty = new StructureTemplatePool(emptyHolder, List.of());
|
||||
emptyHolder.bind(empty);
|
||||
BoundPoolHolder startHolder = new BoundPoolHolder("test:start");
|
||||
StructureTemplatePool start = new StructureTemplatePool(
|
||||
emptyHolder, List.of(Pair.of(inlineElement(20, 8, 12), 1)));
|
||||
startHolder.bind(start);
|
||||
BoundPoolHolder replacementHolder = new BoundPoolHolder("test:replacement");
|
||||
StructureTemplatePool replacement = new StructureTemplatePool(
|
||||
emptyHolder, List.of(Pair.of(inlineElement(30, 8, 12), 1)));
|
||||
replacementHolder.bind(replacement);
|
||||
BoundPoolHolder unrelatedHolder = new BoundPoolHolder("test:unrelated");
|
||||
StructureTemplatePool unrelated = new StructureTemplatePool(
|
||||
emptyHolder, List.of(Pair.of(inlineElement(200, 8, 12), 1)));
|
||||
unrelatedHolder.bind(unrelated);
|
||||
ResourceKey<StructureTemplatePool> startKey = startHolder.unwrapKey().orElseThrow();
|
||||
ResourceKey<StructureTemplatePool> replacementKey = replacementHolder.unwrapKey().orElseThrow();
|
||||
ResourceKey<StructureTemplatePool> unrelatedKey = unrelatedHolder.unwrapKey().orElseThrow();
|
||||
ResourceKey<StructureTemplatePool> childAlias = ResourceKey.create(
|
||||
Registries.TEMPLATE_POOL, Identifier.parse("test:child_alias"));
|
||||
List<PoolAliasBinding> aliases = List.of(
|
||||
PoolAliasBinding.direct(startKey, replacementKey),
|
||||
PoolAliasBinding.direct(childAlias, unrelatedKey));
|
||||
Map<ResourceKey<StructureTemplatePool>, StructureTemplatePool> pools = Map.of(
|
||||
replacementKey, replacement,
|
||||
unrelatedKey, unrelated);
|
||||
|
||||
assertEquals(30, NativeStructureTemplatePoolBounds.sourceHorizontalSpan(
|
||||
null, start, startKey, aliases, pools::get));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceStartSpanDoesNotIncludeItsFallbackPool() {
|
||||
BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:large_fallback");
|
||||
StructureTemplatePool fallback = new StructureTemplatePool(
|
||||
fallbackHolder, List.of(Pair.of(inlineElement(180, 8, 10), 1)));
|
||||
fallbackHolder.bind(fallback);
|
||||
BoundPoolHolder startHolder = new BoundPoolHolder("test:small_start");
|
||||
StructureTemplatePool start = new StructureTemplatePool(
|
||||
fallbackHolder, List.of(Pair.of(inlineElement(24, 8, 10), 1)));
|
||||
startHolder.bind(start);
|
||||
|
||||
assertEquals(180, NativeStructureTemplatePoolBounds.horizontalSpan(start, null));
|
||||
assertEquals(24, NativeStructureTemplatePoolBounds.sourceHorizontalSpan(
|
||||
null, start, startHolder.unwrapKey().orElseThrow(), List.of(), ignored -> null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void customPoolElementUsesItsBoundingBoxCapability() {
|
||||
BoundPoolHolder fallbackHolder = new BoundPoolHolder("test:custom_fallback");
|
||||
StructureTemplatePool fallback = new StructureTemplatePool(fallbackHolder, List.of());
|
||||
fallbackHolder.bind(fallback);
|
||||
BoundPoolHolder poolHolder = new BoundPoolHolder("test:custom");
|
||||
StructureTemplatePool pool = new StructureTemplatePool(
|
||||
fallbackHolder, List.of(Pair.of(new BoundingPoolElement(37, 11), 1)));
|
||||
poolHolder.bind(pool);
|
||||
|
||||
assertEquals(37, NativeStructureTemplatePoolBounds.horizontalSpan(pool, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forcedGeneratorBypassesBiomeEligibilityWithoutFabricatingTerrain() {
|
||||
ChunkGenerator delegate = new TestChunkGenerator();
|
||||
Holder<Biome> biome = Holder.direct((Biome) null);
|
||||
LevelHeightAccessor heightAccessor = LevelHeightAccessor.create(-64, 384);
|
||||
ForcedStructureChunkGenerator generator = new ForcedStructureChunkGenerator(
|
||||
delegate, biome, -20);
|
||||
delegate, biome);
|
||||
|
||||
assertEquals(-63, generator.getSeaLevel());
|
||||
assertEquals(81, generator.getBaseHeight(
|
||||
assertEquals(63, generator.getSeaLevel());
|
||||
assertEquals(64, generator.getBaseHeight(
|
||||
0, 0, Heightmap.Types.WORLD_SURFACE_WG, heightAccessor, null));
|
||||
NoiseColumn column = generator.getBaseColumn(
|
||||
0, 0, heightAccessor, null);
|
||||
assertSame(Blocks.STONE, column.getBlock(80).getBlock());
|
||||
assertSame(Blocks.AIR, column.getBlock(81).getBlock());
|
||||
assertSame(Blocks.STONE, column.getBlock(81).getBlock());
|
||||
assertNotSame(delegate.getBiomeSource(), generator.getBiomeSource());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -103,6 +260,96 @@ public class NativeStructureFactoryTest {
|
||||
assertEquals(-20, relocated.getBoundingBox().minY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidGeneratedStartIsANormalSkippedCandidate() {
|
||||
Structure source = new SwampHutStructure(
|
||||
new Structure.StructureSettings(HolderSet.empty()));
|
||||
StructureStart valid = new StructureStart(
|
||||
source,
|
||||
new ChunkPos(0, 0),
|
||||
0,
|
||||
new PiecesContainer(List.of(
|
||||
new SwampHutPiece(RandomSource.create(23L), 0, 0)))
|
||||
);
|
||||
|
||||
assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(
|
||||
StructureStart.INVALID_START));
|
||||
assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(null));
|
||||
assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(
|
||||
new StructureStart(source, new ChunkPos(0, 0), 0,
|
||||
new PiecesContainer(List.of()))));
|
||||
assertTrue(NativeStructureStartInjector.isUsableGeneratedStart(valid));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrepresentableGeneratedContentBecomesAnUnpublishedInvalidStart() {
|
||||
Structure source = new SwampHutStructure(
|
||||
new Structure.StructureSettings(HolderSet.empty()));
|
||||
SwampHutPiece boundaryPiece = new SwampHutPiece(RandomSource.create(29L), 0, 0);
|
||||
boundaryPiece.move(143 - boundaryPiece.getBoundingBox().maxX(), 0, 0);
|
||||
StructureStart boundary = new StructureStart(
|
||||
source,
|
||||
new ChunkPos(0, 0),
|
||||
0,
|
||||
new PiecesContainer(List.of(boundaryPiece))
|
||||
);
|
||||
SwampHutPiece oversizedPiece = new SwampHutPiece(RandomSource.create(31L), 0, 0);
|
||||
oversizedPiece.move(144 - oversizedPiece.getBoundingBox().maxX(), 0, 0);
|
||||
StructureStart oversized = new StructureStart(
|
||||
source,
|
||||
new ChunkPos(0, 0),
|
||||
0,
|
||||
new PiecesContainer(List.of(oversizedPiece))
|
||||
);
|
||||
|
||||
StructureStart boundaryResult = NativeStructureReferenceEnvelope.wrapForPublication(
|
||||
boundary, source, 0, new IrisStructureTerrain(), "test:boundary_content");
|
||||
|
||||
StructureStart result = NativeStructureReferenceEnvelope.wrapForPublication(
|
||||
oversized, source, 0,
|
||||
new IrisStructureTerrain(),
|
||||
"test:oversized_content");
|
||||
|
||||
assertTrue(boundaryResult.isValid());
|
||||
assertSame(StructureStart.INVALID_START, result);
|
||||
assertFalse(NativeStructureStartInjector.isUsableGeneratedStart(result));
|
||||
assertEquals(1, oversized.getPieces().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedManualGenerationClearsStaleOwnershipAfterReplacementHandling() throws Exception {
|
||||
String source = Files.readString(
|
||||
Path.of(System.getProperty("iris.nativeStructureStartInjectorSource")),
|
||||
StandardCharsets.UTF_8);
|
||||
int failureStart = source.indexOf("if (!isUsableGeneratedStart(generated))");
|
||||
int failureEnd = source.indexOf("continue;", failureStart);
|
||||
String failureBranch = source.substring(failureStart, failureEnd);
|
||||
|
||||
assertTrue(failureBranch.contains("if (replacement)"));
|
||||
assertTrue(failureBranch.indexOf("NativeStructureOwnershipStore.discard(")
|
||||
> failureBranch.lastIndexOf("}") );
|
||||
assertTrue(failureStart < source.indexOf("NativeStructureOwnershipFingerprint.capture("));
|
||||
assertTrue(failureStart < source.indexOf("NativeStructureOwnershipStore.record("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedManualStartPublicationClearsRecordedOwnershipAndPreservesTheFailure() throws Exception {
|
||||
String source = Files.readString(
|
||||
Path.of(System.getProperty("iris.nativeStructureStartInjectorSource")),
|
||||
StandardCharsets.UTF_8);
|
||||
int ownershipRecord = source.indexOf("NativeStructureOwnershipStore.record(");
|
||||
int publication = source.indexOf("context.structureManager().setStartForStructure(", ownershipRecord);
|
||||
int cleanup = source.indexOf("NativeStructureOwnershipStore.discard(", publication);
|
||||
int suppressed = source.indexOf("publicationError.addSuppressed(cleanupError)", cleanup);
|
||||
int rethrow = source.indexOf("throw publicationError;", suppressed);
|
||||
|
||||
assertTrue(ownershipRecord >= 0);
|
||||
assertTrue(publication > ownershipRecord);
|
||||
assertTrue(cleanup > publication);
|
||||
assertTrue(suppressed > cleanup);
|
||||
assertTrue(rethrow > suppressed);
|
||||
}
|
||||
|
||||
private static final class TestChunkGenerator extends ChunkGenerator {
|
||||
private TestChunkGenerator() {
|
||||
super(new FixedBiomeSource(Holder.direct((Biome) null)));
|
||||
@@ -171,4 +418,91 @@ public class NativeStructureFactoryTest {
|
||||
BlockPos feetPos) {
|
||||
}
|
||||
}
|
||||
|
||||
private static StructurePoolElement inlineElement(int sizeX, int sizeY, int sizeZ) {
|
||||
StructureTemplate template = new StructureTemplate();
|
||||
CompoundTag tag = new CompoundTag();
|
||||
ListTag size = new ListTag();
|
||||
size.add(IntTag.valueOf(sizeX));
|
||||
size.add(IntTag.valueOf(sizeY));
|
||||
size.add(IntTag.valueOf(sizeZ));
|
||||
tag.put("size", size);
|
||||
template.load(BuiltInRegistries.BLOCK, tag);
|
||||
return new InlinePoolElement(template);
|
||||
}
|
||||
|
||||
private static final class InlinePoolElement extends SinglePoolElement {
|
||||
private final StructureTemplate inlineTemplate;
|
||||
|
||||
private InlinePoolElement(StructureTemplate template) {
|
||||
super(Either.right(template), Holder.direct(new StructureProcessorList(List.of())),
|
||||
StructureTemplatePool.Projection.RIGID, Optional.<LiquidSettings>empty());
|
||||
inlineTemplate = template;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoundingBox getBoundingBox(StructureTemplateManager templateManager,
|
||||
BlockPos position, Rotation rotation) {
|
||||
return inlineTemplate.getBoundingBox(
|
||||
new StructurePlaceSettings().setRotation(rotation), position);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BoundingPoolElement extends StructurePoolElement {
|
||||
private final int width;
|
||||
private final int depth;
|
||||
|
||||
private BoundingPoolElement(int width, int depth) {
|
||||
super(StructureTemplatePool.Projection.RIGID);
|
||||
this.width = width;
|
||||
this.depth = depth;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Vec3i getSize(StructureTemplateManager templateManager, Rotation rotation) {
|
||||
return new Vec3i(width, 1, depth);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StructureTemplate.JigsawBlockInfo> getShuffledJigsawBlocks(
|
||||
StructureTemplateManager templateManager, BlockPos position,
|
||||
Rotation rotation, RandomSource random) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoundingBox getBoundingBox(StructureTemplateManager templateManager,
|
||||
BlockPos position, Rotation rotation) {
|
||||
return new BoundingBox(
|
||||
position.getX(), position.getY(), position.getZ(),
|
||||
position.getX() + width - 1, position.getY(),
|
||||
position.getZ() + depth - 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(StructureTemplateManager templateManager,
|
||||
WorldGenLevel world,
|
||||
StructureManager structureManager, ChunkGenerator chunkGenerator,
|
||||
BlockPos position, BlockPos pivot, Rotation rotation,
|
||||
BoundingBox area, RandomSource random,
|
||||
LiquidSettings liquidSettings, boolean keepJigsaws) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StructurePoolElementType<?> getType() {
|
||||
return StructurePoolElementType.EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BoundPoolHolder extends Holder.Reference<StructureTemplatePool> {
|
||||
private BoundPoolHolder(String key) {
|
||||
super(Type.STAND_ALONE, new HolderOwner<>() {
|
||||
}, ResourceKey.create(Registries.TEMPLATE_POOL, Identifier.parse(key)), null);
|
||||
}
|
||||
|
||||
private void bind(StructureTemplatePool pool) {
|
||||
bindValue(pool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
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.assertTrue;
|
||||
|
||||
public class NativeStructureLocatePersistenceTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void storageProbeScansEachCandidateOnlyOnce() {
|
||||
AtomicInteger reads = new AtomicInteger();
|
||||
CompoundTag chunk = new CompoundTag();
|
||||
chunk.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version());
|
||||
chunk.putString("Status", "full");
|
||||
CompoundTag start = new CompoundTag();
|
||||
start.putString("id", "minecraft:village_plains");
|
||||
start.putInt("references", 0);
|
||||
CompoundTag starts = new CompoundTag();
|
||||
starts.put("minecraft:village_plains", start);
|
||||
CompoundTag structures = new CompoundTag();
|
||||
structures.put("starts", starts);
|
||||
chunk.put("structures", structures);
|
||||
NativeStructureLocatePersistence.ProbeBudget budget =
|
||||
new NativeStructureLocatePersistence.ProbeBudget(
|
||||
512,
|
||||
(chunkPos, visitor) -> {
|
||||
reads.incrementAndGet();
|
||||
chunk.acceptAsRoot(visitor);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
});
|
||||
ChunkPos chunkPos = new ChunkPos(7, -11);
|
||||
|
||||
assertTrue(budget.acceptsStored(
|
||||
null, chunkPos, "minecraft:village_plains", true));
|
||||
assertTrue(budget.acceptsStored(
|
||||
null, chunkPos, "minecraft:village_plains", true));
|
||||
assertEquals(1, reads.get());
|
||||
assertEquals(1, budget.used());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyStoredChunkIsDatafixedBeforeItsStartIsClassified() {
|
||||
AtomicBoolean datafixed = new AtomicBoolean();
|
||||
CompoundTag legacy = new CompoundTag();
|
||||
legacy.putInt("DataVersion", 100);
|
||||
legacy.putString("Status", "full");
|
||||
NativeStructureLocatePersistence.ProbeBudget budget =
|
||||
new NativeStructureLocatePersistence.ProbeBudget(
|
||||
512,
|
||||
(chunkPos, visitor) -> {
|
||||
legacy.acceptAsRoot(visitor);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
},
|
||||
(level, storedChunk) -> {
|
||||
datafixed.set(true);
|
||||
CompoundTag fixed = storedChunk.copy();
|
||||
fixed.putInt("DataVersion",
|
||||
SharedConstants.getCurrentVersion().dataVersion().version());
|
||||
CompoundTag start = new CompoundTag();
|
||||
start.putString("id", "minecraft:village_plains");
|
||||
start.putInt("references", 1);
|
||||
CompoundTag starts = new CompoundTag();
|
||||
starts.put("minecraft:village_plains", start);
|
||||
CompoundTag structures = new CompoundTag();
|
||||
structures.put("starts", starts);
|
||||
fixed.put("structures", structures);
|
||||
return fixed;
|
||||
});
|
||||
|
||||
assertFalse(budget.acceptsStored(
|
||||
null, new ChunkPos(3, 4), "minecraft:village_plains", true));
|
||||
assertTrue(datafixed.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialDatafixFailureFallsBackToSelectedChunkVerification() {
|
||||
CompoundTag legacy = new CompoundTag();
|
||||
legacy.putInt("DataVersion", 100);
|
||||
NativeStructureLocatePersistence.ProbeBudget budget =
|
||||
new NativeStructureLocatePersistence.ProbeBudget(
|
||||
512,
|
||||
(chunkPos, visitor) -> {
|
||||
legacy.acceptAsRoot(visitor);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
},
|
||||
(level, storedChunk) -> {
|
||||
throw new IllegalStateException("broken partial datafix");
|
||||
});
|
||||
|
||||
assertTrue(budget.acceptsStored(
|
||||
null, new ChunkPos(9, -2), "minecraft:village_plains", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingStartsContainerFallsBackToSelectedChunkVerification() {
|
||||
CompoundTag chunk = currentChunk();
|
||||
NativeStructureLocatePersistence.ProbeBudget budget = budget(chunk);
|
||||
|
||||
assertTrue(budget.acceptsStored(
|
||||
null, new ChunkPos(5, 8), "minecraft:village_plains", false));
|
||||
assertTrue(budget.acceptsStored(
|
||||
null, new ChunkPos(5, 8), "minecraft:village_plains", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void presentEmptyStartsContainerRejectsTheCandidateWithoutLoading() {
|
||||
CompoundTag chunk = currentChunk();
|
||||
CompoundTag structures = new CompoundTag();
|
||||
structures.put("starts", new CompoundTag());
|
||||
chunk.put("structures", structures);
|
||||
NativeStructureLocatePersistence.ProbeBudget budget = budget(chunk);
|
||||
|
||||
assertFalse(budget.acceptsStored(
|
||||
null, new ChunkPos(-3, 2), "minecraft:village_plains", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collisionTombstoneIsRejectedForNormalAndUnexploredLocate() {
|
||||
CompoundTag chunk = currentChunk();
|
||||
CompoundTag tombstone = new CompoundTag();
|
||||
tombstone.putString("id", "INVALID");
|
||||
CompoundTag starts = new CompoundTag();
|
||||
starts.put("minecraft:village_plains", tombstone);
|
||||
CompoundTag structures = new CompoundTag();
|
||||
structures.put("starts", starts);
|
||||
chunk.put("structures", structures);
|
||||
ChunkPos chunkPos = new ChunkPos(12, -9);
|
||||
|
||||
assertFalse(budget(chunk).acceptsStored(
|
||||
null, chunkPos, "minecraft:village_plains", false));
|
||||
assertFalse(budget(chunk).acceptsStored(
|
||||
null, chunkPos, "minecraft:village_plains", true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedNonCompoundStartIsRejectedWithoutLoading() {
|
||||
CompoundTag chunk = currentChunk();
|
||||
CompoundTag starts = new CompoundTag();
|
||||
starts.putInt("minecraft:village_plains", 1);
|
||||
CompoundTag structures = new CompoundTag();
|
||||
structures.put("starts", starts);
|
||||
chunk.put("structures", structures);
|
||||
|
||||
assertFalse(budget(chunk).acceptsStored(
|
||||
null, new ChunkPos(1, 1), "minecraft:village_plains", false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void referencedStoredStartIsOnlyRejectedForUnexploredLocate() {
|
||||
CompoundTag chunk = currentChunk();
|
||||
CompoundTag start = new CompoundTag();
|
||||
start.putString("id", "minecraft:village_plains");
|
||||
start.putInt("references", 1);
|
||||
CompoundTag starts = new CompoundTag();
|
||||
starts.put("minecraft:village_plains", start);
|
||||
CompoundTag structures = new CompoundTag();
|
||||
structures.put("starts", starts);
|
||||
chunk.put("structures", structures);
|
||||
ChunkPos chunkPos = new ChunkPos(-4, -7);
|
||||
|
||||
assertTrue(budget(chunk).acceptsStored(
|
||||
null, chunkPos, "minecraft:village_plains", false));
|
||||
assertFalse(budget(chunk).acceptsStored(
|
||||
null, chunkPos, "minecraft:village_plains", true));
|
||||
}
|
||||
|
||||
private static CompoundTag currentChunk() {
|
||||
CompoundTag chunk = new CompoundTag();
|
||||
chunk.putInt("DataVersion", SharedConstants.getCurrentVersion().dataVersion().version());
|
||||
return chunk;
|
||||
}
|
||||
|
||||
private static NativeStructureLocatePersistence.ProbeBudget budget(CompoundTag chunk) {
|
||||
return new NativeStructureLocatePersistence.ProbeBudget(
|
||||
512,
|
||||
(chunkPos, visitor) -> {
|
||||
chunk.acceptAsRoot(visitor);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
});
|
||||
}
|
||||
}
|
||||
+346
@@ -0,0 +1,346 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructure;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.LegacyRandomSource;
|
||||
import net.minecraft.world.level.levelgen.RandomSupport;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.StructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
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.StructurePoolElementType;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class NativeStructureOwnershipFingerprintTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void monumentFingerprintSurvivesSourceYRegenerationWithoutSyntheticPieces() {
|
||||
long seed = 8372619L;
|
||||
ChunkPos origin = new ChunkPos(9, -4);
|
||||
OceanMonumentStructure structure = new OceanMonumentStructure(
|
||||
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
|
||||
StructureStart generated = monumentStart(structure, origin, seed);
|
||||
NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel(
|
||||
generated, 0, 80, -64, 320);
|
||||
int alignedLocatorY = NativeStructureOwnershipFingerprint.locatorY(generated);
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(24);
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
|
||||
generated,
|
||||
structure,
|
||||
0,
|
||||
terrain);
|
||||
String beforeReload = NativeStructureOwnershipFingerprint.fingerprint(
|
||||
"minecraft:monument", wrapped);
|
||||
|
||||
ListTag serializedPieces = (ListTag) new PiecesContainer(wrapped.getPieces()).save(null);
|
||||
PiecesContainer loadedPieces = PiecesContainer.load(serializedPieces, null);
|
||||
assertEquals(1, loadedPieces.pieces().size());
|
||||
assertTrue(loadedPieces.pieces().getFirst()
|
||||
instanceof OceanMonumentPieces.MonumentBuilding);
|
||||
PiecesContainer regeneratedPieces = OceanMonumentStructure.regeneratePiecesAfterLoad(
|
||||
origin, seed, loadedPieces);
|
||||
StructureStart reloaded = new StructureStart(structure, origin, 0, regeneratedPieces);
|
||||
String afterReload = NativeStructureOwnershipFingerprint.fingerprint(
|
||||
"minecraft:monument", reloaded);
|
||||
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
|
||||
"minecraft:monument",
|
||||
wrapped,
|
||||
plan(origin, NativeStructureReferenceEnvelope.contentBounds(wrapped).minY()),
|
||||
NativeStructureReferenceEnvelope.referenceBounds(wrapped, structure, terrain));
|
||||
|
||||
assertEquals(beforeReload, afterReload);
|
||||
assertTrue(NativeStructureOwnershipFingerprint.matches(ownership, reloaded));
|
||||
assertNotEquals(alignedLocatorY, NativeStructureOwnershipFingerprint.locatorY(reloaded));
|
||||
assertEquals(alignedLocatorY, ownership.locatorY());
|
||||
assertEquals(1, reloaded.getPieces().size());
|
||||
assertEquals(1, wrapped.getPieces().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameKeyAndOriginStillRequiresMatchingContentIdentity() {
|
||||
long seed = 18273L;
|
||||
ChunkPos origin = new ChunkPos(2, 3);
|
||||
OceanMonumentStructure structure = new OceanMonumentStructure(
|
||||
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
|
||||
StructureStart expected = monumentStart(structure, origin, seed);
|
||||
StructureStart moved = monumentStart(structure, origin, seed);
|
||||
for (StructurePiece piece : moved.getPieces()) {
|
||||
piece.move(1, 0, 0);
|
||||
}
|
||||
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
|
||||
"minecraft:monument",
|
||||
expected,
|
||||
plan(origin, NativeStructureReferenceEnvelope.contentBounds(expected).minY()),
|
||||
expected.getBoundingBox());
|
||||
|
||||
assertFalse(NativeStructureOwnershipFingerprint.matches(ownership, moved));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistedEnvelopeRepairsReloadedMonumentReferenceOutsideLiveBounds() {
|
||||
long seed = 772931L;
|
||||
ChunkPos origin = new ChunkPos(4, -7);
|
||||
OceanMonumentStructure structure = new OceanMonumentStructure(
|
||||
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
|
||||
StructureStart generated = monumentStart(structure, origin, seed);
|
||||
NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel(
|
||||
generated, 0, 80, -64, 320);
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(24);
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
|
||||
generated,
|
||||
structure,
|
||||
0,
|
||||
terrain);
|
||||
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
|
||||
"minecraft:monument",
|
||||
wrapped,
|
||||
plan(origin, NativeStructureReferenceEnvelope.contentBounds(wrapped).minY()),
|
||||
NativeStructureReferenceEnvelope.referenceBounds(wrapped, structure, terrain));
|
||||
PiecesContainer regeneratedPieces = OceanMonumentStructure.regeneratePiecesAfterLoad(
|
||||
origin, seed, new PiecesContainer(wrapped.getPieces()));
|
||||
StructureStart reloaded = new StructureStart(structure, origin, 0, regeneratedPieces);
|
||||
ChunkPos target = outsideLiveBounds(ownership, reloaded);
|
||||
|
||||
assertNotNull(target);
|
||||
assertTrue(NativeStructureReferenceRepair.requiresReference(
|
||||
target, "minecraft:monument", reloaded, ownership));
|
||||
assertFalse(NativeStructureReferenceRepair.requiresReference(
|
||||
target, "minecraft:monument", reloaded, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void overlappingManualStartsSixteenChunksApartRemainIndependentlyReferenced() {
|
||||
OceanMonumentStructure structure = new OceanMonumentStructure(
|
||||
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
|
||||
ChunkPos westOrigin = new ChunkPos(0, 0);
|
||||
ChunkPos eastOrigin = new ChunkPos(16, 0);
|
||||
StructureStart west = monumentStart(structure, westOrigin, 317L);
|
||||
StructureStart east = monumentStart(structure, eastOrigin, 941L);
|
||||
west.getPieces().getFirst().move(112, 0, 0);
|
||||
east.getPieces().getFirst().move(-96, 0, 0);
|
||||
NativeStructureOwnershipRecord westOwnership = ownership(westOrigin, west, structure);
|
||||
NativeStructureOwnershipRecord eastOwnership = ownership(eastOrigin, east, structure);
|
||||
ChunkPos shared = new ChunkPos(8, 0);
|
||||
|
||||
assertTrue(NativeStructureReferenceEnvelope.contentBounds(west).intersects(
|
||||
NativeStructureReferenceEnvelope.contentBounds(east)));
|
||||
assertTrue(NativeStructureReferenceRepair.requiresReference(
|
||||
shared, "minecraft:monument", west, westOwnership));
|
||||
assertTrue(NativeStructureReferenceRepair.requiresReference(
|
||||
shared, "minecraft:monument", east, eastOwnership));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void denseAdjacentManualStartsDoNotSuppressEachOther() {
|
||||
OceanMonumentStructure structure = new OceanMonumentStructure(
|
||||
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
|
||||
ChunkPos firstOrigin = new ChunkPos(0, 0);
|
||||
ChunkPos secondOrigin = new ChunkPos(1, 0);
|
||||
StructureStart first = monumentStart(structure, firstOrigin, 17L);
|
||||
StructureStart second = monumentStart(structure, secondOrigin, 23L);
|
||||
NativeStructureOwnershipRecord firstOwnership = ownership(firstOrigin, first, structure);
|
||||
NativeStructureOwnershipRecord secondOwnership = ownership(secondOrigin, second, structure);
|
||||
ChunkPos shared = new ChunkPos(0, 0);
|
||||
|
||||
assertTrue(NativeStructureReferenceEnvelope.contentBounds(first).intersects(
|
||||
NativeStructureReferenceEnvelope.contentBounds(second)));
|
||||
assertTrue(NativeStructureReferenceRepair.requiresReference(
|
||||
shared, "minecraft:monument", first, firstOwnership));
|
||||
assertTrue(NativeStructureReferenceRepair.requiresReference(
|
||||
shared, "minecraft:monument", second, secondOwnership));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void customPoolElementIdentityDoesNotDependOnProcessLocalToString() {
|
||||
OceanMonumentStructure structure = new OceanMonumentStructure(
|
||||
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
|
||||
ChunkPos origin = new ChunkPos(3, -2);
|
||||
StructureStart first = poolStart(structure, origin, new IdentityStringPoolElement());
|
||||
StructureStart reloaded = poolStart(structure, origin, new IdentityStringPoolElement());
|
||||
|
||||
assertEquals(
|
||||
NativeStructureOwnershipFingerprint.fingerprint("test:custom_pool", first),
|
||||
NativeStructureOwnershipFingerprint.fingerprint("test:custom_pool", reloaded));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void namedPoolTemplateRemainsPartOfTheStableFingerprint() {
|
||||
OceanMonumentStructure structure = new OceanMonumentStructure(
|
||||
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
|
||||
ChunkPos origin = new ChunkPos(3, -2);
|
||||
StructurePoolElement firstElement = StructurePoolElement.single("test:first")
|
||||
.apply(StructureTemplatePool.Projection.RIGID);
|
||||
StructurePoolElement secondElement = StructurePoolElement.single("test:second")
|
||||
.apply(StructureTemplatePool.Projection.RIGID);
|
||||
|
||||
assertNotEquals(
|
||||
NativeStructureOwnershipFingerprint.fingerprint(
|
||||
"test:named_pool", poolStart(structure, origin, firstElement)),
|
||||
NativeStructureOwnershipFingerprint.fingerprint(
|
||||
"test:named_pool", poolStart(structure, origin, secondElement)));
|
||||
}
|
||||
|
||||
private static StructureStart monumentStart(OceanMonumentStructure structure,
|
||||
ChunkPos origin, long seed) {
|
||||
WorldgenRandom random = new WorldgenRandom(
|
||||
new LegacyRandomSource(RandomSupport.generateUniqueSeed()));
|
||||
random.setLargeFeatureSeed(seed, origin.x(), origin.z());
|
||||
Direction orientation = Direction.Plane.HORIZONTAL.getRandomDirection(random);
|
||||
OceanMonumentPieces.MonumentBuilding building = new OceanMonumentPieces.MonumentBuilding(
|
||||
random,
|
||||
origin.getMinBlockX() - 29,
|
||||
origin.getMinBlockZ() - 29,
|
||||
orientation
|
||||
);
|
||||
return new StructureStart(
|
||||
structure,
|
||||
origin,
|
||||
0,
|
||||
new PiecesContainer(List.of(building))
|
||||
);
|
||||
}
|
||||
|
||||
private static NativeStructureStartPlan plan(ChunkPos origin, int baseY) {
|
||||
IrisNativeStructure source = new IrisNativeStructure()
|
||||
.setStructure("minecraft:monument")
|
||||
.setWeight(1);
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement()
|
||||
.setPlacementId("monument-test")
|
||||
.setNativeStructures(new KList<IrisNativeStructure>().qadd(source));
|
||||
return new NativeStructureStartPlan(
|
||||
placement,
|
||||
source,
|
||||
origin.x(),
|
||||
origin.z(),
|
||||
baseY
|
||||
);
|
||||
}
|
||||
|
||||
private static NativeStructureOwnershipRecord ownership(
|
||||
ChunkPos origin, StructureStart start, OceanMonumentStructure structure) {
|
||||
return NativeStructureOwnershipFingerprint.capture(
|
||||
"minecraft:monument",
|
||||
start,
|
||||
plan(origin, NativeStructureReferenceEnvelope.contentBounds(start).minY()),
|
||||
NativeStructureReferenceEnvelope.referenceBounds(
|
||||
start, structure, new IrisStructureTerrain())
|
||||
);
|
||||
}
|
||||
|
||||
private static StructureStart poolStart(
|
||||
OceanMonumentStructure structure, ChunkPos origin, StructurePoolElement element) {
|
||||
BlockPos position = new BlockPos(
|
||||
origin.getMiddleBlockX(), 64, origin.getMiddleBlockZ());
|
||||
PoolElementStructurePiece piece = new PoolElementStructurePiece(
|
||||
null, element, position, 0, Rotation.NONE,
|
||||
new BoundingBox(position), LiquidSettings.APPLY_WATERLOGGING);
|
||||
return new StructureStart(
|
||||
structure, origin, 0, new PiecesContainer(List.of(piece)));
|
||||
}
|
||||
|
||||
private static final class IdentityStringPoolElement extends StructurePoolElement {
|
||||
private IdentityStringPoolElement() {
|
||||
super(StructureTemplatePool.Projection.RIGID);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Vec3i getSize(StructureTemplateManager templateManager, Rotation rotation) {
|
||||
return new Vec3i(1, 1, 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<StructureTemplate.JigsawBlockInfo> getShuffledJigsawBlocks(
|
||||
StructureTemplateManager templateManager, BlockPos position,
|
||||
Rotation rotation, RandomSource random) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BoundingBox getBoundingBox(StructureTemplateManager templateManager,
|
||||
BlockPos position, Rotation rotation) {
|
||||
return new BoundingBox(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean place(StructureTemplateManager templateManager,
|
||||
WorldGenLevel world, StructureManager structureManager,
|
||||
ChunkGenerator chunkGenerator, BlockPos position, BlockPos pivot,
|
||||
Rotation rotation, BoundingBox area, RandomSource random,
|
||||
LiquidSettings liquidSettings, boolean keepJigsaws) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StructurePoolElementType<?> getType() {
|
||||
return StructurePoolElementType.EMPTY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "IdentityStringPoolElement@"
|
||||
+ Integer.toHexString(System.identityHashCode(this));
|
||||
}
|
||||
}
|
||||
|
||||
private static ChunkPos outsideLiveBounds(NativeStructureOwnershipRecord ownership,
|
||||
StructureStart start) {
|
||||
for (int chunkX = ownership.referenceMinChunkX();
|
||||
chunkX <= ownership.referenceMaxChunkX(); chunkX++) {
|
||||
for (int chunkZ = ownership.referenceMinChunkZ();
|
||||
chunkZ <= ownership.referenceMaxChunkZ(); chunkZ++) {
|
||||
ChunkPos candidate = new ChunkPos(chunkX, chunkZ);
|
||||
if (!start.getBoundingBox().intersects(
|
||||
candidate.getMinBlockX(), candidate.getMinBlockZ(),
|
||||
candidate.getMaxBlockX(), candidate.getMaxBlockZ())) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementGrid;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructure;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.levelgen.LegacyRandomSource;
|
||||
import net.minecraft.world.level.levelgen.RandomSupport;
|
||||
import net.minecraft.world.level.levelgen.WorldgenRandom;
|
||||
import net.minecraft.world.level.levelgen.structure.StructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class NativeStructureOwnershipRecoveryTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void missingSidecarRecoversExactPolicyEnvelopeAndLocatorAfterReload() {
|
||||
long seed = 5519231L;
|
||||
ChunkPos origin = new ChunkPos(7, -5);
|
||||
OceanMonumentStructure structure = structure();
|
||||
StructureStart expected = monumentStart(structure, origin, seed);
|
||||
NativeStructureVerticalPlacer.alignOceanMonumentToSeaLevel(
|
||||
expected, 0, 80, -64, 320);
|
||||
int expectedLocatorY = NativeStructureOwnershipFingerprint.locatorY(expected);
|
||||
NativeStructureStartPlan plan = plan(origin, "recovery-exact", 24);
|
||||
StructureStart reloaded = new StructureStart(
|
||||
structure,
|
||||
origin,
|
||||
expected.getReferences(),
|
||||
OceanMonumentStructure.regeneratePiecesAfterLoad(
|
||||
origin, seed, new PiecesContainer(expected.getPieces()))
|
||||
);
|
||||
|
||||
NativeStructureOwnershipRecord recovered =
|
||||
NativeStructureOwnershipRecovery.proveCandidate(
|
||||
"minecraft:monument", structure, reloaded,
|
||||
plan, expected, false);
|
||||
|
||||
assertNotNull(recovered);
|
||||
assertEquals(StructurePlacementGrid.placementIdentity(plan.placement()),
|
||||
recovered.placementIdentity());
|
||||
assertEquals(expectedLocatorY, recovered.locatorY());
|
||||
assertNotEquals(NativeStructureOwnershipFingerprint.locatorY(reloaded),
|
||||
recovered.locatorY());
|
||||
assertEquals(IrisStructureTerrainMode.FORCE_CARVE,
|
||||
recovered.restoredDecision().terrain().resolvedMode());
|
||||
assertEquals(24, recovered.restoredDecision().terrain().getHorizontalPadding());
|
||||
assertTrue(recovered.referenceMinChunkX()
|
||||
< NativeStructureReferenceEnvelope.contentBounds(reloaded).minX() >> 4);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void naturalSameKeyStartRemainsUnownedWhenOriginIsAmbiguous() {
|
||||
long seed = 22817L;
|
||||
ChunkPos origin = new ChunkPos(-3, 6);
|
||||
OceanMonumentStructure structure = structure();
|
||||
StructureStart natural = monumentStart(structure, origin, seed);
|
||||
NativeStructureStartPlan plan = plan(origin, "natural-ambiguous", 0);
|
||||
|
||||
NativeStructureOwnershipRecord recovered =
|
||||
NativeStructureOwnershipRecovery.proveCandidate(
|
||||
"minecraft:monument", structure, natural,
|
||||
plan, natural, true);
|
||||
|
||||
assertNull(recovered);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changedPlacementIdentityOrGeometryCannotRecoverOwnership() {
|
||||
long seed = 991723L;
|
||||
ChunkPos origin = new ChunkPos(2, 4);
|
||||
OceanMonumentStructure structure = structure();
|
||||
StructureStart expected = monumentStart(structure, origin, seed);
|
||||
StructureStart changedGeometry = monumentStart(structure, origin, seed);
|
||||
for (StructurePiece piece : changedGeometry.getPieces()) {
|
||||
piece.move(1, 0, 0);
|
||||
}
|
||||
NativeStructureStartPlan originalIdentity = plan(
|
||||
origin, "original-identity", 0);
|
||||
NativeStructureStartPlan changedIdentity = plan(
|
||||
new ChunkPos(origin.x() + 1, origin.z()), "changed-identity", 0);
|
||||
|
||||
assertNotEquals(
|
||||
StructurePlacementGrid.placementIdentity(originalIdentity.placement()),
|
||||
StructurePlacementGrid.placementIdentity(changedIdentity.placement()));
|
||||
assertNull(NativeStructureOwnershipRecovery.proveCandidate(
|
||||
"minecraft:monument", structure, expected,
|
||||
changedIdentity, expected, false));
|
||||
assertNull(NativeStructureOwnershipRecovery.proveCandidate(
|
||||
"minecraft:monument", structure, changedGeometry,
|
||||
plan(origin, "geometry-check", 0), expected, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoveredEnvelopeStillRequiresExactFingerprint() {
|
||||
long seed = 81337L;
|
||||
ChunkPos origin = new ChunkPos(-8, -9);
|
||||
OceanMonumentStructure structure = structure();
|
||||
StructureStart expected = monumentStart(structure, origin, seed);
|
||||
NativeStructureOwnershipRecord recovered =
|
||||
NativeStructureOwnershipRecovery.proveCandidate(
|
||||
"minecraft:monument", structure, expected,
|
||||
plan(origin, "fingerprint-check", 16), expected, false);
|
||||
assertNotNull(recovered);
|
||||
|
||||
StructureStart moved = monumentStart(structure, origin, seed);
|
||||
for (StructurePiece piece : moved.getPieces()) {
|
||||
piece.move(0, 0, 1);
|
||||
}
|
||||
|
||||
assertFalse(NativeStructureOwnershipFingerprint.matches(recovered, moved));
|
||||
}
|
||||
|
||||
private static OceanMonumentStructure structure() {
|
||||
return new OceanMonumentStructure(
|
||||
new OceanMonumentStructure.StructureSettings(HolderSet.empty()));
|
||||
}
|
||||
|
||||
private static StructureStart monumentStart(OceanMonumentStructure structure,
|
||||
ChunkPos origin, long seed) {
|
||||
WorldgenRandom random = new WorldgenRandom(
|
||||
new LegacyRandomSource(RandomSupport.generateUniqueSeed()));
|
||||
random.setLargeFeatureSeed(seed, origin.x(), origin.z());
|
||||
Direction orientation = Direction.Plane.HORIZONTAL.getRandomDirection(random);
|
||||
OceanMonumentPieces.MonumentBuilding building =
|
||||
new OceanMonumentPieces.MonumentBuilding(
|
||||
random,
|
||||
origin.getMinBlockX() - 29,
|
||||
origin.getMinBlockZ() - 29,
|
||||
orientation
|
||||
);
|
||||
return new StructureStart(
|
||||
structure, origin, 0, new PiecesContainer(List.of(building)));
|
||||
}
|
||||
|
||||
private static NativeStructureStartPlan plan(ChunkPos origin,
|
||||
String placementId,
|
||||
int horizontalPadding) {
|
||||
IrisNativeStructure source = new IrisNativeStructure()
|
||||
.setStructure("minecraft:monument")
|
||||
.setWeight(1);
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(horizontalPadding);
|
||||
IrisStructurePlacement placement = new IrisStructurePlacement()
|
||||
.setPlacementId(placementId)
|
||||
.setTerrain(terrain)
|
||||
.setNativeStructures(new KList<IrisNativeStructure>().qadd(source));
|
||||
return new NativeStructureStartPlan(
|
||||
placement,
|
||||
source,
|
||||
origin.x(),
|
||||
origin.z(),
|
||||
NativeStructureReferenceEnvelope.contentBounds(
|
||||
monumentStart(structure(), origin, 1L)).minY()
|
||||
);
|
||||
}
|
||||
}
|
||||
+273
-27
@@ -7,22 +7,35 @@ import art.arcane.iris.engine.object.IrisStructureYBand;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
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.dimension.BuiltinDimensionTypes;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
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.StructurePiece;
|
||||
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.DesertPyramidPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.BlockIgnoreProcessor;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.LiquidSettings;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureProcessorList;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -31,14 +44,16 @@ import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.BitSet;
|
||||
import java.util.HashMap;
|
||||
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.assertNotNull;
|
||||
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 NativeStructurePostProcessorEncaseTest {
|
||||
@@ -98,6 +113,96 @@ public class NativeStructurePostProcessorEncaseTest {
|
||||
state(blocks, bounds.minX(), 0, bounds.minZ()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultEncaseMaterialFollowsVanillaDimensionTerrain() {
|
||||
assertEquals(Blocks.NETHERRACK.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.NETHER, 64));
|
||||
assertEquals(Blocks.END_STONE.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.END, 64));
|
||||
assertEquals(Blocks.STONE.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.defaultEncaseBlock(Level.OVERWORLD, 64));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceEncaseMaterialFollowsNearbyIrisTerrain() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
BlockPos fill = new BlockPos(4, 64, 8);
|
||||
put(blocks, fill.getX() + 3, fill.getY(), fill.getZ(), Blocks.TUFF.defaultBlockState());
|
||||
|
||||
assertEquals(Blocks.TUFF.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.sourceEncaseBlock(world(blocks), fill));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceMaterialSnapshotIgnoresPreviouslyDecoratedAdjacentChunks() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
BlockPos fill = new BlockPos(0, 64, 8);
|
||||
put(blocks, -1, 64, 8, Blocks.OAK_PLANKS.defaultBlockState());
|
||||
put(blocks, 3, 64, 8, Blocks.TUFF.defaultBlockState());
|
||||
WorldGenLevel world = world(blocks);
|
||||
NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain =
|
||||
sourceSnapshot(world, new BoundingBox(0, 64, 0, 15, 64, 15));
|
||||
|
||||
assertEquals(Blocks.TUFF.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.sourceEncaseBlock(
|
||||
world, fill, sourceTerrain));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceMaterialSnapshotIsStableAcrossEarlierFillsInTheSameChunk() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
BlockPos fill = new BlockPos(0, 64, 8);
|
||||
put(blocks, 3, 64, 8, Blocks.TUFF.defaultBlockState());
|
||||
WorldGenLevel world = world(blocks);
|
||||
NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain =
|
||||
sourceSnapshot(world, new BoundingBox(0, 64, 0, 15, 64, 15));
|
||||
put(blocks, 1, 64, 8, Blocks.OAK_PLANKS.defaultBlockState());
|
||||
|
||||
assertEquals(Blocks.TUFF.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.sourceEncaseBlock(
|
||||
world, fill, sourceTerrain));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceMaterialRejectsStructureBlocksAndOresBeforeDimensionFallback() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
BlockPos fill = new BlockPos(4, -20, 8);
|
||||
put(blocks, 3, -20, 8, Blocks.OAK_PLANKS.defaultBlockState());
|
||||
put(blocks, 5, -20, 8, Blocks.STONE_BRICKS.defaultBlockState());
|
||||
put(blocks, 4, -20, 7, Blocks.IRON_ORE.defaultBlockState());
|
||||
|
||||
assertEquals(Blocks.DEEPSLATE.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.sourceEncaseBlock(world(blocks), fill));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceMaterialSnapshotOnlyAllocatesActualFillLayers() {
|
||||
StructureStart start = start(TerrainAdjustment.BURY, 64);
|
||||
BoundingBox area = new BoundingBox(0, -2032, 0, 15, 2030, 15);
|
||||
NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain =
|
||||
NativeStructureTerrainIntegrator.captureSourceTerrain(
|
||||
world(new HashMap<>()), area,
|
||||
List.of(new NativeStructureTerrainIntegrator.TerrainTarget(
|
||||
"test:bounded-snapshot", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))));
|
||||
|
||||
assertNotNull(sourceTerrain);
|
||||
assertEquals(23 * 16 * 16, sourceTerrain.sampledCells());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void customDimensionKeyUsesRegisteredVanillaDimensionType() {
|
||||
ResourceKey<Level> customDimension = ResourceKey.create(
|
||||
Registries.DIMENSION, Identifier.parse("iris:custom_nether"));
|
||||
|
||||
assertEquals(Blocks.NETHERRACK.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.defaultEncaseBlock(
|
||||
customDimension, BuiltinDimensionTypes.NETHER, null, 64));
|
||||
assertEquals(Blocks.END_STONE.defaultBlockState(),
|
||||
NativeStructureTerrainIntegrator.defaultEncaseBlock(
|
||||
customDimension, BuiltinDimensionTypes.END, null, 64));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredEncasePaletteReplacesTheDefaultMaterial() {
|
||||
StructureStart start = start(TerrainAdjustment.BURY, 64);
|
||||
@@ -129,58 +234,170 @@ public class NativeStructurePostProcessorEncaseTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buryAndEncapsulateAdaptationsAutoDefaultToEncase() {
|
||||
for (TerrainAdjustment adjustment : List.of(TerrainAdjustment.BURY, TerrainAdjustment.ENCAPSULATE)) {
|
||||
public void omittedTerrainConfigurationRetainsSourceSemantics() {
|
||||
for (TerrainAdjustment adjustment : TerrainAdjustment.values()) {
|
||||
IrisStructureTerrain resolved = NativeStructureTerrainIntegrator.resolveNativeTerrain(
|
||||
start(adjustment, 64), null);
|
||||
|
||||
assertEquals(IrisStructureTerrainMode.ENCASE, resolved.resolvedMode());
|
||||
assertEquals(3, resolved.getHorizontalPadding());
|
||||
assertEquals(3, resolved.getCeilingPadding());
|
||||
assertEquals(3, resolved.getFloorPadding());
|
||||
assertNull(resolved.getEncasePalette());
|
||||
assertEquals(IrisStructureTerrainMode.SOURCE, resolved.resolvedMode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void otherAdaptationsNeverAutoDefaultToEncase() {
|
||||
for (TerrainAdjustment adjustment : List.of(
|
||||
TerrainAdjustment.NONE, TerrainAdjustment.BEARD_THIN, TerrainAdjustment.BEARD_BOX)) {
|
||||
assertNull(NativeStructureTerrainIntegrator.resolveNativeTerrain(
|
||||
start(adjustment, 64), null));
|
||||
}
|
||||
public void sourceBuryUsesTheVanillaSixByTwelveInfluenceEnvelope() {
|
||||
StructureStart start = start(TerrainAdjustment.BURY, 64);
|
||||
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
|
||||
int groundY = bounds.minY();
|
||||
BoundingBox area = new BoundingBox(
|
||||
bounds.minX() - 7, groundY - 13, bounds.minZ(),
|
||||
bounds.maxX(), groundY + 13, bounds.maxZ());
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
|
||||
NativeStructureTerrainIntegrator.integrateTerrain(
|
||||
world(blocks), area, "test:bury", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE), null);
|
||||
|
||||
assertEquals(Blocks.STONE.defaultBlockState(),
|
||||
state(blocks, bounds.minX() - 5, groundY, bounds.minZ()));
|
||||
assertEquals(Blocks.STONE.defaultBlockState(),
|
||||
state(blocks, bounds.minX(), groundY + 11, bounds.minZ()));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(),
|
||||
state(blocks, bounds.minX() - 6, groundY, bounds.minZ()));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(),
|
||||
state(blocks, bounds.minX(), groundY + 12, bounds.minZ()));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(),
|
||||
state(blocks, bounds.minX() - 7, groundY, bounds.minZ()));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(),
|
||||
state(blocks, bounds.minX(), groundY + 13, bounds.minZ()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceEncapsulateWrapsTheFullPieceBounds() {
|
||||
StructureStart start = start(TerrainAdjustment.ENCAPSULATE, 64);
|
||||
BoundingBox bounds = start.getPieces().getFirst().getBoundingBox();
|
||||
BoundingBox area = new BoundingBox(
|
||||
bounds.minX() - 13, bounds.minY(), bounds.minZ(),
|
||||
bounds.maxX(), bounds.maxY() + 13, bounds.maxZ());
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
|
||||
NativeStructureTerrainIntegrator.integrateTerrain(
|
||||
world(blocks), area, "test:encapsulate", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE), null);
|
||||
|
||||
assertEquals(Blocks.STONE.defaultBlockState(),
|
||||
state(blocks, bounds.minX() - 11, bounds.minY(), bounds.minZ()));
|
||||
assertEquals(Blocks.STONE.defaultBlockState(),
|
||||
state(blocks, bounds.minX(), bounds.maxY() + 11, bounds.minZ()));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(),
|
||||
state(blocks, bounds.minX() - 12, bounds.minY(), bounds.minZ()));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(),
|
||||
state(blocks, bounds.minX(), bounds.maxY() + 12, bounds.minZ()));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(),
|
||||
state(blocks, bounds.minX() - 13, bounds.minY(), bounds.minZ()));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(),
|
||||
state(blocks, bounds.minX(), bounds.maxY() + 13, bounds.minZ()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceTerrainDensityOnlyUsesRigidPoolPieces() {
|
||||
PoolElementStructurePiece terrainMatching = poolPiece(StructureTemplatePool.Projection.TERRAIN_MATCHING);
|
||||
PoolElementStructurePiece rigid = poolPiece(StructureTemplatePool.Projection.RIGID);
|
||||
|
||||
assertFalse(NativeStructureTerrainIntegrator.isSourceRigidPiece(terrainMatching));
|
||||
assertTrue(NativeStructureTerrainIntegrator.isSourceRigidPiece(rigid));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sourceJunctionTerrainUsesTheStrictLowerTwelveBlockEnvelope() {
|
||||
assertTrue(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -1, 0));
|
||||
assertTrue(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -11, 0));
|
||||
assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, 0, 0));
|
||||
assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(0, -12, 0));
|
||||
assertFalse(NativeStructureTerrainIntegrator.insideSourceJunctionEnvelope(12, -1, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitTerrainConfigurationWinsOverAutoEncase() {
|
||||
IrisStructureTerrain configured = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.SOURCE);
|
||||
.setMode(IrisStructureTerrainMode.PRESERVE);
|
||||
|
||||
assertSame(configured, NativeStructureTerrainIntegrator.resolveNativeTerrain(
|
||||
start(TerrainAdjustment.BURY, 64), configured));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void legacyTemplateAirIsClearedAfterEveryTerrainFillPath() {
|
||||
IrisStructureTerrain source = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.SOURCE);
|
||||
IrisStructureTerrain preserve = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.PRESERVE);
|
||||
IrisStructureTerrain encase = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.ENCASE);
|
||||
|
||||
assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
|
||||
start(TerrainAdjustment.BURY, 64), source));
|
||||
assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
|
||||
start(TerrainAdjustment.BEARD_BOX, 64), source));
|
||||
assertTrue(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
|
||||
start(TerrainAdjustment.NONE, 64), encase));
|
||||
assertFalse(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
|
||||
start(TerrainAdjustment.NONE, 64), source));
|
||||
assertFalse(NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
|
||||
start(TerrainAdjustment.BURY, 64), preserve));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void encaseReservesTheNeighborChunkEnvelope() {
|
||||
StructureStart generated = start(TerrainAdjustment.BURY, 64);
|
||||
BoundingBox content = generated.getBoundingBox();
|
||||
BoundingBox sourceBounds = generated.getBoundingBox();
|
||||
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated);
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.ENCASE)
|
||||
.setHorizontalPadding(4);
|
||||
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
|
||||
generated,
|
||||
generated.getStructure(),
|
||||
0,
|
||||
null,
|
||||
new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.ENCASE)
|
||||
.setHorizontalPadding(4));
|
||||
terrain);
|
||||
BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
|
||||
wrapped, wrapped.getStructure(), terrain);
|
||||
|
||||
assertEquals(content.minX() - 4, wrapped.getBoundingBox().minX());
|
||||
assertEquals(content.maxX() + 4, wrapped.getBoundingBox().maxX());
|
||||
assertEquals(content.minZ() - 4, wrapped.getBoundingBox().minZ());
|
||||
assertEquals(content.maxZ() + 4, wrapped.getBoundingBox().maxZ());
|
||||
assertEquals(2, wrapped.getPieces().stream()
|
||||
.filter(NativeStructureReferenceEnvelope::isMarker)
|
||||
.count());
|
||||
assertEquals(sourceBounds, wrapped.getBoundingBox());
|
||||
assertEquals(content.minX() - 4, referenceBounds.minX());
|
||||
assertEquals(content.maxX() + 4, referenceBounds.maxX());
|
||||
assertEquals(content.minZ() - 4, referenceBounds.minZ());
|
||||
assertEquals(content.maxZ() + 4, referenceBounds.maxZ());
|
||||
assertEquals(generated.getPieces().size(), wrapped.getPieces().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void referenceEnvelopeRejectsChunkCoordinateOverflow() {
|
||||
StructureStart generated = start(TerrainAdjustment.NONE, 64);
|
||||
StructureStart extreme = new StructureStart(
|
||||
generated.getStructure(), new ChunkPos(Integer.MAX_VALUE, 0), 0,
|
||||
new PiecesContainer(generated.getPieces()));
|
||||
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
NativeStructureReferenceEnvelope.referenceBounds(
|
||||
extreme, extreme.getStructure(), new IrisStructureTerrain()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitTerrainEnvelopeDoesNotInheritSourceAdjustment() {
|
||||
StructureStart generated = start(TerrainAdjustment.BURY, 64);
|
||||
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated);
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.ENCASE)
|
||||
.setHorizontalPadding(112);
|
||||
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
|
||||
generated, generated.getStructure(), 0, terrain);
|
||||
BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
|
||||
wrapped, wrapped.getStructure(), terrain);
|
||||
|
||||
assertEquals(content.minX() - 112, referenceBounds.minX());
|
||||
assertEquals(content.maxX() + 112, referenceBounds.maxX());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -259,6 +476,24 @@ public class NativeStructurePostProcessorEncaseTest {
|
||||
structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece)));
|
||||
}
|
||||
|
||||
private static PoolElementStructurePiece poolPiece(StructureTemplatePool.Projection projection) {
|
||||
BlockPos position = new BlockPos(0, 64, 0);
|
||||
StructurePoolElement element = StructurePoolElement.single(
|
||||
"minecraft:empty",
|
||||
Holder.direct(new StructureProcessorList(List.of(
|
||||
BlockIgnoreProcessor.STRUCTURE_AND_AIR))),
|
||||
LiquidSettings.APPLY_WATERLOGGING
|
||||
).apply(projection);
|
||||
return new PoolElementStructurePiece(
|
||||
null,
|
||||
element,
|
||||
position,
|
||||
0,
|
||||
Rotation.NONE,
|
||||
new BoundingBox(position),
|
||||
LiquidSettings.APPLY_WATERLOGGING);
|
||||
}
|
||||
|
||||
private static WorldGenLevel world(Map<BlockPos, BlockState> blocks) {
|
||||
InvocationHandler handler = (proxy, method, arguments) -> {
|
||||
String methodName = method.getName();
|
||||
@@ -275,6 +510,9 @@ public class NativeStructurePostProcessorEncaseTest {
|
||||
if (methodName.equals("getSeed")) {
|
||||
return TEST_SEED;
|
||||
}
|
||||
if (methodName.equals("getLevel")) {
|
||||
return null;
|
||||
}
|
||||
if (methodName.equals("hashCode")) {
|
||||
return System.identityHashCode(proxy);
|
||||
}
|
||||
@@ -290,6 +528,14 @@ public class NativeStructurePostProcessorEncaseTest {
|
||||
WorldGenLevel.class.getClassLoader(), new Class<?>[]{WorldGenLevel.class}, handler);
|
||||
}
|
||||
|
||||
private static NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceSnapshot(
|
||||
WorldGenLevel world, BoundingBox area) {
|
||||
BitSet requiredLayers = new BitSet(area.getYSpan());
|
||||
requiredLayers.set(0, area.getYSpan());
|
||||
return NativeStructureTerrainIntegrator.SourceTerrainSnapshot.capture(
|
||||
world, area, requiredLayers);
|
||||
}
|
||||
|
||||
private static void put(Map<BlockPos, BlockState> blocks,
|
||||
int x, int y, int z, BlockState state) {
|
||||
blocks.put(new BlockPos(x, y, z), state);
|
||||
|
||||
+62
@@ -1,5 +1,7 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
@@ -16,7 +18,12 @@ import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStru
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
@@ -80,6 +87,25 @@ public class NativeStructurePostProcessorMonumentTest {
|
||||
assertEquals(51, start.getBoundingBox().maxY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void referenceEnvelopeDoesNotAddMonumentPieces() {
|
||||
StructureStart start = monumentStart(1337L);
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
|
||||
start,
|
||||
start.getStructure(),
|
||||
0,
|
||||
new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(4));
|
||||
|
||||
int offset = NativeStructureVerticalPlacer.applyVerticalPlacement(
|
||||
wrapped, "minecraft:monument", 0, 63, -64, 320,
|
||||
false, false, null, (x, z) -> 0);
|
||||
|
||||
assertEquals(0, offset);
|
||||
assertEquals(1, wrapped.getPieces().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void vanillaReloadRegenerationIsRealignedBeforePlacement() {
|
||||
long seed = 1337L;
|
||||
@@ -101,6 +127,42 @@ public class NativeStructurePostProcessorMonumentTest {
|
||||
assertEquals(48, reloaded.getBoundingBox().maxY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void neighboringChunkPlacementRealignsReloadedMonumentOnce() throws Exception {
|
||||
long seed = 1337L;
|
||||
ChunkPos chunkPos = new ChunkPos(0, 0);
|
||||
StructureStart initial = monumentStart(seed);
|
||||
NativeStructureVerticalPlacer.applyVerticalPlacement(
|
||||
initial, "minecraft:monument", 0, 50, -256, 512,
|
||||
false, false, null, (x, z) -> 0);
|
||||
PiecesContainer regenerated = OceanMonumentStructure.regeneratePiecesAfterLoad(
|
||||
chunkPos, seed, new PiecesContainer(initial.getPieces()));
|
||||
StructureStart reloaded = new StructureStart(
|
||||
monumentStructure(), chunkPos, 0, regenerated);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(8);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
List<Future<?>> futures = new ArrayList<>();
|
||||
try {
|
||||
for (int task = 0; task < 16; task++) {
|
||||
futures.add(executor.submit(() -> {
|
||||
release.await();
|
||||
NativeStructureVerticalPlacer.ensureMonumentSeaLevelAlignment(
|
||||
reloaded, "minecraft:monument", 0, 50, -256, 512);
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
release.countDown();
|
||||
for (Future<?> future : futures) {
|
||||
future.get();
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
assertEquals(26, reloaded.getBoundingBox().minY());
|
||||
assertEquals(48, reloaded.getBoundingBox().maxY());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void impossibleSeaLevelAlignmentFailsInsteadOfClippingTheMonument() {
|
||||
StructureStart start = monumentStart(1337L);
|
||||
|
||||
+21
@@ -135,6 +135,27 @@ public class NativeStructurePostProcessorScatteredFeatureTest {
|
||||
assertTrue(field.trySetAccessible());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void datapackOverridesOfSpecialVanillaIdsUseGenericPlacement() {
|
||||
StructureStart pyramidOverride = swampStart(
|
||||
new SwampHutPiece(RandomSource.create(43L), 0, 0));
|
||||
int pyramidMinY = pyramidOverride.getBoundingBox().minY();
|
||||
int pyramidOffset = NativeStructureVerticalPlacer.applyVerticalPlacement(
|
||||
pyramidOverride, "minecraft:desert_pyramid", 5,
|
||||
63, -64, 320, false, false, null, (x, z) -> 200);
|
||||
|
||||
assertEquals(5, pyramidOffset);
|
||||
assertEquals(pyramidMinY + 5, pyramidOverride.getBoundingBox().minY());
|
||||
|
||||
StructureStart monumentOverride = swampStart(
|
||||
new SwampHutPiece(RandomSource.create(47L), 0, 0));
|
||||
int monumentMinY = monumentOverride.getBoundingBox().minY();
|
||||
NativeStructureVerticalPlacer.ensureMonumentSeaLevelAlignment(
|
||||
monumentOverride, "minecraft:monument", 0, 40, -64, 320);
|
||||
|
||||
assertEquals(monumentMinY, monumentOverride.getBoundingBox().minY());
|
||||
}
|
||||
|
||||
private static StructureStart desertStart(StructurePiece piece) {
|
||||
Structure structure = new DesertPyramidStructure(new Structure.StructureSettings(HolderSet.empty()));
|
||||
return start(structure, piece);
|
||||
|
||||
+416
-59
@@ -5,23 +5,41 @@ import art.arcane.iris.engine.object.IrisStructureCarveShape;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import com.mojang.datafixers.util.Either;
|
||||
import com.mojang.serialization.Codec;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.IdMapper;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
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.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.LevelChunkSection;
|
||||
import net.minecraft.world.level.chunk.PalettedContainer;
|
||||
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.levelgen.GenerationStep;
|
||||
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.StructurePiece;
|
||||
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.SinglePoolElement;
|
||||
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.templatesystem.LiquidSettings;
|
||||
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.templatesystem.StructurePlaceSettings;
|
||||
@@ -35,15 +53,22 @@ import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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 static org.junit.Assert.assertArrayEquals;
|
||||
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;
|
||||
|
||||
public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
@@ -60,34 +85,148 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onlySurfaceBeardThinStructuresPrepareTerrain() {
|
||||
public void sourceBeardAdjustmentsPrepareTerrainAcrossDeclaredSteps() {
|
||||
assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.SURFACE_STRUCTURES));
|
||||
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.BURY, GenerationStep.Decoration.SURFACE_STRUCTURES));
|
||||
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.SURFACE_STRUCTURES));
|
||||
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.ENCAPSULATE, GenerationStep.Decoration.SURFACE_STRUCTURES));
|
||||
assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.BEARD_BOX, GenerationStep.Decoration.FLUID_SPRINGS));
|
||||
assertTrue(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.BEARD_THIN, GenerationStep.Decoration.UNDERGROUND_STRUCTURES));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void undergroundAdjustmentsNeverPrepareSurfaceTerrain() {
|
||||
for (TerrainAdjustment adjustment : List.of(
|
||||
TerrainAdjustment.BEARD_THIN,
|
||||
TerrainAdjustment.BURY,
|
||||
TerrainAdjustment.BEARD_BOX,
|
||||
TerrainAdjustment.ENCAPSULATE)) {
|
||||
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
adjustment, GenerationStep.Decoration.UNDERGROUND_STRUCTURES));
|
||||
public void nonBeardAdjustmentsDoNotPrepareSurfaceTerrain() {
|
||||
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.BURY, GenerationStep.Decoration.SURFACE_STRUCTURES));
|
||||
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.ENCAPSULATE, GenerationStep.Decoration.SURFACE_STRUCTURES));
|
||||
assertFalse(NativeStructureSurfaceFitter.shouldPrepareSurfaceTerrain(
|
||||
TerrainAdjustment.NONE, GenerationStep.Decoration.FLUID_SPRINGS));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitTerrainOverrideDisablesSourceBeardFitting() {
|
||||
StructureStart start = desertStart(TerrainAdjustment.BEARD_BOX);
|
||||
|
||||
assertTrue(NativeStructureSurfaceFitter.requiresSurfaceTerrain(
|
||||
new NativeStructureTerrainIntegrator.TerrainTarget(
|
||||
"test:beard_box", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))));
|
||||
assertFalse(NativeStructureSurfaceFitter.requiresSurfaceTerrain(
|
||||
new NativeStructureTerrainIntegrator.TerrainTarget(
|
||||
"test:beard_box", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.PRESERVE))));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void beardBoxUsesTheFullRigidHeightWhileBeardThinUsesTheGroundPlane() {
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor thin =
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 64, 2);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor box =
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(
|
||||
0, 4, 0, 4, 64, 2, 65, 90);
|
||||
|
||||
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(thin), 2, 2, 80));
|
||||
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(box), 2, 2, 80));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void beardInfluenceBeginsAtGroundYRatherThanTheSurfaceBlock() {
|
||||
BoundingBox bounds = new BoundingBox(0, 64, 0, 4, 90, 4);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor thin =
|
||||
NativeStructureSurfaceFitter.surfaceAnchor(
|
||||
bounds, 68, 2, TerrainAdjustment.BEARD_THIN);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor box =
|
||||
NativeStructureSurfaceFitter.surfaceAnchor(
|
||||
bounds, 68, 2, TerrainAdjustment.BEARD_BOX);
|
||||
|
||||
assertEquals(67, thin.meetY());
|
||||
assertEquals(0, thin.verticalDistance(68));
|
||||
assertEquals(1, thin.verticalDistance(67));
|
||||
assertEquals(0, box.verticalDistance(68));
|
||||
assertEquals(1, box.verticalDistance(67));
|
||||
assertEquals(0, box.verticalDistance(90));
|
||||
assertEquals(1, box.verticalDistance(91));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rigidFootprintsRaiseTerrainAcrossGapsLargerThanTheBeardKernel() {
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(72, 2);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(72, 1);
|
||||
|
||||
assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(rigid), 2, 2, 40));
|
||||
assertEquals(40, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(junction), 2, 2, 40));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void elevatedRigidFootprintsBlendIntoAHorizontalRescueTaper() {
|
||||
int originalY = 40;
|
||||
int kernelEdge = NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor(51, 2)), 5, 2, originalY);
|
||||
int gapThirteen = NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor(52, 2)), 5, 2, originalY);
|
||||
int gapEighteen = NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor(57, 2)), 5, 2, originalY);
|
||||
int gapTwentyFour = NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor(63, 2)), 5, 2, originalY);
|
||||
|
||||
assertEquals(originalY, kernelEdge);
|
||||
assertTrue(gapThirteen >= kernelEdge);
|
||||
assertTrue(gapEighteen > gapThirteen);
|
||||
assertTrue(gapTwentyFour > gapEighteen);
|
||||
assertTrue(gapTwentyFour < 63);
|
||||
assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor(63, 2)), 16, 2, originalY));
|
||||
assertEquals(originalY, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor(63, 1)), 5, 2, originalY));
|
||||
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor(64, 2)), 5, 2, 80));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void projectedCenterAndRigidChildrenShareTerrainSupportAcrossTheAssembly() {
|
||||
Structure structure = new DesertPyramidStructure(
|
||||
new Structure.StructureSettings(
|
||||
HolderSet.empty(), Map.of(),
|
||||
GenerationStep.Decoration.SURFACE_STRUCTURES,
|
||||
TerrainAdjustment.BEARD_BOX));
|
||||
PoolElementStructurePiece center = rigidPiece(
|
||||
new BoundingBox(0, 71, 0, 3, 80, 3), 1);
|
||||
PoolElementStructurePiece child = rigidPiece(
|
||||
new BoundingBox(10, 65, 0, 13, 76, 3), 7);
|
||||
StructureStart start = new StructureStart(
|
||||
structure, new ChunkPos(0, 0), 0,
|
||||
new PiecesContainer(List.of(center, child)));
|
||||
BoundingBox area = new BoundingBox(0, 60, 0, 15, 90, 15);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
for (int x = area.minX(); x <= area.maxX(); x++) {
|
||||
for (int z = area.minZ(); z <= area.maxZ(); z++) {
|
||||
put(blocks, x, 63, z, Blocks.DIRT.defaultBlockState());
|
||||
put(blocks, x, 64, z, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
}
|
||||
}
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world(blocks), area,
|
||||
List.of(new NativeStructureTerrainIntegrator.TerrainTarget(
|
||||
"nova_structures:tavern_oak", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))),
|
||||
(x, z) -> 64);
|
||||
|
||||
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 1, 71, 1));
|
||||
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 11, 71, 1));
|
||||
assertEquals(Blocks.DIRT.defaultBlockState(), state(blocks, 11, 70, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void surfaceAnchorIsFlushInsideAndUnchangedAtRadius() {
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(80, 2);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor anchor = anchor(72, 2);
|
||||
|
||||
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
assertEquals(72, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor), 2, 2, 64));
|
||||
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(anchor), 16, 2, 64));
|
||||
@@ -97,54 +236,88 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
|
||||
@Test
|
||||
public void surfaceAnchorRaisesAndLowersThroughTheTaper() {
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(80, 2);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor raised = anchor(68, 2);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor lowered = anchor(64, 2);
|
||||
|
||||
assertEquals(68, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(raised), 10, 2, 64));
|
||||
assertEquals(76, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(lowered), 10, 2, 80));
|
||||
assertEquals(67, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(lowered), 10, 2, 68));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void containingRigidFloorsHaveDeterministicPriority() {
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor rigid = anchor(70, 2);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(90, 1);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor junction = anchor(74, 1);
|
||||
|
||||
assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(rigid, junction), 2, 2, 64));
|
||||
assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(junction, rigid), 2, 2, 64));
|
||||
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor weakTie = anchor(48, 1);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor strongTie = anchor(80, 2);
|
||||
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor weakTie = anchor(58, 1);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor strongTie = anchor(70, 2);
|
||||
assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(weakTie, strongTie), 2, 2, 64));
|
||||
assertEquals(80, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
assertEquals(70, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(strongTie, weakTie), 2, 2, 64));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stackedRigidPiecesPreserveTheLowerAuthoredSurface() {
|
||||
Structure structure = new DesertPyramidStructure(
|
||||
new Structure.StructureSettings(
|
||||
HolderSet.empty(), Map.of(),
|
||||
GenerationStep.Decoration.SURFACE_STRUCTURES,
|
||||
TerrainAdjustment.BEARD_BOX));
|
||||
PoolElementStructurePiece lower = rigidPiece(
|
||||
new BoundingBox(0, 62, 0, 4, 65, 4), 1);
|
||||
PoolElementStructurePiece upper = rigidPiece(
|
||||
new BoundingBox(0, 66, 0, 4, 78, 4), 1);
|
||||
StructureStart start = new StructureStart(
|
||||
structure, new ChunkPos(0, 0), 0,
|
||||
new PiecesContainer(List.of(upper, lower)));
|
||||
BoundingBox area = new BoundingBox(0, 58, 0, 4, 78, 4);
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
for (int x = area.minX(); x <= area.maxX(); x++) {
|
||||
for (int z = area.minZ(); z <= area.maxZ(); z++) {
|
||||
put(blocks, x, 59, z, Blocks.DIRT.defaultBlockState());
|
||||
put(blocks, x, 60, z, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
}
|
||||
}
|
||||
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world(blocks), area,
|
||||
List.of(new NativeStructureTerrainIntegrator.TerrainTarget(
|
||||
"nova_structures:tavern_oak", start,
|
||||
new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE))),
|
||||
(x, z) -> 60);
|
||||
|
||||
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 2, 62, 2));
|
||||
assertEquals(Blocks.AIR.defaultBlockState(), state(blocks, 2, 65, 2));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void containingFootprintOverridesAnAdjacentPiecesFalloff() {
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor local =
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 65, 2);
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, 66, 2);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor adjacent =
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 80, 2);
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(5, 9, 0, 4, 70, 2);
|
||||
|
||||
assertEquals(77, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(adjacent), 4, 2, 64));
|
||||
assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(local, adjacent), 4, 2, 64));
|
||||
assertEquals(65, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
assertEquals(66, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(adjacent, local), 4, 2, 64));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void opposingFalloffsBlendWithoutAnAbruptMidpointSeam() {
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor high =
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 80, 2);
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(0, 0, 0, 0, 70, 2);
|
||||
NativeStructureSurfaceFitter.SurfaceAnchor low =
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(12, 12, 0, 0, 48, 2);
|
||||
new NativeStructureSurfaceFitter.SurfaceAnchor(12, 12, 0, 0, 58, 2);
|
||||
int previous = NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(high, low), 0, 0, 64);
|
||||
|
||||
@@ -154,7 +327,7 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
int reversed = NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
List.of(low, high), x, 0, 64);
|
||||
assertEquals(forward, reversed);
|
||||
assertTrue(Math.abs(forward - previous) <= 4);
|
||||
assertTrue(Math.abs(forward - previous) <= 6);
|
||||
previous = forward;
|
||||
}
|
||||
assertEquals(64, NativeStructureSurfaceFitter.resolveSurfaceTarget(
|
||||
@@ -195,6 +368,44 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(raised, 0, 68, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rigidSurfaceBaseClosesAOneBlockSubsurfaceGap() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
put(blocks, 0, 62, 0, Blocks.STONE.defaultBlockState());
|
||||
put(blocks, 0, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
|
||||
NativeStructureSurfaceFitter.applySurfaceColumn(
|
||||
world(blocks), new BlockPos.MutableBlockPos(),
|
||||
0, 0, 64, 64, -64, 319, true);
|
||||
|
||||
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 63, 0));
|
||||
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), state(blocks, 0, 64, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rigidSurfaceBaseReconstructsAMissingMeetLayer() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
put(blocks, 0, 62, 0, Blocks.STONE.defaultBlockState());
|
||||
|
||||
NativeStructureSurfaceFitter.applySurfaceColumn(
|
||||
world(blocks), new BlockPos.MutableBlockPos(),
|
||||
0, 0, 64, 64, -64, 319, true);
|
||||
|
||||
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 63, 0));
|
||||
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, 64, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rigidSurfaceBaseReconstructsAMissingMeetLayerAtTheWorldFloor() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
|
||||
NativeStructureSurfaceFitter.applySurfaceColumn(
|
||||
world(blocks), new BlockPos.MutableBlockPos(),
|
||||
0, 0, -64, -64, -64, 319, true);
|
||||
|
||||
assertEquals(Blocks.STONE.defaultBlockState(), state(blocks, 0, -64, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void raisedSurfaceTerrainDoesNotSliceTreeBlocks() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
@@ -213,6 +424,31 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
assertEquals(leaves, state(blocks, 0, 68, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearedIntersectingTreeCellsBecomeRaisedTerrainSupport() {
|
||||
ProtoChunk chunk = new ProtoChunk(
|
||||
new ChunkPos(0, 0), UpgradeData.EMPTY,
|
||||
LevelHeightAccessor.create(-64, 384), containerFactory(), null);
|
||||
write(chunk, 0, 63, 0, Blocks.DIRT.defaultBlockState());
|
||||
write(chunk, 0, 64, 0, Blocks.GRASS_BLOCK.defaultBlockState());
|
||||
write(chunk, 0, 66, 0, Blocks.OAK_LOG.defaultBlockState());
|
||||
write(chunk, 0, 68, 0, Blocks.OAK_LEAVES.defaultBlockState());
|
||||
WorldGenLevel world = world(chunk);
|
||||
BoundingBox area = new BoundingBox(0, 63, 0, 0, 68, 0);
|
||||
|
||||
NativeStructureVegetationClearer.clearIntersectingVegetation(
|
||||
world, chunk, area, List.of(
|
||||
new NativeStructureVegetationClearer.VegetationTarget(desertStart(), false)));
|
||||
NativeStructureSurfaceFitter.applySurfaceColumn(
|
||||
world, new BlockPos.MutableBlockPos(),
|
||||
0, 0, 64, 68, -64, 319);
|
||||
|
||||
assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 65, 0)));
|
||||
assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 66, 0)));
|
||||
assertEquals(Blocks.DIRT.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 67, 0)));
|
||||
assertEquals(Blocks.GRASS_BLOCK.defaultBlockState(), chunk.getBlockState(new BlockPos(0, 68, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loweredFluidColumnsRemainFluidFilled() {
|
||||
Map<BlockPos, BlockState> blocks = new HashMap<>();
|
||||
@@ -437,6 +673,47 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
assertNotSame(first, other);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void concurrentCarveRequestsShareOneFootprintBuild() throws Exception {
|
||||
StructureStart start = desertStart();
|
||||
int requestCount = 16;
|
||||
ExecutorService executor = Executors.newFixedThreadPool(requestCount);
|
||||
CountDownLatch ready = new CountDownLatch(requestCount);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
List<Future<StructureCarvingFootprint>> futures = new ArrayList<>();
|
||||
try {
|
||||
for (int request = 0; request < requestCount; request++) {
|
||||
futures.add(executor.submit(() -> {
|
||||
ready.countDown();
|
||||
release.await();
|
||||
return NativeStructureTerrainIntegrator.carveFootprint(
|
||||
start, 11,
|
||||
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager);
|
||||
}));
|
||||
}
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS));
|
||||
release.countDown();
|
||||
StructureCarvingFootprint expected = futures.getFirst().get();
|
||||
for (Future<StructureCarvingFootprint> future : futures) {
|
||||
assertSame(expected, future.get());
|
||||
}
|
||||
} finally {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void carveFootprintCacheStaysInsideItsCellBudget() {
|
||||
for (int index = 0; index < 40; index++) {
|
||||
NativeStructureTerrainIntegrator.carveFootprint(
|
||||
desertStart(), 128,
|
||||
NativeStructurePostProcessorSurfaceTerrainTest::forbiddenTemplateManager);
|
||||
}
|
||||
|
||||
assertTrue(NativeStructureTerrainIntegrator.cachedCarveFootprintCells()
|
||||
<= NativeStructureTerrainIntegrator.maximumCachedCarveFootprintCells());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void organicCarveNeverCutsBelowTheColumnSupportingFloor() {
|
||||
StructureStart start = desertStart();
|
||||
@@ -594,43 +871,48 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
public void nativeTerrainEnvelopePersistsHorizontalReferenceCoverage() {
|
||||
StructureStart generated = desertStart();
|
||||
BoundingBox content = generated.getBoundingBox();
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(24);
|
||||
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
|
||||
generated,
|
||||
generated.getStructure(),
|
||||
0,
|
||||
null,
|
||||
new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(24));
|
||||
terrain);
|
||||
BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
|
||||
wrapped, wrapped.getStructure(), terrain);
|
||||
|
||||
assertEquals(content, NativeStructureReferenceEnvelope.contentBounds(wrapped));
|
||||
assertEquals(content.minX() - 24, wrapped.getBoundingBox().minX());
|
||||
assertEquals(content.minZ() - 24, wrapped.getBoundingBox().minZ());
|
||||
assertEquals(content.maxX() + 24, wrapped.getBoundingBox().maxX());
|
||||
assertEquals(content.maxZ() + 24, wrapped.getBoundingBox().maxZ());
|
||||
assertEquals(2, wrapped.getPieces().stream()
|
||||
.filter(NativeStructureReferenceEnvelope::isMarker)
|
||||
.count());
|
||||
assertEquals(content, wrapped.getBoundingBox());
|
||||
assertEquals(content.minX() - 24, referenceBounds.minX());
|
||||
assertEquals(content.minZ() - 24, referenceBounds.minZ());
|
||||
assertEquals(content.maxX() + 24, referenceBounds.maxX());
|
||||
assertEquals(content.maxZ() + 24, referenceBounds.maxZ());
|
||||
assertEquals(generated.getPieces().size(), wrapped.getPieces().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeTerrainEnvelopeClipsToMinecraftReferenceCoverage() {
|
||||
public void nativeTerrainEnvelopeClipsOptionalCoverageWithoutDroppingContent() {
|
||||
StructureStart generated = desertStart();
|
||||
ChunkPos origin = generated.getChunkPos();
|
||||
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(generated);
|
||||
IrisStructureTerrain terrain = new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(124);
|
||||
|
||||
StructureStart wrapped = NativeStructureReferenceEnvelope.wrap(
|
||||
generated,
|
||||
generated.getStructure(),
|
||||
0,
|
||||
null,
|
||||
new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.FORCE_CARVE)
|
||||
.setHorizontalPadding(128));
|
||||
BoundingBox references = NativeStructureReferenceEnvelope.referenceBounds(
|
||||
generated, generated.getStructure(), terrain, "test:clipped_terrain");
|
||||
StructureStart published = NativeStructureReferenceEnvelope.wrapForPublication(
|
||||
generated, generated.getStructure(), 0, terrain, "test:clipped_terrain");
|
||||
|
||||
assertEquals(-128, wrapped.getBoundingBox().minX());
|
||||
assertEquals(-128, wrapped.getBoundingBox().minZ());
|
||||
assertEquals(143, wrapped.getBoundingBox().maxX());
|
||||
assertEquals(143, wrapped.getBoundingBox().maxZ());
|
||||
assertEquals(Math.max(content.minX() - 124, (origin.x() - 8) << 4), references.minX());
|
||||
assertEquals(Math.min(content.maxX() + 124, ((origin.x() + 8) << 4) + 15), references.maxX());
|
||||
assertEquals(Math.max(content.minZ() - 124, (origin.z() - 8) << 4), references.minZ());
|
||||
assertEquals(Math.min(content.maxZ() + 124, ((origin.z() + 8) << 4) + 15), references.maxZ());
|
||||
assertTrue(references.isInside(content.getCenter()));
|
||||
assertTrue(published.isValid());
|
||||
assertEquals(1, generated.getPieces().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -726,6 +1008,16 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
return new NativeStructureSurfaceFitter.SurfaceAnchor(0, 4, 0, 4, meetY, strength);
|
||||
}
|
||||
|
||||
private static PoolElementStructurePiece rigidPiece(BoundingBox bounds, int groundLevelDelta) {
|
||||
StructurePoolElement element = StructurePoolElement.single(
|
||||
"minecraft:empty").apply(StructureTemplatePool.Projection.RIGID);
|
||||
return new PoolElementStructurePiece(
|
||||
null, element,
|
||||
new BlockPos(bounds.minX(), bounds.minY(), bounds.minZ()),
|
||||
groundLevelDelta, Rotation.NONE, bounds,
|
||||
LiquidSettings.APPLY_WATERLOGGING);
|
||||
}
|
||||
|
||||
private static NativeStructureTerrainIntegrator.OrganicCarve organicCarve(
|
||||
StructureStart start, int horizontalPadding) {
|
||||
return organicCarve(start, horizontalPadding, 0.85D);
|
||||
@@ -831,8 +1123,14 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
}
|
||||
|
||||
private static StructureStart desertStart() {
|
||||
return desertStart(TerrainAdjustment.NONE);
|
||||
}
|
||||
|
||||
private static StructureStart desertStart(TerrainAdjustment adjustment) {
|
||||
Structure structure = new DesertPyramidStructure(
|
||||
new Structure.StructureSettings(HolderSet.empty()));
|
||||
new Structure.StructureSettings(
|
||||
HolderSet.empty(), Map.of(),
|
||||
GenerationStep.Decoration.SURFACE_STRUCTURES, adjustment));
|
||||
DesertPyramidPiece piece = new DesertPyramidPiece(RandomSource.create(7L), 0, 0);
|
||||
return new StructureStart(
|
||||
structure, new ChunkPos(0, 0), 0, new PiecesContainer(List.of(piece)));
|
||||
@@ -854,6 +1152,9 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
if (methodName.equals("getSeed")) {
|
||||
return TEST_SEED;
|
||||
}
|
||||
if (methodName.equals("getLevel")) {
|
||||
return null;
|
||||
}
|
||||
if (methodName.equals("hashCode")) {
|
||||
return System.identityHashCode(proxy);
|
||||
}
|
||||
@@ -869,6 +1170,62 @@ public class NativeStructurePostProcessorSurfaceTerrainTest {
|
||||
WorldGenLevel.class.getClassLoader(), new Class<?>[]{WorldGenLevel.class}, handler);
|
||||
}
|
||||
|
||||
private static WorldGenLevel world(ChunkAccess chunk) {
|
||||
InvocationHandler handler = (proxy, method, arguments) -> {
|
||||
String methodName = method.getName();
|
||||
if (methodName.equals("getBlockState")) {
|
||||
return chunk.getBlockState((BlockPos) arguments[0]);
|
||||
}
|
||||
if (methodName.equals("setBlock")) {
|
||||
return chunk.setBlockState(
|
||||
((BlockPos) arguments[0]).immutable(),
|
||||
(BlockState) arguments[1], (int) arguments[2]) != null;
|
||||
}
|
||||
if (methodName.equals("getSeed")) {
|
||||
return TEST_SEED;
|
||||
}
|
||||
if (methodName.equals("getLevel")) {
|
||||
return null;
|
||||
}
|
||||
if (methodName.equals("hashCode")) {
|
||||
return System.identityHashCode(proxy);
|
||||
}
|
||||
if (methodName.equals("equals")) {
|
||||
return proxy == arguments[0];
|
||||
}
|
||||
if (methodName.equals("toString")) {
|
||||
return "surface-test-chunk-world";
|
||||
}
|
||||
throw new UnsupportedOperationException(method.toString());
|
||||
};
|
||||
return (WorldGenLevel) Proxy.newProxyInstance(
|
||||
WorldGenLevel.class.getClassLoader(), new Class<?>[]{WorldGenLevel.class}, handler);
|
||||
}
|
||||
|
||||
private static void write(ProtoChunk chunk, int x, int y, int z, BlockState state) {
|
||||
LevelChunkSection section = chunk.getSection(chunk.getSectionIndex(y));
|
||||
section.setBlockState(x, y & 15, z, state, false);
|
||||
}
|
||||
|
||||
private static PalettedContainerFactory containerFactory() {
|
||||
Strategy<BlockState> blockStates = Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY);
|
||||
BlockState air = Blocks.AIR.defaultBlockState();
|
||||
IdMapper<Holder<Biome>> biomeIds = new IdMapper<>();
|
||||
Holder<Biome> defaultBiome = Holder.direct((Biome) null);
|
||||
biomeIds.add(defaultBiome);
|
||||
Strategy<Holder<Biome>> biomes = Strategy.createForBiomes(biomeIds);
|
||||
Codec<Holder<Biome>> biomeCodec = Codec.STRING.xmap(
|
||||
name -> defaultBiome, holder -> "iris-test-biome");
|
||||
return new PalettedContainerFactory(
|
||||
blockStates,
|
||||
air,
|
||||
PalettedContainer.codecRW(BlockState.CODEC, blockStates, air),
|
||||
biomes,
|
||||
defaultBiome,
|
||||
PalettedContainer.codecRO(biomeCodec, biomes, defaultBiome),
|
||||
PalettedContainer.codecRW(biomeCodec, biomes, defaultBiome));
|
||||
}
|
||||
|
||||
private static void put(Map<BlockPos, BlockState> blocks,
|
||||
int x, int y, int z, BlockState state) {
|
||||
blocks.put(new BlockPos(x, y, z), state);
|
||||
|
||||
+35
@@ -1,14 +1,33 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.util.RandomSource;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.levelgen.GenerationStep;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class NativeStructurePostProcessorVegetationTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void surfaceStructuresClearTreeColumnsAutomatically() {
|
||||
assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(100, 100, false));
|
||||
@@ -26,6 +45,22 @@ public class NativeStructurePostProcessorVegetationTest {
|
||||
assertTrue(NativeStructureVegetationClearer.shouldClearVegetationColumn(20, 100, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nonForcedValidTargetsReachIntersectionProcessing() {
|
||||
Structure structure = new DesertPyramidStructure(
|
||||
new Structure.StructureSettings(HolderSet.empty()));
|
||||
StructureStart start = new StructureStart(
|
||||
structure, new ChunkPos(0, 0), 0,
|
||||
new PiecesContainer(List.of(
|
||||
new DesertPyramidPiece(RandomSource.create(1L), 0, 0))));
|
||||
|
||||
assertTrue(NativeStructureVegetationClearer.shouldProcessTarget(
|
||||
new NativeStructureVegetationClearer.VegetationTarget(start, false)));
|
||||
assertFalse(NativeStructureVegetationClearer.shouldProcessTarget(
|
||||
new NativeStructureVegetationClearer.VegetationTarget(
|
||||
StructureStart.INVALID_START, true)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void surfaceStructuresPreserveVegetationUnlessConfigured() {
|
||||
assertFalse(NativeStructureVegetationClearer.shouldClearEntireVegetationFootprint(
|
||||
|
||||
@@ -58,6 +58,7 @@ tasks.named('jar', Jar).configure {
|
||||
tasks.named('test').configure {
|
||||
systemProperty('iris.commandFindSource', file('src/main/java/art/arcane/iris/core/commands/CommandFind.java').absolutePath)
|
||||
systemProperty('iris.commandStructureSource', file('src/main/java/art/arcane/iris/core/commands/CommandStructure.java').absolutePath)
|
||||
systemProperty('iris.commandIrisSource', file('src/main/java/art/arcane/iris/core/commands/CommandIris.java').absolutePath)
|
||||
systemProperty('iris.bukkitChunkGeneratorSource', rootProject.file('core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java').absolutePath)
|
||||
systemProperty('iris.pregeneratorJobSource', rootProject.file('core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java').absolutePath)
|
||||
systemProperty('iris.bukkitEnginePlatformHooksSource', file('src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java').absolutePath)
|
||||
|
||||
@@ -36,6 +36,7 @@ import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.core.lifecycle.PaperLibBootstrap;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks;
|
||||
import art.arcane.iris.core.runtime.WorldDeletionQueue;
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.api.terrain.IrisTerrainService;
|
||||
import art.arcane.iris.core.link.IrisPapiInstaller;
|
||||
@@ -571,7 +572,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IrisServices.register(EnginePlatformHooks.class, new BukkitEnginePlatformHooks());
|
||||
IrisServices.register(EngineWorldManagerProvider.class,
|
||||
(EngineWorldManagerProvider) IrisWorldManager::new);
|
||||
IrisServices.register(art.arcane.iris.core.runtime.WorldDeletionQueue.class, (art.arcane.iris.core.runtime.WorldDeletionQueue) pendingWorldDeletes::queueWorldDeletionOnStartup);
|
||||
IrisServices.register(WorldDeletionQueue.class, pendingWorldDeletes);
|
||||
SettingsHotloadWatch watch = new SettingsHotloadWatch(getDataFile("settings.json"));
|
||||
settingsHotloadWatch = watch;
|
||||
configHotloadEngine = new ConfigHotloadEngine(
|
||||
|
||||
+674
-53
@@ -19,77 +19,381 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.platform.bukkit.BukkitEnvironment;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Loads Iris worlds that are staged in bukkit.yml but not yet present on the server.
|
||||
*/
|
||||
public final class BukkitWorldReconciler {
|
||||
private final Iris plugin;
|
||||
private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
|
||||
|
||||
private final Backend backend;
|
||||
private final LifecycleOperationCoordinator coordinator;
|
||||
|
||||
public BukkitWorldReconciler(Iris plugin) {
|
||||
this.plugin = plugin;
|
||||
this(new BukkitBackend(plugin), LifecycleOperationCoordinator.get());
|
||||
}
|
||||
|
||||
public void checkForBukkitWorlds(Predicate<String> filter) {
|
||||
BukkitWorldReconciler(Backend backend, LifecycleOperationCoordinator coordinator) {
|
||||
this.backend = Objects.requireNonNull(backend, "backend");
|
||||
this.coordinator = Objects.requireNonNull(coordinator, "coordinator");
|
||||
}
|
||||
|
||||
public CompletableFuture<LoadResult> loadWorld(
|
||||
File configurationFile,
|
||||
String worldName
|
||||
) {
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
KList<String> deferredStartupWorlds = new KList<>();
|
||||
IrisWorlds.readBukkitWorlds().forEach((s, generator) -> {
|
||||
try {
|
||||
NamespacedKey worldKey = IrisWorldStorage.keyFromName(s);
|
||||
if (WorldIdentity.resolve(worldKey).isPresent() || !filter.test(s)) return;
|
||||
|
||||
Iris.info("Loading World: %s | Generator: %s", s, generator);
|
||||
ChunkGenerator gen = plugin.getDefaultWorldGenerator(s, generator);
|
||||
IrisDimension dim = IrisWorldGeneratorResolver.loadDimension(s, generator);
|
||||
assert dim != null && gen != null;
|
||||
|
||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + s + "' using Iris:" + generator + "...");
|
||||
WorldCreator c = WorldCreatorCompat.ofKey(worldKey)
|
||||
.generator(gen)
|
||||
.environment(BukkitEnvironment.from(dim.getEnvironment()));
|
||||
Long stagedSeed = IrisWorlds.readBukkitWorldSeed(s);
|
||||
if (stagedSeed != null) {
|
||||
c.seed(stagedSeed);
|
||||
}
|
||||
INMS.get().createWorld(c);
|
||||
Iris.info(C.LIGHT_PURPLE + "Loaded " + s + "!");
|
||||
} catch (Throwable e) {
|
||||
if (containsCreateWorldUnsupportedOperation(e)) {
|
||||
if (J.isFolia()) {
|
||||
if (!deferredStartupWorlds.contains(s)) {
|
||||
deferredStartupWorlds.add(s);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Iris.error("Failed to load world " + s + "!");
|
||||
Iris.error("This server denied Bukkit.createWorld for \"" + s + "\" at the current startup phase.");
|
||||
Iris.error("Ensure Iris is loaded at STARTUP and restart after staging worlds in bukkit.yml.");
|
||||
Iris.reportError("Failed to load staged startup world \"" + s + "\".", e);
|
||||
return;
|
||||
}
|
||||
Iris.reportError("Failed to load startup world \"" + s + "\".", e);
|
||||
}
|
||||
});
|
||||
if (!deferredStartupWorlds.isEmpty()) {
|
||||
Iris.warn("Staged Iris worlds could not load on Folia: %s", String.join(", ", deferredStartupWorlds));
|
||||
Iris.warn("Bukkit.createWorld is unsupported on this server and the Iris runtime world backend is unavailable (%s).", WorldLifecycleService.get().capabilities().paperLikeResolution());
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed while loading startup Iris worlds.", e);
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(worldName);
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.completedFuture(LoadResult.validationFailure(worldName, failure));
|
||||
}
|
||||
|
||||
LifecycleOperationCoordinator.Lease lease;
|
||||
try {
|
||||
lease = acquireWorldLoad(worldKey);
|
||||
} catch (LifecycleOperationCoordinator.BusyException failure) {
|
||||
return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure));
|
||||
}
|
||||
|
||||
DimensionResolution dimensionResolution;
|
||||
Long configuredSeed;
|
||||
try {
|
||||
dimensionResolution = backend.resolveDimension(worldKey);
|
||||
configuredSeed = dimensionResolution.succeeded()
|
||||
? backend.configuredSeed(IrisWorldStorage.logicalName(worldKey))
|
||||
: null;
|
||||
} catch (Throwable failure) {
|
||||
dimensionResolution = DimensionResolution.failed(failure);
|
||||
configuredSeed = null;
|
||||
}
|
||||
if (!dimensionResolution.succeeded()) {
|
||||
lease.close();
|
||||
return CompletableFuture.completedFuture(LoadResult.dimensionFailure(
|
||||
worldKey,
|
||||
dimensionResolution.failure()));
|
||||
}
|
||||
return loadWithLease(
|
||||
configurationFile,
|
||||
worldKey,
|
||||
dimensionResolution.dimension(),
|
||||
configuredSeed,
|
||||
lease);
|
||||
}
|
||||
|
||||
public CompletableFuture<BatchResult> checkForBukkitWorlds(Predicate<String> filter) {
|
||||
Predicate<String> requiredFilter = Objects.requireNonNull(filter, "filter");
|
||||
Map<String, String> configuredWorlds;
|
||||
try {
|
||||
configuredWorlds = backend.configuredWorlds();
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed while reading staged Bukkit worlds.", failure);
|
||||
return CompletableFuture.completedFuture(new BatchResult(List.of(), failure));
|
||||
}
|
||||
|
||||
CompletableFuture<List<LoadResult>> chain = CompletableFuture.completedFuture(new ArrayList<>());
|
||||
for (Map.Entry<String, String> entry : configuredWorlds.entrySet()) {
|
||||
String worldName = entry.getKey();
|
||||
boolean selected;
|
||||
try {
|
||||
selected = requiredFilter.test(worldName);
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed while filtering staged Bukkit world \"" + worldName + "\".", failure);
|
||||
return CompletableFuture.completedFuture(new BatchResult(List.of(), failure));
|
||||
}
|
||||
if (!selected) {
|
||||
continue;
|
||||
}
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.keyFromName(worldName);
|
||||
} catch (Throwable failure) {
|
||||
chain = chain.thenApply(results -> {
|
||||
results.add(LoadResult.validationFailure(worldName, failure));
|
||||
return results;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
String dimension = entry.getValue();
|
||||
Long seed;
|
||||
try {
|
||||
seed = backend.configuredSeed(worldName);
|
||||
} catch (Throwable failure) {
|
||||
chain = chain.thenApply(results -> {
|
||||
results.add(LoadResult.configurationFailure(worldKey, failure));
|
||||
return results;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
chain = chain.thenCompose(results -> loadConfiguredWorld(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
worldKey,
|
||||
dimension,
|
||||
seed)
|
||||
.thenApply(result -> {
|
||||
results.add(result);
|
||||
return results;
|
||||
}));
|
||||
}
|
||||
|
||||
return chain.thenApply(results -> {
|
||||
BatchResult batchResult = new BatchResult(results, null);
|
||||
reportBatch(batchResult);
|
||||
return batchResult;
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<LoadResult> loadConfiguredWorld(
|
||||
File configurationFile,
|
||||
NamespacedKey worldKey,
|
||||
String dimension,
|
||||
Long seed
|
||||
) {
|
||||
LifecycleOperationCoordinator.Lease lease;
|
||||
try {
|
||||
lease = acquireWorldLoad(worldKey);
|
||||
} catch (LifecycleOperationCoordinator.BusyException failure) {
|
||||
return CompletableFuture.completedFuture(LoadResult.busy(worldKey, failure));
|
||||
}
|
||||
|
||||
return loadWithLease(configurationFile, worldKey, dimension, seed, lease);
|
||||
}
|
||||
|
||||
private LifecycleOperationCoordinator.Lease acquireWorldLoad(NamespacedKey worldKey) {
|
||||
return coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_LOAD,
|
||||
worldKey.toString());
|
||||
}
|
||||
|
||||
private CompletableFuture<LoadResult> loadWithLease(
|
||||
File configurationFile,
|
||||
NamespacedKey worldKey,
|
||||
String dimension,
|
||||
Long seed,
|
||||
LifecycleOperationCoordinator.Lease lease
|
||||
) {
|
||||
|
||||
BukkitWorldConfiguration.Registration registration;
|
||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
||||
try {
|
||||
registration = BukkitWorldConfiguration.register(
|
||||
configurationFile,
|
||||
worldName,
|
||||
dimension,
|
||||
seed);
|
||||
} catch (Throwable failure) {
|
||||
lease.close();
|
||||
return CompletableFuture.completedFuture(LoadResult.configurationFailure(worldKey, failure));
|
||||
}
|
||||
|
||||
CompletableFuture<ReconciliationResult> reconciliation;
|
||||
try {
|
||||
reconciliation = reconcile(worldKey, dimension, seed);
|
||||
} catch (Throwable failure) {
|
||||
reconciliation = CompletableFuture.completedFuture(ReconciliationResult.createFailure(worldKey, failure));
|
||||
}
|
||||
|
||||
return reconciliation.handle((result, failure) -> {
|
||||
ReconciliationResult settled = failure == null
|
||||
? result
|
||||
: ReconciliationResult.createFailure(worldKey, unwrap(failure));
|
||||
if (settled == null) {
|
||||
settled = ReconciliationResult.createFailure(
|
||||
worldKey,
|
||||
new IllegalStateException("World reconciliation completed without a result."));
|
||||
}
|
||||
if (settled.succeeded()
|
||||
|| settled.status() == ReconciliationStatus.RESTART_REQUIRED
|
||||
|| registration != BukkitWorldConfiguration.Registration.CREATED) {
|
||||
return new LoadResult(settled, registration, false, true, null);
|
||||
}
|
||||
try {
|
||||
boolean rolledBack = BukkitWorldConfiguration.removeIfMatching(
|
||||
configurationFile,
|
||||
worldName,
|
||||
dimension,
|
||||
seed);
|
||||
return new LoadResult(settled, registration, true, rolledBack, null);
|
||||
} catch (Throwable rollbackFailure) {
|
||||
return new LoadResult(settled, registration, true, false, rollbackFailure);
|
||||
}
|
||||
})
|
||||
.whenComplete((result, failure) -> lease.close());
|
||||
}
|
||||
|
||||
private CompletableFuture<ReconciliationResult> reconcile(
|
||||
NamespacedKey worldKey,
|
||||
String dimension,
|
||||
Long seed
|
||||
) {
|
||||
Optional<World> loaded = backend.loadedWorld(worldKey);
|
||||
if (loaded.isPresent()) {
|
||||
return CompletableFuture.completedFuture(verifyLoadedWorld(worldKey, loaded.get(), true));
|
||||
}
|
||||
|
||||
CompletableFuture<World> created;
|
||||
try {
|
||||
created = Objects.requireNonNull(
|
||||
backend.createWorld(worldKey, dimension, seed),
|
||||
"World backend returned no creation future.");
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.completedFuture(classifyCreationFailure(worldKey, failure));
|
||||
}
|
||||
|
||||
CompletableFuture<World> guardedCreation = guardCreateCompletion(
|
||||
created,
|
||||
worldKey,
|
||||
TimeUnit.SECONDS.toMillis(WORLD_CREATE_TIMEOUT_SECONDS),
|
||||
() -> ServerConfigurator.restart("World load timed out for \"" + worldKey + "\"."));
|
||||
return guardedCreation.handle((createdWorld, failure) -> {
|
||||
if (failure != null) {
|
||||
return classifyCreationFailure(worldKey, unwrap(failure));
|
||||
}
|
||||
if (createdWorld == null) {
|
||||
return ReconciliationResult.notLoaded(worldKey);
|
||||
}
|
||||
|
||||
NamespacedKey createdKey;
|
||||
try {
|
||||
createdKey = WorldIdentity.key(createdWorld);
|
||||
} catch (Throwable identityFailure) {
|
||||
return ReconciliationResult.createFailure(worldKey, identityFailure);
|
||||
}
|
||||
if (!worldKey.equals(createdKey)) {
|
||||
return ReconciliationResult.identityMismatch(worldKey, createdWorld, createdKey);
|
||||
}
|
||||
|
||||
Optional<World> resolved = backend.loadedWorld(worldKey);
|
||||
if (resolved.isEmpty()) {
|
||||
return ReconciliationResult.notLoaded(worldKey);
|
||||
}
|
||||
return verifyLoadedWorld(worldKey, resolved.get(), false);
|
||||
});
|
||||
}
|
||||
|
||||
private ReconciliationResult verifyLoadedWorld(NamespacedKey worldKey, World loadedWorld, boolean alreadyLoaded) {
|
||||
NamespacedKey loadedKey;
|
||||
try {
|
||||
loadedKey = WorldIdentity.key(loadedWorld);
|
||||
} catch (Throwable identityFailure) {
|
||||
return ReconciliationResult.createFailure(worldKey, identityFailure);
|
||||
}
|
||||
if (!worldKey.equals(loadedKey)) {
|
||||
return ReconciliationResult.identityMismatch(worldKey, loadedWorld, loadedKey);
|
||||
}
|
||||
if (!backend.isIrisWorld(loadedWorld)) {
|
||||
return ReconciliationResult.identityConflict(worldKey, loadedWorld);
|
||||
}
|
||||
return alreadyLoaded
|
||||
? ReconciliationResult.alreadyLoaded(worldKey, loadedWorld)
|
||||
: ReconciliationResult.loaded(worldKey, loadedWorld);
|
||||
}
|
||||
|
||||
private static ReconciliationResult classifyCreationFailure(NamespacedKey worldKey, Throwable failure) {
|
||||
Throwable cause = unwrap(failure);
|
||||
if (cause instanceof TimeoutException || containsCreateWorldUnsupportedOperation(cause)) {
|
||||
return ReconciliationResult.restartRequired(worldKey, cause);
|
||||
}
|
||||
return ReconciliationResult.createFailure(worldKey, cause);
|
||||
}
|
||||
|
||||
static CompletableFuture<World> guardCreateCompletion(
|
||||
CompletableFuture<World> source,
|
||||
NamespacedKey worldKey,
|
||||
long timeoutMillis,
|
||||
Runnable timeoutAction
|
||||
) {
|
||||
Objects.requireNonNull(source, "source");
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(timeoutAction, "timeoutAction");
|
||||
if (timeoutMillis < 1L) {
|
||||
throw new IllegalArgumentException("timeoutMillis must be positive");
|
||||
}
|
||||
|
||||
CompletableFuture<World> guarded = new CompletableFuture<>();
|
||||
AtomicBoolean settled = new AtomicBoolean(false);
|
||||
source.whenComplete((world, throwable) -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (throwable == null) {
|
||||
guarded.complete(world);
|
||||
} else {
|
||||
guarded.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
CompletableFuture.delayedExecutor(timeoutMillis, TimeUnit.MILLISECONDS).execute(() -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
TimeoutException timeout = new TimeoutException(
|
||||
"World load did not settle within " + timeoutMillis + " milliseconds for \""
|
||||
+ worldKey + "\".");
|
||||
try {
|
||||
timeoutAction.run();
|
||||
} catch (Throwable failure) {
|
||||
timeout.addSuppressed(failure);
|
||||
}
|
||||
guarded.completeExceptionally(timeout);
|
||||
});
|
||||
return guarded;
|
||||
}
|
||||
|
||||
private static void reportBatch(BatchResult batchResult) {
|
||||
for (LoadResult result : batchResult.results()) {
|
||||
if (result.succeeded()) {
|
||||
Iris.info(C.LIGHT_PURPLE + result.message());
|
||||
continue;
|
||||
}
|
||||
if (result.status() == ReconciliationStatus.BUSY) {
|
||||
Iris.warn(result.message());
|
||||
continue;
|
||||
}
|
||||
Iris.error(result.message());
|
||||
Throwable failure = result.failure();
|
||||
if (failure != null) {
|
||||
Iris.reportError("Failed to reconcile staged world \"" + result.worldKey() + "\".", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Throwable unwrap(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current instanceof CompletionException && current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static boolean containsCreateWorldUnsupportedOperation(Throwable throwable) {
|
||||
@@ -107,4 +411,321 @@ public final class BukkitWorldReconciler {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface Backend {
|
||||
Map<String, String> configuredWorlds();
|
||||
|
||||
Long configuredSeed(String worldName);
|
||||
|
||||
Optional<World> loadedWorld(NamespacedKey worldKey);
|
||||
|
||||
CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed);
|
||||
|
||||
boolean isIrisWorld(World world);
|
||||
|
||||
DimensionResolution resolveDimension(NamespacedKey worldKey);
|
||||
}
|
||||
|
||||
public enum ReconciliationStatus {
|
||||
LOADED,
|
||||
ALREADY_LOADED,
|
||||
BUSY,
|
||||
INVALID_WORLD,
|
||||
DIMENSION_UNRESOLVED,
|
||||
CONFIGURATION_FAILED,
|
||||
CREATE_FAILED,
|
||||
RESTART_REQUIRED,
|
||||
IDENTITY_MISMATCH,
|
||||
IDENTITY_CONFLICT,
|
||||
NOT_LOADED
|
||||
}
|
||||
|
||||
public record ReconciliationResult(
|
||||
ReconciliationStatus status,
|
||||
NamespacedKey worldKey,
|
||||
World world,
|
||||
Throwable failure,
|
||||
String message
|
||||
) {
|
||||
public ReconciliationResult {
|
||||
Objects.requireNonNull(status, "status");
|
||||
Objects.requireNonNull(message, "message");
|
||||
}
|
||||
|
||||
public boolean succeeded() {
|
||||
return status == ReconciliationStatus.LOADED || status == ReconciliationStatus.ALREADY_LOADED;
|
||||
}
|
||||
|
||||
private static ReconciliationResult loaded(NamespacedKey worldKey, World world) {
|
||||
return new ReconciliationResult(
|
||||
ReconciliationStatus.LOADED,
|
||||
worldKey,
|
||||
world,
|
||||
null,
|
||||
"Loaded Iris world \"" + worldKey + "\".");
|
||||
}
|
||||
|
||||
private static ReconciliationResult alreadyLoaded(NamespacedKey worldKey, World world) {
|
||||
return new ReconciliationResult(
|
||||
ReconciliationStatus.ALREADY_LOADED,
|
||||
worldKey,
|
||||
world,
|
||||
null,
|
||||
"Iris world \"" + worldKey + "\" is already loaded.");
|
||||
}
|
||||
|
||||
private static ReconciliationResult createFailure(NamespacedKey worldKey, Throwable failure) {
|
||||
return new ReconciliationResult(
|
||||
ReconciliationStatus.CREATE_FAILED,
|
||||
worldKey,
|
||||
null,
|
||||
failure,
|
||||
"Failed to create Iris world \"" + worldKey + "\": " + failure.getMessage());
|
||||
}
|
||||
|
||||
private static ReconciliationResult restartRequired(NamespacedKey worldKey, Throwable failure) {
|
||||
return new ReconciliationResult(
|
||||
ReconciliationStatus.RESTART_REQUIRED,
|
||||
worldKey,
|
||||
null,
|
||||
failure,
|
||||
"The server cannot load exact Iris world \"" + worldKey + "\" at this runtime phase.");
|
||||
}
|
||||
|
||||
private static ReconciliationResult identityMismatch(
|
||||
NamespacedKey worldKey,
|
||||
World world,
|
||||
NamespacedKey actualKey
|
||||
) {
|
||||
return new ReconciliationResult(
|
||||
ReconciliationStatus.IDENTITY_MISMATCH,
|
||||
worldKey,
|
||||
world,
|
||||
null,
|
||||
"World creation returned \"" + actualKey + "\" instead of \"" + worldKey + "\".");
|
||||
}
|
||||
|
||||
private static ReconciliationResult identityConflict(NamespacedKey worldKey, World world) {
|
||||
return new ReconciliationResult(
|
||||
ReconciliationStatus.IDENTITY_CONFLICT,
|
||||
worldKey,
|
||||
world,
|
||||
null,
|
||||
"World \"" + worldKey + "\" is loaded, but it is not an Iris world.");
|
||||
}
|
||||
|
||||
private static ReconciliationResult notLoaded(NamespacedKey worldKey) {
|
||||
return new ReconciliationResult(
|
||||
ReconciliationStatus.NOT_LOADED,
|
||||
worldKey,
|
||||
null,
|
||||
null,
|
||||
"World creation completed without loading exact Iris world \"" + worldKey + "\".");
|
||||
}
|
||||
}
|
||||
|
||||
public record LoadResult(
|
||||
ReconciliationResult reconciliation,
|
||||
BukkitWorldConfiguration.Registration registration,
|
||||
boolean rollbackAttempted,
|
||||
boolean rollbackSucceeded,
|
||||
Throwable rollbackFailure
|
||||
) {
|
||||
public LoadResult {
|
||||
Objects.requireNonNull(reconciliation, "reconciliation");
|
||||
}
|
||||
|
||||
public boolean succeeded() {
|
||||
return reconciliation.succeeded() && rollbackFailure == null;
|
||||
}
|
||||
|
||||
public ReconciliationStatus status() {
|
||||
return reconciliation.status();
|
||||
}
|
||||
|
||||
public NamespacedKey worldKey() {
|
||||
return reconciliation.worldKey();
|
||||
}
|
||||
|
||||
public World world() {
|
||||
return reconciliation.world();
|
||||
}
|
||||
|
||||
public Throwable failure() {
|
||||
return rollbackFailure == null ? reconciliation.failure() : rollbackFailure;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
if (rollbackFailure != null) {
|
||||
return reconciliation.message() + " Failed to roll back bukkit.yml: " + rollbackFailure.getMessage();
|
||||
}
|
||||
if (rollbackAttempted && rollbackSucceeded) {
|
||||
return reconciliation.message() + " The new bukkit.yml entry was rolled back.";
|
||||
}
|
||||
if (rollbackAttempted && !rollbackSucceeded) {
|
||||
return reconciliation.message() + " The new bukkit.yml entry was no longer an exact match and was not modified.";
|
||||
}
|
||||
return reconciliation.message();
|
||||
}
|
||||
|
||||
private static LoadResult validationFailure(String worldName, Throwable failure) {
|
||||
ReconciliationResult reconciliation = new ReconciliationResult(
|
||||
ReconciliationStatus.INVALID_WORLD,
|
||||
null,
|
||||
null,
|
||||
failure,
|
||||
"Invalid Iris world identifier \"" + worldName + "\": " + failure.getMessage());
|
||||
return new LoadResult(reconciliation, null, false, true, null);
|
||||
}
|
||||
|
||||
private static LoadResult busy(NamespacedKey worldKey, LifecycleOperationCoordinator.BusyException failure) {
|
||||
ReconciliationResult reconciliation = new ReconciliationResult(
|
||||
ReconciliationStatus.BUSY,
|
||||
worldKey,
|
||||
null,
|
||||
failure,
|
||||
failure.getMessage());
|
||||
return new LoadResult(reconciliation, null, false, true, null);
|
||||
}
|
||||
|
||||
private static LoadResult configurationFailure(NamespacedKey worldKey, Throwable failure) {
|
||||
ReconciliationResult reconciliation = new ReconciliationResult(
|
||||
ReconciliationStatus.CONFIGURATION_FAILED,
|
||||
worldKey,
|
||||
null,
|
||||
failure,
|
||||
"Failed to register Iris world \"" + worldKey + "\" in bukkit.yml: " + failure.getMessage());
|
||||
return new LoadResult(reconciliation, null, false, true, null);
|
||||
}
|
||||
|
||||
private static LoadResult dimensionFailure(NamespacedKey worldKey, Throwable failure) {
|
||||
ReconciliationResult reconciliation = new ReconciliationResult(
|
||||
ReconciliationStatus.DIMENSION_UNRESOLVED,
|
||||
worldKey,
|
||||
null,
|
||||
failure,
|
||||
"Could not determine one Iris dimension for world \"" + worldKey + "\": " + failure.getMessage());
|
||||
return new LoadResult(reconciliation, null, false, true, null);
|
||||
}
|
||||
}
|
||||
|
||||
record DimensionResolution(String dimension, Throwable failure) {
|
||||
DimensionResolution {
|
||||
if ((dimension == null) == (failure == null)) {
|
||||
throw new IllegalArgumentException("Dimension resolution must contain exactly one outcome.");
|
||||
}
|
||||
}
|
||||
|
||||
static DimensionResolution resolved(String dimension) {
|
||||
return new DimensionResolution(Objects.requireNonNull(dimension, "dimension"), null);
|
||||
}
|
||||
|
||||
static DimensionResolution failed(Throwable failure) {
|
||||
return new DimensionResolution(null, Objects.requireNonNull(failure, "failure"));
|
||||
}
|
||||
|
||||
boolean succeeded() {
|
||||
return dimension != null;
|
||||
}
|
||||
}
|
||||
|
||||
public record BatchResult(List<LoadResult> results, Throwable failure) {
|
||||
public BatchResult {
|
||||
results = List.copyOf(Objects.requireNonNull(results, "results"));
|
||||
}
|
||||
|
||||
public boolean succeeded() {
|
||||
return failure == null && results.stream().allMatch(LoadResult::succeeded);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class BukkitBackend implements Backend {
|
||||
private final Iris plugin;
|
||||
|
||||
private BukkitBackend(Iris plugin) {
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> configuredWorlds() {
|
||||
return new LinkedHashMap<>(IrisWorlds.readBukkitWorlds());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long configuredSeed(String worldName) {
|
||||
return IrisWorlds.readBukkitWorldSeed(worldName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<World> loadedWorld(NamespacedKey worldKey) {
|
||||
return WorldIdentity.resolve(worldKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<World> createWorld(NamespacedKey worldKey, String dimension, Long seed) {
|
||||
try {
|
||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
||||
Iris.info("Loading World: %s | Generator: %s", worldName, dimension);
|
||||
ChunkGenerator generator = plugin.getDefaultWorldGenerator(worldName, dimension);
|
||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
|
||||
if (generator == null || irisDimension == null) {
|
||||
throw new IllegalStateException("Could not resolve the Iris generator or dimension \"" + dimension + "\".");
|
||||
}
|
||||
|
||||
Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + worldName + " using Iris:" + dimension + "...");
|
||||
WorldCreator creator = WorldCreatorCompat.ofKey(worldKey)
|
||||
.generator(generator)
|
||||
.environment(BukkitEnvironment.from(irisDimension.getEnvironment()));
|
||||
if (seed != null) {
|
||||
creator.seed(seed);
|
||||
}
|
||||
return INMS.get().createWorldAsync(creator);
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isIrisWorld(World world) {
|
||||
return IrisToolbelt.isIrisWorld(world);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DimensionResolution resolveDimension(NamespacedKey worldKey) {
|
||||
File dimensionsDirectory = new File(IrisWorldStorage.packRoot(worldKey), "dimensions");
|
||||
if (!dimensionsDirectory.isDirectory()) {
|
||||
return DimensionResolution.failed(new IllegalStateException("The world has no Iris dimensions directory."));
|
||||
}
|
||||
|
||||
List<String> dimensions = new ArrayList<>();
|
||||
Path dimensionsRoot = dimensionsDirectory.toPath().toAbsolutePath().normalize();
|
||||
try (Stream<Path> paths = Files.walk(dimensionsRoot)) {
|
||||
paths.filter(path -> Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS))
|
||||
.filter(path -> path.getFileName().toString().endsWith(".json"))
|
||||
.forEach(path -> {
|
||||
String relative = dimensionsRoot.relativize(path).toString().replace(File.separatorChar, '/');
|
||||
dimensions.add(relative.substring(0, relative.length() - 5));
|
||||
});
|
||||
} catch (IOException failure) {
|
||||
return DimensionResolution.failed(new IllegalStateException(
|
||||
"The Iris dimensions directory could not be read.",
|
||||
failure));
|
||||
}
|
||||
Collections.sort(dimensions);
|
||||
|
||||
String registeredDimension = IrisWorlds.get().getWorlds().get(worldKey.toString());
|
||||
if (registeredDimension != null && dimensions.contains(registeredDimension)) {
|
||||
return DimensionResolution.resolved(registeredDimension);
|
||||
}
|
||||
if (dimensions.size() == 1) {
|
||||
return DimensionResolution.resolved(dimensions.getFirst());
|
||||
}
|
||||
if (dimensions.isEmpty()) {
|
||||
return DimensionResolution.failed(new IllegalStateException("No dimension definitions were found."));
|
||||
}
|
||||
return DimensionResolution.failed(new IllegalStateException(
|
||||
"Multiple dimension definitions were found without an exact registered dimension: "
|
||||
+ String.join(", ", dimensions)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-14
@@ -23,6 +23,7 @@ import art.arcane.iris.core.lifecycle.WorldLifecycleStaging;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.BrokenPackException;
|
||||
import art.arcane.iris.core.pack.PackDownloader;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
@@ -33,7 +34,6 @@ import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import art.arcane.iris.util.common.plugin.VolmitPlugin;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import lombok.NonNull;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -42,6 +42,7 @@ import org.bukkit.generator.ChunkGenerator;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
@@ -57,15 +58,12 @@ public final class IrisWorldGeneratorResolver {
|
||||
|
||||
public void validateAllPacks() {
|
||||
File packsRoot = plugin.getDataFolder("packs");
|
||||
File[] packDirs = packsRoot.listFiles(File::isDirectory);
|
||||
if (packDirs == null || packDirs.length == 0) {
|
||||
List<File> packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||
PackValidationRegistry.clear();
|
||||
if (packDirs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
PackValidationRegistry.clear();
|
||||
for (File packDir : packDirs) {
|
||||
if (packDir.getName().contains(".importing-")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(packDir);
|
||||
PackValidationRegistry.publish(result);
|
||||
@@ -167,16 +165,16 @@ public final class IrisWorldGeneratorResolver {
|
||||
Iris.debug("Generator Config: " + w.toString());
|
||||
|
||||
File ff = new File(w.worldFolder(), "iris/pack");
|
||||
File[] files = ff.listFiles();
|
||||
if (files == null || files.length == 0)
|
||||
IO.delete(ff);
|
||||
|
||||
if (!ff.exists()) {
|
||||
ff.mkdirs();
|
||||
dim = Iris.service(StudioSVC.class).installIntoWorld(Iris.getSender(), dim, w.worldFolder());
|
||||
IrisDimension installedDimension = ff.isDirectory()
|
||||
? IrisData.get(ff).getDimensionLoader().load(dim.getLoadKey(), false)
|
||||
: null;
|
||||
if (installedDimension == null) {
|
||||
dim = Iris.service(StudioSVC.class).replaceIntoWorld(Iris.getSender(), dim, w.worldFolder());
|
||||
if (dim == null) {
|
||||
throw new IllegalStateException("Failed to install dimension pack for " + id);
|
||||
}
|
||||
} else {
|
||||
dim = installedDimension;
|
||||
}
|
||||
|
||||
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
|
||||
|
||||
+399
-145
@@ -19,14 +19,11 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.runtime.WorldDeletionQueue;
|
||||
import art.arcane.iris.core.runtime.TransientWorldCleanupSupport;
|
||||
import art.arcane.iris.core.tools.IrisCreator;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||
import art.arcane.iris.util.common.plugin.VolmitPlugin;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
@@ -34,21 +31,38 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.SimpleFileVisitor;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Persistent queue of world folders that must be deleted on the next startup, plus the startup
|
||||
* drain that actually removes them.
|
||||
*/
|
||||
public final class PendingWorldDeleteQueue {
|
||||
public final class PendingWorldDeleteQueue implements WorldDeletionQueue {
|
||||
private static final String PENDING_WORLD_DELETE_FILE = "pending-world-deletes.txt";
|
||||
private static final String EXACT_PREFIX = "exact:";
|
||||
private static final Pattern SAFE_LOGICAL_NAME = Pattern.compile("^[a-z0-9_-]+$");
|
||||
private static final Pattern QUARANTINE_NAME = Pattern.compile("^\\.iris-delete-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
|
||||
private static final Set<String> VANILLA_DIMENSION_ALIASES = Set.of("overworld", "the_nether", "the_end");
|
||||
|
||||
private final VolmitPlugin plugin;
|
||||
|
||||
@@ -56,173 +70,413 @@ public final class PendingWorldDeleteQueue {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
public synchronized int queueWorldDeletionOnStartup(Collection<String> worldNames) throws IOException {
|
||||
@Override
|
||||
public synchronized int queueExactForStartupDeletion(Collection<String> worldNames) throws IOException {
|
||||
return queueWorldDeletionOnStartup(worldNames, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized int queueFamilyForStartupDeletion(Collection<String> worldNames) throws IOException {
|
||||
return queueWorldDeletionOnStartup(worldNames, false);
|
||||
}
|
||||
|
||||
private int queueWorldDeletionOnStartup(Collection<String> worldNames, boolean exact) throws IOException {
|
||||
if (worldNames == null || worldNames.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap();
|
||||
int before = queue.size();
|
||||
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
ArrayList<String> normalizedNames = new ArrayList<>(worldNames.size());
|
||||
for (String worldName : worldNames) {
|
||||
String normalized = normalizeWorldName(worldName);
|
||||
String normalized = normalizeQueueEntry(worldName, levelRoot.getName());
|
||||
if (normalized == null) {
|
||||
continue;
|
||||
throw new IllegalArgumentException("Unsafe Iris world deletion target: " + worldName);
|
||||
}
|
||||
queue.putIfAbsent(normalized.toLowerCase(Locale.ROOT), normalized);
|
||||
normalizedNames.add(exact && !QUARANTINE_NAME.matcher(normalized).matches()
|
||||
? EXACT_PREFIX + normalized
|
||||
: normalized);
|
||||
}
|
||||
|
||||
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
|
||||
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap(queueFile, levelRoot.getName());
|
||||
int before = queue.size();
|
||||
for (String normalized : normalizedNames) {
|
||||
mergeQueueEntry(queue, normalized);
|
||||
}
|
||||
|
||||
if (queue.size() != before) {
|
||||
writePendingWorldDeleteMap(queue);
|
||||
writePendingWorldDeleteMap(queueFile, queue);
|
||||
}
|
||||
|
||||
return queue.size() - before;
|
||||
}
|
||||
|
||||
public void processPendingStartupWorldDeletes() {
|
||||
public synchronized void processPendingStartupWorldDeletes() {
|
||||
try {
|
||||
try {
|
||||
int unregistered = IrisCreator.removeTransientStudioWorldsFromBukkitYml();
|
||||
if (unregistered > 0) {
|
||||
Iris.info("Unregistered " + unregistered + " transient studio world(s) from bukkit.yml on startup.");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed to unregister transient studio worlds from bukkit.yml on startup.", e);
|
||||
}
|
||||
unregisterTransientStudioWorlds();
|
||||
|
||||
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap();
|
||||
for (String transientStudioWorld : TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot())) {
|
||||
queue.putIfAbsent(transientStudioWorld.toLowerCase(Locale.ROOT), transientStudioWorld);
|
||||
File levelRoot = IrisWorldStorage.levelRoot();
|
||||
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
|
||||
LinkedHashMap<String, String> queue = loadPendingWorldDeleteMap(queueFile, levelRoot.getName());
|
||||
for (String discoveredName : discoverStartupWorldNames(levelRoot)) {
|
||||
mergeQueueEntry(queue, discoveredName);
|
||||
}
|
||||
if (queue.isEmpty()) {
|
||||
if (queueFile.exists()) {
|
||||
writePendingWorldDeleteMap(queueFile, queue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
LinkedHashMap<String, String> remaining = new LinkedHashMap<>();
|
||||
for (String worldName : queue.values()) {
|
||||
if (worldName.equalsIgnoreCase(ServerProperties.LEVEL_NAME)) {
|
||||
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is configured as level-name.");
|
||||
continue;
|
||||
}
|
||||
|
||||
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
|
||||
World loaded = WorldIdentity.resolve(worldKey).orElse(null);
|
||||
if (loaded != null) {
|
||||
if (TransientWorldCleanupSupport.isTransientStudioWorldName(worldName)) {
|
||||
try {
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(loaded);
|
||||
if (generator != null) {
|
||||
generator.close();
|
||||
}
|
||||
IrisToolbelt.evacuate(loaded);
|
||||
Bukkit.unloadWorld(loaded, false);
|
||||
Iris.info("Unloaded leftover studio world \"" + worldName + "\" for deletion.");
|
||||
} catch (Throwable e) {
|
||||
Iris.reportError("Failed to unload leftover studio world \"" + worldName + "\".", e);
|
||||
}
|
||||
|
||||
if (WorldIdentity.resolve(worldKey).isPresent()) {
|
||||
Iris.warn("Studio world \"" + worldName + "\" is still loaded after unload; will retry next startup.");
|
||||
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded.");
|
||||
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
boolean foundAny = false;
|
||||
boolean deletedAll = true;
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
File worldFolder = IrisWorldStorage.dimensionRoot(familyWorldName);
|
||||
if (!worldFolder.exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foundAny = true;
|
||||
IO.delete(worldFolder);
|
||||
if (worldFolder.exists()) {
|
||||
deletedAll = false;
|
||||
Iris.warn("Failed to delete queued world folder \"" + familyWorldName + "\". Retrying on next startup.");
|
||||
} else {
|
||||
Iris.info("Deleted queued world folder \"" + familyWorldName + "\".");
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundAny) {
|
||||
Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing).");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!deletedAll) {
|
||||
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
writePendingWorldDeleteMap(remaining);
|
||||
} catch (Throwable e) {
|
||||
Iris.error("Failed to process queued startup world deletions.");
|
||||
Iris.reportError(e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
private LinkedHashMap<String, String> loadPendingWorldDeleteMap() throws IOException {
|
||||
LinkedHashMap<String, String> queue = new LinkedHashMap<>();
|
||||
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
|
||||
if (!queueFile.exists()) {
|
||||
return queue;
|
||||
}
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(new FileReader(queueFile))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String normalized = normalizeWorldName(line);
|
||||
if (normalized == null) {
|
||||
continue;
|
||||
}
|
||||
queue.putIfAbsent(normalized.toLowerCase(Locale.ROOT), normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
private void writePendingWorldDeleteMap(Map<String, String> queue) throws IOException {
|
||||
File queueFile = plugin.getDataFile(PENDING_WORLD_DELETE_FILE);
|
||||
if (queue.isEmpty()) {
|
||||
if (queueFile.exists()) {
|
||||
IO.delete(queueFile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
File parent = queueFile.getParentFile();
|
||||
if (parent != null && !parent.exists() && !parent.mkdirs()) {
|
||||
throw new IOException("Failed to create queue directory: " + parent.getAbsolutePath());
|
||||
}
|
||||
|
||||
try (PrintWriter writer = new PrintWriter(new FileWriter(queueFile))) {
|
||||
for (String worldName : queue.values()) {
|
||||
writer.println(worldName);
|
||||
processEntry(levelRoot, worldName, remaining);
|
||||
}
|
||||
writePendingWorldDeleteMap(queueFile, remaining);
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to process queued startup world deletions.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String normalizeWorldName(String worldName) {
|
||||
static String normalizeQueueEntry(String worldName, String levelName) {
|
||||
if (worldName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String trimmed = worldName.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
String candidate = worldName.trim();
|
||||
if (candidate.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (QUARANTINE_NAME.matcher(candidate).matches()) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
String logicalName = candidate.startsWith("iris:") ? candidate.substring("iris:".length()) : candidate;
|
||||
if (!SAFE_LOGICAL_NAME.matcher(logicalName).matches()) {
|
||||
return null;
|
||||
}
|
||||
String normalizedLevelName = Objects.requireNonNull(levelName, "levelName").trim().toLowerCase(Locale.ROOT);
|
||||
if (VANILLA_DIMENSION_ALIASES.contains(logicalName)
|
||||
|| logicalName.equals(normalizedLevelName)
|
||||
|| logicalName.equals(normalizedLevelName + "_nether")
|
||||
|| logicalName.equals(normalizedLevelName + "_the_end")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
try {
|
||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(candidate, normalizedLevelName);
|
||||
return key.getKey().equals(logicalName) ? logicalName : null;
|
||||
} catch (IllegalArgumentException failure) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static LinkedHashMap<String, String> loadPendingWorldDeleteMap(File queueFile, String levelName) throws IOException {
|
||||
LinkedHashMap<String, String> queue = new LinkedHashMap<>();
|
||||
Path queuePath = Objects.requireNonNull(queueFile, "queueFile").toPath();
|
||||
if (!Files.exists(queuePath, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return queue;
|
||||
}
|
||||
|
||||
try (BufferedReader reader = Files.newBufferedReader(queuePath, StandardCharsets.UTF_8)) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
String normalized = normalizeStoredQueueEntry(line, levelName);
|
||||
if (normalized != null) {
|
||||
mergeQueueEntry(queue, normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String normalizeStoredQueueEntry(String storedEntry, String levelName) {
|
||||
if (storedEntry == null) {
|
||||
return null;
|
||||
}
|
||||
String candidate = storedEntry.trim();
|
||||
boolean exact = candidate.startsWith(EXACT_PREFIX);
|
||||
String rawName = exact ? candidate.substring(EXACT_PREFIX.length()) : candidate;
|
||||
String normalized = normalizeQueueEntry(rawName, levelName);
|
||||
if (normalized == null || QUARANTINE_NAME.matcher(normalized).matches()) {
|
||||
return normalized;
|
||||
}
|
||||
return exact ? EXACT_PREFIX + normalized : normalized;
|
||||
}
|
||||
|
||||
private static void mergeQueueEntry(Map<String, String> queue, String storedEntry) {
|
||||
String logicalKey = storedEntry.startsWith(EXACT_PREFIX)
|
||||
? storedEntry.substring(EXACT_PREFIX.length())
|
||||
: storedEntry;
|
||||
String key = logicalKey.toLowerCase(Locale.ROOT);
|
||||
String existing = queue.get(key);
|
||||
if (existing == null || (existing.startsWith(EXACT_PREFIX) && !storedEntry.startsWith(EXACT_PREFIX))) {
|
||||
queue.put(key, storedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
static void writePendingWorldDeleteMap(File queueFile, Map<String, String> queue) throws IOException {
|
||||
Path queuePath = Objects.requireNonNull(queueFile, "queueFile").toPath().toAbsolutePath().normalize();
|
||||
Path parent = queuePath.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("Queue file has no parent directory: " + queuePath);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
|
||||
StringBuilder content = new StringBuilder();
|
||||
for (String worldName : Objects.requireNonNull(queue, "queue").values()) {
|
||||
content.append(worldName).append('\n');
|
||||
}
|
||||
|
||||
Path temporary = parent.resolve(queuePath.getFileName() + ".tmp-" + UUID.randomUUID());
|
||||
IOException failure = null;
|
||||
try {
|
||||
byte[] bytes = content.toString().getBytes(StandardCharsets.UTF_8);
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
temporary,
|
||||
StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE
|
||||
)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bytes);
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
|
||||
replaceQueueFile(temporary, queuePath);
|
||||
forceDirectory(parent);
|
||||
} catch (IOException writeFailure) {
|
||||
failure = writeFailure;
|
||||
throw writeFailure;
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(temporary);
|
||||
} catch (IOException cleanupFailure) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static LinkedHashSet<String> discoverStartupWorldNames(File levelRoot) throws IOException {
|
||||
LinkedHashSet<String> worldNames = new LinkedHashSet<>();
|
||||
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toPath().toAbsolutePath().normalize();
|
||||
Path dimensions = root.resolve("dimensions");
|
||||
Path irisNamespace = dimensions.resolve("iris");
|
||||
if (!Files.exists(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return worldNames;
|
||||
}
|
||||
if (Files.isSymbolicLink(dimensions) || Files.isSymbolicLink(irisNamespace)) {
|
||||
throw new IOException("Iris dimension storage contains a symbolic link: " + irisNamespace);
|
||||
}
|
||||
if (!Files.isDirectory(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Iris dimension storage is not a directory: " + irisNamespace);
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(irisNamespace)) {
|
||||
for (Path child : children) {
|
||||
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String name = child.getFileName().toString();
|
||||
if (QUARANTINE_NAME.matcher(name).matches()) {
|
||||
worldNames.add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
String transientBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(name);
|
||||
String normalized = normalizeQueueEntry(transientBaseName, root.getFileName().toString());
|
||||
if (normalized != null) {
|
||||
worldNames.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
return worldNames;
|
||||
}
|
||||
|
||||
static List<Path> resolveQueueEntryPaths(File levelRoot, String worldName) throws IOException {
|
||||
QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName());
|
||||
List<DeleteTarget> targets = entry.targets(levelRoot);
|
||||
ArrayList<Path> paths = new ArrayList<>(targets.size());
|
||||
for (DeleteTarget target : targets) {
|
||||
paths.add(target.path());
|
||||
}
|
||||
return List.copyOf(paths);
|
||||
}
|
||||
|
||||
private static void replaceQueueFile(Path temporary, Path queuePath) throws IOException {
|
||||
try {
|
||||
Files.move(temporary, queuePath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException unsupported) {
|
||||
Files.move(temporary, queuePath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void unregisterTransientStudioWorlds() {
|
||||
try {
|
||||
int unregistered = IrisCreator.removeTransientStudioWorldsFromBukkitYml();
|
||||
if (unregistered > 0) {
|
||||
Iris.info("Unregistered " + unregistered + " transient studio world(s) from bukkit.yml on startup.");
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to unregister transient studio worlds from bukkit.yml on startup.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void processEntry(
|
||||
File levelRoot,
|
||||
String worldName,
|
||||
LinkedHashMap<String, String> remaining
|
||||
) {
|
||||
try {
|
||||
QueueEntry entry = QueueEntry.parse(worldName, levelRoot.getName());
|
||||
List<DeleteTarget> targets = entry.targets(levelRoot);
|
||||
if (targets.stream().anyMatch(PendingWorldDeleteQueue::isLoaded)) {
|
||||
Iris.warn("Skipping queued deletion for \"" + worldName + "\" because it is currently loaded.");
|
||||
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
|
||||
return;
|
||||
}
|
||||
|
||||
boolean foundAny = false;
|
||||
boolean deletedAll = true;
|
||||
for (DeleteTarget target : targets) {
|
||||
Path worldFolder = target.path();
|
||||
if (!Files.exists(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
if (Files.isSymbolicLink(worldFolder) || !Files.isDirectory(worldFolder, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Queued world target is not a safe directory: " + worldFolder);
|
||||
}
|
||||
|
||||
foundAny = true;
|
||||
try {
|
||||
deleteTree(worldFolder);
|
||||
Iris.info("Deleted queued world folder \"" + worldFolder.getFileName() + "\".");
|
||||
} catch (IOException failure) {
|
||||
deletedAll = false;
|
||||
Iris.reportError("Failed to delete queued world folder \"" + worldFolder + "\".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundAny) {
|
||||
Iris.info("Queued world deletion skipped for \"" + worldName + "\" (folder missing).");
|
||||
return;
|
||||
}
|
||||
if (!deletedAll) {
|
||||
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
remaining.put(worldName.toLowerCase(Locale.ROOT), worldName);
|
||||
Iris.reportError("Failed to safely process queued world deletion for \"" + worldName + "\".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLoaded(DeleteTarget target) {
|
||||
if (target.key() != null && WorldIdentity.resolve(target.key()).isPresent()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Path targetPath = target.path().toAbsolutePath().normalize();
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
if (world.getWorldFolder().toPath().toAbsolutePath().normalize().equals(targetPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void deleteTree(Path target) throws IOException {
|
||||
Files.walkFileTree(target, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
Files.delete(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path directory, IOException failure) throws IOException {
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
Files.delete(directory);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private enum QueueEntryType {
|
||||
EXACT,
|
||||
LOGICAL,
|
||||
QUARANTINE
|
||||
}
|
||||
|
||||
private record QueueEntry(String storedName, QueueEntryType type) {
|
||||
private static QueueEntry parse(String worldName, String levelName) {
|
||||
String stored = normalizeStoredQueueEntry(worldName, levelName);
|
||||
if (stored == null) {
|
||||
throw new IllegalArgumentException("Unsafe queued Iris world deletion target: " + worldName);
|
||||
}
|
||||
boolean exact = stored.startsWith(EXACT_PREFIX);
|
||||
String normalized = exact ? stored.substring(EXACT_PREFIX.length()) : stored;
|
||||
QueueEntryType type = QUARANTINE_NAME.matcher(normalized).matches()
|
||||
? QueueEntryType.QUARANTINE
|
||||
: exact ? QueueEntryType.EXACT : QueueEntryType.LOGICAL;
|
||||
return new QueueEntry(normalized, type);
|
||||
}
|
||||
|
||||
private List<DeleteTarget> targets(File levelRoot) throws IOException {
|
||||
if (type == QueueEntryType.QUARANTINE) {
|
||||
return List.of(new DeleteTarget(null, requireSafeQuarantinePath(levelRoot, storedName)));
|
||||
}
|
||||
|
||||
if (type == QueueEntryType.EXACT) {
|
||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(storedName, levelRoot.getName());
|
||||
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
|
||||
return List.of(new DeleteTarget(key, path));
|
||||
}
|
||||
|
||||
ArrayList<DeleteTarget> targets = new ArrayList<>(3);
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(storedName)) {
|
||||
NamespacedKey key = IrisWorldStorage.managedKeyFromName(familyWorldName, levelRoot.getName());
|
||||
Path path = IrisWorldStorage.requireSafeManagedDimensionRoot(levelRoot, key).toPath();
|
||||
targets.add(new DeleteTarget(key, path));
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
}
|
||||
|
||||
private static Path requireSafeQuarantinePath(File levelRoot, String quarantineName) throws IOException {
|
||||
if (!QUARANTINE_NAME.matcher(quarantineName).matches()) {
|
||||
throw new IOException("Invalid Iris quarantine directory name: " + quarantineName);
|
||||
}
|
||||
|
||||
Path root = levelRoot.toPath().toAbsolutePath().normalize();
|
||||
Path dimensions = root.resolve("dimensions");
|
||||
Path irisNamespace = dimensions.resolve("iris");
|
||||
Path target = irisNamespace.resolve(quarantineName).normalize();
|
||||
if (!Objects.equals(target.getParent(), irisNamespace)) {
|
||||
throw new IOException("Iris quarantine target escapes its namespace: " + target);
|
||||
}
|
||||
for (Path path : List.of(dimensions, irisNamespace, target)) {
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IOException("Iris quarantine storage contains a symbolic link: " + path);
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private record DeleteTarget(@Nullable NamespacedKey key, Path path) {
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -21,6 +21,7 @@ package art.arcane.iris.core.commands;
|
||||
import com.google.gson.JsonObject;
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.runtime.ChunkClearer;
|
||||
import art.arcane.iris.core.runtime.GoldenHashScanner;
|
||||
@@ -216,7 +217,15 @@ public class CommandDeveloper implements DirectorExecutor {
|
||||
Iris.service(StudioSVC.class).downloadSearch(sender(), pack.getLoadKey(), true);
|
||||
}
|
||||
|
||||
Iris.service(StudioSVC.class).installIntoWorld(sender(), pack, folder);
|
||||
try (LifecycleOperationCoordinator.Lease lease = LifecycleOperationCoordinator.get().acquire(
|
||||
LifecycleOperationCoordinator.Domain.PACK_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.PACK_PUBLISH,
|
||||
pack.getLoadKey()
|
||||
)) {
|
||||
Iris.service(StudioSVC.class).replaceIntoWorld(sender(), pack, folder);
|
||||
} catch (LifecycleOperationCoordinator.BusyException e) {
|
||||
sender().sendMessage(C.YELLOW + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Director(description = "Test", descriptionKey = "iris.director.commanddeveloper.director.test")
|
||||
|
||||
@@ -125,6 +125,7 @@ public class CommandFind implements DirectorExecutor {
|
||||
|
||||
String structureKey = structure == null ? "" : structure.trim();
|
||||
Structure nativeStructure = resolveNativeStructure(structureKey);
|
||||
boolean irisReplacement = false;
|
||||
if (nativeStructure != null) {
|
||||
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(
|
||||
e, structureKey, false);
|
||||
@@ -134,7 +135,8 @@ public class CommandFind implements DirectorExecutor {
|
||||
structureKey, decision.status()));
|
||||
return;
|
||||
}
|
||||
if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) {
|
||||
irisReplacement = decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS;
|
||||
if (irisReplacement && !IrisStructureLocator.hasNativePlacement(e, structureKey)) {
|
||||
locateIrisStructure(e, structureKey, commandSender);
|
||||
return;
|
||||
}
|
||||
@@ -149,6 +151,9 @@ public class CommandFind implements DirectorExecutor {
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_UNKNOWN_STRUCTURE, MessageArgument.untrusted("structureKey", structureKey)));
|
||||
return;
|
||||
}
|
||||
final boolean replacementLocate = irisReplacement;
|
||||
final boolean explicitNativePlacement = IrisStructureLocator.hasNativePlacement(
|
||||
e, structureKey);
|
||||
|
||||
Player target = player();
|
||||
if (target == null) {
|
||||
@@ -161,7 +166,8 @@ public class CommandFind implements DirectorExecutor {
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_LOCATING, MessageArgument.untrusted("structureKey", structureKey)));
|
||||
J.s(() -> {
|
||||
try {
|
||||
if (!StructureReachability.isReachable(e, structureKey)) {
|
||||
if (!replacementLocate && !explicitNativePlacement
|
||||
&& !StructureReachability.isReachable(e, structureKey)) {
|
||||
KList<String> miss = StructureReachability.missingBiomeKeys(e, structureKey);
|
||||
sendStructureMessage(target, commandSender,
|
||||
C.YELLOW + structureKey + " cannot generate in this world (its required biomes are not produced by this pack"
|
||||
|
||||
+592
-205
@@ -19,16 +19,24 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.BukkitWorldReconciler;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.core.IrisSettings;
|
||||
import art.arcane.iris.core.DatapackInstallResult;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.IrisWorldRemovalService;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.PackDownloader;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
@@ -41,7 +49,6 @@ import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
|
||||
import art.arcane.iris.util.common.director.specialhandlers.NullablePlayerHandler;
|
||||
import art.arcane.iris.util.common.format.C;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
@@ -49,32 +56,44 @@ import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
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.stream.Stream;
|
||||
|
||||
import static art.arcane.iris.core.service.EditSVC.deletingWorld;
|
||||
import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
|
||||
import static org.bukkit.Bukkit.getServer;
|
||||
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.localization.IrisMessages;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import art.arcane.iris.core.localization.BukkitCommandMessages;
|
||||
import art.arcane.iris.core.localization.BukkitCommandMessagesExtended;
|
||||
import art.arcane.iris.core.localization.RuntimeUiMessages;
|
||||
@Director(name = "iris", aliases = {"ir", "irs"}, description = "Basic Command", descriptionKey = "iris.director.commandiris.director.basic_command")
|
||||
public class CommandIris implements DirectorExecutor {
|
||||
private static final long WORLD_UNLOAD_TIMEOUT_SECONDS = 150L;
|
||||
|
||||
private CommandStudio studio;
|
||||
private CommandPregen pregen;
|
||||
private CommandObject object;
|
||||
@@ -85,10 +104,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
private CommandPack pack;
|
||||
private CommandFind find;
|
||||
private CommandDatapack datapack;
|
||||
public static boolean worldCreation = false;
|
||||
private static final AtomicReference<Thread> mainWorld = new AtomicReference<>();
|
||||
String WorldEngine;
|
||||
String worldNameToCheck = "YourWorldName";
|
||||
VolmitSender sender = Iris.getSender();
|
||||
|
||||
@Director(description = "Create a new world", descriptionKey = "iris.director.commandiris.director.create_new_world", aliases = {"c"})
|
||||
@@ -107,7 +123,15 @@ public class CommandIris implements DirectorExecutor {
|
||||
@Param(aliases = "main-world", description = "Whether or not to automatically use this world as the main world", descriptionKey = "iris.director.commandiris.param.whether_not_automatically_use_this_world_as_main_world", defaultValue = "false")
|
||||
boolean main
|
||||
) {
|
||||
String worldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(name));
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(name);
|
||||
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||
} catch (IllegalArgumentException e) {
|
||||
sender().sendMessage(C.RED + e.getMessage());
|
||||
return;
|
||||
}
|
||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
||||
if (worldName.equalsIgnoreCase("iris")) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS));
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_MAY_WE_SUGGEST_NAME_IRISWORLD_INSTEAD));
|
||||
@@ -145,7 +169,6 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
|
||||
try {
|
||||
worldCreation = true;
|
||||
IrisToolbelt.createWorld()
|
||||
.dimension(resolvedType)
|
||||
.name(worldName)
|
||||
@@ -160,22 +183,46 @@ public class CommandIris implements DirectorExecutor {
|
||||
}));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
if (reportExpectedCreationInterruption(e)) {
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_EXCEPTION_RAISED_DURING_CREATION_SEE_CONSOLE_MORE_DETAILS));
|
||||
Iris.reportError("Exception raised during world creation for \"" + worldName + "\".", e);
|
||||
worldCreation = false;
|
||||
return;
|
||||
}
|
||||
|
||||
worldCreation = false;
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_CREATED_YOUR_WORLD));
|
||||
if (main) sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_YOUR_WORLD_WILL_AUTOMATICALLY_BE_SET_AS_MAIN_WORLD_WHEN));
|
||||
}
|
||||
|
||||
private boolean updateMainWorld(String newName) {
|
||||
LifecycleOperationCoordinator.Lease lease;
|
||||
try {
|
||||
lease = LifecycleOperationCoordinator.get().acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_PROMOTE,
|
||||
newName
|
||||
);
|
||||
} catch (LifecycleOperationCoordinator.BusyException e) {
|
||||
Iris.error("Could not promote Iris world \"" + newName + "\": " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return updateMainWorldUnderLease(newName);
|
||||
} finally {
|
||||
lease.close();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean updateMainWorldUnderLease(String newName) {
|
||||
try {
|
||||
File oldLevelRoot = IrisWorldStorage.levelRoot();
|
||||
File worldContainer = oldLevelRoot.getParentFile();
|
||||
Properties data = ServerProperties.DATA;
|
||||
if (worldContainer == null) {
|
||||
throw new IllegalStateException("Current level folder has no world container.");
|
||||
}
|
||||
Properties data = new Properties();
|
||||
try (FileInputStream in = new FileInputStream(ServerProperties.SERVER_PROPERTIES)) {
|
||||
data.load(in);
|
||||
}
|
||||
@@ -186,22 +233,6 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
|
||||
File newLevelRoot = new File(worldContainer, newName);
|
||||
if (!newLevelRoot.exists() && !newLevelRoot.mkdirs()) {
|
||||
throw new IllegalStateException("Could not create target level folder: " + newLevelRoot.getAbsolutePath());
|
||||
}
|
||||
|
||||
for (String sub : List.of("data", "datapacks", "players")) {
|
||||
File source = new File(oldLevelRoot, sub);
|
||||
if (!source.exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
IO.copyDirectory(source.toPath(), new File(newLevelRoot, sub).toPath());
|
||||
}
|
||||
|
||||
File targetDimensionRoot = IrisWorldStorage.dimensionRoot(newLevelRoot, NamespacedKey.minecraft("overworld"));
|
||||
IO.copyDirectory(sourceDimensionRoot.toPath(), targetDimensionRoot.toPath());
|
||||
|
||||
World sourceWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(newName)).orElse(null);
|
||||
Long stagedSeed = IrisWorlds.readBukkitWorldSeed(newName);
|
||||
if (sourceWorld == null && stagedSeed == null) {
|
||||
@@ -210,8 +241,18 @@ public class CommandIris implements DirectorExecutor {
|
||||
long promotedSeed = sourceWorld == null ? stagedSeed : sourceWorld.getSeed();
|
||||
data.setProperty("level-name", newName);
|
||||
data.setProperty("level-seed", Long.toString(promotedSeed));
|
||||
try (FileOutputStream out = new FileOutputStream(ServerProperties.SERVER_PROPERTIES)) {
|
||||
data.store(out, null);
|
||||
|
||||
try (MainWorldPublication publication = publishMainWorldFiles(
|
||||
oldLevelRoot.toPath(),
|
||||
sourceDimensionRoot.toPath(),
|
||||
newLevelRoot.toPath()
|
||||
)) {
|
||||
writeServerPropertiesAtomically(ServerProperties.SERVER_PROPERTIES.toPath(), data);
|
||||
publication.commit();
|
||||
}
|
||||
synchronized (ServerProperties.DATA) {
|
||||
ServerProperties.DATA.clear();
|
||||
ServerProperties.DATA.putAll(data);
|
||||
}
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
@@ -221,57 +262,204 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA));
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP));
|
||||
|
||||
File worldFolder = IrisWorldStorage.dimensionRoot(name);
|
||||
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder);
|
||||
if (installed == null) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey())));
|
||||
return false;
|
||||
static MainWorldPublication publishMainWorldFiles(
|
||||
Path currentLevelRoot,
|
||||
Path sourceDimensionRoot,
|
||||
Path targetLevelRoot
|
||||
) throws IOException {
|
||||
Path current = Objects.requireNonNull(currentLevelRoot, "currentLevelRoot").toAbsolutePath().normalize();
|
||||
Path sourceDimension = Objects.requireNonNull(sourceDimensionRoot, "sourceDimensionRoot").toAbsolutePath().normalize();
|
||||
Path target = Objects.requireNonNull(targetLevelRoot, "targetLevelRoot").toAbsolutePath().normalize();
|
||||
Path worldContainer = current.getParent();
|
||||
Path sourceNamespace = current.resolve("dimensions/iris");
|
||||
if (worldContainer == null || !Objects.equals(target.getParent(), worldContainer)) {
|
||||
throw new IOException("Promoted main world must be a direct child of the world container.");
|
||||
}
|
||||
|
||||
if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) {
|
||||
return false;
|
||||
if (!Objects.equals(sourceDimension.getParent(), sourceNamespace)) {
|
||||
throw new IOException("Promoted source must be a direct Iris dimension.");
|
||||
}
|
||||
if (Objects.equals(current, target)) {
|
||||
throw new IOException("Promoted main world cannot replace the current main world.");
|
||||
}
|
||||
if (Files.isSymbolicLink(worldContainer)
|
||||
|| Files.isSymbolicLink(current)
|
||||
|| !Files.isDirectory(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Current world storage is missing or unsafe.");
|
||||
}
|
||||
if (Files.isSymbolicLink(sourceDimension)
|
||||
|| !Files.isDirectory(sourceDimension, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Promoted Iris dimension is missing or unsafe: " + sourceDimension);
|
||||
}
|
||||
requireAbsentMainWorldTarget(target);
|
||||
|
||||
if (main) {
|
||||
if (updateMainWorld(name)) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name)));
|
||||
} else {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD));
|
||||
return false;
|
||||
Path stage = Files.createTempDirectory(worldContainer, "." + target.getFileName() + ".promoting-");
|
||||
boolean published = false;
|
||||
try {
|
||||
for (String subdirectory : List.of("data", "datapacks", "players")) {
|
||||
Path source = current.resolve(subdirectory);
|
||||
if (!Files.exists(source, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(source)) {
|
||||
continue;
|
||||
}
|
||||
copyWorldTree(source, stage.resolve(subdirectory));
|
||||
}
|
||||
|
||||
Path targetDimension = IrisWorldStorage.dimensionRoot(
|
||||
stage.toFile(),
|
||||
NamespacedKey.minecraft("overworld")
|
||||
).toPath();
|
||||
copyWorldTree(sourceDimension, targetDimension);
|
||||
requireAbsentMainWorldTarget(target);
|
||||
Files.move(stage, target);
|
||||
published = true;
|
||||
return new MainWorldPublication(target);
|
||||
} finally {
|
||||
if (!published) {
|
||||
AtomicDirectoryPublisher.deleteTree(stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed)));
|
||||
if (main) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART));
|
||||
private static void requireAbsentMainWorldTarget(Path target) throws IOException {
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
|
||||
throw new FileAlreadyExistsException("Main-world target already exists: " + target);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyWorldTree(Path source, Path target) throws IOException {
|
||||
if (Files.isSymbolicLink(source)) {
|
||||
throw new IOException("World data contains a symbolic link: " + source);
|
||||
}
|
||||
if (Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
return;
|
||||
}
|
||||
if (!Files.isDirectory(source, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("World data contains an unsupported entry: " + source);
|
||||
}
|
||||
try (Stream<Path> entries = Files.walk(source)) {
|
||||
for (Path entry : entries.sorted(Comparator.naturalOrder()).toList()) {
|
||||
if (Files.isSymbolicLink(entry)) {
|
||||
throw new IOException("World data contains a symbolic link: " + entry);
|
||||
}
|
||||
Path destination = target.resolve(source.relativize(entry)).normalize();
|
||||
if (!destination.startsWith(target)) {
|
||||
throw new IOException("World data escapes its promotion stage: " + entry);
|
||||
}
|
||||
if (Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(destination);
|
||||
} else if (Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(destination.getParent());
|
||||
Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
} else {
|
||||
throw new IOException("World data contains an unsupported entry: " + entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeServerPropertiesAtomically(Path propertiesFile, Properties data) throws IOException {
|
||||
Path target = propertiesFile.toAbsolutePath().normalize();
|
||||
Path parent = target.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("server.properties has no parent directory.");
|
||||
}
|
||||
Path stage = Files.createTempFile(parent, ".server.properties.promoting-", ".tmp");
|
||||
IOException operationFailure = null;
|
||||
try {
|
||||
try (FileOutputStream out = new FileOutputStream(stage.toFile())) {
|
||||
data.store(out, null);
|
||||
out.getFD().sync();
|
||||
}
|
||||
try {
|
||||
Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(stage, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
operationFailure = e;
|
||||
throw e;
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(stage);
|
||||
} catch (IOException cleanupFailure) {
|
||||
if (operationFailure != null) {
|
||||
operationFailure.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean stageFoliaWorldCreation(String name, IrisDimension dimension, long seed, boolean main) {
|
||||
NamespacedKey worldKey = IrisWorldStorage.managedKeyFromName(name);
|
||||
LifecycleOperationCoordinator.Lease worldLease = null;
|
||||
File worldFolder = IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||
try {
|
||||
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
|
||||
worldLease = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_CREATE,
|
||||
worldKey.toString());
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_RUNTIME_WORLD_CREATION_IS_DISABLED_ON_FOLIA));
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_PREPARING_WORLD_FILES_BUKKIT_YML_NEXT_STARTUP));
|
||||
if (worldFolder.exists()) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS));
|
||||
return false;
|
||||
}
|
||||
|
||||
DatapackInstallResult datapackResult = ServerConfigurator.installDataPacksIfChanged(true);
|
||||
if (!datapackResult.succeeded()) {
|
||||
sender().sendMessage(C.RED + "Failed to compile the Iris datapack. No world files were staged.");
|
||||
return false;
|
||||
}
|
||||
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(sender(), dimension, worldFolder);
|
||||
if (installed == null) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_STAGE_WORLD_FILES_DIMENSION, MessageArgument.untrusted("value", dimension.getLoadKey())));
|
||||
deleteDirectorySafely(worldFolder);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!registerWorldInBukkitYml(name, dimension.getLoadKey(), seed)) {
|
||||
deleteDirectorySafely(worldFolder);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (main) {
|
||||
if (updateMainWorldUnderLease(name)) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UPDATED_SERVER_PROPERTIES_LEVEL_NAME, MessageArgument.untrusted("name", name)));
|
||||
} else {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_WAS_STAGED_BUT_FAILED_UPDATE_SERVER_PROPERTIES_MAIN_WORLD));
|
||||
try {
|
||||
BukkitWorldConfiguration.remove(BUKKIT_YML, name);
|
||||
} catch (IOException e) {
|
||||
Iris.reportError("Failed to roll back bukkit.yml after main-world staging failed.", e);
|
||||
}
|
||||
deleteDirectorySafely(worldFolder);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_STAGED_IRIS_WORLD_WITH_GENERATOR_IRIS_SEED, MessageArgument.untrusted("name", name), MessageArgument.untrusted("value", dimension.getLoadKey()), MessageArgument.untrusted("seed", seed)));
|
||||
if (main) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_WORLD_IS_NOW_CONFIGURED_AS_MAIN_NEXT_RESTART));
|
||||
}
|
||||
return true;
|
||||
} catch (LifecycleOperationCoordinator.BusyException e) {
|
||||
sender().sendMessage(C.YELLOW + e.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
if (worldLease != null) {
|
||||
worldLease.close();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean registerWorldInBukkitYml(String worldName, String dimension, Long seed) {
|
||||
String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(worldName));
|
||||
YamlConfiguration yml = YamlConfiguration.loadConfiguration(BUKKIT_YML);
|
||||
ConfigurationSection worlds = yml.getConfigurationSection("worlds");
|
||||
if (worlds == null) {
|
||||
worlds = yml.createSection("worlds");
|
||||
}
|
||||
ConfigurationSection worldSection = worlds.getConfigurationSection(logicalWorldName);
|
||||
if (worldSection == null) {
|
||||
worldSection = worlds.createSection(logicalWorldName);
|
||||
}
|
||||
|
||||
String generator = "Iris:" + dimension;
|
||||
worldSection.set("generator", generator);
|
||||
if (seed != null) {
|
||||
worldSection.set("seed", seed);
|
||||
}
|
||||
|
||||
try {
|
||||
yml.save(BUKKIT_YML);
|
||||
BukkitWorldConfiguration.register(BUKKIT_YML, logicalWorldName, dimension, seed);
|
||||
Iris.info("Registered \"" + logicalWorldName + "\" in bukkit.yml");
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
@@ -282,6 +470,31 @@ public class CommandIris implements DirectorExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteDirectorySafely(File directory) {
|
||||
try {
|
||||
AtomicDirectoryPublisher.deleteTree(directory.toPath());
|
||||
} catch (IOException e) {
|
||||
Iris.reportError("Failed to roll back staged world folder \"" + directory.getAbsolutePath() + "\".", e);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean reportExpectedCreationInterruption(Throwable failure) {
|
||||
Throwable current = failure;
|
||||
while (current != null) {
|
||||
if (current instanceof LifecycleOperationCoordinator.BusyException) {
|
||||
sender().sendMessage(C.YELLOW + current.getMessage());
|
||||
return true;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
String message = failure.getMessage();
|
||||
if (message != null && message.contains("queued a restart")) {
|
||||
sender().sendMessage(C.YELLOW + message);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Director(description = "Teleport to another world", descriptionKey = "iris.director.commandiris.director.teleport_another_world", aliases = {"tp"}, sync = true)
|
||||
public void teleport(
|
||||
@Param(description = "World to teleport to", descriptionKey = "iris.director.commandiris.param.world_teleport")
|
||||
@@ -368,78 +581,73 @@ public class CommandIris implements DirectorExecutor {
|
||||
|
||||
@Director(description = "Remove an Iris world", descriptionKey = "iris.director.commandiris.director.remove_iris_world", aliases = {"rm"}, sync = true)
|
||||
public void remove(
|
||||
@Param(description = "The world to remove", descriptionKey = "iris.director.commandiris.param.world_remove")
|
||||
World world,
|
||||
@Param(description = "The loaded or disk-only Iris world to remove", descriptionKey = "iris.director.commandiris.param.world_remove", customHandler = ManagedWorldNameHandler.class)
|
||||
String world,
|
||||
@Param(description = "Whether to also remove the folder (if set to false, just does not load the world)", descriptionKey = "iris.director.commandiris.param.whether_also_remove_folder_if_set_false_just_does_not_load_world", defaultValue = "true")
|
||||
boolean delete
|
||||
) {
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()))));
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_REMOVING_WORLD, MessageArgument.untrusted("value", world.getName())));
|
||||
|
||||
if (!IrisToolbelt.evacuate(world)) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_EVACUATE_WORLD, MessageArgument.untrusted("value", world.getName())));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!WorldLifecycleService.get().unload(world, false)) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD, MessageArgument.untrusted("value", world.getName())));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (IrisToolbelt.removeWorld(world)) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_SUCCESSFULLY_REMOVED_FROM_BUKKIT_YML, MessageArgument.untrusted("value", world.getName())));
|
||||
} else {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOOKS_LIKE_WORLD_WAS_ALREADY_REMOVED_FROM_BUKKIT_YML));
|
||||
VolmitSender responseSender = sender();
|
||||
responseSender.sendMessage(C.GRAY + "Removing Iris world '" + world + "'...");
|
||||
IrisWorldRemovalService.get().remove(world, delete).whenComplete((result, throwable) -> {
|
||||
Runnable response = () -> reportRemovalResult(responseSender, world, result, throwable);
|
||||
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_SAVE_BUKKIT_YML_BECAUSE, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
|
||||
Iris.reportError("Failed to remove world \"" + world.getName() + "\" from bukkit.yml.", e);
|
||||
}
|
||||
IrisToolbelt.evacuate(world, "Deleting world");
|
||||
deletingWorld = true;
|
||||
if (!delete) {
|
||||
deletingWorld = false;
|
||||
return;
|
||||
}
|
||||
VolmitSender sender = sender();
|
||||
J.a(() -> {
|
||||
int retries = 12;
|
||||
|
||||
if (deleteDirectory(world.getWorldFolder())) {
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_SUCCESSFULLY_REMOVED_WORLD_FOLDER));
|
||||
} else {
|
||||
while(true){
|
||||
if (deleteDirectory(world.getWorldFolder())){
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_SUCCESSFULLY_REMOVED_WORLD_FOLDER_2));
|
||||
break;
|
||||
}
|
||||
retries--;
|
||||
if (retries == 0){
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_IRIS_FAILED_REMOVE_WORLD_FOLDER));
|
||||
break;
|
||||
}
|
||||
J.sleep(3000);
|
||||
}
|
||||
}
|
||||
deletingWorld = false;
|
||||
J.s(response);
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean deleteDirectory(File dir) {
|
||||
if (dir.isDirectory()) {
|
||||
File[] children = dir.listFiles();
|
||||
for (int i = 0; i < children.length; i++) {
|
||||
boolean success = deleteDirectory(children[i]);
|
||||
if (!success) {
|
||||
return false;
|
||||
private void reportRemovalResult(
|
||||
VolmitSender responseSender,
|
||||
String requestedWorld,
|
||||
IrisWorldRemovalService.RemovalResult result,
|
||||
Throwable throwable
|
||||
) {
|
||||
if (throwable != null || result == null) {
|
||||
Throwable failure = throwable == null
|
||||
? new IllegalStateException("World removal returned no result.")
|
||||
: throwable;
|
||||
responseSender.sendMessage(C.RED + "World removal failed unexpectedly; nothing further was deleted.");
|
||||
Iris.reportError("Unexpected world removal failure for \"" + requestedWorld + "\".", failure);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (result.status()) {
|
||||
case UNREGISTERED -> responseSender.sendMessage(C.GREEN + "Unloaded and unregistered '"
|
||||
+ result.target().logicalName() + "'; its files were preserved.");
|
||||
case DELETED -> responseSender.sendMessage(C.GREEN + "Removed Iris world '"
|
||||
+ result.target().logicalName() + "' and deleted its folder.");
|
||||
case DELETE_QUEUED -> responseSender.sendMessage(C.YELLOW + "Removed Iris world '"
|
||||
+ result.target().logicalName() + "'; its quarantined folder will be deleted at startup.");
|
||||
case BUSY -> responseSender.sendMessage(C.YELLOW + "World changes are busy with "
|
||||
+ result.blockingOperation().kind().name().toLowerCase(Locale.ROOT) + " for '"
|
||||
+ result.blockingOperation().target() + "'. Try again when it completes.");
|
||||
case INVALID_IDENTIFIER, PROTECTED_WORLD, NOT_IRIS_WORLD, UNSAFE_PATH, NOT_FOUND ->
|
||||
responseSender.sendMessage(C.RED + removalFailureDetail(result));
|
||||
default -> {
|
||||
responseSender.sendMessage(C.RED + "World removal stopped at "
|
||||
+ result.status().name().toLowerCase(Locale.ROOT) + ": " + removalFailureDetail(result));
|
||||
if (result.quarantineDirectory() != null) {
|
||||
responseSender.sendMessage(C.YELLOW + "The recoverable world folder is "
|
||||
+ result.quarantineDirectory().toAbsolutePath() + ".");
|
||||
} else if (result.configurationChanged() || result.registryChanged()) {
|
||||
responseSender.sendMessage(C.YELLOW + "Removal changed registration state before stopping; "
|
||||
+ "the original world folder was not deleted.");
|
||||
}
|
||||
if (result.failure() != null) {
|
||||
Iris.reportError("World removal failed for \"" + requestedWorld + "\" at "
|
||||
+ result.status().name() + ".", result.failure());
|
||||
}
|
||||
}
|
||||
}
|
||||
return dir.delete();
|
||||
}
|
||||
|
||||
private String removalFailureDetail(IrisWorldRemovalService.RemovalResult result) {
|
||||
Throwable failure = result.failure();
|
||||
if (failure == null || failure.getMessage() == null || failure.getMessage().isBlank()) {
|
||||
return result.status().name().toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
}
|
||||
return failure.getMessage();
|
||||
}
|
||||
|
||||
@Director(description = "Toggle debug", descriptionKey = "iris.director.commandiris.director.toggle_debug")
|
||||
@@ -466,7 +674,6 @@ public class CommandIris implements DirectorExecutor {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOWNLOADING_PACK, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("branch", branch), MessageArgument.trusted("value", overwrite ? IrisLanguage.text(RuntimeUiMessages.DOWNLOAD_OVERWRITE_SUFFIX) : "")));
|
||||
Iris.service(StudioSVC.class).downloadSearch(sender(), "IrisDimensions/" + pack + "/" + branch, overwrite);
|
||||
}
|
||||
ServerConfigurator.installDataPacksIfChanged(true);
|
||||
}
|
||||
|
||||
@Director(description = "Get metrics for your world", descriptionKey = "iris.director.commandiris.director.get_metrics_your_world", aliases = "measure", origin = DirectorOrigin.PLAYER)
|
||||
@@ -508,76 +715,187 @@ public class CommandIris implements DirectorExecutor {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THIS_IS_NOT_IRIS_WORLD_IRIS_WORLDS_2, MessageArgument.untrusted("value", String.join(", ", getServer().getWorlds().stream().filter(IrisToolbelt::isIrisWorld).map(World::getName).toList()))));
|
||||
return;
|
||||
}
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UNLOADING_WORLD, MessageArgument.untrusted("value", world.getName())));
|
||||
VolmitSender responseSender = sender();
|
||||
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_UNLOADING_WORLD, MessageArgument.untrusted("value", world.getName())));
|
||||
LifecycleOperationCoordinator.Lease lease;
|
||||
try {
|
||||
IrisToolbelt.evacuate(world);
|
||||
boolean unloaded = WorldLifecycleService.get().unload(world, false);
|
||||
if (unloaded) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_UNLOADED_SUCCESSFULLY));
|
||||
} else {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_2));
|
||||
}
|
||||
lease = LifecycleOperationCoordinator.get().acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_UNLOAD,
|
||||
WorldIdentity.serialize(world)
|
||||
);
|
||||
} catch (LifecycleOperationCoordinator.BusyException e) {
|
||||
responseSender.sendMessage(C.YELLOW + e.getMessage());
|
||||
return;
|
||||
}
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
IrisToolbelt.beginWorldMaintenance(world, "world-unload", true);
|
||||
try {
|
||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||
CompletableFuture<Boolean> sequence = IrisToolbelt.evacuateAsync(world)
|
||||
.thenCompose(evacuated -> {
|
||||
if (terminalTimeout.get()) {
|
||||
return CompletableFuture.failedFuture(new TimeoutException(
|
||||
"World unload stopped after its terminal timeout."));
|
||||
}
|
||||
if (!Boolean.TRUE.equals(evacuated)) {
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
return WorldLifecycleService.get().unloadAsync(world, true);
|
||||
})
|
||||
.thenCompose(unloaded -> {
|
||||
if (terminalTimeout.get()) {
|
||||
return CompletableFuture.failedFuture(new TimeoutException(
|
||||
"World unload stopped after its terminal timeout."));
|
||||
}
|
||||
if (!Boolean.TRUE.equals(unloaded) || generator == null) {
|
||||
return CompletableFuture.completedFuture(Boolean.TRUE.equals(unloaded));
|
||||
}
|
||||
return generator.closeAsync().thenApply(ignored -> true);
|
||||
});
|
||||
guardUnloadCompletion(sequence, terminalTimeout, world.getName())
|
||||
.whenComplete((unloaded, throwable) -> {
|
||||
IrisToolbelt.endWorldMaintenance(world, "world-unload");
|
||||
lease.close();
|
||||
Runnable response = () -> reportUnloadResult(responseSender, world, unloaded, throwable);
|
||||
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
|
||||
return;
|
||||
}
|
||||
J.s(response);
|
||||
});
|
||||
} catch (Exception e) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
|
||||
IrisToolbelt.endWorldMaintenance(world, "world-unload");
|
||||
lease.close();
|
||||
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3, MessageArgument.untrusted("value", String.valueOf(e.getMessage()))));
|
||||
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", e);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<Boolean> guardUnloadCompletion(
|
||||
CompletableFuture<Boolean> source,
|
||||
AtomicBoolean terminalTimeout,
|
||||
String worldName
|
||||
) {
|
||||
CompletableFuture<Boolean> guarded = new CompletableFuture<>();
|
||||
AtomicBoolean settled = new AtomicBoolean(false);
|
||||
source.whenComplete((unloaded, throwable) -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (throwable == null) {
|
||||
guarded.complete(Boolean.TRUE.equals(unloaded));
|
||||
} else {
|
||||
guarded.completeExceptionally(throwable);
|
||||
}
|
||||
});
|
||||
CompletableFuture.delayedExecutor(WORLD_UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
terminalTimeout.set(true);
|
||||
TimeoutException timeout = new TimeoutException(
|
||||
"World unload did not settle within " + WORLD_UNLOAD_TIMEOUT_SECONDS
|
||||
+ " seconds for \"" + worldName + "\".");
|
||||
ServerConfigurator.restart("World unload timed out for \"" + worldName + "\".");
|
||||
guarded.completeExceptionally(timeout);
|
||||
});
|
||||
return guarded;
|
||||
}
|
||||
|
||||
private void reportUnloadResult(VolmitSender responseSender, World world, Boolean unloaded, Throwable throwable) {
|
||||
if (throwable != null) {
|
||||
responseSender.sendMessage(IrisLanguage.text(
|
||||
BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_3,
|
||||
MessageArgument.untrusted("value", String.valueOf(throwable.getMessage()))
|
||||
));
|
||||
Iris.reportError("Failed to unload world \"" + world.getName() + "\".", throwable);
|
||||
return;
|
||||
}
|
||||
if (Boolean.TRUE.equals(unloaded)) {
|
||||
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_UNLOADED_SUCCESSFULLY));
|
||||
} else {
|
||||
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FAILED_UNLOAD_WORLD_2));
|
||||
}
|
||||
}
|
||||
|
||||
@Director(description = "Load an Iris World", descriptionKey = "iris.director.commandiris.director.load_iris_world", origin = DirectorOrigin.PLAYER, sync = true, aliases = {"import"})
|
||||
public void loadWorld(
|
||||
@Param(description = "The name of the world to load", descriptionKey = "iris.director.commandiris.param.name_world_load")
|
||||
@Param(
|
||||
description = "The name of the world to load",
|
||||
descriptionKey = "iris.director.commandiris.param.name_world_load",
|
||||
customHandler = ManagedWorldNameHandler.class)
|
||||
String world
|
||||
) {
|
||||
String logicalWorldName = IrisWorldStorage.logicalName(IrisWorldStorage.keyFromName(world));
|
||||
worldNameToCheck = logicalWorldName;
|
||||
boolean worldExists = doesWorldExist(worldNameToCheck);
|
||||
WorldEngine = logicalWorldName;
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(world);
|
||||
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
sender().sendMessage(C.RED + failure.getMessage());
|
||||
return;
|
||||
}
|
||||
String logicalWorldName = IrisWorldStorage.logicalName(worldKey);
|
||||
boolean worldExists = doesWorldExist(logicalWorldName);
|
||||
|
||||
if (!worldExists) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_DOESNT_EXIST_ON_SERVER, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
|
||||
return;
|
||||
}
|
||||
|
||||
File directory = new File(IrisWorldStorage.packRoot(IrisWorldStorage.keyFromName(logicalWorldName)), "dimensions");
|
||||
|
||||
String dimension = null;
|
||||
if (directory.exists() && directory.isDirectory()) {
|
||||
File[] files = directory.listFiles();
|
||||
if (files != null) {
|
||||
for (File file : files) {
|
||||
if (file.isFile()) {
|
||||
String fileName = file.getName();
|
||||
if (fileName.endsWith(".json")) {
|
||||
dimension = fileName.substring(0, fileName.length() - 5);
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_GENERATOR, MessageArgument.untrusted("dimension", dimension)));
|
||||
}
|
||||
VolmitSender responseSender = sender();
|
||||
responseSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADING_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
|
||||
Iris.instance.worldReconciler()
|
||||
.loadWorld(BUKKIT_YML, worldKey.toString())
|
||||
.whenComplete((result, failure) -> {
|
||||
Runnable response = () -> reportLoadWorldResult(
|
||||
responseSender,
|
||||
logicalWorldName,
|
||||
result,
|
||||
failure);
|
||||
if (responseSender.isPlayer() && J.runEntity(responseSender.player(), response)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_IS_NOT_IRIS_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (dimension == null) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_COULD_NOT_DETERMINE_IRIS_DIMENSION, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
|
||||
return;
|
||||
}
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADING_WORLD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
|
||||
|
||||
if (!registerWorldInBukkitYml(logicalWorldName, dimension, null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (J.isFolia()) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_FOLIA_CANNOT_LOAD_NEW_WORLDS_AT_RUNTIME_RESTART_SERVER_LOAD, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
|
||||
return;
|
||||
}
|
||||
|
||||
Iris.instance.worldReconciler().checkForBukkitWorlds(logicalWorldName::equals);
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_LOADED_SUCCESSFULLY, MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
|
||||
J.s(response);
|
||||
});
|
||||
}
|
||||
|
||||
private void reportLoadWorldResult(
|
||||
VolmitSender responseSender,
|
||||
String logicalWorldName,
|
||||
BukkitWorldReconciler.LoadResult result,
|
||||
Throwable failure
|
||||
) {
|
||||
if (failure != null) {
|
||||
responseSender.sendMessage(C.RED + "Failed to load Iris world \"" + logicalWorldName + "\": " + failure.getMessage());
|
||||
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", failure);
|
||||
return;
|
||||
}
|
||||
if (result == null) {
|
||||
IllegalStateException missingResult = new IllegalStateException("World load completed without a result.");
|
||||
responseSender.sendMessage(C.RED + missingResult.getMessage());
|
||||
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", missingResult);
|
||||
return;
|
||||
}
|
||||
if (result.succeeded()) {
|
||||
responseSender.sendMessage(IrisLanguage.text(
|
||||
BukkitCommandMessagesExtended.COMMAND_IRIS_LOADED_SUCCESSFULLY,
|
||||
MessageArgument.untrusted("logicalWorldName", logicalWorldName)));
|
||||
return;
|
||||
}
|
||||
|
||||
C color = result.status() == BukkitWorldReconciler.ReconciliationStatus.BUSY
|
||||
|| result.status() == BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED
|
||||
? C.YELLOW
|
||||
: C.RED;
|
||||
responseSender.sendMessage(color + result.message());
|
||||
Throwable resultFailure = result.failure();
|
||||
if (resultFailure != null
|
||||
&& result.status() != BukkitWorldReconciler.ReconciliationStatus.BUSY
|
||||
&& result.status() != BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED) {
|
||||
Iris.reportError("Failed to load Iris world \"" + logicalWorldName + "\".", resultFailure);
|
||||
}
|
||||
}
|
||||
|
||||
@Director(description = "Evacuate an iris world", descriptionKey = "iris.director.commandiris.director.evacuate_iris_world", origin = DirectorOrigin.PLAYER, sync = true)
|
||||
public void evacuate(
|
||||
@Param(description = "Evacuate the world", descriptionKey = "iris.director.commandiris.param.evacuate_world")
|
||||
@@ -596,6 +914,54 @@ public class CommandIris implements DirectorExecutor {
|
||||
return worldDirectory.exists() && worldDirectory.isDirectory();
|
||||
}
|
||||
|
||||
public static class ManagedWorldNameHandler implements DirectorParameterHandler<String> {
|
||||
@Override
|
||||
public KList<String> getPossibilities() {
|
||||
Set<String> options = new LinkedHashSet<>();
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
if (IrisToolbelt.isIrisWorld(world)) {
|
||||
options.add(IrisWorldStorage.logicalName(world));
|
||||
}
|
||||
}
|
||||
for (String identity : IrisWorlds.get().getWorlds().keySet()) {
|
||||
try {
|
||||
options.add(IrisWorldStorage.logicalName(WorldIdentity.parse(identity)));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
File namespace = new File(IrisWorldStorage.levelRoot(), "dimensions/iris");
|
||||
File[] diskWorlds = namespace.listFiles(File::isDirectory);
|
||||
if (diskWorlds != null) {
|
||||
for (File diskWorld : diskWorlds) {
|
||||
if (!Files.isSymbolicLink(diskWorld.toPath())
|
||||
&& diskWorld.getName().matches("[a-z0-9_-]+")) {
|
||||
options.add(diskWorld.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new KList<>(options);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String parse(String in, boolean force) throws DirectorParsingException {
|
||||
if (in == null || in.isBlank()) {
|
||||
throw new DirectorParsingException("World identifier cannot be empty");
|
||||
}
|
||||
return in.trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> type) {
|
||||
return type == String.class;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PackDimensionTypeHandler implements DirectorParameterHandler<String> {
|
||||
@Override
|
||||
public KList<String> getPossibilities() {
|
||||
@@ -603,28 +969,21 @@ public class CommandIris implements DirectorExecutor {
|
||||
options.add("default");
|
||||
|
||||
File packsFolder = Iris.instance.getDataFolder("packs");
|
||||
File[] packs = packsFolder.listFiles();
|
||||
if (packs != null) {
|
||||
for (File pack : packs) {
|
||||
if (pack == null || !pack.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
|
||||
options.add(pack.getName());
|
||||
|
||||
options.add(pack.getName());
|
||||
|
||||
try {
|
||||
IrisData data = IrisData.get(pack);
|
||||
for (String key : data.getDimensionLoader().getPossibleKeys()) {
|
||||
options.add(key);
|
||||
options.add(pack.getName() + ":" + key);
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
Iris.warn("Failed to read dimension keys from pack %s: %s%s",
|
||||
pack.getName(),
|
||||
ex.getClass().getSimpleName(),
|
||||
ex.getMessage() == null ? "" : " - " + ex.getMessage());
|
||||
Iris.reportError(ex);
|
||||
try {
|
||||
IrisData data = IrisData.get(pack);
|
||||
for (String key : data.getDimensionLoader().getPossibleKeys()) {
|
||||
options.add(key);
|
||||
options.add(pack.getName() + ":" + key);
|
||||
}
|
||||
} catch (Throwable ex) {
|
||||
Iris.warn("Failed to read dimension keys from pack %s: %s%s",
|
||||
pack.getName(),
|
||||
ex.getClass().getSimpleName(),
|
||||
ex.getMessage() == null ? "" : " - " + ex.getMessage());
|
||||
Iris.reportError(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -650,4 +1009,32 @@ public class CommandIris implements DirectorExecutor {
|
||||
return type == String.class;
|
||||
}
|
||||
}
|
||||
|
||||
static final class MainWorldPublication implements AutoCloseable {
|
||||
private final Path target;
|
||||
private boolean committed;
|
||||
private boolean closed;
|
||||
|
||||
MainWorldPublication(Path target) {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
void commit() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Main-world publication is already closed.");
|
||||
}
|
||||
committed = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (!committed) {
|
||||
AtomicDirectoryPublisher.deleteTree(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-25
@@ -22,6 +22,7 @@ import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.core.link.WorldEditLink;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.core.runtime.ObjectStudioActivation;
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.core.service.ObjectSVC;
|
||||
@@ -73,7 +74,6 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
@@ -109,25 +109,20 @@ public class CommandObject implements DirectorExecutor {
|
||||
sources.put(data.getDataFolder().getName(), data);
|
||||
} else {
|
||||
File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder();
|
||||
File[] packs = workspace == null ? null : workspace.listFiles();
|
||||
if (packs != null) {
|
||||
Arrays.sort(packs, Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER));
|
||||
for (File pack : packs) {
|
||||
if (!pack.isDirectory()) continue;
|
||||
File dimensionsDir = new File(pack, "dimensions");
|
||||
if (!dimensionsDir.isDirectory()) continue;
|
||||
IrisData data = IrisData.get(pack);
|
||||
String[] keys = data.getObjectLoader().getPossibleKeys();
|
||||
if (keys == null || keys.length == 0) continue;
|
||||
sources.put(pack.getName(), data);
|
||||
if (hostDimension == null) {
|
||||
File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json"));
|
||||
if (dimFiles != null && dimFiles.length > 0) {
|
||||
String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", "");
|
||||
IrisDimension loaded = data.getDimensionLoader().load(loadKey);
|
||||
if (loaded != null) {
|
||||
hostDimension = loaded;
|
||||
}
|
||||
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(workspace)) {
|
||||
File dimensionsDir = new File(pack, "dimensions");
|
||||
if (!dimensionsDir.isDirectory()) continue;
|
||||
IrisData data = IrisData.get(pack);
|
||||
String[] keys = data.getObjectLoader().getPossibleKeys();
|
||||
if (keys == null || keys.length == 0) continue;
|
||||
sources.put(pack.getName(), data);
|
||||
if (hostDimension == null) {
|
||||
File[] dimFiles = dimensionsDir.listFiles((f) -> f.isFile() && f.getName().endsWith(".json"));
|
||||
if (dimFiles != null && dimFiles.length > 0) {
|
||||
String loadKey = dimFiles[0].getName().replaceFirst("\\.json$", "");
|
||||
IrisDimension loaded = data.getDimensionLoader().load(loadKey);
|
||||
if (loaded != null) {
|
||||
hostDimension = loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,11 +389,7 @@ public class CommandObject implements DirectorExecutor {
|
||||
private static List<TreePlausibilizeBatch.Target> resolveFromPacks(String target) {
|
||||
List<TreePlausibilizeBatch.Target> out = new ArrayList<>();
|
||||
File packsFolder = Iris.instance.getDataFolder("packs");
|
||||
File[] packs = packsFolder.listFiles(File::isDirectory);
|
||||
if (packs == null) {
|
||||
return out;
|
||||
}
|
||||
for (File pack : packs) {
|
||||
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsFolder)) {
|
||||
File objectsRoot = new File(pack, "objects");
|
||||
if (!objectsRoot.isDirectory()) {
|
||||
continue;
|
||||
|
||||
@@ -52,8 +52,8 @@ public class CommandPack implements DirectorExecutor {
|
||||
}
|
||||
|
||||
if (pack == null || pack.isBlank()) {
|
||||
File[] dirs = packsRoot.listFiles(File::isDirectory);
|
||||
if (dirs == null || dirs.length == 0) {
|
||||
List<File> dirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||
if (dirs.isEmpty()) {
|
||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_PACKS_VALIDATE));
|
||||
return;
|
||||
}
|
||||
@@ -64,7 +64,7 @@ public class CommandPack implements DirectorExecutor {
|
||||
broken++;
|
||||
}
|
||||
}
|
||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.length))));
|
||||
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_VALIDATION_COMPLETE_BROKEN_PACKS, MessageArgument.untrusted("broken", String.valueOf(broken)), MessageArgument.untrusted("value", String.valueOf(dirs.size()))));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -153,6 +153,11 @@ public class CommandStructure implements DirectorExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String placedKey : IrisStructureLocator.placedKeys(engine)) {
|
||||
if (!structureKeys.contains(placedKey)) {
|
||||
structureKeys.add(placedKey);
|
||||
}
|
||||
}
|
||||
VolmitSender commandSender = sender();
|
||||
Player target = senderIsPlayer ? player() : null;
|
||||
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STRUCTURE_VERIFYING_STRUCTURES_FROM_WITHIN_CHUNKS, MessageArgument.untrusted("value", world.getName()), MessageArgument.untrusted("value2", center.getBlockX()), MessageArgument.untrusted("value3", center.getBlockZ()), MessageArgument.untrusted("searchRadius", searchRadius)));
|
||||
@@ -169,7 +174,8 @@ public class CommandStructure implements DirectorExecutor {
|
||||
for (String keyName : structureKeys) {
|
||||
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, keyName, false);
|
||||
decisions.put(keyName, decision);
|
||||
requiresNativeReachability |= decision.generate();
|
||||
requiresNativeReachability |= !IrisStructureLocator.isPlaced(engine, keyName)
|
||||
&& decision.generate();
|
||||
}
|
||||
Set<String> reachable = Set.of();
|
||||
if (requiresNativeReachability) {
|
||||
@@ -190,7 +196,7 @@ public class CommandStructure implements DirectorExecutor {
|
||||
int errors = 0;
|
||||
for (String keyName : structureKeys) {
|
||||
IrisNativeStructureDecision decision = decisions.get(keyName);
|
||||
if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) {
|
||||
if (IrisStructureLocator.isPlaced(engine, keyName)) {
|
||||
try {
|
||||
IrisStructureLocator.LocateResult result =
|
||||
IrisStructureLocator.locate(engine, keyName, centerX, centerZ, searchRadius);
|
||||
@@ -205,7 +211,7 @@ public class CommandStructure implements DirectorExecutor {
|
||||
continue;
|
||||
}
|
||||
located++;
|
||||
messages.add(C.AQUA + "[iris] " + C.WHITE + keyName + C.GREEN + " @ "
|
||||
messages.add(C.AQUA + "[iris-planned] " + C.WHITE + keyName + C.GREEN + " @ "
|
||||
+ result.originX() + "," + result.baseY() + "," + result.originZ());
|
||||
} catch (Throwable error) {
|
||||
errors++;
|
||||
@@ -230,7 +236,7 @@ public class CommandStructure implements DirectorExecutor {
|
||||
nativeEligible++;
|
||||
messages.add(C.GREEN + "[native-eligible] " + C.WHITE + keyName);
|
||||
}
|
||||
messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placements located, "
|
||||
messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placement plans found, "
|
||||
+ C.WHITE + nativeEligible + C.GREEN + " native structures eligible, "
|
||||
+ C.WHITE + disabled + C.GREEN + " disabled by policy, "
|
||||
+ C.WHITE + unreachable + C.GREEN + " biome-unreachable, "
|
||||
|
||||
+4
-12
@@ -229,29 +229,21 @@ public class CommandStudio implements DirectorExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
@Director(description = "Create a new studio project", descriptionKey = "iris.director.commandstudio.director.create_new_studio_project", aliases = "+", sync = true)
|
||||
@Director(description = "Create a new studio project", descriptionKey = "iris.director.commandstudio.director.create_new_studio_project", aliases = "+")
|
||||
public void create(
|
||||
@Param(description = "The name of this new Iris Project.", descriptionKey = "iris.director.commandstudio.param.name_this_new_iris_project", defaultValue = "studio")
|
||||
String name,
|
||||
@Param(
|
||||
description = "Copy the contents of an existing project in your packs folder and use it as a template in this new project.", descriptionKey = "iris.director.commandstudio.param.copy_contents_existing_project_your_packs_folder_use_it_as_template_this",
|
||||
defaultValue = "null",
|
||||
contextual = true,
|
||||
customHandler = NullableDimensionHandler.class
|
||||
)
|
||||
IrisDimension template) {
|
||||
String projectName = name;
|
||||
if (name.equals("studio")) {
|
||||
File workspace = Iris.service(StudioSVC.class).getWorkspaceFolder();
|
||||
int suffix = 2;
|
||||
while (new File(workspace, projectName).exists()) {
|
||||
projectName = "studio" + suffix++;
|
||||
}
|
||||
}
|
||||
|
||||
if (template != null) {
|
||||
Iris.service(StudioSVC.class).create(sender(), projectName, template.getLoadKey());
|
||||
Iris.service(StudioSVC.class).create(sender(), name, template);
|
||||
} else {
|
||||
Iris.service(StudioSVC.class).create(sender(), projectName);
|
||||
Iris.service(StudioSVC.class).create(sender(), name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class BukkitWorldReconcilerTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void loadLeaseCoversRegistrationThroughExactWorldCompletion() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
LifecycleOperationCoordinator coordinator = coordinator();
|
||||
FakeBackend backend = new FakeBackend();
|
||||
CompletableFuture<World> creation = new CompletableFuture<>();
|
||||
backend.creation = creation;
|
||||
World exactWorld = world(backend.worldKey);
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator);
|
||||
|
||||
CompletableFuture<BukkitWorldReconciler.LoadResult> load = reconciler.loadWorld(configuration, backend.worldKey.toString());
|
||||
|
||||
assertFalse(load.isDone());
|
||||
assertEquals(LifecycleOperationCoordinator.OperationKind.WORLD_LOAD,
|
||||
coordinator.active(LifecycleOperationCoordinator.Domain.WORLD_MUTATION)
|
||||
.orElseThrow()
|
||||
.kind());
|
||||
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
|
||||
.getString("worlds.probe.generator"));
|
||||
assertThrows(LifecycleOperationCoordinator.BusyException.class,
|
||||
() -> coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
|
||||
backend.worldKey.toString()));
|
||||
|
||||
backend.loaded = Optional.of(exactWorld);
|
||||
creation.complete(exactWorld);
|
||||
BukkitWorldReconciler.LoadResult result = load.join();
|
||||
|
||||
assertTrue(result.succeeded());
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.LOADED, result.status());
|
||||
assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration());
|
||||
assertFalse(result.rollbackAttempted());
|
||||
assertTrue(coordinator.isIdle());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCreationRollsBackOnlyNewRegistration() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed"));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.CREATE_FAILED, result.status());
|
||||
assertEquals(BukkitWorldConfiguration.Registration.CREATED, result.registration());
|
||||
assertTrue(result.rollbackAttempted());
|
||||
assertTrue(result.rollbackSucceeded());
|
||||
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCreationPreservesPreexistingMatchingRegistration() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
BukkitWorldConfiguration.register(configuration, "probe", "overworld", 1337L);
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.configuredSeed = 1337L;
|
||||
backend.creation = CompletableFuture.failedFuture(new IllegalStateException("create failed"));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertEquals(BukkitWorldConfiguration.Registration.UNCHANGED, result.registration());
|
||||
assertFalse(result.rollbackAttempted());
|
||||
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
|
||||
.getString("worlds.probe.generator"));
|
||||
assertEquals(1337L, YamlConfiguration.loadConfiguration(configuration)
|
||||
.getLong("worlds.probe.seed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rollbackDoesNotRemoveARegistrationChangedDuringCreation() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
CompletableFuture<World> creation = new CompletableFuture<>();
|
||||
backend.creation = creation;
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
CompletableFuture<BukkitWorldReconciler.LoadResult> load = reconciler
|
||||
.loadWorld(configuration, backend.worldKey.toString());
|
||||
YamlConfiguration changed = YamlConfiguration.loadConfiguration(configuration);
|
||||
changed.set("worlds.probe.generator", "Other:generator");
|
||||
changed.save(configuration);
|
||||
|
||||
creation.completeExceptionally(new IllegalStateException("create failed"));
|
||||
BukkitWorldReconciler.LoadResult result = load.join();
|
||||
|
||||
assertTrue(result.rollbackAttempted());
|
||||
assertFalse(result.rollbackSucceeded());
|
||||
assertEquals("Other:generator", YamlConfiguration.loadConfiguration(configuration)
|
||||
.getString("worlds.probe.generator"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mismatchedCreatedWorldIsNotReportedAsLoaded() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.creation = CompletableFuture.completedFuture(world(new NamespacedKey("iris", "other")));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertFalse(result.succeeded());
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status());
|
||||
assertTrue(result.rollbackSucceeded());
|
||||
assertNull(YamlConfiguration.loadConfiguration(configuration).get("worlds.probe"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mismatchedResolvedWorldIsNotReportedAsLoaded() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
World exactCreatedWorld = world(backend.worldKey);
|
||||
backend.creation = CompletableFuture.completedFuture(exactCreatedWorld);
|
||||
backend.loaded = Optional.of(world(new NamespacedKey("iris", "other")));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler
|
||||
.loadWorld(configuration, backend.worldKey.toString())
|
||||
.join();
|
||||
|
||||
assertFalse(result.succeeded());
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_MISMATCH, result.status());
|
||||
assertTrue(result.rollbackSucceeded());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactNonIrisWorldIsAnIdentityConflict() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.loaded = Optional.of(world(backend.worldKey));
|
||||
backend.irisWorld = false;
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.IDENTITY_CONFLICT, result.status());
|
||||
assertEquals(0, backend.createCount.get());
|
||||
assertTrue(result.rollbackSucceeded());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unresolvedDimensionAndBusyLifecycleDoNotTouchConfiguration() throws Exception {
|
||||
File unresolvedConfiguration = temporaryFolder.newFile("unresolved.yml");
|
||||
FakeBackend unresolvedBackend = new FakeBackend();
|
||||
unresolvedBackend.dimensionResolution = BukkitWorldReconciler.DimensionResolution.failed(
|
||||
new IllegalStateException("ambiguous"));
|
||||
BukkitWorldReconciler unresolved = new BukkitWorldReconciler(unresolvedBackend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult unresolvedResult = unresolved
|
||||
.loadWorld(unresolvedConfiguration, unresolvedBackend.worldKey.toString())
|
||||
.join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.DIMENSION_UNRESOLVED, unresolvedResult.status());
|
||||
assertEquals(0L, unresolvedConfiguration.length());
|
||||
|
||||
File busyConfiguration = temporaryFolder.newFile("busy.yml");
|
||||
LifecycleOperationCoordinator coordinator = coordinator();
|
||||
LifecycleOperationCoordinator.Lease removal = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REMOVE,
|
||||
"iris:other");
|
||||
FakeBackend busyBackend = new FakeBackend();
|
||||
BukkitWorldReconciler busy = new BukkitWorldReconciler(busyBackend, coordinator);
|
||||
try {
|
||||
BukkitWorldReconciler.LoadResult busyResult = busy.loadWorld(busyConfiguration, busyBackend.worldKey.toString()).join();
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, busyResult.status());
|
||||
assertEquals(0, busyBackend.createCount.get());
|
||||
assertEquals(0L, busyConfiguration.length());
|
||||
} finally {
|
||||
removal.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pendingRestartRefusesWorldLoadBeforeRegistration() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("bukkit.yml");
|
||||
LifecycleOperationCoordinator coordinator = coordinator();
|
||||
assertTrue(coordinator.quiesceForRestart(() -> {
|
||||
}));
|
||||
FakeBackend backend = new FakeBackend();
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator);
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler.loadWorld(configuration, backend.worldKey.toString()).join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.BUSY, result.status());
|
||||
assertEquals(LifecycleOperationCoordinator.OperationKind.SERVER_RESTART,
|
||||
((LifecycleOperationCoordinator.BusyException) result.failure()).operationKind());
|
||||
assertEquals(0, backend.createCount.get());
|
||||
assertEquals(0L, configuration.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void terminalCreateTimeoutWinsOverLateWorldCompletion() {
|
||||
NamespacedKey worldKey = new NamespacedKey("iris", "probe");
|
||||
CompletableFuture<World> source = new CompletableFuture<>();
|
||||
AtomicInteger timeoutActions = new AtomicInteger();
|
||||
|
||||
CompletableFuture<World> guarded = BukkitWorldReconciler.guardCreateCompletion(
|
||||
source,
|
||||
worldKey,
|
||||
1L,
|
||||
timeoutActions::incrementAndGet);
|
||||
|
||||
CompletionException failure = assertThrows(CompletionException.class, guarded::join);
|
||||
assertTrue(failure.getCause() instanceof TimeoutException);
|
||||
assertEquals(1, timeoutActions.get());
|
||||
source.complete(world(worldKey));
|
||||
assertTrue(guarded.isCompletedExceptionally());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedOutCreationPreservesNewRegistrationForRestartReconciliation() throws Exception {
|
||||
File configuration = temporaryFolder.newFile("timeout.yml");
|
||||
FakeBackend backend = new FakeBackend();
|
||||
backend.creation = CompletableFuture.failedFuture(new TimeoutException("create timed out"));
|
||||
BukkitWorldReconciler reconciler = new BukkitWorldReconciler(backend, coordinator());
|
||||
|
||||
BukkitWorldReconciler.LoadResult result = reconciler
|
||||
.loadWorld(configuration, backend.worldKey.toString())
|
||||
.join();
|
||||
|
||||
assertEquals(BukkitWorldReconciler.ReconciliationStatus.RESTART_REQUIRED, result.status());
|
||||
assertFalse(result.rollbackAttempted());
|
||||
assertEquals("Iris:overworld", YamlConfiguration.loadConfiguration(configuration)
|
||||
.getString("worlds.probe.generator"));
|
||||
}
|
||||
|
||||
private static LifecycleOperationCoordinator coordinator() throws Exception {
|
||||
Constructor<LifecycleOperationCoordinator> constructor = LifecycleOperationCoordinator.class
|
||||
.getDeclaredConstructor();
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance();
|
||||
}
|
||||
|
||||
private static World world(NamespacedKey worldKey) {
|
||||
return (World) Proxy.newProxyInstance(
|
||||
BukkitWorldReconcilerTest.class.getClassLoader(),
|
||||
new Class[]{World.class},
|
||||
(proxy, method, arguments) -> switch (method.getName()) {
|
||||
case "getKey" -> worldKey;
|
||||
case "getName" -> worldKey.getKey();
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "equals" -> proxy == arguments[0];
|
||||
case "toString" -> worldKey.toString();
|
||||
default -> defaultValue(method.getReturnType());
|
||||
});
|
||||
}
|
||||
|
||||
private static Object defaultValue(Class<?> type) {
|
||||
if (!type.isPrimitive()) {
|
||||
return null;
|
||||
}
|
||||
if (type == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (type == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
if (type == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (type == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (type == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (type == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (type == float.class) {
|
||||
return 0F;
|
||||
}
|
||||
if (type == double.class) {
|
||||
return 0D;
|
||||
}
|
||||
throw new IllegalStateException("Unsupported primitive type: " + type);
|
||||
}
|
||||
|
||||
private static final class FakeBackend implements BukkitWorldReconciler.Backend {
|
||||
private final NamespacedKey worldKey;
|
||||
private final AtomicInteger createCount;
|
||||
private CompletableFuture<World> creation;
|
||||
private Optional<World> loaded;
|
||||
private boolean irisWorld;
|
||||
private Long configuredSeed;
|
||||
private BukkitWorldReconciler.DimensionResolution dimensionResolution;
|
||||
|
||||
private FakeBackend() {
|
||||
worldKey = new NamespacedKey("iris", "probe");
|
||||
createCount = new AtomicInteger();
|
||||
creation = CompletableFuture.completedFuture(null);
|
||||
loaded = Optional.empty();
|
||||
irisWorld = true;
|
||||
configuredSeed = null;
|
||||
dimensionResolution = BukkitWorldReconciler.DimensionResolution.resolved("overworld");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> configuredWorlds() {
|
||||
return Map.of("probe", "overworld");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long configuredSeed(String worldName) {
|
||||
return configuredSeed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<World> loadedWorld(NamespacedKey requestedWorldKey) {
|
||||
return worldKey.equals(requestedWorldKey) ? loaded : Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<World> createWorld(NamespacedKey requestedWorldKey, String dimension, Long seed) {
|
||||
createCount.incrementAndGet();
|
||||
return creation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isIrisWorld(World world) {
|
||||
return irisWorld;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BukkitWorldReconciler.DimensionResolution resolveDimension(NamespacedKey requestedWorldKey) {
|
||||
return dimensionResolution;
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class PendingWorldDeleteQueueTest {
|
||||
private static final String QUARANTINE_NAME = ".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f45";
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void acceptsOnlyCanonicalManagedNamesAndStrictQuarantines() {
|
||||
assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("alpha", "world"));
|
||||
assertEquals("alpha", PendingWorldDeleteQueue.normalizeQueueEntry("iris:alpha", "world"));
|
||||
assertEquals(QUARANTINE_NAME, PendingWorldDeleteQueue.normalizeQueueEntry(QUARANTINE_NAME, "world"));
|
||||
|
||||
for (String rejected : List.of(
|
||||
"world",
|
||||
"world_nether",
|
||||
"world_the_end",
|
||||
"overworld",
|
||||
"the_nether",
|
||||
"the_end",
|
||||
"minecraft:overworld",
|
||||
"minecraft:the_nether",
|
||||
"minecraft:the_end",
|
||||
"Alpha",
|
||||
"alpha beta",
|
||||
"../alpha",
|
||||
"alpha/beta",
|
||||
".iris-delete-6a4fd7fd-8e75-4f2f-b9fa-523b90c41f4",
|
||||
".iris-delete-6A4FD7FD-8E75-4F2F-B9FA-523B90C41F45"
|
||||
)) {
|
||||
assertNull(rejected, PendingWorldDeleteQueue.normalizeQueueEntry(rejected, "world"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loadFiltersUnsafeEntriesAndCanonicalizesDuplicates() throws IOException {
|
||||
File queueFile = temporaryFolder.newFile("pending-world-deletes.txt");
|
||||
Files.writeString(
|
||||
queueFile.toPath(),
|
||||
String.join("\n", "alpha", "iris:alpha", "world", "../escape", QUARANTINE_NAME),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
|
||||
LinkedHashMap<String, String> queue = PendingWorldDeleteQueue.loadPendingWorldDeleteMap(queueFile, "world");
|
||||
|
||||
assertEquals(List.of("alpha", QUARANTINE_NAME), List.copyOf(queue.values()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queueFileReplacementIsCompleteAndLeavesNoTemporaryFile() throws IOException {
|
||||
File queueFile = new File(temporaryFolder.getRoot(), "state/pending-world-deletes.txt");
|
||||
LinkedHashMap<String, String> first = new LinkedHashMap<>();
|
||||
first.put("alpha", "alpha");
|
||||
first.put(QUARANTINE_NAME, QUARANTINE_NAME);
|
||||
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, first);
|
||||
|
||||
assertEquals("alpha\n" + QUARANTINE_NAME + "\n", Files.readString(queueFile.toPath()));
|
||||
|
||||
LinkedHashMap<String, String> replacement = new LinkedHashMap<>();
|
||||
replacement.put("beta", "beta");
|
||||
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, replacement);
|
||||
|
||||
assertEquals("beta\n", Files.readString(queueFile.toPath()));
|
||||
try (Stream<Path> files = Files.list(queueFile.toPath().getParent())) {
|
||||
assertFalse(files.anyMatch(path -> path.getFileName().toString().contains(".tmp-")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyQueueIsDurablyRepresentedByAnEmptyFile() throws IOException {
|
||||
File queueFile = new File(temporaryFolder.getRoot(), "pending-world-deletes.txt");
|
||||
|
||||
PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>());
|
||||
|
||||
assertTrue(queueFile.isFile());
|
||||
assertEquals(0L, queueFile.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writeFailuresPropagateToTheCaller() throws IOException {
|
||||
File parentFile = temporaryFolder.newFile("not-a-directory");
|
||||
File queueFile = new File(parentFile, "pending-world-deletes.txt");
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> PendingWorldDeleteQueue.writePendingWorldDeleteMap(queueFile, new LinkedHashMap<>())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void discoversOnlyDirectNonSymlinkStartupDirectories() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("world");
|
||||
Path irisNamespace = levelRoot.toPath().resolve("dimensions/iris");
|
||||
Files.createDirectories(irisNamespace);
|
||||
Files.createDirectory(irisNamespace.resolve(QUARANTINE_NAME));
|
||||
Files.createDirectory(irisNamespace.resolve(".iris-delete-not-a-uuid"));
|
||||
String transientName = "iris-45ba411e-bf7c-493a-bf41-aa020754990b";
|
||||
Files.createDirectory(irisNamespace.resolve(transientName + "_nether"));
|
||||
Path nested = irisNamespace.resolve("ordinary/nested");
|
||||
Files.createDirectories(nested);
|
||||
Files.createDirectory(nested.resolve(".iris-delete-f70b8c21-9174-43a2-b7b7-a84fc0b2fe4a"));
|
||||
Path symlinkTarget = temporaryFolder.newFolder("quarantine-target").toPath();
|
||||
Files.createSymbolicLink(
|
||||
irisNamespace.resolve(".iris-delete-b8c7ff2d-2efd-410d-b228-6da5d5a46c36"),
|
||||
symlinkTarget
|
||||
);
|
||||
|
||||
LinkedHashSet<String> discovered = PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot);
|
||||
|
||||
assertEquals(Set.of(QUARANTINE_NAME, transientName), discovered);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refusesSymlinkedIrisNamespace() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("world");
|
||||
Path dimensions = levelRoot.toPath().resolve("dimensions");
|
||||
Files.createDirectories(dimensions);
|
||||
Path external = temporaryFolder.newFolder("external-iris").toPath();
|
||||
Files.createSymbolicLink(dimensions.resolve("iris"), external);
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> PendingWorldDeleteQueue.discoverStartupWorldNames(levelRoot)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void quarantineEntriesResolveToOnlyTheirExactDirectory() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("world");
|
||||
|
||||
List<Path> paths = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, QUARANTINE_NAME);
|
||||
|
||||
assertEquals(List.of(
|
||||
levelRoot.toPath().resolve("dimensions/iris").resolve(QUARANTINE_NAME).toAbsolutePath()
|
||||
), paths);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactLogicalEntriesDoNotExpandIntoDimensionFamilies() throws IOException {
|
||||
File levelRoot = temporaryFolder.newFolder("world");
|
||||
|
||||
List<Path> exact = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "exact:alpha");
|
||||
List<Path> family = PendingWorldDeleteQueue.resolveQueueEntryPaths(levelRoot, "alpha");
|
||||
|
||||
assertEquals(List.of(
|
||||
levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath()
|
||||
), exact);
|
||||
assertEquals(List.of(
|
||||
levelRoot.toPath().resolve("dimensions/iris/alpha").toAbsolutePath(),
|
||||
levelRoot.toPath().resolve("dimensions/iris/alpha_nether").toAbsolutePath(),
|
||||
levelRoot.toPath().resolve("dimensions/iris/alpha_the_end").toAbsolutePath()
|
||||
), family);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.core.BukkitWorldReconciler;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandIrisLoadWorldContractTest {
|
||||
@Test
|
||||
public void commandUsesTypedAsyncReconciliationBeforeReportingSuccess() throws Exception {
|
||||
Method reconciliation = BukkitWorldReconciler.class.getDeclaredMethod(
|
||||
"loadWorld",
|
||||
File.class,
|
||||
String.class);
|
||||
assertEquals(CompletableFuture.class, reconciliation.getReturnType());
|
||||
Method commandMethod = CommandIris.class.getDeclaredMethod("loadWorld", String.class);
|
||||
Parameter worldParameter = commandMethod.getParameters()[0];
|
||||
assertEquals(CommandIris.ManagedWorldNameHandler.class,
|
||||
worldParameter.getAnnotation(Param.class).customHandler());
|
||||
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
|
||||
String command = source.substring(
|
||||
source.indexOf("public void loadWorld("),
|
||||
source.indexOf("private void reportLoadWorldResult("));
|
||||
String reporter = source.substring(
|
||||
source.indexOf("private void reportLoadWorldResult("),
|
||||
source.indexOf("@Director(description = \"Evacuate an iris world\""));
|
||||
|
||||
assertTrue(command.contains("IrisWorldStorage.managedKeyFromName(world)"));
|
||||
assertTrue(command.contains(".loadWorld(BUKKIT_YML, worldKey.toString())"));
|
||||
assertTrue(command.contains(".whenComplete((result, failure) ->"));
|
||||
assertFalse(command.contains("checkForBukkitWorlds"));
|
||||
assertFalse(command.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY"));
|
||||
assertTrue(reporter.contains("if (result.succeeded())"));
|
||||
assertTrue(reporter.contains("COMMAND_IRIS_LOADED_SUCCESSFULLY"));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandIrisMainWorldPromotionTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void existingTopLevelWorldIsRefusedWithoutMerging() throws IOException {
|
||||
PromotionPaths paths = createPromotionPaths("existing-target");
|
||||
Files.createDirectories(paths.target());
|
||||
Files.writeString(paths.target().resolve("sentinel.txt"), "keep");
|
||||
|
||||
assertThrows(FileAlreadyExistsException.class, () -> CommandIris.publishMainWorldFiles(
|
||||
paths.current(),
|
||||
paths.sourceDimension(),
|
||||
paths.target()
|
||||
));
|
||||
|
||||
assertEquals("keep", Files.readString(paths.target().resolve("sentinel.txt")));
|
||||
assertFalse(Files.exists(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
|
||||
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uncommittedPromotionRollsBackThePublishedWorld() throws IOException {
|
||||
PromotionPaths paths = createPromotionPaths("rollback-target");
|
||||
|
||||
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
|
||||
paths.current(),
|
||||
paths.sourceDimension(),
|
||||
paths.target()
|
||||
)) {
|
||||
assertTrue(Files.isRegularFile(paths.target().resolve("data/map.dat")));
|
||||
assertTrue(Files.isRegularFile(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
|
||||
}
|
||||
|
||||
assertFalse(Files.exists(paths.target()));
|
||||
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void committedPromotionKeepsTheCompleteStagedWorld() throws IOException {
|
||||
PromotionPaths paths = createPromotionPaths("committed-target");
|
||||
|
||||
try (CommandIris.MainWorldPublication publication = CommandIris.publishMainWorldFiles(
|
||||
paths.current(),
|
||||
paths.sourceDimension(),
|
||||
paths.target()
|
||||
)) {
|
||||
publication.commit();
|
||||
}
|
||||
|
||||
assertEquals("map", Files.readString(paths.target().resolve("data/map.dat")));
|
||||
assertEquals("region", Files.readString(paths.target().resolve("dimensions/minecraft/overworld/region/r.0.0.mca")));
|
||||
assertFalse(hasPromotionStage(paths.root(), paths.target().getFileName().toString()));
|
||||
}
|
||||
|
||||
private PromotionPaths createPromotionPaths(String targetName) throws IOException {
|
||||
Path root = temporaryFolder.newFolder(targetName + "-root").toPath();
|
||||
Path current = root.resolve("world");
|
||||
Path sourceDimension = current.resolve("dimensions/iris/" + targetName);
|
||||
Path target = root.resolve(targetName);
|
||||
Files.createDirectories(current.resolve("data"));
|
||||
Files.writeString(current.resolve("data/map.dat"), "map");
|
||||
Files.createDirectories(sourceDimension.resolve("region"));
|
||||
Files.writeString(sourceDimension.resolve("region/r.0.0.mca"), "region");
|
||||
return new PromotionPaths(root, current, sourceDimension, target);
|
||||
}
|
||||
|
||||
private boolean hasPromotionStage(Path root, String targetName) throws IOException {
|
||||
try (Stream<Path> entries = Files.list(root)) {
|
||||
return entries.anyMatch(path -> path.getFileName().toString().startsWith("." + targetName + ".promoting-"));
|
||||
}
|
||||
}
|
||||
|
||||
private record PromotionPaths(Path root, Path current, Path sourceDimension, Path target) {
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import org.bukkit.World;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
public class CommandIrisRemovalCommandContractTest {
|
||||
@Test
|
||||
public void removalAcceptsDiskOnlyNamesAndUsesManagedSuggestions() throws NoSuchMethodException {
|
||||
Method command = CommandIris.class.getDeclaredMethod("remove", String.class, boolean.class);
|
||||
Parameter worldParameter = command.getParameters()[0];
|
||||
Param world = worldParameter.getAnnotation(Param.class);
|
||||
|
||||
assertEquals(CommandIris.ManagedWorldNameHandler.class, world.customHandler());
|
||||
assertFalse(Arrays.stream(CommandIris.class.getDeclaredMethods())
|
||||
.anyMatch(method -> method.getName().equals("remove")
|
||||
&& method.getParameterCount() == 2
|
||||
&& method.getParameterTypes()[0] == World.class));
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class CommandIrisUnloadContractTest {
|
||||
@Test
|
||||
public void unloadAwaitsEvacuationAndHasATerminalLifecycleTimeout() throws Exception {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.commandIrisSource")));
|
||||
String command = source.substring(
|
||||
source.indexOf("public void unloadWorld("),
|
||||
source.indexOf("private void reportUnloadResult("));
|
||||
|
||||
assertTrue(command.contains("IrisToolbelt.evacuateAsync(world)"));
|
||||
assertTrue(command.indexOf("IrisToolbelt.evacuateAsync(world)")
|
||||
< command.indexOf("WorldLifecycleService.get().unloadAsync(world, true)"));
|
||||
assertTrue(command.contains("guardUnloadCompletion(sequence, terminalTimeout, world.getName())"));
|
||||
assertTrue(command.contains("ServerConfigurator.restart(\"World unload timed out"));
|
||||
assertTrue(command.indexOf("ServerConfigurator.restart(\"World unload timed out")
|
||||
< command.indexOf("guarded.completeExceptionally(timeout)"));
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.util.common.director.specialhandlers.NullableDimensionHandler;
|
||||
import art.arcane.volmlib.util.director.annotations.Director;
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Parameter;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
public class CommandStudioCreationContractTest {
|
||||
@Test
|
||||
public void createRunsAsynchronouslyAndTemplateIsOptional() throws NoSuchMethodException {
|
||||
Method command = CommandStudio.class.getDeclaredMethod("create", String.class, IrisDimension.class);
|
||||
Director director = command.getAnnotation(Director.class);
|
||||
Parameter templateParameter = command.getParameters()[1];
|
||||
Param template = templateParameter.getAnnotation(Param.class);
|
||||
|
||||
assertFalse(director.sync());
|
||||
assertEquals("null", template.defaultValue());
|
||||
assertEquals(NullableDimensionHandler.class, template.customHandler());
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -25,7 +25,7 @@ public class IrisStructureLocateCommandContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void findRoutesOnlyExplicitNativeReplacementThroughIrisLocate() throws IOException {
|
||||
public void findRoutesRegisteredReplacementThroughPersistedNativeLocate() throws IOException {
|
||||
String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource")));
|
||||
int methodStart = source.indexOf("public void structure(");
|
||||
int methodEnd = source.indexOf("private static Structure resolveNativeStructure", methodStart);
|
||||
@@ -34,16 +34,19 @@ public class IrisStructureLocateCommandContractTest {
|
||||
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(", nativeResolution);
|
||||
int replacementCheck = method.indexOf(
|
||||
"decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution);
|
||||
int replacementLocate = method.indexOf("locateIrisStructure(e, structureKey, commandSender)", replacementCheck);
|
||||
int genericIrisLookup = method.indexOf(
|
||||
"nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementLocate);
|
||||
"nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementCheck);
|
||||
int replacementLocate = method.indexOf("final boolean replacementLocate = irisReplacement", genericIrisLookup);
|
||||
int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", genericIrisLookup);
|
||||
assertTrue(nativeResolution >= 0);
|
||||
assertTrue(policyResolution > nativeResolution);
|
||||
assertTrue(replacementCheck > policyResolution);
|
||||
assertTrue(replacementLocate > replacementCheck);
|
||||
assertTrue(genericIrisLookup > replacementLocate);
|
||||
assertTrue(genericIrisLookup > replacementCheck);
|
||||
assertTrue(replacementLocate > genericIrisLookup);
|
||||
assertTrue(nativeLocate > policyResolution);
|
||||
assertTrue(method.contains("irisReplacement && !IrisStructureLocator.hasNativePlacement"));
|
||||
assertTrue(method.contains("!replacementLocate && !explicitNativePlacement"));
|
||||
assertTrue(method.contains("&& !StructureReachability.isReachable"));
|
||||
assertTrue(method.contains("decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS"));
|
||||
assertFalse(method.contains("NativeStructureLocateCapability"));
|
||||
}
|
||||
@@ -82,7 +85,8 @@ public class IrisStructureLocateCommandContractTest {
|
||||
int methodEnd = source.indexOf("private void sendVerificationMessages(", methodStart);
|
||||
String method = source.substring(methodStart, methodEnd);
|
||||
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, keyName, false)");
|
||||
int nativeRequirement = method.indexOf("requiresNativeReachability |= decision.generate()", policyResolution);
|
||||
int nativeRequirement = method.indexOf(
|
||||
"requiresNativeReachability |= !IrisStructureLocator.isPlaced", policyResolution);
|
||||
int reachabilityGuard = method.indexOf("if (requiresNativeReachability)", nativeRequirement);
|
||||
int reachabilityLookup = method.indexOf("StructureReachability.reachableKeys(engine)", reachabilityGuard);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user