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);
|
||||
|
||||
|
||||
+4
-28
@@ -10,8 +10,6 @@ import net.minecraft.world.level.StructureManager;
|
||||
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.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
@@ -22,16 +20,11 @@ import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
final class ForcedStructureChunkGenerator extends ChunkGenerator {
|
||||
private static final int MIN_SAFE_STRUCTURE_Y = 80;
|
||||
private static final int VERTICAL_MARGIN = 32;
|
||||
|
||||
private final ChunkGenerator delegate;
|
||||
private final int targetY;
|
||||
|
||||
ForcedStructureChunkGenerator(ChunkGenerator delegate, Holder<Biome> sourceBiome, int targetY) {
|
||||
ForcedStructureChunkGenerator(ChunkGenerator delegate, Holder<Biome> sourceBiome) {
|
||||
super(new FixedBiomeSource(sourceBiome));
|
||||
this.delegate = delegate;
|
||||
this.targetY = targetY;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,7 +65,7 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator {
|
||||
|
||||
@Override
|
||||
public int getSeaLevel() {
|
||||
return delegate.getMinY() + 1;
|
||||
return delegate.getSeaLevel();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -83,21 +76,13 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator {
|
||||
@Override
|
||||
public int getBaseHeight(int x, int z, Heightmap.Types type,
|
||||
LevelHeightAccessor heightAccessor, RandomState randomState) {
|
||||
return safeOccupiedY(heightAccessor) + 1;
|
||||
return delegate.getBaseHeight(x, z, type, heightAccessor, randomState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor,
|
||||
RandomState randomState) {
|
||||
int minY = heightAccessor.getMinY();
|
||||
BlockState[] states = new BlockState[heightAccessor.getHeight()];
|
||||
for (int index = 0; index < states.length; index++) {
|
||||
int y = minY + index;
|
||||
states[index] = (y & 1) == 0
|
||||
? Blocks.STONE.defaultBlockState()
|
||||
: Blocks.AIR.defaultBlockState();
|
||||
}
|
||||
return new NoiseColumn(minY, states);
|
||||
return delegate.getBaseColumn(x, z, heightAccessor, randomState);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -106,13 +91,4 @@ final class ForcedStructureChunkGenerator extends ChunkGenerator {
|
||||
delegate.addDebugScreenInfo(result, randomState, feetPos);
|
||||
}
|
||||
|
||||
private int safeOccupiedY(LevelHeightAccessor heightAccessor) {
|
||||
int minY = heightAccessor.getMinY() + VERTICAL_MARGIN;
|
||||
int maxY = heightAccessor.getMaxY() - VERTICAL_MARGIN;
|
||||
if (minY > maxY) {
|
||||
return heightAccessor.getMinY()
|
||||
+ Math.max(0, heightAccessor.getHeight() / 2);
|
||||
}
|
||||
return Math.max(minY, Math.min(maxY, Math.max(MIN_SAFE_STRUCTURE_Y, targetY)));
|
||||
}
|
||||
}
|
||||
|
||||
+56
-9
@@ -4,6 +4,7 @@ import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.object.IrisJigsawConfiguration;
|
||||
import art.arcane.iris.engine.object.IrisJigsawHeightmap;
|
||||
import art.arcane.iris.engine.object.IrisJigsawLiquidSettings;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
@@ -18,6 +19,7 @@ import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
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.structures.JigsawStructure;
|
||||
@@ -43,7 +45,7 @@ public final class NativeStructureFactory {
|
||||
.orElseThrow(() -> new IllegalStateException("Configured native structure '"
|
||||
+ plan.source().getStructure() + "' has no usable generation biome"));
|
||||
ChunkGenerator forcedGenerator = new ForcedStructureChunkGenerator(
|
||||
context.generator(), sourceBiome, plan.baseY());
|
||||
context.generator(), sourceBiome);
|
||||
StructureStart generated = configured.generate(
|
||||
sourceHolder,
|
||||
context.levelKey(),
|
||||
@@ -82,15 +84,41 @@ public final class NativeStructureFactory {
|
||||
if (!positioned.isValid()) {
|
||||
return StructureStart.INVALID_START;
|
||||
}
|
||||
return NativeStructureReferenceEnvelope.wrap(
|
||||
return NativeStructureReferenceEnvelope.wrapForPublication(
|
||||
positioned,
|
||||
source,
|
||||
references,
|
||||
context.templateManager(),
|
||||
plan.placement().resolvedTerrain()
|
||||
plan.placement().resolvedTerrain(),
|
||||
plan.source().getStructure()
|
||||
);
|
||||
}
|
||||
|
||||
public static JigsawSourceMetadata sourceMetadata(RegistryAccess registryAccess,
|
||||
StructureTemplateManager templateManager,
|
||||
JigsawStructure source) {
|
||||
CompoundTag sourceTag = encodeJigsaw(registryAccess, source);
|
||||
return new JigsawSourceMetadata(
|
||||
sourceDistance(sourceTag, "horizontal"),
|
||||
horizontalReferenceExpansion(source),
|
||||
NativeStructureTemplatePoolBounds.sourceHorizontalSpan(
|
||||
registryAccess, templateManager, source));
|
||||
}
|
||||
|
||||
public static int templatePoolHorizontalSpan(RegistryAccess registryAccess,
|
||||
StructureTemplateManager templateManager,
|
||||
String templatePoolKey) {
|
||||
return NativeStructureTemplatePoolBounds.horizontalSpan(
|
||||
registryAccess, templateManager, templatePoolKey);
|
||||
}
|
||||
|
||||
public static int jigsawStartPoolHorizontalSpan(RegistryAccess registryAccess,
|
||||
StructureTemplateManager templateManager,
|
||||
JigsawStructure source,
|
||||
String templatePoolKey) {
|
||||
return NativeStructureTemplatePoolBounds.horizontalSpan(
|
||||
registryAccess, templateManager, source, templatePoolKey);
|
||||
}
|
||||
|
||||
static Structure configure(RegistryAccess registryAccess, Structure source,
|
||||
IrisJigsawConfiguration configuration,
|
||||
boolean underground, int baseY) {
|
||||
@@ -103,10 +131,7 @@ public final class NativeStructureFactory {
|
||||
return source;
|
||||
}
|
||||
RegistryOps<Tag> registryOps = RegistryOps.create(NbtOps.INSTANCE, registryAccess);
|
||||
Tag encoded = Structure.DIRECT_CODEC.encodeStart(registryOps, sourceJigsaw).getOrThrow();
|
||||
if (!(encoded instanceof CompoundTag structureTag)) {
|
||||
throw new IllegalStateException("Native jigsaw codec did not produce a compound");
|
||||
}
|
||||
CompoundTag structureTag = encodeJigsaw(registryOps, sourceJigsaw);
|
||||
CompoundTag configuredTag = structureTag.copy();
|
||||
if (underground) {
|
||||
CompoundTag height = new CompoundTag();
|
||||
@@ -123,6 +148,20 @@ public final class NativeStructureFactory {
|
||||
return configured;
|
||||
}
|
||||
|
||||
private static CompoundTag encodeJigsaw(RegistryAccess registryAccess,
|
||||
JigsawStructure source) {
|
||||
return encodeJigsaw(RegistryOps.create(NbtOps.INSTANCE, registryAccess), source);
|
||||
}
|
||||
|
||||
private static CompoundTag encodeJigsaw(RegistryOps<Tag> registryOps,
|
||||
JigsawStructure source) {
|
||||
Tag encoded = Structure.DIRECT_CODEC.encodeStart(registryOps, source).getOrThrow();
|
||||
if (!(encoded instanceof CompoundTag structureTag)) {
|
||||
throw new IllegalStateException("Native jigsaw codec did not produce a compound");
|
||||
}
|
||||
return structureTag;
|
||||
}
|
||||
|
||||
private static void applyConfiguration(CompoundTag tag, IrisJigsawConfiguration configuration) {
|
||||
if (configuration == null) {
|
||||
return;
|
||||
@@ -168,7 +207,7 @@ public final class NativeStructureFactory {
|
||||
tag.put("max_distance_from_center", distance);
|
||||
}
|
||||
|
||||
private static int sourceDistance(CompoundTag tag, String axis) {
|
||||
static int sourceDistance(CompoundTag tag, String axis) {
|
||||
Tag raw = tag.get("max_distance_from_center");
|
||||
if (raw instanceof CompoundTag compound) {
|
||||
return compound.getIntOr(axis, 128);
|
||||
@@ -176,6 +215,14 @@ public final class NativeStructureFactory {
|
||||
return tag.getIntOr("max_distance_from_center", 128);
|
||||
}
|
||||
|
||||
static int horizontalReferenceExpansion(Structure structure) {
|
||||
BoundingBox content = new BoundingBox(0, 0, 0, 0, 0, 0);
|
||||
BoundingBox adjusted = structure.adjustBoundingBox(content);
|
||||
int expansion = Math.max(content.minX() - adjusted.minX(), adjusted.maxX() - content.maxX());
|
||||
expansion = Math.max(expansion, content.minZ() - adjusted.minZ());
|
||||
return Math.max(0, Math.max(expansion, adjusted.maxZ() - content.maxZ()));
|
||||
}
|
||||
|
||||
private static void applyHeightmap(CompoundTag tag, IrisJigsawHeightmap heightmap) {
|
||||
if (heightmap == null || heightmap == IrisJigsawHeightmap.SOURCE) {
|
||||
return;
|
||||
|
||||
-3
@@ -60,9 +60,6 @@ public final class NativeStructureFoundationBuilder {
|
||||
private static void markFoundationEnvelope(BitSet envelope, List<StructurePiece> pieces, BoundingBox area,
|
||||
int x, int z) {
|
||||
for (StructurePiece piece : pieces) {
|
||||
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
|
||||
continue;
|
||||
}
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
if (x < bounds.minX() || x > bounds.maxX() || z < bounds.minZ() || z > bounds.maxZ()) {
|
||||
continue;
|
||||
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.IntTag;
|
||||
import net.minecraft.nbt.NbtUtils;
|
||||
import net.minecraft.nbt.StreamTagVisitor;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.nbt.visitors.CollectFields;
|
||||
import net.minecraft.nbt.visitors.FieldSelector;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.util.datafix.DataFixTypes;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.storage.SimpleRegionStorage;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public final class NativeStructureLocatePersistence {
|
||||
public static final int MAX_SELECTED_CANDIDATE_RETRIES = 64;
|
||||
private static final int MAX_STORAGE_PROBES = 512;
|
||||
private static final Method PAPER_LEVEL_TYPE_KEY = paperLevelTypeKey();
|
||||
|
||||
private NativeStructureLocatePersistence() {
|
||||
}
|
||||
|
||||
public static Probe probe(ServerLevel level, Structure structure, boolean requireUnreferenced) {
|
||||
return probe(level, structure, requireUnreferenced, new ProbeBudget(MAX_STORAGE_PROBES));
|
||||
}
|
||||
|
||||
public static ProbeBudget probeBudget() {
|
||||
return new ProbeBudget(MAX_STORAGE_PROBES);
|
||||
}
|
||||
|
||||
public static Probe probe(ServerLevel level, Structure structure, boolean requireUnreferenced,
|
||||
ProbeBudget budget) {
|
||||
return new Probe(level, structure, requireUnreferenced, budget);
|
||||
}
|
||||
|
||||
public static Search search(Engine engine, String structureKey,
|
||||
int blockX, int blockZ, int radius, Probe probe) {
|
||||
return new Search(engine, structureKey, blockX, blockZ, radius, probe);
|
||||
}
|
||||
|
||||
public static final class Probe {
|
||||
private final ServerLevel level;
|
||||
private final Structure structure;
|
||||
private final String structureKey;
|
||||
private final boolean requireUnreferenced;
|
||||
private final ProbeBudget budget;
|
||||
private final Map<Long, Boolean> storedDecisions = new HashMap<>();
|
||||
|
||||
Probe(ServerLevel level, Structure structure, boolean requireUnreferenced,
|
||||
ProbeBudget budget) {
|
||||
this.level = level;
|
||||
this.structure = structure;
|
||||
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||
Identifier identifier = registry.getKey(structure);
|
||||
if (identifier == null) {
|
||||
throw new IllegalStateException("Cannot probe an unregistered native structure");
|
||||
}
|
||||
this.structureKey = identifier.toString();
|
||||
this.requireUnreferenced = requireUnreferenced;
|
||||
this.budget = budget;
|
||||
}
|
||||
|
||||
public boolean accepts(int chunkX, int chunkZ) {
|
||||
ChunkAccess loaded = level.getChunkSource().getChunk(
|
||||
chunkX, chunkZ, ChunkStatus.STRUCTURE_STARTS, false);
|
||||
if (loaded != null) {
|
||||
StructureStart start = loaded.getStartForStructure(structure);
|
||||
return start != null && start.isValid()
|
||||
&& (!requireUnreferenced || start.canBeReferenced());
|
||||
}
|
||||
|
||||
ChunkPos chunkPos = new ChunkPos(chunkX, chunkZ);
|
||||
Boolean storedDecision = storedDecisions.get(chunkPos.pack());
|
||||
if (storedDecision != null) {
|
||||
return storedDecision;
|
||||
}
|
||||
boolean accepted = budget.acceptsStored(
|
||||
level, chunkPos, structureKey, requireUnreferenced);
|
||||
storedDecisions.put(chunkPos.pack(), accepted);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
public StructureStart verifySelected(int chunkX, int chunkZ) {
|
||||
ChunkAccess chunk = level.getChunk(chunkX, chunkZ, ChunkStatus.STRUCTURE_STARTS);
|
||||
StructureStart start = chunk.getStartForStructure(structure);
|
||||
if (start == null || !start.isValid()) {
|
||||
return null;
|
||||
}
|
||||
if (requireUnreferenced) {
|
||||
if (!start.canBeReferenced()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
private void reference(StructureStart start) {
|
||||
if (requireUnreferenced) {
|
||||
level.structureManager().addReference(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ProbeBudget {
|
||||
private final int maximum;
|
||||
private final Map<Long, StoredChunkState> storedChunks = new HashMap<>();
|
||||
private final ChunkStorageScanner scanner;
|
||||
private final StoredChunkDatafixer datafixer;
|
||||
private int used;
|
||||
private boolean scanFailureReported;
|
||||
|
||||
private ProbeBudget(int maximum) {
|
||||
this(maximum, null, ProbeBudget::datafix);
|
||||
}
|
||||
|
||||
ProbeBudget(int maximum, ChunkStorageScanner scanner) {
|
||||
this(maximum, scanner, ProbeBudget::datafix);
|
||||
}
|
||||
|
||||
ProbeBudget(int maximum, ChunkStorageScanner scanner,
|
||||
StoredChunkDatafixer datafixer) {
|
||||
this.maximum = maximum;
|
||||
this.scanner = scanner;
|
||||
this.datafixer = Objects.requireNonNull(
|
||||
datafixer, "Stored chunk datafixer must not be null");
|
||||
}
|
||||
|
||||
private void claim() {
|
||||
if (used >= maximum) {
|
||||
throw new IrisStructureLocator.CandidateSearchLimitException();
|
||||
}
|
||||
used++;
|
||||
}
|
||||
|
||||
private StoredChunkState storedState(ServerLevel level, ChunkPos chunkPos) {
|
||||
StoredChunkState cached = storedChunks.get(chunkPos.pack());
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
claim();
|
||||
CollectFields fields = new CollectFields(
|
||||
new FieldSelector(IntTag.TYPE, "DataVersion"),
|
||||
new FieldSelector("Level", "Structures", CompoundTag.TYPE, "Starts"),
|
||||
new FieldSelector("structures", CompoundTag.TYPE, "starts"));
|
||||
ChunkStorageScanner activeScanner = scanner == null
|
||||
? level.getChunkSource().chunkMap.chunkScanner()::scanChunk : scanner;
|
||||
try {
|
||||
activeScanner.scan(chunkPos, fields).join();
|
||||
} catch (RuntimeException error) {
|
||||
if (!scanFailureReported) {
|
||||
scanFailureReported = true;
|
||||
IrisLogging.reportError(
|
||||
"Native structure locate could not scan stored chunk state; candidates will be verified by loading their structure starts.",
|
||||
error);
|
||||
}
|
||||
StoredChunkState unresolved = StoredChunkState.missing();
|
||||
storedChunks.put(chunkPos.pack(), unresolved);
|
||||
return unresolved;
|
||||
}
|
||||
Tag result = fields.getResult();
|
||||
StoredChunkState resolved;
|
||||
try {
|
||||
resolved = result instanceof CompoundTag storedChunk
|
||||
? StoredChunkState.parse(datafixer.datafix(level, storedChunk))
|
||||
: StoredChunkState.missing();
|
||||
} catch (RuntimeException error) {
|
||||
if (!scanFailureReported) {
|
||||
scanFailureReported = true;
|
||||
IrisLogging.reportError(
|
||||
"Native structure locate could not datafix stored chunk state; candidates will be verified by loading their structure starts.",
|
||||
error);
|
||||
}
|
||||
resolved = StoredChunkState.missing();
|
||||
}
|
||||
storedChunks.put(chunkPos.pack(), resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static CompoundTag datafix(ServerLevel level, CompoundTag storedChunk) {
|
||||
int dataVersion = NbtUtils.getDataVersion(storedChunk);
|
||||
int currentDataVersion = SharedConstants.getCurrentVersion().dataVersion().version();
|
||||
if (dataVersion >= currentDataVersion) {
|
||||
return storedChunk;
|
||||
}
|
||||
if (level == null) {
|
||||
throw new IllegalStateException("Stored chunk datafix requires an active level");
|
||||
}
|
||||
CompoundTag context = chunkDataFixContext(level);
|
||||
SimpleRegionStorage.injectDatafixingContext(storedChunk, context);
|
||||
return DataFixTypes.CHUNK.updateToCurrentVersion(
|
||||
level.getServer().getFixerUpper(), storedChunk, dataVersion);
|
||||
}
|
||||
|
||||
private static CompoundTag chunkDataFixContext(ServerLevel level) {
|
||||
CompoundTag context = new CompoundTag();
|
||||
String levelIdentifier = level.dimension().identifier().toString();
|
||||
if (PAPER_LEVEL_TYPE_KEY == null) {
|
||||
context.putString("dimension", levelIdentifier);
|
||||
} else {
|
||||
context.putString("dimension", paperLevelTypeKey(level).identifier().toString());
|
||||
context.putString("level_identifier", levelIdentifier);
|
||||
}
|
||||
level.getChunkSource().getGenerator().getTypeNameForDataFixer()
|
||||
.ifPresent(identifier -> context.putString("generator", identifier.toString()));
|
||||
return context;
|
||||
}
|
||||
|
||||
boolean acceptsStored(ServerLevel level, ChunkPos chunkPos,
|
||||
String structureKey, boolean requireUnreferenced) {
|
||||
return storedState(level, chunkPos).accepts(structureKey, requireUnreferenced);
|
||||
}
|
||||
|
||||
int used() {
|
||||
return used;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Search {
|
||||
private final Engine engine;
|
||||
private final String structureKey;
|
||||
private final int blockX;
|
||||
private final int blockZ;
|
||||
private final int radius;
|
||||
private final Probe probe;
|
||||
private final Set<Long> rejectedChunks = new HashSet<>();
|
||||
|
||||
private Search(Engine engine, String structureKey, int blockX, int blockZ,
|
||||
int radius, Probe probe) {
|
||||
this.engine = engine;
|
||||
this.structureKey = structureKey;
|
||||
this.blockX = blockX;
|
||||
this.blockZ = blockZ;
|
||||
this.radius = radius;
|
||||
this.probe = probe;
|
||||
}
|
||||
|
||||
public IrisStructureLocator.LocateResult predict() {
|
||||
if (rejectedChunks.size() >= MAX_SELECTED_CANDIDATE_RETRIES) {
|
||||
return new IrisStructureLocator.LocateResult(
|
||||
IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0);
|
||||
}
|
||||
return IrisStructureLocator.locateInPlacementRings(
|
||||
engine, structureKey, blockX, blockZ, radius,
|
||||
(chunkX, chunkZ) -> !rejectedChunks.contains(ChunkPos.pack(chunkX, chunkZ))
|
||||
&& probe.accepts(chunkX, chunkZ));
|
||||
}
|
||||
|
||||
public VerifiedStart verify(IrisStructureLocator.LocateResult result) {
|
||||
int chunkX = result.originX() >> 4;
|
||||
int chunkZ = result.originZ() >> 4;
|
||||
StructureStart start = probe.verifySelected(chunkX, chunkZ);
|
||||
if (start == null) {
|
||||
return null;
|
||||
}
|
||||
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipRecovery.resolve(
|
||||
engine, probe.level, structureKey, probe.structure, start);
|
||||
if (ownership == null) {
|
||||
return null;
|
||||
}
|
||||
return new VerifiedStart(start, ownership);
|
||||
}
|
||||
|
||||
public void reject(IrisStructureLocator.LocateResult result) {
|
||||
rejectedChunks.add(ChunkPos.pack(result.originX() >> 4, result.originZ() >> 4));
|
||||
}
|
||||
|
||||
public void reference(VerifiedStart verified) {
|
||||
probe.reference(verified.start());
|
||||
}
|
||||
}
|
||||
|
||||
private static Method paperLevelTypeKey() {
|
||||
try {
|
||||
return ServerLevel.class.getMethod("getTypeKey");
|
||||
} catch (NoSuchMethodException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static ResourceKey<?> paperLevelTypeKey(ServerLevel level) {
|
||||
try {
|
||||
Object value = PAPER_LEVEL_TYPE_KEY.invoke(level);
|
||||
if (value instanceof ResourceKey<?> key) {
|
||||
return key;
|
||||
}
|
||||
throw new IllegalStateException("Paper level type key has an unexpected value");
|
||||
} catch (IllegalAccessException | InvocationTargetException error) {
|
||||
throw new IllegalStateException("Cannot read Paper level type key", error);
|
||||
}
|
||||
}
|
||||
|
||||
public record VerifiedStart(StructureStart start,
|
||||
NativeStructureOwnershipRecord ownership) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface ChunkStorageScanner {
|
||||
CompletableFuture<Void> scan(ChunkPos chunkPos, StreamTagVisitor visitor);
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface StoredChunkDatafixer {
|
||||
CompoundTag datafix(ServerLevel level, CompoundTag storedChunk);
|
||||
}
|
||||
|
||||
private record StoredChunkState(boolean stored, boolean startsPresent, CompoundTag starts) {
|
||||
private static StoredChunkState missing() {
|
||||
return new StoredChunkState(false, false, new CompoundTag());
|
||||
}
|
||||
|
||||
private static StoredChunkState parse(CompoundTag chunk) {
|
||||
CompoundTag structures = chunk.getCompoundOrEmpty("structures");
|
||||
return structures.getCompound("starts")
|
||||
.map(starts -> new StoredChunkState(true, true, starts))
|
||||
.orElseGet(() -> new StoredChunkState(true, false, new CompoundTag()));
|
||||
}
|
||||
|
||||
private boolean accepts(String structureKey, boolean requireUnreferenced) {
|
||||
if (!stored || !startsPresent) {
|
||||
return true;
|
||||
}
|
||||
return starts.getCompound(structureKey).map(start -> {
|
||||
String id = start.getStringOr("id", "");
|
||||
return !StructureStart.INVALID_START_ID.equals(id)
|
||||
&& (!requireUnreferenced || start.getIntOr("references", 0) == 0);
|
||||
}).orElse(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -37,6 +37,19 @@ public final class NativeStructureLocateResults {
|
||||
<= horizontalDistanceSquared(origin, replacement.getFirst()) ? nativeResult : replacement;
|
||||
}
|
||||
|
||||
public static <T> Pair<BlockPos, T> selectAndReference(
|
||||
BlockPos origin,
|
||||
Pair<BlockPos, T> replacement, Runnable replacementReference,
|
||||
Pair<BlockPos, T> nativeResult, Runnable nativeReference) {
|
||||
Pair<BlockPos, T> selected = nearest(origin, replacement, nativeResult);
|
||||
if (selected == replacement && replacement != null) {
|
||||
replacementReference.run();
|
||||
} else if (selected == nativeResult && nativeResult != null) {
|
||||
nativeReference.run();
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static long horizontalDistanceSquared(BlockPos origin, BlockPos target) {
|
||||
long dx = (long) target.getX() - origin.getX();
|
||||
long dz = (long) target.getZ() - origin.getZ();
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementGrid;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.StructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
|
||||
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 java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class NativeStructureOwnershipFingerprint {
|
||||
private static final Comparator<PieceIdentity> PIECE_ORDER = Comparator
|
||||
.comparing(PieceIdentity::type)
|
||||
.thenComparingInt(PieceIdentity::minX)
|
||||
.thenComparingInt(PieceIdentity::minY)
|
||||
.thenComparingInt(PieceIdentity::minZ)
|
||||
.thenComparingInt(PieceIdentity::maxX)
|
||||
.thenComparingInt(PieceIdentity::maxY)
|
||||
.thenComparingInt(PieceIdentity::maxZ)
|
||||
.thenComparingInt(PieceIdentity::orientation)
|
||||
.thenComparingInt(PieceIdentity::generationDepth)
|
||||
.thenComparing(PieceIdentity::detail);
|
||||
|
||||
private NativeStructureOwnershipFingerprint() {
|
||||
}
|
||||
|
||||
public static NativeStructureOwnershipRecord capture(String structureKey,
|
||||
StructureStart start,
|
||||
NativeStructureStartPlan plan,
|
||||
BoundingBox referenceEnvelope) {
|
||||
StructureStart resolvedStart = requireValid(start);
|
||||
NativeStructureStartPlan resolvedPlan = Objects.requireNonNull(
|
||||
plan, "Native structure start plan must not be null");
|
||||
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(resolvedStart);
|
||||
BoundingBox references = Objects.requireNonNull(
|
||||
referenceEnvelope, "Native structure reference envelope must not be null");
|
||||
ChunkPos origin = resolvedStart.getChunkPos();
|
||||
return NativeStructureOwnershipRecord.create(
|
||||
structureKey,
|
||||
origin.x(),
|
||||
origin.z(),
|
||||
StructurePlacementGrid.placementIdentity(resolvedPlan.placement()),
|
||||
resolvedPlan.baseY(),
|
||||
content.minX(),
|
||||
content.minY(),
|
||||
content.minZ(),
|
||||
content.maxX(),
|
||||
content.maxY(),
|
||||
content.maxZ(),
|
||||
locatorY(resolvedStart),
|
||||
references.minX() >> 4,
|
||||
references.maxX() >> 4,
|
||||
references.minZ() >> 4,
|
||||
references.maxZ() >> 4,
|
||||
fingerprint(structureKey, resolvedStart),
|
||||
NativeStructurePlacementPlanner.decisionFor(resolvedPlan)
|
||||
);
|
||||
}
|
||||
|
||||
public static boolean matches(NativeStructureOwnershipRecord record, StructureStart start) {
|
||||
if (record == null || start == null || !start.isValid()) {
|
||||
return false;
|
||||
}
|
||||
ChunkPos origin = start.getChunkPos();
|
||||
if (origin.x() != record.originChunkX()
|
||||
|| origin.z() != record.originChunkZ()
|
||||
|| !fingerprint(record.structureKey(), start).equals(record.contentFingerprint())) {
|
||||
return false;
|
||||
}
|
||||
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(start);
|
||||
if (content.getXSpan() != (record.contentMaxX() - record.contentMinX()) + 1
|
||||
|| content.getYSpan() != (record.contentMaxY() - record.contentMinY()) + 1
|
||||
|| content.getZSpan() != (record.contentMaxZ() - record.contentMinZ()) + 1) {
|
||||
return false;
|
||||
}
|
||||
return start.getStructure() instanceof OceanMonumentStructure
|
||||
|| content.minX() == record.contentMinX()
|
||||
&& content.minY() == record.contentMinY()
|
||||
&& content.minZ() == record.contentMinZ()
|
||||
&& content.maxX() == record.contentMaxX()
|
||||
&& content.maxY() == record.contentMaxY()
|
||||
&& content.maxZ() == record.contentMaxZ();
|
||||
}
|
||||
|
||||
public static String fingerprint(String structureKey, StructureStart start) {
|
||||
StructureStart resolvedStart = requireValid(start);
|
||||
MessageDigest digest = sha256();
|
||||
updateString(digest, Objects.requireNonNull(structureKey,
|
||||
"Native structure key must not be null").trim().toLowerCase(Locale.ROOT));
|
||||
updateInt(digest, resolvedStart.getChunkPos().x());
|
||||
updateInt(digest, resolvedStart.getChunkPos().z());
|
||||
BoundingBox content = NativeStructureReferenceEnvelope.contentBounds(resolvedStart);
|
||||
List<PieceIdentity> pieces = contentPieces(resolvedStart, content.minY());
|
||||
updateInt(digest, pieces.size());
|
||||
for (PieceIdentity piece : pieces) {
|
||||
updateString(digest, piece.type());
|
||||
updateInt(digest, piece.minX());
|
||||
updateInt(digest, piece.minY());
|
||||
updateInt(digest, piece.minZ());
|
||||
updateInt(digest, piece.maxX());
|
||||
updateInt(digest, piece.maxY());
|
||||
updateInt(digest, piece.maxZ());
|
||||
updateInt(digest, piece.orientation());
|
||||
updateInt(digest, piece.generationDepth());
|
||||
updateString(digest, piece.detail());
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
public static int locatorY(StructureStart start) {
|
||||
StructureStart resolvedStart = requireValid(start);
|
||||
if (!resolvedStart.getPieces().isEmpty()) {
|
||||
return resolvedStart.getPieces().getFirst().getLocatorPosition().getY();
|
||||
}
|
||||
throw new IllegalStateException("Native structure contains no locatable content pieces");
|
||||
}
|
||||
|
||||
private static List<PieceIdentity> contentPieces(StructureStart start, int contentMinY) {
|
||||
List<PieceIdentity> pieces = new ArrayList<>();
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
Identifier type = BuiltInRegistries.STRUCTURE_PIECE.getKey(piece.getType());
|
||||
Direction orientation = piece.getOrientation();
|
||||
String detail = pieceDetail(piece, contentMinY);
|
||||
pieces.add(new PieceIdentity(
|
||||
type == null ? "" : type.toString(),
|
||||
bounds.minX(), bounds.minY() - contentMinY, bounds.minZ(),
|
||||
bounds.maxX(), bounds.maxY() - contentMinY, bounds.maxZ(),
|
||||
orientation == null ? -1 : orientation.get2DDataValue(),
|
||||
piece.getGenDepth(),
|
||||
detail
|
||||
));
|
||||
}
|
||||
if (pieces.isEmpty()) {
|
||||
throw new IllegalStateException("Native structure contains no content pieces");
|
||||
}
|
||||
pieces.sort(PIECE_ORDER);
|
||||
return List.copyOf(pieces);
|
||||
}
|
||||
|
||||
private static String pieceDetail(StructurePiece piece, int contentMinY) {
|
||||
if (!(piece instanceof PoolElementStructurePiece poolPiece)) {
|
||||
return "";
|
||||
}
|
||||
return poolElementIdentity(poolPiece.getElement()) + "|"
|
||||
+ poolPiece.getPosition().getX() + ","
|
||||
+ (poolPiece.getPosition().getY() - contentMinY) + ","
|
||||
+ poolPiece.getPosition().getZ() + "|"
|
||||
+ poolPiece.getGroundLevelDelta() + "|"
|
||||
+ poolPiece.getRotation().getSerializedName();
|
||||
}
|
||||
|
||||
private static String poolElementIdentity(StructurePoolElement element) {
|
||||
Identifier type = BuiltInRegistries.STRUCTURE_POOL_ELEMENT.getKey(element.getType());
|
||||
StringBuilder identity = new StringBuilder(element.getClass().getName())
|
||||
.append('|')
|
||||
.append(type == null ? "" : type)
|
||||
.append('|')
|
||||
.append(element.getProjection().getSerializedName())
|
||||
.append('|')
|
||||
.append(element.getGroundLevelDelta());
|
||||
if (element == EmptyPoolElement.INSTANCE) {
|
||||
return identity.append("|empty").toString();
|
||||
}
|
||||
if (element instanceof SinglePoolElement single) {
|
||||
try {
|
||||
return identity.append("|template=")
|
||||
.append(single.getTemplateLocation()).toString();
|
||||
} catch (RuntimeException inlineTemplate) {
|
||||
return identity.append("|runtime-template").toString();
|
||||
}
|
||||
}
|
||||
if (element instanceof ListPoolElement list) {
|
||||
identity.append("|list[");
|
||||
for (StructurePoolElement child : list.getElements()) {
|
||||
String childIdentity = poolElementIdentity(child);
|
||||
identity.append(childIdentity.length()).append(':').append(childIdentity);
|
||||
}
|
||||
return identity.append(']').toString();
|
||||
}
|
||||
return identity.append("|bounded-custom").toString();
|
||||
}
|
||||
|
||||
private static StructureStart requireValid(StructureStart start) {
|
||||
StructureStart resolved = Objects.requireNonNull(
|
||||
start, "Native structure start must not be null");
|
||||
if (!resolved.isValid()) {
|
||||
throw new IllegalArgumentException("Native structure start must be valid");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException error) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", error);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateString(MessageDigest digest, String value) {
|
||||
byte[] encoded = value.getBytes(StandardCharsets.UTF_8);
|
||||
updateInt(digest, encoded.length);
|
||||
digest.update(encoded);
|
||||
}
|
||||
|
||||
private static void updateInt(MessageDigest digest, int value) {
|
||||
digest.update((byte) (value >>> 24));
|
||||
digest.update((byte) (value >>> 16));
|
||||
digest.update((byte) (value >>> 8));
|
||||
digest.update((byte) value);
|
||||
}
|
||||
|
||||
private record PieceIdentity(String type,
|
||||
int minX, int minY, int minZ,
|
||||
int maxX, int maxY, int maxZ,
|
||||
int orientation, int generationDepth, String detail) {
|
||||
}
|
||||
}
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
|
||||
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.framework.StructurePlacementGrid;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import art.arcane.iris.engine.object.NativeStructureSuppression;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
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.placement.StructurePlacement;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class NativeStructureOwnershipRecovery {
|
||||
private NativeStructureOwnershipRecovery() {
|
||||
}
|
||||
|
||||
public static NativeStructureOwnershipRecord resolve(
|
||||
Engine engine, ServerLevel level, String structureKey,
|
||||
Structure structure, StructureStart start) {
|
||||
Objects.requireNonNull(engine, "Native structure ownership recovery requires an engine");
|
||||
ServerLevel activeLevel = Objects.requireNonNull(
|
||||
level, "Native structure ownership recovery requires a level");
|
||||
Structure activeStructure = Objects.requireNonNull(
|
||||
structure, "Native structure ownership recovery requires a structure");
|
||||
if (start == null || !start.isValid() || start.getStructure() != activeStructure) {
|
||||
return null;
|
||||
}
|
||||
ChunkPos origin = start.getChunkPos();
|
||||
NativeStructureOwnershipRecord persisted = NativeStructureOwnershipStore.findPersisted(
|
||||
engine, structureKey, origin.x(), origin.z());
|
||||
if (persisted != null) {
|
||||
if (NativeStructureOwnershipFingerprint.matches(persisted, start)) {
|
||||
return persisted;
|
||||
}
|
||||
NativeStructureOwnershipStore.discard(
|
||||
engine, structureKey, origin.x(), origin.z());
|
||||
}
|
||||
NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan(
|
||||
engine, structureKey, origin.x(), origin.z());
|
||||
if (!matchesPlan(structureKey, origin, plan)) {
|
||||
return null;
|
||||
}
|
||||
Registry<Structure> registry = activeLevel.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||
Holder<Structure> holder = registry.wrapAsHolder(activeStructure);
|
||||
ChunkGeneratorStructureState state = activeLevel.getChunkSource().getGeneratorState();
|
||||
if (naturalStartIsAmbiguous(engine, structureKey, activeStructure, holder, state, plan)) {
|
||||
return null;
|
||||
}
|
||||
StructureStart expected = generateExpected(
|
||||
engine, activeLevel, holder, plan, start.getReferences());
|
||||
NativeStructureOwnershipRecord recovered = proveCandidate(
|
||||
structureKey, activeStructure, start, plan, expected, false);
|
||||
if (recovered == null) {
|
||||
return null;
|
||||
}
|
||||
NativeStructureOwnershipStore.record(engine, recovered);
|
||||
return recovered;
|
||||
}
|
||||
|
||||
static NativeStructureOwnershipRecord proveCandidate(
|
||||
String structureKey, Structure structure, StructureStart persisted,
|
||||
NativeStructureStartPlan plan, StructureStart expected,
|
||||
boolean naturalStartAmbiguous) {
|
||||
if (naturalStartAmbiguous || structure == null
|
||||
|| persisted == null || !persisted.isValid()
|
||||
|| expected == null || !expected.isValid()
|
||||
|| persisted.getStructure() != structure
|
||||
|| expected.getStructure() != structure
|
||||
|| !matchesPlan(structureKey, persisted.getChunkPos(), plan)
|
||||
|| !persisted.getChunkPos().equals(expected.getChunkPos())) {
|
||||
return null;
|
||||
}
|
||||
BoundingBox referenceEnvelope = NativeStructureReferenceEnvelope.referenceBounds(
|
||||
expected, structure, plan.placement().resolvedTerrain(), structureKey);
|
||||
NativeStructureOwnershipRecord candidate = NativeStructureOwnershipFingerprint.capture(
|
||||
structureKey, expected, plan, referenceEnvelope);
|
||||
if (candidate.placementIdentity()
|
||||
!= StructurePlacementGrid.placementIdentity(plan.placement())) {
|
||||
return null;
|
||||
}
|
||||
return NativeStructureOwnershipFingerprint.matches(candidate, persisted)
|
||||
? candidate : null;
|
||||
}
|
||||
|
||||
private static boolean matchesPlan(String structureKey, ChunkPos origin,
|
||||
NativeStructureStartPlan plan) {
|
||||
if (structureKey == null || structureKey.isBlank() || plan == null) {
|
||||
return false;
|
||||
}
|
||||
return plan.chunkX() == origin.x()
|
||||
&& plan.chunkZ() == origin.z()
|
||||
&& normalize(structureKey).equals(normalize(plan.source().getStructure()));
|
||||
}
|
||||
|
||||
private static StructureStart generateExpected(
|
||||
Engine engine, ServerLevel level, Holder<Structure> holder,
|
||||
NativeStructureStartPlan plan, int references) {
|
||||
ChunkGeneratorStructureState state = level.getChunkSource().getGeneratorState();
|
||||
ChunkGenerator generator = level.getChunkSource().getGenerator();
|
||||
BiomeSource biomeSource = generator.getBiomeSource();
|
||||
NativeStructureFactory.GenerationContext generationContext =
|
||||
new NativeStructureFactory.GenerationContext(
|
||||
level.registryAccess(),
|
||||
generator,
|
||||
biomeSource,
|
||||
state.randomState(),
|
||||
level.getStructureManager(),
|
||||
state.getLevelSeed(),
|
||||
level.dimension(),
|
||||
level,
|
||||
biome -> true,
|
||||
generator.getSeaLevel(),
|
||||
(x, z) -> engine.getHeight(x, z, true) + engine.getMinHeight()
|
||||
);
|
||||
return NativeStructureFactory.generate(generationContext, holder, plan, references);
|
||||
}
|
||||
|
||||
private static boolean naturalStartIsAmbiguous(
|
||||
Engine engine, String structureKey, Structure structure,
|
||||
Holder<Structure> holder, ChunkGeneratorStructureState state,
|
||||
NativeStructureStartPlan plan) {
|
||||
if (plan.placement().getNativeSuppression() == NativeStructureSuppression.REPLACE_SOURCE) {
|
||||
return false;
|
||||
}
|
||||
NativeStructureGenerationStatus sourceStatus = NativeStructureGenerationPolicy.resolve(
|
||||
engine, structureKey,
|
||||
NativeStructureVegetationClearer.isUndergroundStep(structure.step())).status();
|
||||
if (sourceStatus == NativeStructureGenerationStatus.REPLACED_BY_IRIS) {
|
||||
return false;
|
||||
}
|
||||
for (StructurePlacement placement : state.getPlacementsForStructure(holder)) {
|
||||
if (placement.isStructureChunk(state, plan.chunkX(), plan.chunkZ())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalize(String structureKey) {
|
||||
return structureKey == null ? "" : structureKey.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -42,9 +42,18 @@ public final class NativeStructurePostProcessor {
|
||||
if (targets == null || targets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
NativeStructureTerrainIntegrator.SourceTerrainSnapshot sourceTerrain =
|
||||
NativeStructureTerrainIntegrator.captureSourceTerrain(world, area, targets);
|
||||
for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
|
||||
NativeStructureTerrainIntegrator.integrateTerrain(world, area, target.structureId(), target.start(),
|
||||
target.terrain(), paletteBlockResolver);
|
||||
target.terrain(), paletteBlockResolver, sourceTerrain);
|
||||
}
|
||||
for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
|
||||
if (NativeStructureTerrainIntegrator.clearsLegacyTemplateAir(
|
||||
target.start(), target.terrain())) {
|
||||
NativeStructureTerrainIntegrator.clearLegacyTemplateAir(
|
||||
world, area, target.start(), () -> world.getLevel().getStructureManager());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+156
-66
@@ -3,43 +3,61 @@ package art.arcane.iris.nativegen;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
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.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.templatesystem.BlockIgnoreProcessor;
|
||||
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.StructureTemplateManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class NativeStructureReferenceEnvelope {
|
||||
private static final int MARKER_GROUND_LEVEL_DELTA = Integer.MIN_VALUE;
|
||||
private static final int MAX_REFERENCE_DISTANCE_CHUNKS = 8;
|
||||
private static final StructurePoolElement MARKER_ELEMENT = StructurePoolElement.single(
|
||||
"minecraft:empty",
|
||||
Holder.direct(new StructureProcessorList(List.of(
|
||||
BlockIgnoreProcessor.STRUCTURE_AND_AIR))),
|
||||
LiquidSettings.APPLY_WATERLOGGING
|
||||
).apply(StructureTemplatePool.Projection.RIGID);
|
||||
private static final Set<String> WARNED_CLIPPED_TERRAIN = ConcurrentHashMap.newKeySet();
|
||||
private static final Set<String> WARNED_SKIPPED_CONTENT = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private NativeStructureReferenceEnvelope() {
|
||||
}
|
||||
|
||||
public static StructureStart wrap(StructureStart generated, Structure source, int references,
|
||||
StructureTemplateManager templateManager,
|
||||
IrisStructureTerrain terrain) {
|
||||
List<StructurePiece> pieces = new ArrayList<>(generated.getPieces());
|
||||
return wrap(generated, source, references, terrain, null);
|
||||
}
|
||||
|
||||
public static StructureStart wrap(StructureStart generated, Structure source, int references,
|
||||
IrisStructureTerrain terrain, String structureKey) {
|
||||
referenceBounds(generated, source, terrain, structureKey);
|
||||
return new StructureStart(
|
||||
source,
|
||||
generated.getChunkPos(),
|
||||
references,
|
||||
new PiecesContainer(List.copyOf(generated.getPieces()))
|
||||
);
|
||||
}
|
||||
|
||||
public static StructureStart wrapForPublication(
|
||||
StructureStart generated, Structure source, int references,
|
||||
IrisStructureTerrain terrain, String structureKey) {
|
||||
try {
|
||||
return wrap(generated, source, references, terrain, structureKey);
|
||||
} catch (UnrepresentableContentException exception) {
|
||||
warnSkippedContent(generated, source, structureKey);
|
||||
return StructureStart.INVALID_START;
|
||||
}
|
||||
}
|
||||
|
||||
public static BoundingBox referenceBounds(StructureStart start, Structure source,
|
||||
IrisStructureTerrain terrain) {
|
||||
return referenceBounds(start, source, terrain, null);
|
||||
}
|
||||
|
||||
public static BoundingBox referenceBounds(StructureStart start, Structure source,
|
||||
IrisStructureTerrain terrain,
|
||||
String structureKey) {
|
||||
IrisStructureTerrainMode mode = terrain == null
|
||||
? IrisStructureTerrainMode.PRESERVE : terrain.resolvedMode();
|
||||
boolean usesEnvelope = mode == IrisStructureTerrainMode.BORE
|
||||
@@ -47,9 +65,19 @@ public final class NativeStructureReferenceEnvelope {
|
||||
|| mode == IrisStructureTerrainMode.VACUUM
|
||||
|| mode == IrisStructureTerrainMode.ENCASE;
|
||||
int horizontalPadding = usesEnvelope ? Math.max(0, terrain.getHorizontalPadding()) : 0;
|
||||
if (horizontalPadding > 0) {
|
||||
BoundingBox content = contentBounds(pieces);
|
||||
BoundingBox envelope = new BoundingBox(
|
||||
BoundingBox content = contentBounds(start);
|
||||
if (!fitsReferenceRange(start.getChunkPos(), content)) {
|
||||
throw new UnrepresentableContentException("Native structure content at "
|
||||
+ start.getChunkPos().x() + "," + start.getChunkPos().z()
|
||||
+ " exceeds Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS
|
||||
+ "-chunk structure reference range");
|
||||
}
|
||||
BoundingBox adjustedEnvelope;
|
||||
boolean arithmeticClipped = false;
|
||||
try {
|
||||
BoundingBox envelope = horizontalPadding == 0
|
||||
? content
|
||||
: new BoundingBox(
|
||||
Math.subtractExact(content.minX(), horizontalPadding),
|
||||
content.minY(),
|
||||
Math.subtractExact(content.minZ(), horizontalPadding),
|
||||
@@ -57,41 +85,36 @@ public final class NativeStructureReferenceEnvelope {
|
||||
content.maxY(),
|
||||
Math.addExact(content.maxZ(), horizontalPadding)
|
||||
);
|
||||
BoundingBox referencedEnvelope = clampReferenceRange(generated.getChunkPos(), envelope);
|
||||
if (!sameHorizontalBounds(envelope, referencedEnvelope)) {
|
||||
IrisLogging.warn("Native structure terrain envelope at "
|
||||
+ generated.getChunkPos().x() + "," + generated.getChunkPos().z()
|
||||
+ " was clipped to Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS
|
||||
+ "-chunk structure reference range");
|
||||
}
|
||||
pieces.add(marker(templateManager, referencedEnvelope.minX(),
|
||||
referencedEnvelope.minY(), referencedEnvelope.minZ()));
|
||||
pieces.add(marker(templateManager, referencedEnvelope.maxX(),
|
||||
referencedEnvelope.maxY(), referencedEnvelope.maxZ()));
|
||||
adjustedEnvelope = mode == IrisStructureTerrainMode.SOURCE
|
||||
? source.adjustBoundingBox(content) : envelope;
|
||||
adjustedEnvelope = encapsulating(adjustedEnvelope, content);
|
||||
} catch (ArithmeticException exception) {
|
||||
adjustedEnvelope = maximumReferenceBounds(start.getChunkPos(), content);
|
||||
arithmeticClipped = true;
|
||||
}
|
||||
BoundingBox referencedEnvelope = clampReferenceRange(start.getChunkPos(), adjustedEnvelope);
|
||||
if (arithmeticClipped || !sameHorizontalBounds(adjustedEnvelope, referencedEnvelope)) {
|
||||
warnClippedTerrain(start, source, structureKey);
|
||||
return referencedEnvelope;
|
||||
}
|
||||
return adjustedEnvelope;
|
||||
}
|
||||
|
||||
public static boolean contentFitsReferenceRange(StructureStart start) {
|
||||
try {
|
||||
return fitsReferenceRange(start.getChunkPos(), contentBounds(start));
|
||||
} catch (UnrepresentableContentException exception) {
|
||||
return false;
|
||||
}
|
||||
return new StructureStart(
|
||||
source,
|
||||
generated.getChunkPos(),
|
||||
references,
|
||||
new PiecesContainer(List.copyOf(pieces))
|
||||
);
|
||||
}
|
||||
|
||||
public static BoundingBox contentBounds(StructureStart start) {
|
||||
return contentBounds(start.getPieces());
|
||||
}
|
||||
|
||||
public static boolean isMarker(StructurePiece piece) {
|
||||
return piece instanceof PoolElementStructurePiece poolPiece
|
||||
&& poolPiece.getGroundLevelDelta() == MARKER_GROUND_LEVEL_DELTA;
|
||||
}
|
||||
|
||||
private static BoundingBox contentBounds(List<StructurePiece> pieces) {
|
||||
BoundingBox bounds = null;
|
||||
for (StructurePiece piece : pieces) {
|
||||
if (isMarker(piece)) {
|
||||
continue;
|
||||
}
|
||||
bounds = bounds == null
|
||||
? copy(piece.getBoundingBox())
|
||||
: bounds.encapsulate(piece.getBoundingBox());
|
||||
@@ -102,25 +125,11 @@ public final class NativeStructureReferenceEnvelope {
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private static PoolElementStructurePiece marker(StructureTemplateManager templateManager,
|
||||
int x, int y, int z) {
|
||||
BlockPos position = new BlockPos(x, y, z);
|
||||
return new PoolElementStructurePiece(
|
||||
templateManager,
|
||||
MARKER_ELEMENT,
|
||||
position,
|
||||
MARKER_GROUND_LEVEL_DELTA,
|
||||
Rotation.NONE,
|
||||
new BoundingBox(position),
|
||||
LiquidSettings.APPLY_WATERLOGGING
|
||||
);
|
||||
}
|
||||
|
||||
private static BoundingBox clampReferenceRange(ChunkPos startChunk, BoundingBox envelope) {
|
||||
int minX = (startChunk.x() - MAX_REFERENCE_DISTANCE_CHUNKS) << 4;
|
||||
int maxX = ((startChunk.x() + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15;
|
||||
int minZ = (startChunk.z() - MAX_REFERENCE_DISTANCE_CHUNKS) << 4;
|
||||
int maxZ = ((startChunk.z() + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15;
|
||||
int minX = minimumReferenceBlock(startChunk.x());
|
||||
int maxX = maximumReferenceBlock(startChunk.x());
|
||||
int minZ = minimumReferenceBlock(startChunk.z());
|
||||
int maxZ = maximumReferenceBlock(startChunk.z());
|
||||
return new BoundingBox(
|
||||
Math.max(envelope.minX(), minX),
|
||||
envelope.minY(),
|
||||
@@ -131,6 +140,40 @@ public final class NativeStructureReferenceEnvelope {
|
||||
);
|
||||
}
|
||||
|
||||
private static BoundingBox maximumReferenceBounds(ChunkPos startChunk, BoundingBox content) {
|
||||
return new BoundingBox(
|
||||
minimumReferenceBlock(startChunk.x()), content.minY(),
|
||||
minimumReferenceBlock(startChunk.z()),
|
||||
maximumReferenceBlock(startChunk.x()), content.maxY(),
|
||||
maximumReferenceBlock(startChunk.z())
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean fitsReferenceRange(ChunkPos startChunk, BoundingBox content) {
|
||||
return content.minX() >= minimumReferenceBlock(startChunk.x())
|
||||
&& content.maxX() <= maximumReferenceBlock(startChunk.x())
|
||||
&& content.minZ() >= minimumReferenceBlock(startChunk.z())
|
||||
&& content.maxZ() <= maximumReferenceBlock(startChunk.z());
|
||||
}
|
||||
|
||||
private static int minimumReferenceBlock(int originChunk) {
|
||||
return checkedReferenceBlock(
|
||||
((long) originChunk - MAX_REFERENCE_DISTANCE_CHUNKS) << 4, originChunk);
|
||||
}
|
||||
|
||||
private static int maximumReferenceBlock(int originChunk) {
|
||||
return checkedReferenceBlock(
|
||||
(((long) originChunk + MAX_REFERENCE_DISTANCE_CHUNKS) << 4) + 15L, originChunk);
|
||||
}
|
||||
|
||||
private static int checkedReferenceBlock(long block, int originChunk) {
|
||||
if (block < Integer.MIN_VALUE || block > Integer.MAX_VALUE) {
|
||||
throw new UnrepresentableContentException("Native structure origin chunk " + originChunk
|
||||
+ " exceeds Minecraft's block coordinate range");
|
||||
}
|
||||
return (int) block;
|
||||
}
|
||||
|
||||
private static boolean sameHorizontalBounds(BoundingBox left, BoundingBox right) {
|
||||
return left.minX() == right.minX()
|
||||
&& left.minZ() == right.minZ()
|
||||
@@ -143,4 +186,51 @@ public final class NativeStructureReferenceEnvelope {
|
||||
bounds.minX(), bounds.minY(), bounds.minZ(),
|
||||
bounds.maxX(), bounds.maxY(), bounds.maxZ());
|
||||
}
|
||||
|
||||
private static BoundingBox encapsulating(BoundingBox left, BoundingBox right) {
|
||||
return new BoundingBox(
|
||||
Math.min(left.minX(), right.minX()),
|
||||
Math.min(left.minY(), right.minY()),
|
||||
Math.min(left.minZ(), right.minZ()),
|
||||
Math.max(left.maxX(), right.maxX()),
|
||||
Math.max(left.maxY(), right.maxY()),
|
||||
Math.max(left.maxZ(), right.maxZ())
|
||||
);
|
||||
}
|
||||
|
||||
private static void warnClippedTerrain(
|
||||
StructureStart start, Structure source, String structureKey) {
|
||||
String key = warningKey(source, structureKey);
|
||||
if (WARNED_CLIPPED_TERRAIN.add(key)) {
|
||||
IrisLogging.warn("Clipping optional terrain envelope for native structure '"
|
||||
+ key + "' at " + start.getChunkPos().x() + "," + start.getChunkPos().z()
|
||||
+ " to Minecraft's " + MAX_REFERENCE_DISTANCE_CHUNKS
|
||||
+ "-chunk reference range; all generated content remains exactly referenced");
|
||||
}
|
||||
}
|
||||
|
||||
private static void warnSkippedContent(
|
||||
StructureStart start, Structure source, String structureKey) {
|
||||
String key = warningKey(source, structureKey);
|
||||
if (WARNED_SKIPPED_CONTENT.add(key)) {
|
||||
IrisLogging.warn("Skipping native structure '" + key + "' at "
|
||||
+ start.getChunkPos().x() + "," + start.getChunkPos().z()
|
||||
+ " because its generated content exceeds Minecraft's "
|
||||
+ MAX_REFERENCE_DISTANCE_CHUNKS
|
||||
+ "-chunk reference range; no structure start, ownership, references, or blocks were published");
|
||||
}
|
||||
}
|
||||
|
||||
private static String warningKey(Structure source, String structureKey) {
|
||||
if (structureKey != null && !structureKey.isBlank()) {
|
||||
return structureKey.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
return source.getClass().getName();
|
||||
}
|
||||
|
||||
public static final class UnrepresentableContentException extends IllegalStateException {
|
||||
private UnrepresentableContentException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
|
||||
import it.unimi.dsi.fastutil.longs.LongSet;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
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 java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class NativeStructureReferenceRepair {
|
||||
private static final int REFERENCE_DISTANCE_CHUNKS =
|
||||
NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS;
|
||||
private static final Set<String> WARNED_POLICY_INVALIDATIONS = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private NativeStructureReferenceRepair() {
|
||||
}
|
||||
|
||||
public static void createReferences(Engine engine, WorldGenLevel level,
|
||||
StructureManager structureManager, ChunkAccess targetChunk) {
|
||||
ChunkPos target = targetChunk.getPos();
|
||||
Registry<Structure> registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||
ServerLevel serverLevel = level.getLevel();
|
||||
List<ScannedStart> starts = new ArrayList<>();
|
||||
for (int originChunkX = target.x() - REFERENCE_DISTANCE_CHUNKS;
|
||||
originChunkX <= target.x() + REFERENCE_DISTANCE_CHUNKS; originChunkX++) {
|
||||
for (int originChunkZ = target.z() - REFERENCE_DISTANCE_CHUNKS;
|
||||
originChunkZ <= target.z() + REFERENCE_DISTANCE_CHUNKS; originChunkZ++) {
|
||||
ChunkAccess originChunk = level.getChunk(
|
||||
originChunkX, originChunkZ, ChunkStatus.STRUCTURE_STARTS);
|
||||
for (Map.Entry<Structure, StructureStart> entry : originChunk.getAllStarts().entrySet()) {
|
||||
ScannedStart start = scanStart(
|
||||
engine, serverLevel, structureManager, targetChunk, registry,
|
||||
originChunk, entry.getKey(), entry.getValue());
|
||||
if (start != null && isTargetRelevant(engine, targetChunk, start)) {
|
||||
starts.add(start);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (ScannedStart start : starts) {
|
||||
boolean valid;
|
||||
synchronized (start.originChunk()) {
|
||||
StructureStart current = start.originChunk().getStartForStructure(start.structure());
|
||||
valid = current == start.start() && current.isValid();
|
||||
}
|
||||
if (!valid) {
|
||||
continue;
|
||||
}
|
||||
structureManager.addReferenceForStructure(
|
||||
SectionPos.bottomOf(targetChunk), start.structure(),
|
||||
start.origin().pack(), targetChunk);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTargetRelevant(
|
||||
Engine engine, ChunkAccess targetChunk, ScannedStart scanned) {
|
||||
StructureStart start = scanned.start();
|
||||
Structure structure = scanned.structure();
|
||||
ChunkPos target = targetChunk.getPos();
|
||||
if (scanned.ownership() != null) {
|
||||
return requiresReference(target, scanned.structureKey(), start, scanned.ownership());
|
||||
}
|
||||
if (scanned.registered()) {
|
||||
return requiresNaturalReference(
|
||||
engine, target, scanned.structureKey(), structure, start);
|
||||
}
|
||||
return NativeStructureReferenceEnvelope.contentBounds(start).intersects(
|
||||
target.getMinBlockX(), target.getMinBlockZ(),
|
||||
target.getMaxBlockX(), target.getMaxBlockZ());
|
||||
}
|
||||
|
||||
private static ScannedStart scanStart(
|
||||
Engine engine, ServerLevel level, StructureManager structureManager,
|
||||
ChunkAccess targetChunk, Registry<Structure> registry,
|
||||
ChunkAccess originChunk, Structure structure, StructureStart start) {
|
||||
Identifier identifier = registry.getKey(structure);
|
||||
String structureKey = identifier == null
|
||||
? structure.getClass().getName() : identifier.toString();
|
||||
if (start == null || !start.isValid()) {
|
||||
return null;
|
||||
}
|
||||
if (!NativeStructureReferenceEnvelope.contentFitsReferenceRange(start)) {
|
||||
if (identifier != null) {
|
||||
NativeStructureOwnershipStore.discard(
|
||||
engine, structureKey, start.getChunkPos().x(), start.getChunkPos().z());
|
||||
}
|
||||
invalidateStart(structureManager, originChunk, structure, start, targetChunk);
|
||||
return null;
|
||||
}
|
||||
ChunkPos startOrigin = start.getChunkPos();
|
||||
NativeStructureOwnershipRecord ownership = identifier == null ? null
|
||||
: NativeStructureOwnershipRecovery.resolve(
|
||||
engine, level, structureKey, structure, start);
|
||||
if (ownership != null) {
|
||||
return new ScannedStart(
|
||||
originChunk, structure, start, structureKey, ownership, true);
|
||||
}
|
||||
if (identifier != null && !naturalPolicyAllows(engine, structureKey, structure)) {
|
||||
invalidateStart(structureManager, originChunk, structure, start, targetChunk);
|
||||
if (WARNED_POLICY_INVALIDATIONS.add(structureKey)) {
|
||||
IrisLogging.warn("Invalidating persisted natural structure start '"
|
||||
+ structureKey + "' because the current Iris dimension policy disables it");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return new ScannedStart(
|
||||
originChunk, structure, start, structureKey, null, identifier != null);
|
||||
}
|
||||
|
||||
private static boolean naturalPolicyAllows(
|
||||
Engine engine, String structureKey, Structure structure) {
|
||||
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(
|
||||
engine, structureKey,
|
||||
NativeStructureVegetationClearer.isUndergroundStep(structure.step()));
|
||||
return naturalDecisionAllows(decision);
|
||||
}
|
||||
|
||||
static boolean naturalDecisionAllows(IrisNativeStructureDecision decision) {
|
||||
return Objects.requireNonNull(
|
||||
decision, "Native structure decision must not be null").generate();
|
||||
}
|
||||
|
||||
private static void invalidateStart(
|
||||
StructureManager structureManager, ChunkAccess originChunk,
|
||||
Structure structure, StructureStart start, ChunkAccess targetChunk) {
|
||||
synchronized (originChunk) {
|
||||
StructureStart current = originChunk.getStartForStructure(structure);
|
||||
if (current == start && current.isValid()) {
|
||||
structureManager.setStartForStructure(
|
||||
SectionPos.bottomOf(originChunk), structure,
|
||||
StructureStart.INVALID_START, originChunk);
|
||||
}
|
||||
}
|
||||
if (targetChunk != null) {
|
||||
removeTargetReference(targetChunk, structure, start.getChunkPos().pack());
|
||||
}
|
||||
}
|
||||
|
||||
private static void removeTargetReference(
|
||||
ChunkAccess targetChunk, Structure structure, long origin) {
|
||||
LongSet current = targetChunk.getAllReferences().get(structure);
|
||||
if (current == null || !current.contains(origin)) {
|
||||
return;
|
||||
}
|
||||
Map<Structure, LongSet> updated = new HashMap<>(targetChunk.getAllReferences());
|
||||
LongSet retained = new LongOpenHashSet(current);
|
||||
retained.remove(origin);
|
||||
if (retained.isEmpty()) {
|
||||
updated.remove(structure);
|
||||
} else {
|
||||
updated.put(structure, retained);
|
||||
}
|
||||
targetChunk.setAllReferences(updated);
|
||||
}
|
||||
|
||||
static boolean requiresNaturalReference(Engine engine, ChunkPos target, String structureKey,
|
||||
Structure structure, StructureStart start) {
|
||||
if (target == null || start == null || !start.isValid()) {
|
||||
return false;
|
||||
}
|
||||
IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(
|
||||
engine, structureKey,
|
||||
NativeStructureVegetationClearer.isUndergroundStep(structure.step()));
|
||||
if (!decision.generate()) {
|
||||
return false;
|
||||
}
|
||||
BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
|
||||
start, structure,
|
||||
NativeStructureTerrainIntegrator.resolveNativeTerrain(start, decision.terrain()),
|
||||
structureKey);
|
||||
return referenceBounds.intersects(
|
||||
target.getMinBlockX(), target.getMinBlockZ(),
|
||||
target.getMaxBlockX(), target.getMaxBlockZ());
|
||||
}
|
||||
|
||||
static boolean requiresReference(ChunkPos target, String structureKey,
|
||||
StructureStart start,
|
||||
NativeStructureOwnershipRecord ownership) {
|
||||
if (target == null || start == null || !start.isValid()
|
||||
|| ownership == null || !ownership.structureKey().equals(structureKey)
|
||||
|| !ownership.covers(target.x(), target.z())) {
|
||||
return false;
|
||||
}
|
||||
return NativeStructureOwnershipFingerprint.matches(ownership, start);
|
||||
}
|
||||
|
||||
private record ScannedStart(
|
||||
ChunkAccess originChunk,
|
||||
Structure structure,
|
||||
StructureStart start,
|
||||
String structureKey,
|
||||
NativeStructureOwnershipRecord ownership,
|
||||
boolean registered
|
||||
) {
|
||||
private ChunkPos origin() {
|
||||
return ownership == null
|
||||
? start.getChunkPos()
|
||||
: new ChunkPos(ownership.originChunkX(), ownership.originChunkZ());
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-9
@@ -1,9 +1,14 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
|
||||
import art.arcane.iris.engine.framework.NativeStructurePlacementPlanner;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import art.arcane.iris.engine.object.NativeStructureSuppression;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
@@ -17,6 +22,7 @@ import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGenerator;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
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.templatesystem.StructureTemplateManager;
|
||||
@@ -24,8 +30,12 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class NativeStructureStartInjector {
|
||||
private static final Set<String> WARNED_DUPLICATE_STRUCTURES = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private NativeStructureStartInjector() {
|
||||
}
|
||||
|
||||
@@ -48,14 +58,22 @@ public final class NativeStructureStartInjector {
|
||||
}
|
||||
Holder<Structure> holder = registry.wrapAsHolder(structure);
|
||||
if (configuredStarts.containsKey(structure)) {
|
||||
throw new IllegalStateException("Multiple configured native structure placements selected '"
|
||||
+ identifier + "' in chunk " + chunk.getPos().x() + "," + chunk.getPos().z()
|
||||
+ "; Minecraft can persist only one start per registered structure per chunk");
|
||||
if (WARNED_DUPLICATE_STRUCTURES.add(identifier.toString())) {
|
||||
IrisLogging.warn("Ignoring duplicate native structure placements for '"
|
||||
+ identifier + "'; the first deterministic candidate owns each chunk start");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
StructureStart existing = context.structureManager().getStartForStructure(
|
||||
section, structure, chunk);
|
||||
boolean replacement = plan.placement().getNativeSuppression()
|
||||
== NativeStructureSuppression.REPLACE_SOURCE;
|
||||
if (!replacement && existing != null && existing.isValid()) {
|
||||
NativeStructureGenerationStatus sourceStatus = NativeStructureGenerationPolicy.resolve(
|
||||
context.engine(), identifier.toString(),
|
||||
NativeStructureVegetationClearer.isUndergroundStep(structure.step())).status();
|
||||
replacement = sourceStatus == NativeStructureGenerationStatus.REPLACED_BY_IRIS;
|
||||
}
|
||||
if (!replacement && existing != null && existing.isValid()) {
|
||||
continue;
|
||||
}
|
||||
@@ -77,18 +95,43 @@ public final class NativeStructureStartInjector {
|
||||
);
|
||||
StructureStart generated = NativeStructureFactory.generate(
|
||||
generationContext, holder, plan, references);
|
||||
if (!generated.isValid()) {
|
||||
throw new IllegalStateException("Configured native structure '" + identifier
|
||||
+ "' produced no valid start in chunk " + chunk.getPos().x()
|
||||
+ "," + chunk.getPos().z());
|
||||
if (!isUsableGeneratedStart(generated)) {
|
||||
if (replacement) {
|
||||
context.structureManager().setStartForStructure(
|
||||
section, structure, StructureStart.INVALID_START, chunk);
|
||||
}
|
||||
NativeStructureOwnershipStore.discard(
|
||||
context.engine(), identifier.toString(),
|
||||
chunk.getPos().x(), chunk.getPos().z());
|
||||
continue;
|
||||
}
|
||||
BoundingBox referenceBounds = NativeStructureReferenceEnvelope.referenceBounds(
|
||||
generated, structure, plan.placement().resolvedTerrain(), identifier.toString());
|
||||
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
|
||||
identifier.toString(), generated, plan, referenceBounds);
|
||||
NativeStructureOwnershipStore.record(context.engine(), ownership);
|
||||
try {
|
||||
context.structureManager().setStartForStructure(
|
||||
section, structure, generated, chunk);
|
||||
} catch (RuntimeException | Error publicationError) {
|
||||
try {
|
||||
NativeStructureOwnershipStore.discard(
|
||||
context.engine(), identifier.toString(),
|
||||
chunk.getPos().x(), chunk.getPos().z());
|
||||
} catch (RuntimeException | Error cleanupError) {
|
||||
publicationError.addSuppressed(cleanupError);
|
||||
}
|
||||
throw publicationError;
|
||||
}
|
||||
context.structureManager().setStartForStructure(
|
||||
section, structure, generated, chunk);
|
||||
configuredStarts.put(structure, plan);
|
||||
}
|
||||
return Map.copyOf(configuredStarts);
|
||||
}
|
||||
|
||||
static boolean isUsableGeneratedStart(StructureStart start) {
|
||||
return start != null && start.isValid();
|
||||
}
|
||||
|
||||
public record InjectionContext(
|
||||
Engine engine,
|
||||
RegistryAccess registryAccess,
|
||||
|
||||
+151
-38
@@ -1,6 +1,8 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisObjectVacuum;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
@@ -13,13 +15,11 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.TerrainAdjustment;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.StructureTemplatePool;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.function.IntBinaryOperator;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class NativeStructureSurfaceFitter {
|
||||
private static final double SURFACE_TERRAIN_FALLOFF = 2.0;
|
||||
@@ -30,32 +30,31 @@ public final class NativeStructureSurfaceFitter {
|
||||
}
|
||||
|
||||
public static void prepareSurfaceStructures(WorldGenLevel world, BoundingBox area,
|
||||
List<StructureStart> starts,
|
||||
List<NativeStructureTerrainIntegrator.TerrainTarget> targets,
|
||||
IntBinaryOperator surfaceHeight) {
|
||||
if (starts == null || starts.isEmpty()) {
|
||||
if (targets == null || targets.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Objects.requireNonNull(surfaceHeight, "Surface structure terrain fitting requires an Iris height resolver");
|
||||
List<SurfaceAnchor> anchors = collectSurfaceAnchors(starts);
|
||||
List<SurfaceAnchor> anchors = collectSurfaceAnchors(targets);
|
||||
if (!anchors.isEmpty()) {
|
||||
fitSurfaceTerrain(world, area, anchors, surfaceHeight);
|
||||
}
|
||||
Supplier<StructureTemplateManager> templates = () -> world.getLevel().getStructureManager();
|
||||
for (StructureStart start : starts) {
|
||||
if (requiresSurfaceTerrain(start)) {
|
||||
NativeStructureTerrainIntegrator.clearLegacyTemplateAir(world, area, start, templates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static boolean shouldPrepareSurfaceTerrain(TerrainAdjustment adjustment,
|
||||
GenerationStep.Decoration step) {
|
||||
return adjustment == TerrainAdjustment.BEARD_THIN
|
||||
&& step == GenerationStep.Decoration.SURFACE_STRUCTURES;
|
||||
|| adjustment == TerrainAdjustment.BEARD_BOX;
|
||||
}
|
||||
|
||||
static int resolveSurfaceTarget(List<SurfaceAnchor> anchors, int worldX, int worldZ,
|
||||
int originalY) {
|
||||
return resolveSurface(anchors, worldX, worldZ, originalY).targetY();
|
||||
}
|
||||
|
||||
private static SurfaceResolution resolveSurface(List<SurfaceAnchor> anchors,
|
||||
int worldX, int worldZ, int originalY) {
|
||||
int localTargetY = originalY;
|
||||
SurfaceAnchor selectedLocal = null;
|
||||
long totalInfluence = 0L;
|
||||
@@ -64,11 +63,42 @@ public final class NativeStructureSurfaceFitter {
|
||||
for (SurfaceAnchor anchor : anchors) {
|
||||
int outX = IrisObjectVacuum.outset(worldX, anchor.minX(), anchor.maxX());
|
||||
int outZ = IrisObjectVacuum.outset(worldZ, anchor.minZ(), anchor.maxZ());
|
||||
long distanceSquared = (long) outX * outX + (long) outZ * outZ;
|
||||
if (distanceSquared > (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS) {
|
||||
boolean containsColumn = outX == 0 && outZ == 0;
|
||||
if (containsColumn && anchor.strength() > 1 && originalY < anchor.meetY()) {
|
||||
if (precedes(anchor, selectedLocal)) {
|
||||
localTargetY = anchor.meetY();
|
||||
selectedLocal = anchor;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
int verticalDistance = anchor.verticalDistance(originalY);
|
||||
long horizontalDistanceSquared = (long) outX * outX + (long) outZ * outZ;
|
||||
long distanceSquared = (long) outX * outX + (long) outZ * outZ
|
||||
+ (long) verticalDistance * verticalDistance;
|
||||
double factor = 0D;
|
||||
long radiusSquared = (long) SURFACE_TERRAIN_RADIUS * SURFACE_TERRAIN_RADIUS;
|
||||
if (distanceSquared <= radiusSquared) {
|
||||
double distance = Math.sqrt(distanceSquared);
|
||||
factor = Math.pow(
|
||||
1D - distance / SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF);
|
||||
}
|
||||
if (anchor.strength() > 1 && originalY < anchor.meetY()
|
||||
&& verticalDistance > SURFACE_TERRAIN_RADIUS
|
||||
&& horizontalDistanceSquared <= radiusSquared) {
|
||||
double horizontalDistance = Math.sqrt(horizontalDistanceSquared);
|
||||
double horizontalFactor = Math.pow(
|
||||
1D - horizontalDistance / SURFACE_TERRAIN_RADIUS,
|
||||
SURFACE_TERRAIN_FALLOFF);
|
||||
double rescueProgress = Math.min(1D,
|
||||
(verticalDistance - SURFACE_TERRAIN_RADIUS)
|
||||
/ (double) SURFACE_TERRAIN_RADIUS);
|
||||
double rescueWeight = rescueProgress * rescueProgress
|
||||
* (3D - 2D * rescueProgress);
|
||||
factor = Math.max(factor, horizontalFactor * rescueWeight);
|
||||
}
|
||||
if (factor <= 0D) {
|
||||
continue;
|
||||
}
|
||||
boolean containsColumn = outX == 0 && outZ == 0;
|
||||
if (containsColumn) {
|
||||
if (precedes(anchor, selectedLocal)) {
|
||||
localTargetY = anchor.meetY();
|
||||
@@ -76,10 +106,6 @@ public final class NativeStructureSurfaceFitter {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
double factor = IrisObjectVacuum.columnInfluence(
|
||||
worldX, worldZ,
|
||||
anchor.minX(), anchor.maxX(), anchor.minZ(), anchor.maxZ(),
|
||||
SURFACE_TERRAIN_RADIUS, SURFACE_TERRAIN_FALLOFF);
|
||||
long influence = Math.round(factor * SURFACE_TERRAIN_INFLUENCE_SCALE);
|
||||
if (influence <= 0L) {
|
||||
continue;
|
||||
@@ -90,14 +116,15 @@ public final class NativeStructureSurfaceFitter {
|
||||
maximumInfluence = Math.max(maximumInfluence, influence);
|
||||
}
|
||||
if (selectedLocal != null) {
|
||||
return localTargetY;
|
||||
return new SurfaceResolution(localTargetY, selectedLocal.strength() > 1);
|
||||
}
|
||||
if (totalInfluence == 0L) {
|
||||
return originalY;
|
||||
return new SurfaceResolution(originalY, false);
|
||||
}
|
||||
double blendedMeetY = weightedMeetY / (double) totalInfluence;
|
||||
double factor = maximumInfluence / (double) SURFACE_TERRAIN_INFLUENCE_SCALE;
|
||||
return (int) Math.round(originalY + ((blendedMeetY - originalY) * factor));
|
||||
return new SurfaceResolution(
|
||||
(int) Math.round(originalY + ((blendedMeetY - originalY) * factor)), false);
|
||||
}
|
||||
|
||||
private static boolean precedes(SurfaceAnchor candidate, SurfaceAnchor selected) {
|
||||
@@ -125,41 +152,62 @@ public final class NativeStructureSurfaceFitter {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<SurfaceAnchor> collectSurfaceAnchors(List<StructureStart> starts) {
|
||||
private static List<SurfaceAnchor> collectSurfaceAnchors(
|
||||
List<NativeStructureTerrainIntegrator.TerrainTarget> targets) {
|
||||
List<SurfaceAnchor> anchors = new ArrayList<>();
|
||||
for (StructureStart start : starts) {
|
||||
if (!requiresSurfaceTerrain(start)) {
|
||||
for (NativeStructureTerrainIntegrator.TerrainTarget target : targets) {
|
||||
if (!requiresSurfaceTerrain(target)) {
|
||||
continue;
|
||||
}
|
||||
StructureStart start = target.start();
|
||||
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation();
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
|
||||
continue;
|
||||
}
|
||||
if (piece instanceof PoolElementStructurePiece poolPiece) {
|
||||
if (poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID) {
|
||||
BoundingBox bounds = poolPiece.getBoundingBox();
|
||||
anchors.add(new SurfaceAnchor(
|
||||
bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
|
||||
bounds.minY() + poolPiece.getGroundLevelDelta() - 1, 2));
|
||||
anchors.add(surfaceAnchor(
|
||||
bounds, bounds.minY() + poolPiece.getGroundLevelDelta(),
|
||||
2, adjustment));
|
||||
}
|
||||
for (JigsawJunction junction : poolPiece.getJunctions()) {
|
||||
anchors.add(new SurfaceAnchor(
|
||||
junction.getSourceX(), junction.getSourceX(),
|
||||
junction.getSourceZ(), junction.getSourceZ(),
|
||||
junction.getSourceGroundY() - 1, 1));
|
||||
junction.getSourceGroundY() - 1, 1,
|
||||
junction.getSourceGroundY(), junction.getSourceGroundY()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
anchors.add(new SurfaceAnchor(
|
||||
bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
|
||||
bounds.minY() - 1, 2));
|
||||
anchors.add(surfaceAnchor(bounds, bounds.minY(), 2, adjustment));
|
||||
}
|
||||
}
|
||||
return List.copyOf(anchors);
|
||||
}
|
||||
|
||||
static SurfaceAnchor surfaceAnchor(BoundingBox bounds, int groundY, int strength,
|
||||
TerrainAdjustment adjustment) {
|
||||
int meetY = groundY - 1;
|
||||
if (adjustment == TerrainAdjustment.BEARD_BOX) {
|
||||
return new SurfaceAnchor(
|
||||
bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
|
||||
meetY, strength, groundY, bounds.maxY());
|
||||
}
|
||||
return new SurfaceAnchor(bounds.minX(), bounds.maxX(), bounds.minZ(), bounds.maxZ(),
|
||||
meetY, strength, groundY, groundY);
|
||||
}
|
||||
|
||||
static boolean requiresSurfaceTerrain(StructureStart start) {
|
||||
return requiresSurfaceTerrain(new NativeStructureTerrainIntegrator.TerrainTarget(
|
||||
null, start, new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)));
|
||||
}
|
||||
|
||||
static boolean requiresSurfaceTerrain(NativeStructureTerrainIntegrator.TerrainTarget target) {
|
||||
if (target == null || target.terrain() == null
|
||||
|| target.terrain().resolvedMode() != IrisStructureTerrainMode.SOURCE) {
|
||||
return false;
|
||||
}
|
||||
StructureStart start = target.start();
|
||||
return start != null
|
||||
&& start.isValid()
|
||||
&& shouldPrepareSurfaceTerrain(
|
||||
@@ -173,14 +221,17 @@ public final class NativeStructureSurfaceFitter {
|
||||
int depth = area.getZSpan();
|
||||
int[] originalHeights = new int[width * depth];
|
||||
int[] targetHeights = new int[width * depth];
|
||||
boolean[] rigidBaseSupport = new boolean[width * depth];
|
||||
for (int z = area.minZ(); z <= area.maxZ(); z++) {
|
||||
for (int x = area.minX(); x <= area.maxX(); x++) {
|
||||
int column = (z - area.minZ()) * width + x - area.minX();
|
||||
int originalY = Math.max(area.minY(), Math.min(
|
||||
area.maxY(), surfaceHeight.applyAsInt(x, z)));
|
||||
SurfaceResolution resolution = resolveSurface(anchors, x, z, originalY);
|
||||
originalHeights[column] = originalY;
|
||||
targetHeights[column] = Math.max(area.minY(), Math.min(
|
||||
area.maxY(), resolveSurfaceTarget(anchors, x, z, originalY)));
|
||||
area.maxY(), resolution.targetY()));
|
||||
rigidBaseSupport[column] = resolution.rigidBaseSupport();
|
||||
}
|
||||
}
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
@@ -188,7 +239,8 @@ public final class NativeStructureSurfaceFitter {
|
||||
for (int x = area.minX(); x <= area.maxX(); x++) {
|
||||
int column = (z - area.minZ()) * width + x - area.minX();
|
||||
applySurfaceColumn(world, position, x, z,
|
||||
originalHeights[column], targetHeights[column], area.minY(), area.maxY());
|
||||
originalHeights[column], targetHeights[column], area.minY(), area.maxY(),
|
||||
rigidBaseSupport[column]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +248,18 @@ public final class NativeStructureSurfaceFitter {
|
||||
static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position,
|
||||
int x, int z, int originalY, int targetY,
|
||||
int worldMinY, int worldMaxY) {
|
||||
applySurfaceColumn(world, position, x, z, originalY, targetY,
|
||||
worldMinY, worldMaxY, false);
|
||||
}
|
||||
|
||||
static void applySurfaceColumn(WorldGenLevel world, BlockPos.MutableBlockPos position,
|
||||
int x, int z, int originalY, int targetY,
|
||||
int worldMinY, int worldMaxY,
|
||||
boolean requireRigidBaseSupport) {
|
||||
if (targetY == originalY) {
|
||||
if (requireRigidBaseSupport) {
|
||||
ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
SurfaceMaterials materials = resolveSurfaceMaterials(world, position, x, z, originalY, worldMinY);
|
||||
@@ -207,6 +270,9 @@ public final class NativeStructureSurfaceFitter {
|
||||
world.setBlock(position.set(x, y, z), clearedState, 2);
|
||||
}
|
||||
world.setBlock(position.set(x, targetY, z), materials.surface(), 2);
|
||||
if (requireRigidBaseSupport) {
|
||||
ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (int y = originalY + 1; y < targetY; y++) {
|
||||
@@ -219,6 +285,42 @@ public final class NativeStructureSurfaceFitter {
|
||||
if (!NativeStructureVegetationClearer.isTreeBlock(world.getBlockState(position))) {
|
||||
world.setBlock(position, materials.surface(), 2);
|
||||
}
|
||||
if (requireRigidBaseSupport) {
|
||||
ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureRigidBaseTerrain(WorldGenLevel world,
|
||||
BlockPos.MutableBlockPos position,
|
||||
int x, int z, int targetY, int worldMinY) {
|
||||
int supportY = targetY - 1;
|
||||
boolean targetIsTerrain = isTerrainBlock(world.getBlockState(position.set(x, targetY, z)));
|
||||
boolean supportIsTerrain = supportY < worldMinY
|
||||
|| isTerrainBlock(world.getBlockState(position.set(x, supportY, z)));
|
||||
if (targetIsTerrain && supportIsTerrain) {
|
||||
return;
|
||||
}
|
||||
SurfaceMaterials materials = resolveSurfaceMaterials(
|
||||
world, position, x, z, targetY, worldMinY);
|
||||
ensureRigidBaseTerrain(world, position, x, z, targetY, worldMinY, materials);
|
||||
}
|
||||
|
||||
private static void ensureRigidBaseTerrain(WorldGenLevel world,
|
||||
BlockPos.MutableBlockPos position,
|
||||
int x, int z, int targetY, int worldMinY,
|
||||
SurfaceMaterials materials) {
|
||||
int supportY = targetY - 1;
|
||||
position.set(x, targetY, z);
|
||||
if (!isTerrainBlock(world.getBlockState(position))) {
|
||||
world.setBlock(position, materials.surface(), 2);
|
||||
}
|
||||
if (supportY < worldMinY) {
|
||||
return;
|
||||
}
|
||||
position.set(x, supportY, z);
|
||||
if (!isTerrainBlock(world.getBlockState(position))) {
|
||||
world.setBlock(position, materials.subsurface(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
private static BlockState clearSurfaceDecorationAndResolveFill(
|
||||
@@ -271,9 +373,20 @@ public final class NativeStructureSurfaceFitter {
|
||||
return state.isSolid() && !NativeStructureVegetationClearer.isTreeBlock(state);
|
||||
}
|
||||
|
||||
record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) {
|
||||
record SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength,
|
||||
int minInfluenceY, int maxInfluenceY) {
|
||||
SurfaceAnchor(int minX, int maxX, int minZ, int maxZ, int meetY, int strength) {
|
||||
this(minX, maxX, minZ, maxZ, meetY, strength, meetY + 1, meetY + 1);
|
||||
}
|
||||
|
||||
int verticalDistance(int y) {
|
||||
return IrisObjectVacuum.outset(y, minInfluenceY, maxInfluenceY);
|
||||
}
|
||||
}
|
||||
|
||||
private record SurfaceMaterials(BlockState surface, BlockState subsurface) {
|
||||
}
|
||||
|
||||
private record SurfaceResolution(int targetY, boolean rigidBaseSupport) {
|
||||
}
|
||||
}
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import com.mojang.datafixers.util.Pair;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.block.Rotation;
|
||||
import net.minecraft.world.level.levelgen.structure.BoundingBox;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.EmptyPoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.FeaturePoolElement;
|
||||
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.StructureTemplatePool;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.alias.DirectPoolAlias;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.alias.PoolAliasBinding;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.alias.RandomGroupPoolAlias;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.alias.RandomPoolAlias;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.JigsawStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class NativeStructureTemplatePoolBounds {
|
||||
private NativeStructureTemplatePoolBounds() {
|
||||
}
|
||||
|
||||
public static int sourceHorizontalSpan(RegistryAccess registryAccess,
|
||||
StructureTemplateManager templateManager,
|
||||
JigsawStructure source) {
|
||||
Registry<StructureTemplatePool> pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL);
|
||||
return sourceHorizontalSpan(
|
||||
templateManager,
|
||||
source.getStartPool(),
|
||||
source.getPoolAliases(),
|
||||
key -> pools.getValue(key.identifier()));
|
||||
}
|
||||
|
||||
public static int horizontalSpan(RegistryAccess registryAccess,
|
||||
StructureTemplateManager templateManager,
|
||||
String templatePoolKey) {
|
||||
Identifier identifier = Identifier.tryParse(templatePoolKey);
|
||||
if (identifier == null) {
|
||||
throw new IllegalArgumentException("Invalid registered template pool key: " + templatePoolKey);
|
||||
}
|
||||
Registry<StructureTemplatePool> pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL);
|
||||
StructureTemplatePool pool = pools.getValue(identifier);
|
||||
if (pool == null) {
|
||||
throw new IllegalArgumentException("Registered template pool does not exist: " + templatePoolKey);
|
||||
}
|
||||
return horizontalSpan(pool, templateManager);
|
||||
}
|
||||
|
||||
public static int horizontalSpan(RegistryAccess registryAccess,
|
||||
StructureTemplateManager templateManager,
|
||||
JigsawStructure source,
|
||||
String templatePoolKey) {
|
||||
Identifier identifier = Identifier.tryParse(templatePoolKey);
|
||||
if (identifier == null) {
|
||||
throw new IllegalArgumentException("Invalid registered template pool key: " + templatePoolKey);
|
||||
}
|
||||
Registry<StructureTemplatePool> pools = registryAccess.lookupOrThrow(Registries.TEMPLATE_POOL);
|
||||
ResourceKey<StructureTemplatePool> startPoolKey = ResourceKey.create(
|
||||
Registries.TEMPLATE_POOL, identifier);
|
||||
StructureTemplatePool startPool = pools.getValue(identifier);
|
||||
if (startPool == null) {
|
||||
throw new IllegalArgumentException("Registered template pool does not exist: " + templatePoolKey);
|
||||
}
|
||||
return sourceHorizontalSpan(
|
||||
templateManager,
|
||||
startPool,
|
||||
startPoolKey,
|
||||
source.getPoolAliases(),
|
||||
key -> pools.getValue(key.identifier()));
|
||||
}
|
||||
|
||||
static int sourceHorizontalSpan(StructureTemplateManager templateManager,
|
||||
Holder<StructureTemplatePool> startPool,
|
||||
List<PoolAliasBinding> aliases,
|
||||
Function<ResourceKey<StructureTemplatePool>, StructureTemplatePool> poolLookup) {
|
||||
return sourceHorizontalSpan(templateManager, startPool.value(),
|
||||
startPool.unwrapKey().orElse(null), aliases, poolLookup);
|
||||
}
|
||||
|
||||
static int sourceHorizontalSpan(StructureTemplateManager templateManager,
|
||||
StructureTemplatePool startPool,
|
||||
ResourceKey<StructureTemplatePool> startPoolKey,
|
||||
List<PoolAliasBinding> aliases,
|
||||
Function<ResourceKey<StructureTemplatePool>, StructureTemplatePool> poolLookup) {
|
||||
int maximumSpan = directHorizontalSpan(startPool, templateManager);
|
||||
if (startPoolKey == null) {
|
||||
return maximumSpan;
|
||||
}
|
||||
Set<ResourceKey<StructureTemplatePool>> targets = new HashSet<>();
|
||||
for (PoolAliasBinding binding : aliases) {
|
||||
collectTargets(binding, startPoolKey, targets);
|
||||
}
|
||||
for (ResourceKey<StructureTemplatePool> target : targets) {
|
||||
StructureTemplatePool targetPool = poolLookup.apply(target);
|
||||
if (targetPool == null) {
|
||||
throw new IllegalStateException("Jigsaw start-pool alias target does not exist: "
|
||||
+ target.identifier());
|
||||
}
|
||||
maximumSpan = Math.max(maximumSpan, directHorizontalSpan(targetPool, templateManager));
|
||||
}
|
||||
return maximumSpan;
|
||||
}
|
||||
|
||||
private static void collectTargets(PoolAliasBinding binding,
|
||||
ResourceKey<StructureTemplatePool> startPoolKey,
|
||||
Set<ResourceKey<StructureTemplatePool>> targets) {
|
||||
if (binding instanceof DirectPoolAlias direct) {
|
||||
if (direct.alias().equals(startPoolKey)) {
|
||||
targets.add(direct.target());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (binding instanceof RandomPoolAlias random) {
|
||||
if (random.alias().equals(startPoolKey)) {
|
||||
random.targets().unwrap().forEach(entry -> targets.add(entry.value()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (binding instanceof RandomGroupPoolAlias group) {
|
||||
group.groups().unwrap().forEach(entry -> entry.value().forEach(
|
||||
nested -> collectTargets(nested, startPoolKey, targets)));
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("Unsupported jigsaw pool alias type: "
|
||||
+ binding.getClass().getName());
|
||||
}
|
||||
|
||||
static int horizontalSpan(StructureTemplatePool pool,
|
||||
StructureTemplateManager templateManager) {
|
||||
Set<StructureTemplatePool> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
return horizontalSpan(pool, templateManager, visited);
|
||||
}
|
||||
|
||||
private static int horizontalSpan(StructureTemplatePool pool,
|
||||
StructureTemplateManager templateManager,
|
||||
Set<StructureTemplatePool> visited) {
|
||||
if (!visited.add(pool)) {
|
||||
return 0;
|
||||
}
|
||||
int maximumSpan = directHorizontalSpan(pool, templateManager);
|
||||
Holder<StructureTemplatePool> fallback = pool.getFallback();
|
||||
if (fallback == null || !fallback.isBound()) {
|
||||
throw new IllegalStateException("Template pool has an unresolved fallback");
|
||||
}
|
||||
return Math.max(maximumSpan, horizontalSpan(fallback.value(), templateManager, visited));
|
||||
}
|
||||
|
||||
private static int directHorizontalSpan(StructureTemplatePool pool,
|
||||
StructureTemplateManager templateManager) {
|
||||
int maximumSpan = 0;
|
||||
for (Pair<StructurePoolElement, Integer> entry : pool.getTemplates()) {
|
||||
StructurePoolElement element = entry.getFirst();
|
||||
validateElement(element, templateManager);
|
||||
maximumSpan = Math.max(maximumSpan, horizontalSpan(element, templateManager));
|
||||
}
|
||||
return maximumSpan;
|
||||
}
|
||||
|
||||
private static void validateElement(StructurePoolElement element,
|
||||
StructureTemplateManager templateManager) {
|
||||
if (element == EmptyPoolElement.INSTANCE || element instanceof FeaturePoolElement) {
|
||||
return;
|
||||
}
|
||||
if (element instanceof SinglePoolElement single) {
|
||||
Identifier template;
|
||||
try {
|
||||
template = single.getTemplateLocation();
|
||||
} catch (RuntimeException inlineTemplate) {
|
||||
return;
|
||||
}
|
||||
if (templateManager.get(template).isEmpty()) {
|
||||
throw new IllegalStateException("Template pool element does not resolve a structure template: "
|
||||
+ template);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (element instanceof ListPoolElement list) {
|
||||
for (StructurePoolElement child : list.getElements()) {
|
||||
validateElement(child, templateManager);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static int horizontalSpan(StructurePoolElement element,
|
||||
StructureTemplateManager templateManager) {
|
||||
if (element == EmptyPoolElement.INSTANCE) {
|
||||
return 0;
|
||||
}
|
||||
long maximumSpan = 0L;
|
||||
for (Rotation rotation : Rotation.values()) {
|
||||
BoundingBox box;
|
||||
try {
|
||||
box = element.getBoundingBox(templateManager, BlockPos.ZERO, rotation);
|
||||
} catch (RuntimeException | LinkageError error) {
|
||||
throw new IllegalStateException("Template pool element "
|
||||
+ element.getClass().getName()
|
||||
+ " could not provide bounded geometry for rotation " + rotation, error);
|
||||
}
|
||||
if (box == null) {
|
||||
throw new IllegalStateException("Template pool element "
|
||||
+ element.getClass().getName()
|
||||
+ " returned no bounded geometry for rotation " + rotation);
|
||||
}
|
||||
long xSpan = (long) box.maxX() - box.minX() + 1L;
|
||||
long zSpan = (long) box.maxZ() - box.minZ() + 1L;
|
||||
maximumSpan = Math.max(maximumSpan, Math.max(xSpan, zSpan));
|
||||
}
|
||||
if (maximumSpan < 0L || maximumSpan > Integer.MAX_VALUE) {
|
||||
throw new IllegalStateException("Template pool element has an unbounded horizontal span");
|
||||
}
|
||||
return (int) maximumSpan;
|
||||
}
|
||||
}
|
||||
+610
-46
@@ -9,16 +9,24 @@ import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.iris.util.project.noise.CNG;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
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.dimension.BuiltinDimensionTypes;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
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.TerrainAdjustment;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.LegacySinglePoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.ListPoolElement;
|
||||
import net.minecraft.world.level.levelgen.structure.pools.SinglePoolElement;
|
||||
@@ -29,62 +37,80 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.BitSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class NativeStructureTerrainIntegrator {
|
||||
private static final int AUTO_ENCASE_PADDING = 3;
|
||||
private static final long CARVE_CEILING_ROLL_SIGNATURE = 0x2A17L;
|
||||
private static final long CARVE_FLOOR_ROLL_SIGNATURE = 0x5B3DL;
|
||||
private static final long CARVE_LOBE_SIGNATURE = 0x7C41L;
|
||||
private static final int MAX_CACHED_CARVE_FOOTPRINTS = 4;
|
||||
private static final int MAX_CACHED_CARVE_CELLS = 2_000_000;
|
||||
private static final int MAX_CARVE_COLUMNS = 2_000_000;
|
||||
private static final int MAX_TEMPLATE_OCCUPANCY_CELLS = 4_194_304;
|
||||
private static final int SOURCE_BURY_HORIZONTAL_RADIUS = 6;
|
||||
private static final int SOURCE_BURY_VERTICAL_RADIUS = 12;
|
||||
private static final int SOURCE_ENCAPSULATE_RADIUS = 12;
|
||||
private static final int SOURCE_JUNCTION_RADIUS = 12;
|
||||
private static final int SOURCE_MATERIAL_SAMPLE_RADIUS = 8;
|
||||
private static final List<Block> TEMPLATE_VOID_BLOCKS = List.of(Blocks.AIR, Blocks.STRUCTURE_VOID);
|
||||
private static final Map<CarveFootprintKey, StructureCarvingFootprint> CARVE_FOOTPRINTS =
|
||||
Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75F, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(
|
||||
Map.Entry<CarveFootprintKey, StructureCarvingFootprint> eldest) {
|
||||
return size() > MAX_CACHED_CARVE_FOOTPRINTS;
|
||||
}
|
||||
});
|
||||
private static final Set<Block> VANILLA_SOURCE_TERRAIN_BLOCKS = Set.of(
|
||||
Blocks.STONE, Blocks.GRANITE, Blocks.DIORITE, Blocks.ANDESITE,
|
||||
Blocks.TUFF, Blocks.DEEPSLATE, Blocks.NETHERRACK, Blocks.BASALT,
|
||||
Blocks.BLACKSTONE, Blocks.DIRT, Blocks.COARSE_DIRT, Blocks.ROOTED_DIRT,
|
||||
Blocks.GRASS_BLOCK, Blocks.PODZOL, Blocks.MYCELIUM, Blocks.MUD,
|
||||
Blocks.MOSS_BLOCK, Blocks.SAND, Blocks.RED_SAND, Blocks.TERRACOTTA,
|
||||
Blocks.CRIMSON_NYLIUM, Blocks.WARPED_NYLIUM, Blocks.SNOW_BLOCK,
|
||||
Blocks.ICE, Blocks.PACKED_ICE, Blocks.BLUE_ICE, Blocks.SOUL_SAND,
|
||||
Blocks.SOUL_SOIL, Blocks.END_STONE, Blocks.GRAVEL, Blocks.CLAY,
|
||||
Blocks.CALCITE, Blocks.DRIPSTONE_BLOCK, Blocks.SANDSTONE,
|
||||
Blocks.RED_SANDSTONE, Blocks.SCULK);
|
||||
private static final Map<CarveFootprintKey, CachedCarveFootprint> CARVE_FOOTPRINTS =
|
||||
new LinkedHashMap<>(16, 0.75F, true);
|
||||
private static final ConcurrentHashMap<CarveFootprintKey, CompletableFuture<StructureCarvingFootprint>>
|
||||
CARVE_FOOTPRINT_BUILDS = new ConcurrentHashMap<>();
|
||||
private static int cachedCarveCells;
|
||||
|
||||
private NativeStructureTerrainIntegrator() {
|
||||
}
|
||||
|
||||
public static IrisStructureTerrain resolveNativeTerrain(StructureStart start,
|
||||
IrisStructureTerrain configuredTerrain) {
|
||||
if (configuredTerrain != null) {
|
||||
return configuredTerrain;
|
||||
}
|
||||
if (start == null || !start.isValid()
|
||||
|| !encasesTerrain(start.getStructure().terrainAdaptation())) {
|
||||
return null;
|
||||
}
|
||||
return new IrisStructureTerrain()
|
||||
.setMode(IrisStructureTerrainMode.ENCASE)
|
||||
.setHorizontalPadding(AUTO_ENCASE_PADDING)
|
||||
.setCeilingPadding(AUTO_ENCASE_PADDING)
|
||||
.setFloorPadding(AUTO_ENCASE_PADDING);
|
||||
}
|
||||
|
||||
static boolean encasesTerrain(TerrainAdjustment adjustment) {
|
||||
return adjustment == TerrainAdjustment.BURY || adjustment == TerrainAdjustment.ENCAPSULATE;
|
||||
return configuredTerrain == null
|
||||
? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)
|
||||
: configuredTerrain;
|
||||
}
|
||||
|
||||
static void integrateTerrain(WorldGenLevel world, BoundingBox area, String structureId,
|
||||
StructureStart start, IrisStructureTerrain configuredTerrain,
|
||||
NativeStructurePostProcessor.PaletteBlockResolver paletteBlockResolver) {
|
||||
SourceTerrainSnapshot sourceTerrain = requiresSourceTerrainFill(start, configuredTerrain)
|
||||
? captureSourceTerrain(world, area, start, configuredTerrain) : null;
|
||||
integrateTerrain(world, area, structureId, start, configuredTerrain,
|
||||
paletteBlockResolver, sourceTerrain);
|
||||
}
|
||||
|
||||
static void integrateTerrain(WorldGenLevel world, BoundingBox area, String structureId,
|
||||
StructureStart start, IrisStructureTerrain configuredTerrain,
|
||||
NativeStructurePostProcessor.PaletteBlockResolver paletteBlockResolver,
|
||||
SourceTerrainSnapshot sourceTerrain) {
|
||||
IrisStructureTerrain terrain = configuredTerrain == null
|
||||
? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)
|
||||
: configuredTerrain;
|
||||
IrisStructureTerrainMode mode = terrain.resolvedMode();
|
||||
if (mode == IrisStructureTerrainMode.SOURCE || mode == IrisStructureTerrainMode.PRESERVE) {
|
||||
if (mode == IrisStructureTerrainMode.SOURCE) {
|
||||
integrateSourceTerrain(world, area, start, sourceTerrain);
|
||||
return;
|
||||
}
|
||||
if (mode == IrisStructureTerrainMode.PRESERVE) {
|
||||
return;
|
||||
}
|
||||
if (mode == IrisStructureTerrainMode.VACUUM) {
|
||||
@@ -111,12 +137,381 @@ public final class NativeStructureTerrainIntegrator {
|
||||
terrain, shape, carveNoiseIdentity(world, structureId, start)));
|
||||
}
|
||||
|
||||
static SourceTerrainSnapshot captureSourceTerrain(
|
||||
WorldGenLevel world, BoundingBox area, List<TerrainTarget> targets) {
|
||||
BitSet requiredLayers = new BitSet(area.getYSpan());
|
||||
boolean requiresSnapshot = false;
|
||||
for (TerrainTarget target : targets) {
|
||||
if (target != null && requiresSourceTerrainFill(target.start(), target.terrain())) {
|
||||
requiresSnapshot = true;
|
||||
markSourceTerrainLayers(area, target.start(), target.terrain(), requiredLayers);
|
||||
}
|
||||
}
|
||||
return requiresSnapshot ? SourceTerrainSnapshot.capture(world, area, requiredLayers) : null;
|
||||
}
|
||||
|
||||
private static SourceTerrainSnapshot captureSourceTerrain(
|
||||
WorldGenLevel world, BoundingBox area, StructureStart start,
|
||||
IrisStructureTerrain configuredTerrain) {
|
||||
BitSet requiredLayers = new BitSet(area.getYSpan());
|
||||
markSourceTerrainLayers(area, start, configuredTerrain, requiredLayers);
|
||||
return SourceTerrainSnapshot.capture(world, area, requiredLayers);
|
||||
}
|
||||
|
||||
private static void markSourceTerrainLayers(
|
||||
BoundingBox area, StructureStart start, IrisStructureTerrain configuredTerrain,
|
||||
BitSet requiredLayers) {
|
||||
if (!requiresSourceTerrainFill(start, configuredTerrain)) {
|
||||
return;
|
||||
}
|
||||
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation();
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (!isSourceRigidPiece(piece)) {
|
||||
continue;
|
||||
}
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
long horizontalDistanceSquared = minimumHorizontalDistanceSquared(area, bounds);
|
||||
if (adjustment == TerrainAdjustment.BURY) {
|
||||
long remainingDistanceSquared = (long) SOURCE_BURY_VERTICAL_RADIUS
|
||||
* SOURCE_BURY_VERTICAL_RADIUS - 1L - horizontalDistanceSquared * 4L;
|
||||
if (remainingDistanceSquared < 0L) {
|
||||
continue;
|
||||
}
|
||||
int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared));
|
||||
long groundY = bounds.minY();
|
||||
if (piece instanceof PoolElementStructurePiece poolPiece) {
|
||||
groundY += poolPiece.getGroundLevelDelta();
|
||||
}
|
||||
markLayers(area, requiredLayers,
|
||||
groundY - verticalRadius, groundY + verticalRadius);
|
||||
} else {
|
||||
long remainingDistanceSquared = (long) SOURCE_ENCAPSULATE_RADIUS
|
||||
* SOURCE_ENCAPSULATE_RADIUS - 1L - horizontalDistanceSquared;
|
||||
if (remainingDistanceSquared < 0L) {
|
||||
continue;
|
||||
}
|
||||
int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared));
|
||||
markLayers(area, requiredLayers,
|
||||
(long) bounds.minY() - verticalRadius,
|
||||
(long) bounds.maxY() + verticalRadius);
|
||||
}
|
||||
}
|
||||
markSourceJunctionLayers(area, start, requiredLayers);
|
||||
}
|
||||
|
||||
private static void markSourceJunctionLayers(
|
||||
BoundingBox area, StructureStart start, BitSet requiredLayers) {
|
||||
Set<JunctionAnchor> anchors = sourceJunctionAnchors(start);
|
||||
for (JunctionAnchor anchor : anchors) {
|
||||
long deltaX = intervalDistance(area.minX(), area.maxX(), anchor.x(), anchor.x());
|
||||
long deltaZ = intervalDistance(area.minZ(), area.maxZ(), anchor.z(), anchor.z());
|
||||
long horizontalDistanceSquared = deltaX * deltaX + deltaZ * deltaZ;
|
||||
long remainingDistanceSquared = (long) SOURCE_JUNCTION_RADIUS
|
||||
* SOURCE_JUNCTION_RADIUS - 1L - horizontalDistanceSquared;
|
||||
if (remainingDistanceSquared < 1L) {
|
||||
continue;
|
||||
}
|
||||
int verticalRadius = (int) Math.floor(Math.sqrt(remainingDistanceSquared));
|
||||
markLayers(area, requiredLayers,
|
||||
(long) anchor.y() - verticalRadius, (long) anchor.y() - 1L);
|
||||
}
|
||||
}
|
||||
|
||||
private static long minimumHorizontalDistanceSquared(BoundingBox area, BoundingBox bounds) {
|
||||
long deltaX = intervalDistance(area.minX(), area.maxX(), bounds.minX(), bounds.maxX());
|
||||
long deltaZ = intervalDistance(area.minZ(), area.maxZ(), bounds.minZ(), bounds.maxZ());
|
||||
return deltaX * deltaX + deltaZ * deltaZ;
|
||||
}
|
||||
|
||||
private static long intervalDistance(int firstMin, int firstMax, int secondMin, int secondMax) {
|
||||
if (firstMax < secondMin) {
|
||||
return (long) secondMin - firstMax;
|
||||
}
|
||||
if (firstMin > secondMax) {
|
||||
return (long) firstMin - secondMax;
|
||||
}
|
||||
return 0L;
|
||||
}
|
||||
|
||||
private static void markLayers(
|
||||
BoundingBox area, BitSet requiredLayers, long minimumY, long maximumY) {
|
||||
long clippedMinimumY = Math.max(area.minY(), minimumY);
|
||||
long clippedMaximumY = Math.min(area.maxY(), maximumY);
|
||||
if (clippedMinimumY > clippedMaximumY) {
|
||||
return;
|
||||
}
|
||||
int fromIndex = Math.toIntExact(clippedMinimumY - area.minY());
|
||||
int toIndex = Math.toIntExact(clippedMaximumY - area.minY() + 1L);
|
||||
requiredLayers.set(fromIndex, toIndex);
|
||||
}
|
||||
|
||||
private static boolean requiresSourceTerrainFill(
|
||||
StructureStart start, IrisStructureTerrain configuredTerrain) {
|
||||
if (start == null || !start.isValid()) {
|
||||
return false;
|
||||
}
|
||||
IrisStructureTerrain terrain = configuredTerrain == null
|
||||
? new IrisStructureTerrain().setMode(IrisStructureTerrainMode.SOURCE)
|
||||
: configuredTerrain;
|
||||
if (terrain.resolvedMode() != IrisStructureTerrainMode.SOURCE) {
|
||||
return false;
|
||||
}
|
||||
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation();
|
||||
return adjustment == TerrainAdjustment.BURY
|
||||
|| adjustment == TerrainAdjustment.ENCAPSULATE;
|
||||
}
|
||||
|
||||
static boolean clearsLegacyTemplateAir(StructureStart start, IrisStructureTerrain terrain) {
|
||||
if (start == null || !start.isValid() || terrain == null) {
|
||||
return false;
|
||||
}
|
||||
IrisStructureTerrainMode mode = terrain.resolvedMode();
|
||||
return mode == IrisStructureTerrainMode.ENCASE
|
||||
|| mode == IrisStructureTerrainMode.SOURCE
|
||||
&& start.getStructure().terrainAdaptation() != TerrainAdjustment.NONE;
|
||||
}
|
||||
|
||||
private static void integrateSourceTerrain(WorldGenLevel world, BoundingBox area,
|
||||
StructureStart start,
|
||||
SourceTerrainSnapshot sourceTerrain) {
|
||||
if (start == null || !start.isValid()) {
|
||||
return;
|
||||
}
|
||||
TerrainAdjustment adjustment = start.getStructure().terrainAdaptation();
|
||||
if (adjustment == TerrainAdjustment.BURY) {
|
||||
fillBuriedTerrain(world, area, start, requireSourceTerrain(
|
||||
world, area, start, sourceTerrain));
|
||||
} else if (adjustment == TerrainAdjustment.ENCAPSULATE) {
|
||||
fillEncapsulatedTerrain(world, area, start, requireSourceTerrain(
|
||||
world, area, start, sourceTerrain));
|
||||
}
|
||||
}
|
||||
|
||||
private static SourceTerrainSnapshot requireSourceTerrain(
|
||||
WorldGenLevel world, BoundingBox area, StructureStart start,
|
||||
SourceTerrainSnapshot sourceTerrain) {
|
||||
return sourceTerrain == null
|
||||
? captureSourceTerrain(world, area, start, null) : sourceTerrain;
|
||||
}
|
||||
|
||||
private static void fillBuriedTerrain(WorldGenLevel world, BoundingBox area,
|
||||
StructureStart start,
|
||||
SourceTerrainSnapshot sourceTerrain) {
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (!isSourceRigidPiece(piece)) {
|
||||
continue;
|
||||
}
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
int groundY = bounds.minY();
|
||||
if (piece instanceof PoolElementStructurePiece poolPiece) {
|
||||
groundY += poolPiece.getGroundLevelDelta();
|
||||
}
|
||||
int minX = Math.max(area.minX(), bounds.minX() - SOURCE_BURY_HORIZONTAL_RADIUS);
|
||||
int maxX = Math.min(area.maxX(), bounds.maxX() + SOURCE_BURY_HORIZONTAL_RADIUS);
|
||||
int minY = Math.max(area.minY(), groundY - SOURCE_BURY_VERTICAL_RADIUS);
|
||||
int maxY = Math.min(area.maxY(), groundY + SOURCE_BURY_VERTICAL_RADIUS);
|
||||
int minZ = Math.max(area.minZ(), bounds.minZ() - SOURCE_BURY_HORIZONTAL_RADIUS);
|
||||
int maxZ = Math.min(area.maxZ(), bounds.maxZ() + SOURCE_BURY_HORIZONTAL_RADIUS);
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
int outX = outset(x, bounds.minX(), bounds.maxX());
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
int outZ = outset(z, bounds.minZ(), bounds.maxZ());
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
int vertical = Math.abs(y - groundY);
|
||||
if (!insideBurialEnvelope(outX, vertical, outZ)) {
|
||||
continue;
|
||||
}
|
||||
fillEncaseable(world, position.set(x, y, z), sourceTerrain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fillSourceJunctionTerrain(world, area, start, position, sourceTerrain);
|
||||
}
|
||||
|
||||
private static void fillEncapsulatedTerrain(WorldGenLevel world, BoundingBox area,
|
||||
StructureStart start,
|
||||
SourceTerrainSnapshot sourceTerrain) {
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (!isSourceRigidPiece(piece)) {
|
||||
continue;
|
||||
}
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
int minX = Math.max(area.minX(), bounds.minX() - SOURCE_ENCAPSULATE_RADIUS);
|
||||
int maxX = Math.min(area.maxX(), bounds.maxX() + SOURCE_ENCAPSULATE_RADIUS);
|
||||
int minY = Math.max(area.minY(), bounds.minY() - SOURCE_ENCAPSULATE_RADIUS);
|
||||
int maxY = Math.min(area.maxY(), bounds.maxY() + SOURCE_ENCAPSULATE_RADIUS);
|
||||
int minZ = Math.max(area.minZ(), bounds.minZ() - SOURCE_ENCAPSULATE_RADIUS);
|
||||
int maxZ = Math.min(area.maxZ(), bounds.maxZ() + SOURCE_ENCAPSULATE_RADIUS);
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
int outX = outset(x, bounds.minX(), bounds.maxX());
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
int outZ = outset(z, bounds.minZ(), bounds.maxZ());
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
int outY = outset(y, bounds.minY(), bounds.maxY());
|
||||
if (!insideEncapsulationEnvelope(outX, outY, outZ)) {
|
||||
continue;
|
||||
}
|
||||
fillEncaseable(world, position.set(x, y, z), sourceTerrain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fillSourceJunctionTerrain(world, area, start, position, sourceTerrain);
|
||||
}
|
||||
|
||||
static boolean insideBurialEnvelope(int outX, int verticalDistance, int outZ) {
|
||||
long horizontalSquared = (long) outX * outX + (long) outZ * outZ;
|
||||
long verticalSquared = (long) verticalDistance * verticalDistance;
|
||||
return horizontalSquared * 4L + verticalSquared
|
||||
< (long) SOURCE_BURY_VERTICAL_RADIUS * SOURCE_BURY_VERTICAL_RADIUS;
|
||||
}
|
||||
|
||||
static boolean insideEncapsulationEnvelope(int outX, int outY, int outZ) {
|
||||
return (long) outX * outX + (long) outY * outY + (long) outZ * outZ
|
||||
< (long) SOURCE_ENCAPSULATE_RADIUS * SOURCE_ENCAPSULATE_RADIUS;
|
||||
}
|
||||
|
||||
static boolean isSourceRigidPiece(StructurePiece piece) {
|
||||
if (piece == null) {
|
||||
return false;
|
||||
}
|
||||
return !(piece instanceof PoolElementStructurePiece poolPiece)
|
||||
|| poolPiece.getElement().getProjection() == StructureTemplatePool.Projection.RIGID;
|
||||
}
|
||||
|
||||
static boolean insideSourceJunctionEnvelope(int deltaX, int deltaY, int deltaZ) {
|
||||
if (deltaY >= 0) {
|
||||
return false;
|
||||
}
|
||||
return (long) deltaX * deltaX + (long) deltaY * deltaY + (long) deltaZ * deltaZ
|
||||
< (long) SOURCE_JUNCTION_RADIUS * SOURCE_JUNCTION_RADIUS;
|
||||
}
|
||||
|
||||
private static void fillSourceJunctionTerrain(WorldGenLevel world, BoundingBox area,
|
||||
StructureStart start,
|
||||
BlockPos.MutableBlockPos position,
|
||||
SourceTerrainSnapshot sourceTerrain) {
|
||||
Set<JunctionAnchor> anchors = sourceJunctionAnchors(start);
|
||||
for (JunctionAnchor anchor : anchors) {
|
||||
int minX = Math.max(area.minX(), anchor.x() - SOURCE_JUNCTION_RADIUS + 1);
|
||||
int maxX = Math.min(area.maxX(), anchor.x() + SOURCE_JUNCTION_RADIUS - 1);
|
||||
int minY = Math.max(area.minY(), anchor.y() - SOURCE_JUNCTION_RADIUS + 1);
|
||||
int maxY = Math.min(area.maxY(), anchor.y() - 1);
|
||||
int minZ = Math.max(area.minZ(), anchor.z() - SOURCE_JUNCTION_RADIUS + 1);
|
||||
int maxZ = Math.min(area.maxZ(), anchor.z() + SOURCE_JUNCTION_RADIUS - 1);
|
||||
for (int x = minX; x <= maxX; x++) {
|
||||
int deltaX = x - anchor.x();
|
||||
for (int z = minZ; z <= maxZ; z++) {
|
||||
int deltaZ = z - anchor.z();
|
||||
for (int y = minY; y <= maxY; y++) {
|
||||
if (insideSourceJunctionEnvelope(deltaX, y - anchor.y(), deltaZ)) {
|
||||
fillEncaseable(world, position.set(x, y, z), sourceTerrain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<JunctionAnchor> sourceJunctionAnchors(StructureStart start) {
|
||||
Set<JunctionAnchor> anchors = new HashSet<>();
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (!(piece instanceof PoolElementStructurePiece poolPiece)) {
|
||||
continue;
|
||||
}
|
||||
for (JigsawJunction junction : poolPiece.getJunctions()) {
|
||||
anchors.add(new JunctionAnchor(
|
||||
junction.getSourceX(), junction.getSourceGroundY(), junction.getSourceZ()));
|
||||
}
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
private static int outset(int value, int minimum, int maximum) {
|
||||
if (value < minimum) {
|
||||
return minimum - value;
|
||||
}
|
||||
return Math.max(0, value - maximum);
|
||||
}
|
||||
|
||||
private static void fillEncaseable(WorldGenLevel world, BlockPos position,
|
||||
SourceTerrainSnapshot sourceTerrain) {
|
||||
if (isEncaseable(world.getBlockState(position))) {
|
||||
world.setBlock(position, sourceEncaseBlock(world, position, sourceTerrain), 2);
|
||||
}
|
||||
}
|
||||
|
||||
static BlockState sourceEncaseBlock(WorldGenLevel world, BlockPos position) {
|
||||
int minX = Math.subtractExact(position.getX(), SOURCE_MATERIAL_SAMPLE_RADIUS);
|
||||
int minZ = Math.subtractExact(position.getZ(), SOURCE_MATERIAL_SAMPLE_RADIUS);
|
||||
int maxX = Math.addExact(position.getX(), SOURCE_MATERIAL_SAMPLE_RADIUS);
|
||||
int maxZ = Math.addExact(position.getZ(), SOURCE_MATERIAL_SAMPLE_RADIUS);
|
||||
BoundingBox sampleArea = new BoundingBox(minX, position.getY(), minZ,
|
||||
maxX, position.getY(), maxZ);
|
||||
BitSet requiredLayers = new BitSet(1);
|
||||
requiredLayers.set(0);
|
||||
SourceTerrainSnapshot sourceTerrain = SourceTerrainSnapshot.capture(
|
||||
world, sampleArea, requiredLayers);
|
||||
return sourceEncaseBlock(world, position, sourceTerrain);
|
||||
}
|
||||
|
||||
static BlockState sourceEncaseBlock(WorldGenLevel world, BlockPos position,
|
||||
SourceTerrainSnapshot sourceTerrain) {
|
||||
BlockPos.MutableBlockPos probe = new BlockPos.MutableBlockPos();
|
||||
for (int distance = 1; distance <= SOURCE_MATERIAL_SAMPLE_RADIUS; distance++) {
|
||||
BlockState sampled = sourceTerrain.stateAt(probe.set(
|
||||
position.getX() - distance, position.getY(), position.getZ()));
|
||||
if (sampled != null) {
|
||||
return sampled;
|
||||
}
|
||||
sampled = sourceTerrain.stateAt(probe.set(
|
||||
position.getX() + distance, position.getY(), position.getZ()));
|
||||
if (sampled != null) {
|
||||
return sampled;
|
||||
}
|
||||
sampled = sourceTerrain.stateAt(probe.set(
|
||||
position.getX(), position.getY(), position.getZ() - distance));
|
||||
if (sampled != null) {
|
||||
return sampled;
|
||||
}
|
||||
sampled = sourceTerrain.stateAt(probe.set(
|
||||
position.getX(), position.getY(), position.getZ() + distance));
|
||||
if (sampled != null) {
|
||||
return sampled;
|
||||
}
|
||||
}
|
||||
return defaultEncaseBlock(world, position.getY());
|
||||
}
|
||||
|
||||
private static BlockState sourceTerrainBlock(BlockState state) {
|
||||
if (!state.isSolid() || NativeStructureVegetationClearer.isTreeBlock(state)) {
|
||||
return null;
|
||||
}
|
||||
return VANILLA_SOURCE_TERRAIN_BLOCKS.contains(state.getBlock())
|
||||
|| state.is(BlockTags.BASE_STONE_OVERWORLD)
|
||||
|| state.is(BlockTags.BASE_STONE_NETHER)
|
||||
|| state.is(BlockTags.SUBSTRATE_OVERWORLD)
|
||||
|| state.is(BlockTags.DIRT)
|
||||
|| state.is(BlockTags.SAND)
|
||||
|| state.is(BlockTags.TERRACOTTA)
|
||||
|| state.is(BlockTags.MUD)
|
||||
|| state.is(BlockTags.MOSS_BLOCKS)
|
||||
|| state.is(BlockTags.GRASS_BLOCKS)
|
||||
|| state.is(BlockTags.NYLIUM)
|
||||
|| state.is(BlockTags.SNOW)
|
||||
|| state.is(BlockTags.ICE)
|
||||
|| state.is(BlockTags.CORAL_BLOCKS)
|
||||
|| state.is(BlockTags.SOUL_FIRE_BASE_BLOCKS)
|
||||
? state : null;
|
||||
}
|
||||
|
||||
static List<BoundingBox> contentPieceBounds(StructureStart start) {
|
||||
List<BoundingBox> bounds = new ArrayList<>(start.getPieces().size());
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (!NativeStructureReferenceEnvelope.isMarker(piece)) {
|
||||
bounds.add(piece.getBoundingBox());
|
||||
}
|
||||
bounds.add(piece.getBoundingBox());
|
||||
}
|
||||
return List.copyOf(bounds);
|
||||
}
|
||||
@@ -128,18 +523,84 @@ public final class NativeStructureTerrainIntegrator {
|
||||
static StructureCarvingFootprint carveFootprint(StructureStart start, int horizontalPadding,
|
||||
Supplier<StructureTemplateManager> templates) {
|
||||
CarveFootprintKey key = new CarveFootprintKey(start, horizontalPadding);
|
||||
StructureCarvingFootprint cached = CARVE_FOOTPRINTS.get(key);
|
||||
StructureCarvingFootprint cached = cachedCarveFootprint(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns(
|
||||
sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS);
|
||||
if (footprint == null) {
|
||||
throw new IllegalStateException("Native structure carve footprint is empty or exceeds "
|
||||
+ MAX_CARVE_COLUMNS + " columns");
|
||||
CompletableFuture<StructureCarvingFootprint> build = new CompletableFuture<>();
|
||||
CompletableFuture<StructureCarvingFootprint> active = CARVE_FOOTPRINT_BUILDS.putIfAbsent(key, build);
|
||||
if (active != null) {
|
||||
return awaitCarveFootprint(active);
|
||||
}
|
||||
try {
|
||||
StructureCarvingFootprint footprint = StructureCarvingFootprint.fromColumns(
|
||||
sink -> emitCarveColumns(start, templates, sink), horizontalPadding, MAX_CARVE_COLUMNS);
|
||||
if (footprint == null) {
|
||||
throw new IllegalStateException("Native structure carve footprint is empty or exceeds "
|
||||
+ MAX_CARVE_COLUMNS + " columns");
|
||||
}
|
||||
cacheCarveFootprint(key, footprint);
|
||||
build.complete(footprint);
|
||||
return footprint;
|
||||
} catch (RuntimeException | Error error) {
|
||||
build.completeExceptionally(error);
|
||||
throw error;
|
||||
} finally {
|
||||
CARVE_FOOTPRINT_BUILDS.remove(key, build);
|
||||
}
|
||||
}
|
||||
|
||||
static int cachedCarveFootprintCells() {
|
||||
synchronized (CARVE_FOOTPRINTS) {
|
||||
return cachedCarveCells;
|
||||
}
|
||||
}
|
||||
|
||||
static int maximumCachedCarveFootprintCells() {
|
||||
return MAX_CACHED_CARVE_CELLS;
|
||||
}
|
||||
|
||||
private static StructureCarvingFootprint cachedCarveFootprint(CarveFootprintKey key) {
|
||||
synchronized (CARVE_FOOTPRINTS) {
|
||||
CachedCarveFootprint cached = CARVE_FOOTPRINTS.get(key);
|
||||
return cached == null ? null : cached.footprint();
|
||||
}
|
||||
}
|
||||
|
||||
private static StructureCarvingFootprint awaitCarveFootprint(
|
||||
CompletableFuture<StructureCarvingFootprint> future) {
|
||||
try {
|
||||
return future.join();
|
||||
} catch (CompletionException error) {
|
||||
Throwable cause = error.getCause();
|
||||
if (cause instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
if (cause instanceof Error fatal) {
|
||||
throw fatal;
|
||||
}
|
||||
throw new IllegalStateException("Native structure carve footprint build failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
private static void cacheCarveFootprint(CarveFootprintKey key,
|
||||
StructureCarvingFootprint footprint) {
|
||||
int cells = Math.multiplyExact(footprint.width(), footprint.depth());
|
||||
synchronized (CARVE_FOOTPRINTS) {
|
||||
CachedCarveFootprint previous = CARVE_FOOTPRINTS.remove(key);
|
||||
if (previous != null) {
|
||||
cachedCarveCells -= previous.cells();
|
||||
}
|
||||
while (!CARVE_FOOTPRINTS.isEmpty()
|
||||
&& cachedCarveCells + cells > MAX_CACHED_CARVE_CELLS) {
|
||||
Map.Entry<CarveFootprintKey, CachedCarveFootprint> eldest =
|
||||
CARVE_FOOTPRINTS.entrySet().iterator().next();
|
||||
cachedCarveCells -= eldest.getValue().cells();
|
||||
CARVE_FOOTPRINTS.remove(eldest.getKey());
|
||||
}
|
||||
CARVE_FOOTPRINTS.put(key, new CachedCarveFootprint(footprint, cells));
|
||||
cachedCarveCells += cells;
|
||||
}
|
||||
CARVE_FOOTPRINTS.put(key, footprint);
|
||||
return footprint;
|
||||
}
|
||||
|
||||
static OrganicCarve organicCarve(StructureCarvingFootprint footprint, IrisStructureTerrain terrain,
|
||||
@@ -235,9 +696,6 @@ public final class NativeStructureTerrainIntegrator {
|
||||
Supplier<StructureTemplateManager> templates,
|
||||
StructureCarvingFootprint.ColumnSink sink) {
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
|
||||
continue;
|
||||
}
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
if (!(piece instanceof PoolElementStructurePiece poolPiece)
|
||||
|| !emitTemplateColumns(pieceTemplates(poolPiece, templates),
|
||||
@@ -373,7 +831,7 @@ public final class NativeStructureTerrainIntegrator {
|
||||
continue;
|
||||
}
|
||||
BlockState fill = palette == null
|
||||
? defaultEncaseBlock(y)
|
||||
? defaultEncaseBlock(world, y)
|
||||
: Objects.requireNonNull(
|
||||
paletteBlockResolver.resolve(palette, rng, x, y, z),
|
||||
"Encase palette returned no block for " + structureId + " at "
|
||||
@@ -393,6 +851,41 @@ public final class NativeStructureTerrainIntegrator {
|
||||
return y < 0 ? Blocks.DEEPSLATE.defaultBlockState() : Blocks.STONE.defaultBlockState();
|
||||
}
|
||||
|
||||
static BlockState defaultEncaseBlock(WorldGenLevel world, int y) {
|
||||
ServerLevel level = world == null ? null : world.getLevel();
|
||||
if (level == null) {
|
||||
return defaultEncaseBlock(y);
|
||||
}
|
||||
Holder<DimensionType> dimensionType = level.dimensionTypeRegistration();
|
||||
ResourceKey<DimensionType> dimensionTypeKey = dimensionType.unwrapKey().orElse(null);
|
||||
return defaultEncaseBlock(level.dimension(), dimensionTypeKey, dimensionType.value(), y);
|
||||
}
|
||||
|
||||
static BlockState defaultEncaseBlock(ResourceKey<Level> dimension, int y) {
|
||||
return defaultEncaseBlock(dimension, null, null, y);
|
||||
}
|
||||
|
||||
static BlockState defaultEncaseBlock(ResourceKey<Level> dimension,
|
||||
ResourceKey<DimensionType> dimensionTypeKey,
|
||||
DimensionType dimensionType, int y) {
|
||||
if (Level.NETHER.equals(dimension)) {
|
||||
return Blocks.NETHERRACK.defaultBlockState();
|
||||
}
|
||||
if (Level.END.equals(dimension)) {
|
||||
return Blocks.END_STONE.defaultBlockState();
|
||||
}
|
||||
if (BuiltinDimensionTypes.NETHER.equals(dimensionTypeKey)
|
||||
|| dimensionType != null && dimensionType.hasCeiling() && !dimensionType.hasSkyLight()) {
|
||||
return Blocks.NETHERRACK.defaultBlockState();
|
||||
}
|
||||
if (BuiltinDimensionTypes.END.equals(dimensionTypeKey)
|
||||
|| dimensionType != null && (dimensionType.hasEnderDragonFight()
|
||||
|| dimensionType.hasEndFlashes())) {
|
||||
return Blocks.END_STONE.defaultBlockState();
|
||||
}
|
||||
return defaultEncaseBlock(y);
|
||||
}
|
||||
|
||||
private static void carvePieceBoxes(WorldGenLevel world, BoundingBox area, StructureStart start,
|
||||
IrisStructureTerrain terrain) {
|
||||
BlockState air = Blocks.AIR.defaultBlockState();
|
||||
@@ -431,9 +924,6 @@ public final class NativeStructureTerrainIntegrator {
|
||||
StructureStart start,
|
||||
Supplier<StructureTemplateManager> templates) {
|
||||
for (StructurePiece piece : start.getPieces()) {
|
||||
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
|
||||
continue;
|
||||
}
|
||||
if (!(piece instanceof PoolElementStructurePiece poolPiece)
|
||||
|| poolPiece.getElement().getProjection() != StructureTemplatePool.Projection.RIGID
|
||||
|| !intersects(poolPiece.getBoundingBox(), area)) {
|
||||
@@ -496,6 +986,80 @@ public final class NativeStructureTerrainIntegrator {
|
||||
private record CarveFootprintKey(StructureStart start, int padding) {
|
||||
}
|
||||
|
||||
private record CachedCarveFootprint(StructureCarvingFootprint footprint, int cells) {
|
||||
}
|
||||
|
||||
private record JunctionAnchor(int x, int y, int z) {
|
||||
}
|
||||
|
||||
static final class SourceTerrainSnapshot {
|
||||
private final BoundingBox area;
|
||||
private final int width;
|
||||
private final BlockState[][] statesByLayer;
|
||||
private final int sampledCells;
|
||||
|
||||
private SourceTerrainSnapshot(BoundingBox area, int width,
|
||||
BlockState[][] statesByLayer, int sampledCells) {
|
||||
this.area = area;
|
||||
this.width = width;
|
||||
this.statesByLayer = statesByLayer;
|
||||
this.sampledCells = sampledCells;
|
||||
}
|
||||
|
||||
static SourceTerrainSnapshot capture(
|
||||
WorldGenLevel world, BoundingBox area, BitSet requiredLayers) {
|
||||
Objects.requireNonNull(world, "Source terrain snapshot requires a generation level");
|
||||
Objects.requireNonNull(area, "Source terrain snapshot requires writable bounds");
|
||||
Objects.requireNonNull(requiredLayers, "Source terrain snapshot requires sampled layers");
|
||||
int width = area.getXSpan();
|
||||
int depth = area.getZSpan();
|
||||
int height = area.getYSpan();
|
||||
int horizontalCells = Math.multiplyExact(width, depth);
|
||||
BlockState[][] statesByLayer = new BlockState[height][];
|
||||
int sampledCells = 0;
|
||||
BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos();
|
||||
for (int layer = requiredLayers.nextSetBit(0);
|
||||
layer >= 0; layer = requiredLayers.nextSetBit(layer + 1)) {
|
||||
if (layer >= height) {
|
||||
throw new IllegalArgumentException(
|
||||
"Source terrain snapshot layer exceeds writable bounds: " + layer);
|
||||
}
|
||||
BlockState[] states = new BlockState[horizontalCells];
|
||||
statesByLayer[layer] = states;
|
||||
sampledCells = Math.addExact(sampledCells, horizontalCells);
|
||||
int y = Math.addExact(area.minY(), layer);
|
||||
for (int z = area.minZ(); z <= area.maxZ(); z++) {
|
||||
for (int x = area.minX(); x <= area.maxX(); x++) {
|
||||
int index = (z - area.minZ()) * width + x - area.minX();
|
||||
states[index] = sourceTerrainBlock(
|
||||
world.getBlockState(position.set(x, y, z)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return new SourceTerrainSnapshot(new BoundingBox(
|
||||
area.minX(), area.minY(), area.minZ(),
|
||||
area.maxX(), area.maxY(), area.maxZ()), width,
|
||||
statesByLayer, sampledCells);
|
||||
}
|
||||
|
||||
BlockState stateAt(BlockPos position) {
|
||||
if (!area.isInside(position)) {
|
||||
return null;
|
||||
}
|
||||
BlockState[] states = statesByLayer[position.getY() - area.minY()];
|
||||
if (states == null) {
|
||||
return null;
|
||||
}
|
||||
int index = (position.getZ() - area.minZ()) * width
|
||||
+ position.getX() - area.minX();
|
||||
return states[index];
|
||||
}
|
||||
|
||||
int sampledCells() {
|
||||
return sampledCells;
|
||||
}
|
||||
}
|
||||
|
||||
record OrganicCarve(StructureCarvingFootprint footprint, IrisStructureCarveShape shape,
|
||||
int horizontalPadding, int ceilingPadding, int floorPadding,
|
||||
double strength, double frequency, CNG blob, CNG ceilingRoll, CNG floorRoll,
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import com.mojang.datafixers.util.Pair;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.SectionPos;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.world.level.chunk.status.ChunkStatus;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureCheckResult;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.ConcentricRingsStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class NativeStructureVanillaLocator {
|
||||
private NativeStructureVanillaLocator() {
|
||||
}
|
||||
|
||||
public static Candidate predict(ServerLevel level, HolderSet<Structure> holders,
|
||||
BlockPos origin, int radius, boolean findUnexplored) {
|
||||
if (SharedConstants.DEBUG_DISABLE_FEATURES
|
||||
|| !level.getServer().getWorldGenSettings().options().generateStructures()) {
|
||||
return null;
|
||||
}
|
||||
ChunkGeneratorStructureState state = level.getChunkSource().getGeneratorState();
|
||||
Map<StructurePlacement, Set<Holder<Structure>>> byPlacement = new LinkedHashMap<>();
|
||||
for (Holder<Structure> holder : holders) {
|
||||
for (StructurePlacement placement : state.getPlacementsForStructure(holder)) {
|
||||
byPlacement.computeIfAbsent(placement, ignored -> new LinkedHashSet<>()).add(holder);
|
||||
}
|
||||
}
|
||||
if (byPlacement.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
StructureManager structureManager = level.structureManager();
|
||||
Candidate best = null;
|
||||
double bestDistance = Double.MAX_VALUE;
|
||||
List<Map.Entry<RandomSpreadStructurePlacement, Set<Holder<Structure>>>> randomPlacements =
|
||||
new ArrayList<>(byPlacement.size());
|
||||
for (Map.Entry<StructurePlacement, Set<Holder<Structure>>> entry : byPlacement.entrySet()) {
|
||||
StructurePlacement placement = entry.getKey();
|
||||
if (placement instanceof ConcentricRingsStructurePlacement concentric) {
|
||||
Candidate candidate = predictConcentric(
|
||||
entry.getValue(), level, structureManager, origin, findUnexplored,
|
||||
state, concentric);
|
||||
if (candidate != null) {
|
||||
double distance = origin.distSqr(candidate.result().getFirst());
|
||||
if (distance < bestDistance) {
|
||||
best = candidate;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
} else if (placement instanceof RandomSpreadStructurePlacement randomSpread) {
|
||||
randomPlacements.add(Map.entry(randomSpread, entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
int centerChunkX = SectionPos.blockToSectionCoord(origin.getX());
|
||||
int centerChunkZ = SectionPos.blockToSectionCoord(origin.getZ());
|
||||
int searchRadius = Math.max(0, radius);
|
||||
for (int ring = 0; ring <= searchRadius; ring++) {
|
||||
boolean foundInRing = false;
|
||||
for (Map.Entry<RandomSpreadStructurePlacement, Set<Holder<Structure>>> entry
|
||||
: randomPlacements) {
|
||||
Candidate candidate = predictRandomSpread(
|
||||
entry.getValue(), level, structureManager,
|
||||
centerChunkX, centerChunkZ, ring, findUnexplored,
|
||||
state.getLevelSeed(), entry.getKey());
|
||||
if (candidate == null) {
|
||||
continue;
|
||||
}
|
||||
foundInRing = true;
|
||||
double distance = origin.distSqr(candidate.result().getFirst());
|
||||
if (distance < bestDistance) {
|
||||
best = candidate;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
if (foundInRing) {
|
||||
return best;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Candidate predictConcentric(Set<Holder<Structure>> holders,
|
||||
ServerLevel level,
|
||||
StructureManager structureManager,
|
||||
BlockPos origin,
|
||||
boolean findUnexplored,
|
||||
ChunkGeneratorStructureState state,
|
||||
ConcentricRingsStructurePlacement placement) {
|
||||
List<ChunkPos> positions = state.getRingPositionsFor(placement);
|
||||
if (positions == null) {
|
||||
throw new IllegalStateException(
|
||||
"Tried to locate structures for an unavailable concentric placement");
|
||||
}
|
||||
Candidate best = null;
|
||||
double bestDistance = Double.MAX_VALUE;
|
||||
for (ChunkPos position : positions) {
|
||||
BlockPos locatePosition = new BlockPos(
|
||||
SectionPos.sectionToBlockCoord(position.x(), 8),
|
||||
32,
|
||||
SectionPos.sectionToBlockCoord(position.z(), 8));
|
||||
double distance = locatePosition.distSqr(origin);
|
||||
if (best != null && distance >= bestDistance) {
|
||||
continue;
|
||||
}
|
||||
Candidate candidate = predictAt(
|
||||
holders, level, structureManager, findUnexplored, placement, position);
|
||||
if (candidate != null) {
|
||||
best = candidate;
|
||||
bestDistance = distance;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Candidate predictRandomSpread(Set<Holder<Structure>> holders,
|
||||
ServerLevel level,
|
||||
StructureManager structureManager,
|
||||
int centerChunkX, int centerChunkZ,
|
||||
int ring, boolean findUnexplored,
|
||||
long seed,
|
||||
RandomSpreadStructurePlacement placement) {
|
||||
int spacing = placement.spacing();
|
||||
for (int offsetX = -ring; offsetX <= ring; offsetX++) {
|
||||
boolean edgeX = offsetX == -ring || offsetX == ring;
|
||||
for (int offsetZ = -ring; offsetZ <= ring; offsetZ++) {
|
||||
boolean edgeZ = offsetZ == -ring || offsetZ == ring;
|
||||
if (!edgeX && !edgeZ) {
|
||||
continue;
|
||||
}
|
||||
int gridChunkX = centerChunkX + spacing * offsetX;
|
||||
int gridChunkZ = centerChunkZ + spacing * offsetZ;
|
||||
ChunkPos candidatePosition = placement.getPotentialStructureChunk(
|
||||
seed, gridChunkX, gridChunkZ);
|
||||
Candidate candidate = predictAt(
|
||||
holders, level, structureManager,
|
||||
findUnexplored, placement, candidatePosition);
|
||||
if (candidate != null) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Candidate predictAt(Set<Holder<Structure>> holders,
|
||||
ServerLevel level,
|
||||
StructureManager structureManager,
|
||||
boolean findUnexplored,
|
||||
StructurePlacement placement,
|
||||
ChunkPos candidatePosition) {
|
||||
for (Holder<Structure> holder : holders) {
|
||||
Structure structure = holder.value();
|
||||
StructureCheckResult result = structureManager.checkStructurePresence(
|
||||
candidatePosition, structure, placement, findUnexplored);
|
||||
if (result == StructureCheckResult.START_NOT_PRESENT) {
|
||||
continue;
|
||||
}
|
||||
if (!findUnexplored && result == StructureCheckResult.START_PRESENT) {
|
||||
return new Candidate(
|
||||
Pair.of(placement.getLocatePos(candidatePosition), holder), null);
|
||||
}
|
||||
ChunkAccess chunk = level.getChunk(
|
||||
candidatePosition.x(), candidatePosition.z(), ChunkStatus.STRUCTURE_STARTS);
|
||||
StructureStart start = structureManager.getStartForStructure(
|
||||
SectionPos.bottomOf(chunk), structure, chunk);
|
||||
if (start == null || !start.isValid()
|
||||
|| findUnexplored && !start.canBeReferenced()) {
|
||||
continue;
|
||||
}
|
||||
return new Candidate(
|
||||
Pair.of(placement.getLocatePos(start.getChunkPos()), holder),
|
||||
findUnexplored ? start : null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static final class Candidate {
|
||||
private final Pair<BlockPos, Holder<Structure>> result;
|
||||
private final StructureStart referenceStart;
|
||||
private final AtomicBoolean committed;
|
||||
|
||||
private Candidate(Pair<BlockPos, Holder<Structure>> result,
|
||||
StructureStart referenceStart) {
|
||||
this.result = result;
|
||||
this.referenceStart = referenceStart;
|
||||
this.committed = new AtomicBoolean();
|
||||
}
|
||||
|
||||
public Pair<BlockPos, Holder<Structure>> result() {
|
||||
return result;
|
||||
}
|
||||
|
||||
public boolean reference(StructureManager structureManager) {
|
||||
if (referenceStart == null || !committed.compareAndSet(false, true)) {
|
||||
return false;
|
||||
}
|
||||
if (!referenceStart.canBeReferenced()) {
|
||||
return false;
|
||||
}
|
||||
structureManager.addReference(referenceStart);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -43,13 +43,17 @@ public final class NativeStructureVegetationClearer {
|
||||
}
|
||||
boolean[] clearColumns = new boolean[area.getXSpan() * area.getZSpan()];
|
||||
for (VegetationTarget target : targets) {
|
||||
if (target != null && target.force() && target.start() != null && target.start().isValid()) {
|
||||
if (shouldProcessTarget(target)) {
|
||||
markVegetationColumns(area, snapshot, target, clearColumns);
|
||||
}
|
||||
}
|
||||
clearVegetationColumns(world, area, snapshot, clearColumns);
|
||||
}
|
||||
|
||||
static boolean shouldProcessTarget(VegetationTarget target) {
|
||||
return target != null && target.start() != null && target.start().isValid();
|
||||
}
|
||||
|
||||
private static VegetationSnapshot captureVegetation(ChunkAccess chunk, BoundingBox area) {
|
||||
int width = area.getXSpan();
|
||||
int depth = area.getZSpan();
|
||||
@@ -100,9 +104,6 @@ public final class NativeStructureVegetationClearer {
|
||||
int[] pieceTops = new int[clearColumns.length];
|
||||
Arrays.fill(pieceTops, Integer.MIN_VALUE);
|
||||
for (StructurePiece piece : target.start().getPieces()) {
|
||||
if (NativeStructureReferenceEnvelope.isMarker(piece)) {
|
||||
continue;
|
||||
}
|
||||
BoundingBox bounds = piece.getBoundingBox();
|
||||
int minX = Math.max(area.minX(), bounds.minX());
|
||||
int maxX = Math.min(area.maxX(), bounds.maxX());
|
||||
|
||||
+28
-9
@@ -15,8 +15,11 @@ 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.JigsawJunction;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.DesertPyramidStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.JungleTemplePiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.JungleTempleStructure;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentPieces;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -39,11 +42,11 @@ public final class NativeStructureVerticalPlacer {
|
||||
boolean underground, boolean preserveSourceY,
|
||||
IrisStructureYBand yBand,
|
||||
IntBinaryOperator surfaceHeight) {
|
||||
if (isOceanMonument(structureId)) {
|
||||
if (isOceanMonument(start, structureId)) {
|
||||
return alignOceanMonumentToSeaLevel(
|
||||
start, requestedOffset, seaLevel, worldMinY, worldMaxYExclusive);
|
||||
}
|
||||
if (isAdjustedScatteredStructure(structureId)) {
|
||||
if (isAdjustedScatteredStructure(start, structureId)) {
|
||||
return alignScatteredStructureToSurface(
|
||||
start, structureId, requestedOffset, worldMinY, worldMaxYExclusive, surfaceHeight);
|
||||
}
|
||||
@@ -169,18 +172,28 @@ public final class NativeStructureVerticalPlacer {
|
||||
static void ensureMonumentSeaLevelAlignment(StructureStart start, String structureId,
|
||||
int configuredOffset, int seaLevel,
|
||||
int worldMinY, int worldMaxYExclusive) {
|
||||
if (isOceanMonument(structureId)) {
|
||||
if (!isOceanMonument(start, structureId)) {
|
||||
return;
|
||||
}
|
||||
synchronized (start) {
|
||||
alignOceanMonumentToSeaLevel(
|
||||
start, configuredOffset, seaLevel, worldMinY, worldMaxYExclusive);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isOceanMonument(String structureId) {
|
||||
return OCEAN_MONUMENT_ID.equals(structureId);
|
||||
private static boolean isOceanMonument(StructureStart start, String structureId) {
|
||||
return OCEAN_MONUMENT_ID.equals(structureId)
|
||||
&& start != null
|
||||
&& start.getStructure() instanceof OceanMonumentStructure;
|
||||
}
|
||||
|
||||
private static boolean isAdjustedScatteredStructure(String structureId) {
|
||||
return DESERT_PYRAMID_ID.equals(structureId) || JUNGLE_PYRAMID_ID.equals(structureId);
|
||||
private static boolean isAdjustedScatteredStructure(StructureStart start, String structureId) {
|
||||
if (start == null) {
|
||||
return false;
|
||||
}
|
||||
Structure source = start.getStructure();
|
||||
return (DESERT_PYRAMID_ID.equals(structureId) && source instanceof DesertPyramidStructure)
|
||||
|| (JUNGLE_PYRAMID_ID.equals(structureId) && source instanceof JungleTempleStructure);
|
||||
}
|
||||
|
||||
private static ScatteredFeaturePiece requireAdjustedScatteredPiece(StructureStart start,
|
||||
@@ -252,9 +265,15 @@ public final class NativeStructureVerticalPlacer {
|
||||
private static OceanMonumentPieces.MonumentBuilding requireOceanMonumentBuilding(StructureStart start) {
|
||||
Objects.requireNonNull(start, "Ocean monument start must not be null");
|
||||
List<StructurePiece> pieces = start.getPieces();
|
||||
if (pieces.size() != 1 || !(pieces.get(0) instanceof OceanMonumentPieces.MonumentBuilding building)) {
|
||||
OceanMonumentPieces.MonumentBuilding building = null;
|
||||
for (StructurePiece piece : pieces) {
|
||||
if (piece instanceof OceanMonumentPieces.MonumentBuilding monumentBuilding) {
|
||||
building = monumentBuilding;
|
||||
}
|
||||
}
|
||||
if (pieces.size() != 1 || building == null) {
|
||||
throw new IllegalStateException("minecraft:monument must contain exactly one MonumentBuilding, found "
|
||||
+ pieces.size() + " top-level pieces");
|
||||
+ pieces.size() + " pieces");
|
||||
}
|
||||
return building;
|
||||
}
|
||||
|
||||
+10
-8
@@ -27,7 +27,8 @@ import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.nativegen.NativeStructureStartInjector;
|
||||
import art.arcane.iris.nativegen.NativeStructureLocateResults;
|
||||
import art.arcane.iris.nativegen.NativeStructureReferenceRepair;
|
||||
import art.arcane.iris.nativegen.NativeStructureVanillaLocator;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformBiome;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
@@ -294,14 +295,14 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
Engine current = engine();
|
||||
try (GenerationSessionLease lease = requireGenerationLease(current, "modded_structure_locate");
|
||||
IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) {
|
||||
Pair<BlockPos, Holder<Structure>> irisPlaced = nativeStructures.findNearestIrisStructure(
|
||||
level, holders, pos, Math.max(1, radius), findUnexplored, current);
|
||||
HolderSet<Structure> reachable = nativeStructures.filterReachableNativeStructures(
|
||||
level, holders, current);
|
||||
Pair<BlockPos, Holder<Structure>> nativeLocated = reachable.size() == 0
|
||||
? null
|
||||
: super.findNearestMapStructure(level, reachable, pos, radius, findUnexplored);
|
||||
return NativeStructureLocateResults.nearest(pos, irisPlaced, nativeLocated);
|
||||
NativeStructureVanillaLocator.Candidate nativeCandidate =
|
||||
reachable.size() == 0 ? null
|
||||
: NativeStructureVanillaLocator.predict(
|
||||
level, reachable, pos, radius, findUnexplored);
|
||||
return nativeStructures.findNearestIrisStructure(
|
||||
level, holders, pos, Math.max(0, radius), findUnexplored, current, nativeCandidate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,7 +880,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
Engine current = engine();
|
||||
try (GenerationSessionLease lease = requireGenerationLease(current, "modded_create_references");
|
||||
IrisContext.Scope ignored = IrisContext.open(current, lease.sessionId(), null)) {
|
||||
super.createReferences(level, structureManager, chunk);
|
||||
NativeStructureReferenceRepair.createReferences(
|
||||
current, level, structureManager, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-19
@@ -23,7 +23,7 @@ import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.iris.core.localization.RuntimeUiMessages;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import art.arcane.iris.core.pack.PackValidator;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
@@ -74,7 +74,6 @@ public final class ModdedForcedDatapack {
|
||||
// v2: custom biomes now inherit their vanilla derivative's biome tags, so every already-published pack has
|
||||
// to regenerate once.
|
||||
private static final String HASH_SALT = "iris-forced-datapack-v2";
|
||||
private static final String GIT_DIRECTORY = ".git";
|
||||
private static final long PACKS_HASH_TTL_NANOS = 2_000_000_000L;
|
||||
private static final Object LOCK = new Object();
|
||||
private static final AtomicBoolean LOADED = new AtomicBoolean(false);
|
||||
@@ -147,13 +146,13 @@ public final class ModdedForcedDatapack {
|
||||
return;
|
||||
}
|
||||
Path packsRoot = packsRoot();
|
||||
File[] packs = packsRoot.toFile().listFiles(File::isDirectory);
|
||||
if (packs == null || packs.length == 0) {
|
||||
List<File> packs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot.toFile());
|
||||
if (packs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
LOGGER.error("===============================================================");
|
||||
LOGGER.error("Iris forced datapack '{}' was never loaded by this server.", PACK_ID);
|
||||
LOGGER.error("{} installed pack(s) at {} contributed no dimension types or custom biomes.", packs.length, packsRoot);
|
||||
LOGGER.error("{} installed pack(s) at {} contributed no dimension types or custom biomes.", packs.size(), packsRoot);
|
||||
LOGGER.error("Datapack source injection failed for this loader (mixin/event not applied), so world creation will fail and restarting will not fix it.");
|
||||
LOGGER.error("===============================================================");
|
||||
}
|
||||
@@ -360,8 +359,8 @@ public final class ModdedForcedDatapack {
|
||||
Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
|
||||
// Studio packs can be git checkouts; .git churns constantly and never reaches the datapack.
|
||||
return GIT_DIRECTORY.equals(directory.getFileName().toString())
|
||||
return !directory.equals(root)
|
||||
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())
|
||||
? FileVisitResult.SKIP_SUBTREE
|
||||
: FileVisitResult.CONTINUE;
|
||||
}
|
||||
@@ -397,16 +396,10 @@ public final class ModdedForcedDatapack {
|
||||
int packCount = 0;
|
||||
KList<String> presetIds = new KList<>();
|
||||
File root = packsRoot().toFile();
|
||||
File[] packs = root.listFiles(File::isDirectory);
|
||||
if (packs == null && root.exists()) {
|
||||
throw new IOException("Iris could not read installed pack directory " + root.getAbsolutePath());
|
||||
}
|
||||
if (packs != null) {
|
||||
Arrays.sort(packs, Comparator.comparing(File::getName));
|
||||
for (File pack : packs) {
|
||||
if (stagePack(pack, fixer, stagingDirectory, seenBiomes, presetIds)) {
|
||||
packCount++;
|
||||
}
|
||||
List<File> packs = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(root);
|
||||
for (File pack : packs) {
|
||||
if (stagePack(pack, fixer, stagingDirectory, seenBiomes, presetIds)) {
|
||||
packCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,8 +424,7 @@ public final class ModdedForcedDatapack {
|
||||
KList<String> presetIds) throws IOException {
|
||||
PackValidationResult validation;
|
||||
try {
|
||||
validation = PackValidator.validate(sourcePack);
|
||||
PackValidationRegistry.publish(validation);
|
||||
validation = PackValidator.validateForDatapackBootstrap(sourcePack);
|
||||
} catch (Throwable validationFailure) {
|
||||
LOGGER.error("Iris excluded pack '{}' from Create World because validation failed",
|
||||
sourcePack.getName(), validationFailure);
|
||||
|
||||
+102
-49
@@ -21,17 +21,21 @@ package art.arcane.iris.modded;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
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.object.IrisMaterialPalette;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.nativegen.NativeStructureGenerationException;
|
||||
import art.arcane.iris.nativegen.NativeStructureLocatePersistence;
|
||||
import art.arcane.iris.nativegen.NativeStructureLocateResults;
|
||||
import art.arcane.iris.nativegen.NativeStructureOwnershipRecovery;
|
||||
import art.arcane.iris.nativegen.NativeStructurePostProcessor;
|
||||
import art.arcane.iris.nativegen.NativeStructureReferenceEnvelope;
|
||||
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.volmlib.util.math.RNG;
|
||||
@@ -60,6 +64,7 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
|
||||
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -85,42 +90,85 @@ final class ModdedNativeStructureStage {
|
||||
Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(ServerLevel level,
|
||||
HolderSet<Structure> holders,
|
||||
BlockPos pos, int radius, boolean findUnexplored,
|
||||
Engine current) {
|
||||
if (findUnexplored) {
|
||||
return null;
|
||||
}
|
||||
Engine current,
|
||||
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 bestDistance = Long.MAX_VALUE;
|
||||
List<IrisNativeLocateSearch> searches = new ArrayList<>(holders.size());
|
||||
NativeStructureLocatePersistence.ProbeBudget budget = NativeStructureLocatePersistence.probeBudget();
|
||||
for (Holder<Structure> holder : holders) {
|
||||
Identifier 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(current, structureId)) {
|
||||
if (!IrisStructureLocator.hasNativePlacement(current, structureId)) {
|
||||
continue;
|
||||
}
|
||||
IrisStructureLocator.LocateResult result = IrisStructureLocator.locate(
|
||||
current, 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 distance = dx * dx + dz * dz;
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
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(
|
||||
current, 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");
|
||||
}
|
||||
|
||||
HolderSet<Structure> filterReachableNativeStructures(ServerLevel level, HolderSet<Structure> holders,
|
||||
@@ -193,10 +241,14 @@ final class ModdedNativeStructureStage {
|
||||
decision.preserveSourceY(),
|
||||
decision.yBand(),
|
||||
(x, z) -> current.getHeight(x, z, true) + current.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);
|
||||
chunk.setStartForStructure(structure, wrapped);
|
||||
if (!wrapped.isValid()) {
|
||||
continue;
|
||||
}
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"vertical adjustment", structureId, chunkPos.x(), chunkPos.z(), error);
|
||||
@@ -227,7 +279,6 @@ final class ModdedNativeStructureStage {
|
||||
Engine current = generator.engine();
|
||||
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++) {
|
||||
@@ -245,10 +296,11 @@ final class ModdedNativeStructureStage {
|
||||
List<StructureStart> starts = structureManager.startsForStructure(sectionPos, structure);
|
||||
List<NativePlacement> resolvedPlacements = new ArrayList<>(starts.size());
|
||||
for (StructureStart start : starts) {
|
||||
NativeStructureStartPlan plan = NativeStructurePlacementPlanner.matchingPlan(
|
||||
current, structureId, start.getChunkPos().x(), start.getChunkPos().z());
|
||||
IrisNativeStructureDecision decision = plan == null
|
||||
? sourceDecision : NativeStructurePlacementPlanner.decisionFor(plan);
|
||||
NativeStructureOwnershipRecord ownership =
|
||||
NativeStructureOwnershipRecovery.resolve(
|
||||
current, world.getLevel(), structureId, structure, start);
|
||||
IrisNativeStructureDecision decision =
|
||||
ownership == null ? sourceDecision : ownership.restoredDecision();
|
||||
if (!decision.generate()) {
|
||||
continue;
|
||||
}
|
||||
@@ -258,9 +310,6 @@ final class ModdedNativeStructureStage {
|
||||
structureId, start,
|
||||
NativeStructureTerrainIntegrator.resolveNativeTerrain(
|
||||
start, decision.terrain())));
|
||||
if (plan == null || !plan.placement().isUnderground()) {
|
||||
nativeStarts.add(start);
|
||||
}
|
||||
boolean clearEntireFootprint = NativeStructureVegetationClearer
|
||||
.shouldClearEntireVegetationFootprint(
|
||||
structure.step(), decision.clearVegetation());
|
||||
@@ -289,15 +338,6 @@ final class ModdedNativeStructureStage {
|
||||
"heightmap priming", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
try {
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world, area, nativeStarts,
|
||||
(x, z) -> current.getHeight(x, z, true) + current.getMinHeight());
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"terrain integration", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
try {
|
||||
NativeStructureVegetationClearer.clearIntersectingVegetation(
|
||||
world, chunk, area, vegetationTargets);
|
||||
@@ -306,6 +346,15 @@ final class ModdedNativeStructureStage {
|
||||
"vegetation cleanup", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
try {
|
||||
NativeStructureSurfaceFitter.prepareSurfaceStructures(
|
||||
world, area, terrainTargets,
|
||||
(x, z) -> current.getHeight(x, z, true) + current.getMinHeight());
|
||||
} catch (Throwable error) {
|
||||
throw NativeStructureGenerationException.failure(
|
||||
"terrain integration", nativeStructureBatchContext(placementGroups),
|
||||
chunkPos.x(), chunkPos.z(), error);
|
||||
}
|
||||
try {
|
||||
NativeStructurePostProcessor.prepareTerrain(
|
||||
world, area, terrainTargets, this::resolvePaletteBlock);
|
||||
@@ -413,8 +462,8 @@ final class ModdedNativeStructureStage {
|
||||
ChunkPos chunkPos = chunk.getPos();
|
||||
int minX = chunkPos.getMinBlockX();
|
||||
int minZ = chunkPos.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(minX, minY, minZ, minX + 15, maxY, minZ + 15);
|
||||
}
|
||||
|
||||
@@ -436,6 +485,10 @@ final class ModdedNativeStructureStage {
|
||||
List<NativePlacement> placements) {
|
||||
}
|
||||
|
||||
private record IrisNativeLocateSearch(Holder<Structure> holder, String structureId,
|
||||
NativeStructureLocatePersistence.Search search) {
|
||||
}
|
||||
|
||||
private record StructureStepCache(Registry<Structure> registry, List<List<Structure>> structures) {
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -56,8 +56,8 @@ public final class ModdedPackInstaller {
|
||||
synchronized (installLock) {
|
||||
File packs = configDir.resolve("irisworldgen").resolve("packs").toFile();
|
||||
try {
|
||||
boolean installed = PackDownloader.isDefaultOverworld(pack)
|
||||
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback) != null
|
||||
PackDownloader.PackInstallResult result = PackDownloader.isDefaultOverworld(pack)
|
||||
? PackDownloader.downloadDefaultOverworld(packs, forceOverwrite, feedback)
|
||||
: PackDownloader.download(
|
||||
packs,
|
||||
"IrisDimensions/" + pack,
|
||||
@@ -65,8 +65,9 @@ public final class ModdedPackInstaller {
|
||||
forceOverwrite,
|
||||
false,
|
||||
pack,
|
||||
feedback) != null;
|
||||
if (installed) {
|
||||
feedback);
|
||||
boolean installed = result != null;
|
||||
if (result != null && result.changed()) {
|
||||
// Pack-install completion is one of the four forced-datapack regeneration triggers; every
|
||||
// install call site already runs off the server thread, so regenerate inline here. A
|
||||
// regeneration failure must never turn a successful install into a failed one.
|
||||
@@ -76,6 +77,9 @@ public final class ModdedPackInstaller {
|
||||
LOGGER.error("Iris installed pack '{}' but could not regenerate the forced datapack", pack, regenerationFailure);
|
||||
}
|
||||
}
|
||||
if (result != null && result.restartRequired()) {
|
||||
feedback.accept("Pack '" + pack + "' is installed on disk and requires a server restart before its active data changes.");
|
||||
}
|
||||
return installed;
|
||||
} catch (IOException error) {
|
||||
LOGGER.error("Iris pack download failed for IrisDimensions/{} ({})", pack, branch, error);
|
||||
|
||||
@@ -28,6 +28,7 @@ import art.arcane.iris.spi.PlatformEntityType;
|
||||
import art.arcane.iris.spi.PlatformItem;
|
||||
import art.arcane.iris.spi.PlatformRegistries;
|
||||
import art.arcane.volmlib.util.data.UnresolvedKeyLog;
|
||||
import net.minecraft.core.HolderLookup;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
@@ -40,6 +41,7 @@ import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.block.state.properties.Property;
|
||||
import net.minecraft.world.level.storage.loot.LootTable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -212,6 +214,20 @@ public final class ModdedRegistries implements PlatformRegistries {
|
||||
return keys;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> lootTableKeys() {
|
||||
List<String> keys = new ArrayList<>();
|
||||
MinecraftServer instance = server.get();
|
||||
if (instance == null) {
|
||||
warnNotReady("loot table");
|
||||
return keys;
|
||||
}
|
||||
HolderLookup.RegistryLookup<LootTable> registry = instance.reloadableRegistries().lookup()
|
||||
.lookupOrThrow(Registries.LOOT_TABLE);
|
||||
registry.listElementIds().forEach(key -> keys.add(key.identifier().toString()));
|
||||
return keys;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<PlatformBlockProperty>> blockStateProperties() {
|
||||
Map<String, List<PlatformBlockProperty>> properties = new LinkedHashMap<>();
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.core.pack.BrokenPackException;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.core.pack.PackDownloader;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
@@ -104,9 +105,9 @@ public final class ModdedStartup {
|
||||
|
||||
public static void validateAllPacks() {
|
||||
File packsRoot = ModdedPackCommands.packsRoot();
|
||||
File[] packDirs = packsRoot.listFiles(File::isDirectory);
|
||||
List<File> packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||
PackValidationRegistry.clear();
|
||||
if (packDirs == null || packDirs.length == 0) {
|
||||
if (packDirs.isEmpty()) {
|
||||
LOGGER.info("Iris found no packs to validate under {}; install one with /iris download <pack>",
|
||||
packsRoot.getAbsolutePath());
|
||||
return;
|
||||
@@ -146,8 +147,8 @@ public final class ModdedStartup {
|
||||
if (pack == null || pack.isBlank()) {
|
||||
throw new IllegalArgumentException("Pack name is required for world creation");
|
||||
}
|
||||
File packDir = new File(ModdedPackCommands.packsRoot(), pack);
|
||||
if (!packDir.isDirectory()) {
|
||||
File packDir = PackDirectoryResolver.resolveExisting(ModdedPackCommands.packsRoot(), pack);
|
||||
if (packDir == null) {
|
||||
throw new BrokenPackException(pack, List.of(
|
||||
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.nativegen.NativeStructureGenerationException;
|
||||
import art.arcane.iris.nativegen.NativeStructureFactory;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import art.arcane.iris.spi.PlatformWorld;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
@@ -95,6 +97,67 @@ public final class ModdedStructureHooks implements PlatformStructureHooks {
|
||||
return registryKeys(Registries.TEMPLATE_POOL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JigsawSourceMetadata jigsawSourceMetadata(String structureKey) {
|
||||
MinecraftServer instance = requireServer("resolve live jigsaw metadata for registered structure '"
|
||||
+ structureKey + "'");
|
||||
try {
|
||||
Identifier identifier = Identifier.tryParse(structureKey);
|
||||
if (identifier == null) {
|
||||
throw new IllegalArgumentException("Invalid registered structure key: " + structureKey);
|
||||
}
|
||||
Registry<Structure> registry = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE);
|
||||
Structure structure = registry.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);
|
||||
}
|
||||
return NativeStructureFactory.sourceMetadata(
|
||||
instance.registryAccess(), instance.getStructureManager(), jigsaw);
|
||||
} catch (RuntimeException error) {
|
||||
throw new IllegalStateException("Iris failed to resolve live jigsaw metadata for registered structure '"
|
||||
+ structureKey + "' from the modded structure registry", error);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int templatePoolHorizontalSpan(String templatePoolKey) {
|
||||
MinecraftServer instance = requireServer("resolve the live horizontal span for registered template pool '"
|
||||
+ templatePoolKey + "'");
|
||||
try {
|
||||
return NativeStructureFactory.templatePoolHorizontalSpan(
|
||||
instance.registryAccess(), instance.getStructureManager(), templatePoolKey);
|
||||
} catch (RuntimeException error) {
|
||||
throw new IllegalStateException("Iris failed to resolve the live horizontal span for registered "
|
||||
+ "template pool '" + templatePoolKey + "' from the modded template-pool registry", error);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int jigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) {
|
||||
MinecraftServer instance = requireServer("resolve the effective start-pool span for registered jigsaw '"
|
||||
+ structureKey + "'");
|
||||
try {
|
||||
Identifier identifier = Identifier.tryParse(structureKey);
|
||||
if (identifier == null) {
|
||||
throw new IllegalArgumentException("Invalid registered structure key: " + structureKey);
|
||||
}
|
||||
Structure structure = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE)
|
||||
.getValue(identifier);
|
||||
if (!(structure instanceof JigsawStructure jigsaw)) {
|
||||
throw new IllegalArgumentException("Registered structure is not a jigsaw: " + structureKey);
|
||||
}
|
||||
return NativeStructureFactory.jigsawStartPoolHorizontalSpan(
|
||||
instance.registryAccess(), instance.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
|
||||
+ "' from the modded registries", error);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> structureSetKeys() {
|
||||
return registryKeys(Registries.STRUCTURE_SET);
|
||||
|
||||
+13
-18
@@ -18,6 +18,7 @@
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
@@ -153,24 +154,18 @@ final class ModdedCommandSuggestions {
|
||||
names.add("overworld");
|
||||
try {
|
||||
File packs = ModdedEngineBootstrap.loader().configDir().resolve("irisworldgen").resolve("packs").toFile();
|
||||
File[] children = packs.listFiles();
|
||||
if (children != null) {
|
||||
for (File child : children) {
|
||||
if (!child.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
String packName = child.getName();
|
||||
names.add(packName);
|
||||
File dimensions = new File(child, "dimensions");
|
||||
File[] dimensionFiles = dimensions.listFiles(
|
||||
(File directory, String name) -> name.endsWith(".json"));
|
||||
if (dimensionFiles == null) {
|
||||
continue;
|
||||
}
|
||||
for (File dimensionFile : dimensionFiles) {
|
||||
String fileName = dimensionFile.getName();
|
||||
names.add(packName + ":" + fileName.substring(0, fileName.length() - 5));
|
||||
}
|
||||
for (File child : PackDirectoryResolver.listVisiblePackDirectories(packs)) {
|
||||
String packName = child.getName();
|
||||
names.add(packName);
|
||||
File dimensions = new File(child, "dimensions");
|
||||
File[] dimensionFiles = dimensions.listFiles(
|
||||
(File directory, String name) -> name.endsWith(".json"));
|
||||
if (dimensionFiles == null) {
|
||||
continue;
|
||||
}
|
||||
for (File dimensionFile : dimensionFiles) {
|
||||
String fileName = dimensionFile.getName();
|
||||
names.add(packName + ":" + fileName.substring(0, fileName.length() - 5));
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
|
||||
+54
-26
@@ -20,6 +20,7 @@ package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
@@ -42,8 +43,12 @@ import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
@@ -74,11 +79,11 @@ public final class ModdedDatapackCommands {
|
||||
root.then(Commands.literal("ls")
|
||||
.executes((CommandContext<CommandSourceStack> context) -> list(context.getSource())));
|
||||
|
||||
root.then(message("ingest", "Modrinth datapack ingest requires the Bukkit plugin because its editable-resource import and manifest workflow use Bukkit tooling. Iris modded dimensions do run native vanilla and datapack structure placement; install the datapack in world/datapacks and restart to generate its registered structures."));
|
||||
root.then(message("pull", "Modrinth datapack ingest requires the Bukkit plugin because its editable-resource import and manifest workflow use Bukkit tooling. Iris modded dimensions do run native vanilla and datapack structure placement; install the datapack in world/datapacks and restart to generate its registered structures."));
|
||||
root.then(message("ingest", "Managed Modrinth datapack ingest is Bukkit-only. On modded servers install the datapack folder or zip in world/datapacks, enable it, restart, then use /iris datapack list to confirm it is enabled. Registered structures generate natively in Iris dimensions."));
|
||||
root.then(message("pull", "Managed Modrinth datapack ingest is Bukkit-only. On modded servers install the datapack folder or zip in world/datapacks, enable it, restart, then use /iris datapack list to confirm it is enabled. Registered structures generate natively in Iris dimensions."));
|
||||
|
||||
root.then(message("remove", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart."));
|
||||
root.then(message("rm", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart."));
|
||||
root.then(message("remove", "Managed datapack removal is Bukkit-only. On modded servers disable the pack, delete its folder or zip from world/datapacks, and restart."));
|
||||
root.then(message("rm", "Managed datapack removal is Bukkit-only. On modded servers disable the pack, delete its folder or zip from world/datapacks, and restart."));
|
||||
|
||||
return root;
|
||||
}
|
||||
@@ -215,27 +220,24 @@ public final class ModdedDatapackCommands {
|
||||
MinecraftServer server = source.getServer();
|
||||
LinkedHashSet<String> configured = new LinkedHashSet<>();
|
||||
File packsRoot = ModdedPackCommands.packsRoot();
|
||||
File[] packs = packsRoot.isDirectory() ? packsRoot.listFiles(File::isDirectory) : null;
|
||||
if (packs != null) {
|
||||
for (File pack : packs) {
|
||||
if (!new File(pack, "dimensions").isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
IrisData data = IrisData.get(pack);
|
||||
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
|
||||
if (dimension == null || dimension.getDatapackImports() == null) {
|
||||
continue;
|
||||
}
|
||||
for (String url : dimension.getDatapackImports()) {
|
||||
if (url != null && !url.isBlank()) {
|
||||
configured.add(url.trim());
|
||||
}
|
||||
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(packsRoot)) {
|
||||
if (!new File(pack, "dimensions").isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
IrisData data = IrisData.get(pack);
|
||||
for (IrisDimension dimension : data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys())) {
|
||||
if (dimension == null || dimension.getDatapackImports() == null) {
|
||||
continue;
|
||||
}
|
||||
for (String url : dimension.getDatapackImports()) {
|
||||
if (url != null && !url.isBlank()) {
|
||||
configured.add(url.trim());
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("Iris datapack import scan failed for pack {}", pack.getName(), e);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOGGER.error("Iris datapack import scan failed for pack {}", pack.getName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,19 +250,45 @@ public final class ModdedDatapackCommands {
|
||||
}
|
||||
|
||||
File datapacks = worldDatapacksFolder(server);
|
||||
File[] installed = datapacks.isDirectory() ? datapacks.listFiles(File::isDirectory) : null;
|
||||
File[] installed = datapacks.isDirectory()
|
||||
? datapacks.listFiles(file -> file.isDirectory() || file.isFile() && file.getName().toLowerCase(Locale.ROOT).endsWith(".zip"))
|
||||
: null;
|
||||
Set<String> availableIds = new HashSet<>(server.getPackRepository().getAvailableIds());
|
||||
Set<String> selectedIds = new HashSet<>(server.getPackRepository().getSelectedIds());
|
||||
KList<String> names = new KList<>();
|
||||
if (installed != null) {
|
||||
for (File folder : installed) {
|
||||
if (new File(folder, "pack.mcmeta").isFile()) {
|
||||
names.add(folder.getName());
|
||||
for (File installedPack : installed) {
|
||||
String name = installedPack.getName();
|
||||
String repositoryId = resolveRepositoryId(name, availableIds);
|
||||
String state;
|
||||
if (repositoryId == null) {
|
||||
state = "unavailable";
|
||||
} else if (selectedIds.contains(repositoryId)) {
|
||||
state = "enabled";
|
||||
} else {
|
||||
state = "disabled";
|
||||
}
|
||||
names.add(name + " [" + state + "]");
|
||||
}
|
||||
}
|
||||
Collections.sort(names);
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_INSTALLED_WORLD_DATAPACKS, MessageArgument.untrusted("value", names.size())));
|
||||
for (String name : names) {
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_DATAPACK_COMMANDS_MESSAGE_2, MessageArgument.untrusted("name", name)));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static String resolveRepositoryId(String filename, Set<String> availableIds) {
|
||||
String direct = "file/" + filename;
|
||||
if (availableIds.contains(direct)) {
|
||||
return direct;
|
||||
}
|
||||
for (String availableId : availableIds) {
|
||||
if (availableId.equals(filename) || availableId.endsWith("/" + filename)) {
|
||||
return availableId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -173,10 +173,16 @@ final class ModdedLocateCommands {
|
||||
return 0;
|
||||
}
|
||||
if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) {
|
||||
locateIrisStructure(source, level, engine, player, target.key());
|
||||
if (!IrisStructureLocator.hasNativePlacement(engine, target.key())) {
|
||||
locateIrisStructure(source, level, engine, player, target.key());
|
||||
return 1;
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_SEARCHING_NATIVE_STRUCTURE_WITHIN_CHUNKS, MessageArgument.untrusted("value", target.key()), MessageArgument.untrusted("NATIVESTRUCTURELOCATERADIUS", NATIVE_STRUCTURE_LOCATE_RADIUS)));
|
||||
runNativeStructureLocate(source, level, player, target);
|
||||
return 1;
|
||||
}
|
||||
if (target.availability() != NativeStructureAvailability.AVAILABLE) {
|
||||
if (!IrisStructureLocator.hasNativePlacement(engine, target.key())
|
||||
&& target.availability() != NativeStructureAvailability.AVAILABLE) {
|
||||
IrisModdedCommands.fail(source, nativeUnavailableMessage(target.key(), target.availability()));
|
||||
return 0;
|
||||
}
|
||||
|
||||
+3
-5
@@ -110,14 +110,12 @@ public final class ModdedPackCommands {
|
||||
|
||||
List<File> targets = new ArrayList<>();
|
||||
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()) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_NO_PACKS_VALIDATE_UNDER, MessageArgument.untrusted("value", packsRoot.getAbsolutePath())));
|
||||
return 0;
|
||||
}
|
||||
for (File dir : dirs) {
|
||||
targets.add(dir);
|
||||
}
|
||||
targets.addAll(dirs);
|
||||
} else {
|
||||
File target = PackDirectoryResolver.resolveExisting(packsRoot, pack);
|
||||
if (target == null) {
|
||||
|
||||
+2
-1
@@ -48,7 +48,8 @@ public class ModdedGenerationLeaseContractTest {
|
||||
String references = method(source, "public void createReferences(");
|
||||
assertTrue(references.contains("requireGenerationLease(current, \"modded_create_references\")"));
|
||||
assertTrue(references.contains("IrisContext.open(current, lease.sessionId(), null)"));
|
||||
assertTrue(references.contains("super.createReferences(level, structureManager, chunk);"));
|
||||
assertTrue(references.contains("NativeStructureReferenceRepair.createReferences("));
|
||||
assertFalse(references.contains("super.createReferences(level, structureManager, chunk);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+7
@@ -80,6 +80,11 @@ public class ModdedStructureHooksTest {
|
||||
IllegalStateException.class, hooks::jigsawStructureKeys);
|
||||
IllegalStateException templatePoolKeys = assertThrows(
|
||||
IllegalStateException.class, hooks::templatePoolKeys);
|
||||
IllegalStateException jigsawMetadata = assertThrows(
|
||||
IllegalStateException.class, () -> hooks.jigsawSourceMetadata("minecraft:village"));
|
||||
IllegalStateException templatePoolSpan = assertThrows(
|
||||
IllegalStateException.class,
|
||||
() -> hooks.templatePoolHorizontalSpan("minecraft:village/plains/town_centers"));
|
||||
IllegalStateException structureSetKeys = assertThrows(
|
||||
IllegalStateException.class, hooks::structureSetKeys);
|
||||
IllegalStateException structureBiomeKeys = assertThrows(
|
||||
@@ -88,6 +93,8 @@ public class ModdedStructureHooksTest {
|
||||
assertTrue(structureKeys.getMessage().contains("before the Minecraft server is available"));
|
||||
assertTrue(jigsawStructureKeys.getMessage().contains("before the Minecraft server is available"));
|
||||
assertTrue(templatePoolKeys.getMessage().contains("before the Minecraft server is available"));
|
||||
assertTrue(jigsawMetadata.getMessage().contains("before the Minecraft server is available"));
|
||||
assertTrue(templatePoolSpan.getMessage().contains("before the Minecraft server is available"));
|
||||
assertTrue(structureSetKeys.getMessage().contains("before the Minecraft server is available"));
|
||||
assertTrue(structureBiomeKeys.getMessage().contains("before the Minecraft server is available"));
|
||||
}
|
||||
|
||||
+4
-3
@@ -56,7 +56,7 @@ public class NativeStructureFailureContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void structureTerrainPreparationPrecedesVegetationAndPlacement() throws IOException {
|
||||
public void structureVegetationCleanupPrecedesTerrainPreparationAndPlacement() throws IOException {
|
||||
String source = moddedSource("ModdedNativeStructureStage.java");
|
||||
int placementStart = source.indexOf("void placeVanillaStructures");
|
||||
int placementEnd = source.indexOf("private static String nativeStructureBatchContext", placementStart);
|
||||
@@ -65,9 +65,9 @@ public class NativeStructureFailureContractTest {
|
||||
assertTrue(placement.contains("\"terrain integration\""));
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ public class NativeStructureFailureContractTest {
|
||||
< placement.indexOf("prepareSurfaceStructures"));
|
||||
assertTrue(source.contains("generationEngine.getHeight(x, z, false) + runtimeMinY + 1"));
|
||||
assertTrue(source.contains("generationEngine.getHeight(x, z, true) + runtimeMinY + 1"));
|
||||
assertTrue(source.contains("int minY = chunk.getMinY() + 1;"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.core.project.SchemaBuilder;
|
||||
import art.arcane.iris.engine.object.IrisDirection;
|
||||
import art.arcane.iris.engine.object.annotations.ArrayType;
|
||||
import art.arcane.iris.engine.object.annotations.Desc;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class SchemaBuilderPlatformIsolationTest {
|
||||
@Test
|
||||
public void enumSchemaDoesNotResolveBukkitOnlyMethodSignatures() {
|
||||
assertThrows(ClassNotFoundException.class, () -> Class.forName("org.bukkit.block.BlockFace"));
|
||||
|
||||
JSONObject schema = new SchemaBuilder(DirectionModel.class, null).construct();
|
||||
JSONObject direction = schema.getJSONObject("properties").getJSONObject("direction");
|
||||
String definitionKey = direction.getString("$ref").substring("#/definitions/".length());
|
||||
JSONArray values = schema.getJSONObject("definitions").getJSONObject(definitionKey).getJSONArray("oneOf");
|
||||
List<String> names = new ArrayList<>(values.length());
|
||||
for (int index = 0; index < values.length(); index++) {
|
||||
names.add(values.getJSONObject(index).getString("const"));
|
||||
}
|
||||
|
||||
assertEquals(List.of(
|
||||
"UP_POSITIVE_Y",
|
||||
"DOWN_NEGATIVE_Y",
|
||||
"NORTH_NEGATIVE_Z",
|
||||
"SOUTH_POSITIVE_Z",
|
||||
"EAST_POSITIVE_X",
|
||||
"WEST_NEGATIVE_X"), names);
|
||||
assertEquals("#/definitions/" + definitionKey,
|
||||
schema.getJSONObject("properties").getJSONObject("directions")
|
||||
.getJSONObject("items").getString("$ref"));
|
||||
}
|
||||
|
||||
@Desc("Direction model.")
|
||||
public static class DirectionModel {
|
||||
@Desc("Direction.")
|
||||
private IrisDirection direction = IrisDirection.NORTH_NEGATIVE_Z;
|
||||
|
||||
@Desc("Directions.")
|
||||
@ArrayType(type = IrisDirection.class)
|
||||
private KList<IrisDirection> directions = new KList<>();
|
||||
}
|
||||
}
|
||||
+56
-7
@@ -1,15 +1,49 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.nativegen.NativeStructureLocateResults;
|
||||
import com.mojang.datafixers.util.Pair;
|
||||
import net.minecraft.core.BlockPos;
|
||||
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.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisModdedStructureCommandTest {
|
||||
@Test
|
||||
public void mixedUnexploredLocateReferencesOnlyTheSelectedProvider() {
|
||||
BlockPos origin = BlockPos.ZERO;
|
||||
Pair<BlockPos, String> irisNear = Pair.of(new BlockPos(4, 70, 0), "iris");
|
||||
Pair<BlockPos, String> nativeFar = Pair.of(new BlockPos(8, 70, 0), "native");
|
||||
AtomicInteger irisReferences = new AtomicInteger();
|
||||
AtomicInteger nativeReferences = new AtomicInteger();
|
||||
|
||||
Pair<BlockPos, String> irisSelected = NativeStructureLocateResults.selectAndReference(
|
||||
origin,
|
||||
irisNear, () -> irisReferences.incrementAndGet(),
|
||||
nativeFar, () -> nativeReferences.incrementAndGet());
|
||||
|
||||
assertSame(irisNear, irisSelected);
|
||||
assertEquals(1, irisReferences.get());
|
||||
assertEquals(0, nativeReferences.get());
|
||||
|
||||
Pair<BlockPos, String> nativeNear = Pair.of(new BlockPos(2, 70, 0), "native");
|
||||
Pair<BlockPos, String> nativeSelected = NativeStructureLocateResults.selectAndReference(
|
||||
origin,
|
||||
irisNear, () -> irisReferences.incrementAndGet(),
|
||||
nativeNear, () -> nativeReferences.incrementAndGet());
|
||||
|
||||
assertSame(nativeNear, nativeSelected);
|
||||
assertEquals(1, irisReferences.get());
|
||||
assertEquals(1, nativeReferences.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void gotoStructureSupportsIrisAndNativeRegistryTargets() throws IOException {
|
||||
String source = source("ModdedLocateCommands.java");
|
||||
@@ -40,21 +74,34 @@ public class IrisModdedStructureCommandTest {
|
||||
int methodStart = source.indexOf("Pair<BlockPos, Holder<Structure>> findNearestIrisStructure(");
|
||||
int methodEnd = source.indexOf("HolderSet<Structure> filterReachableNativeStructures(", methodStart);
|
||||
String method = source.substring(methodStart, methodEnd);
|
||||
int unexploredGuard = method.indexOf("if (findUnexplored)");
|
||||
int registryLookup = method.indexOf("level.registryAccess().lookupOrThrow(Registries.STRUCTURE)");
|
||||
int placedCheck = method.indexOf(
|
||||
"if (!IrisStructureLocator.isPlaced(current, structureId))");
|
||||
int irisLocate = method.indexOf("IrisStructureLocator.locate(", placedCheck);
|
||||
"if (!IrisStructureLocator.hasNativePlacement(current, structureId))");
|
||||
int irisLocate = method.indexOf("NativeStructureLocatePersistence.search(", placedCheck);
|
||||
int nearestSelection = method.indexOf(
|
||||
"NativeStructureLocateResults.nearest(pos, predicted, nativeLocated)", irisLocate);
|
||||
int selectedVerification = method.indexOf("bestSearch.search().verify(bestResult)", nearestSelection);
|
||||
int selectedReference = method.indexOf(
|
||||
"selectedSearch.search().reference(selectedStart)", selectedVerification);
|
||||
|
||||
assertTrue(unexploredGuard >= 0);
|
||||
assertTrue(registryLookup > unexploredGuard);
|
||||
assertTrue(registryLookup >= 0);
|
||||
assertTrue(placedCheck > registryLookup);
|
||||
assertTrue(irisLocate > placedCheck);
|
||||
assertTrue(nearestSelection > irisLocate);
|
||||
assertTrue(selectedVerification > nearestSelection);
|
||||
assertTrue(selectedReference > selectedVerification);
|
||||
assertTrue(method.contains("NativeStructureLocatePersistence.probe("));
|
||||
assertTrue(method.contains("findUnexplored"));
|
||||
assertTrue(method.contains("LocateStatus.SEARCH_LIMIT_REACHED"));
|
||||
assertTrue(method.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())"));
|
||||
assertTrue(method.contains("verified.ownership().locatorY()"));
|
||||
assertTrue(method.contains("selectedSearch.search().reference(selectedStart)"));
|
||||
assertTrue(method.contains("NativeStructureLocateResults.selectAndReference("));
|
||||
assertFalse(method.contains("NativeStructureLocateCapability"));
|
||||
assertTrue(source.contains("structureBiomeSource.isStructureReachable(holder)"));
|
||||
assertFalse(source.contains("isPaperUnavailable"));
|
||||
String generator = moddedSource("IrisModdedChunkGenerator.java");
|
||||
assertTrue(generator.contains("NativeStructureVanillaLocator.predict("));
|
||||
assertFalse(generator.contains("super.findNearestMapStructure(level, reachable"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,7 +115,7 @@ public class IrisModdedStructureCommandTest {
|
||||
int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", genericIrisLookup);
|
||||
int replacementCheck = method.indexOf(
|
||||
"decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution);
|
||||
int replacementLocate = method.indexOf("locateIrisStructure(source, level, engine, player, target.key())",
|
||||
int replacementLocate = method.indexOf("runNativeStructureLocate(source, level, player, target)",
|
||||
replacementCheck);
|
||||
|
||||
assertTrue(nativeResolution >= 0);
|
||||
@@ -76,6 +123,8 @@ public class IrisModdedStructureCommandTest {
|
||||
assertTrue(policyResolution > genericIrisLookup);
|
||||
assertTrue(replacementCheck > policyResolution);
|
||||
assertTrue(replacementLocate > replacementCheck);
|
||||
assertTrue(method.contains("!IrisStructureLocator.hasNativePlacement(engine, target.key())"));
|
||||
assertTrue(method.contains("&& target.availability() != NativeStructureAvailability.AVAILABLE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
package art.arcane.iris.nativegen;
|
||||
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipBundle;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipRecord;
|
||||
import art.arcane.iris.engine.framework.NativeStructureOwnershipStore;
|
||||
import art.arcane.iris.engine.framework.NativeStructureStartPlan;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructure;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrain;
|
||||
import art.arcane.iris.engine.object.IrisStructureTerrainMode;
|
||||
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Direction;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.Registry;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.StructureManager;
|
||||
import net.minecraft.world.level.WorldGenLevel;
|
||||
import net.minecraft.world.level.chunk.ChunkAccess;
|
||||
import net.minecraft.world.level.chunk.ProtoChunk;
|
||||
import net.minecraft.world.level.chunk.UpgradeData;
|
||||
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.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.OceanMonumentPieces;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.OceanMonumentStructure;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationHandler;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class NativeStructureReferenceRepairTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistedEnvelopeRepairsReloadedMonumentReferenceOutsideLiveBounds() {
|
||||
long seed = 119723L;
|
||||
ChunkPos origin = new ChunkPos(-3, 6);
|
||||
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 deniedNaturalStartsCannotEnterCollisionArbitration() {
|
||||
IrisNativeStructureDecision enabled = new IrisNativeStructureDecision(
|
||||
NativeStructureGenerationStatus.GENERATE_NATIVE,
|
||||
0, null, false, false, null, new IrisStructureTerrain());
|
||||
IrisNativeStructureDecision disabled = new IrisNativeStructureDecision(
|
||||
NativeStructureGenerationStatus.DISABLED_BY_PACK,
|
||||
0, null, false, false, null, new IrisStructureTerrain());
|
||||
IrisNativeStructureDecision replaced = new IrisNativeStructureDecision(
|
||||
NativeStructureGenerationStatus.REPLACED_BY_IRIS,
|
||||
0, null, false, false, null, new IrisStructureTerrain());
|
||||
|
||||
assertTrue(NativeStructureReferenceRepair.naturalDecisionAllows(enabled));
|
||||
assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(disabled));
|
||||
assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(replaced));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void persistedManualOwnershipSurvivesDisabledNaturalPolicyDuringReferenceScans()
|
||||
throws Exception {
|
||||
String structureKey = "minecraft:monument";
|
||||
long seed = 582119L;
|
||||
ChunkPos origin = new ChunkPos(2, -4);
|
||||
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 start = NativeStructureReferenceEnvelope.wrap(
|
||||
generated, structure, 0, terrain);
|
||||
NativeStructureOwnershipRecord ownership = NativeStructureOwnershipFingerprint.capture(
|
||||
structureKey,
|
||||
start,
|
||||
plan(origin, NativeStructureReferenceEnvelope.contentBounds(start).minY()),
|
||||
NativeStructureReferenceEnvelope.referenceBounds(start, structure, terrain));
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
dimension.getImportedStructures().getDisabled().add(structureKey);
|
||||
IrisData data = allocateWithoutConstructor(IrisData.class);
|
||||
Engine engine = engine(dimension, data);
|
||||
IrisNativeStructureDecision currentDecision = NativeStructureGenerationPolicy.resolve(
|
||||
engine, structureKey, false);
|
||||
|
||||
assertFalse(currentDecision.generate());
|
||||
assertFalse(NativeStructureReferenceRepair.naturalDecisionAllows(currentDecision));
|
||||
|
||||
ProtoChunk originChunk = emptyChunk(origin);
|
||||
originChunk.setStartForStructure(structure, start);
|
||||
ProtoChunk emptyScannedChunk = emptyChunk(new ChunkPos(Integer.MIN_VALUE, Integer.MIN_VALUE));
|
||||
Registry<Structure> registry = structureRegistry(structure, structureKey);
|
||||
ServerLevel serverLevel = allocateWithoutConstructor(ServerLevel.class);
|
||||
StructureManager structureManager = new StructureManager(null, null, null);
|
||||
ChunkPos coveredTarget = new ChunkPos(
|
||||
ownership.referenceMinChunkX(), ownership.referenceMinChunkZ());
|
||||
ChunkPos uncoveredTarget = outsideReferenceEnvelopeWithinScan(ownership);
|
||||
|
||||
assertNotNull(uncoveredTarget);
|
||||
|
||||
installOwnership(engine, ownership);
|
||||
try {
|
||||
WorldGenLevel level = worldGenLevel(
|
||||
registry, serverLevel, originChunk, emptyScannedChunk);
|
||||
ProtoChunk coveredChunk = emptyChunk(coveredTarget);
|
||||
NativeStructureReferenceRepair.createReferences(
|
||||
engine, level, structureManager, coveredChunk);
|
||||
|
||||
assertSame(start, originChunk.getStartForStructure(structure));
|
||||
assertTrue(originChunk.getStartForStructure(structure).isValid());
|
||||
assertTrue(coveredChunk.getReferencesForStructure(structure).contains(origin.pack()));
|
||||
|
||||
ProtoChunk uncoveredChunk = emptyChunk(uncoveredTarget);
|
||||
NativeStructureReferenceRepair.createReferences(
|
||||
engine, level, structureManager, uncoveredChunk);
|
||||
|
||||
assertSame(start, originChunk.getStartForStructure(structure));
|
||||
assertTrue(originChunk.getStartForStructure(structure).isValid());
|
||||
assertFalse(uncoveredChunk.getReferencesForStructure(structure).contains(origin.pack()));
|
||||
} finally {
|
||||
NativeStructureOwnershipStore.close(engine);
|
||||
}
|
||||
}
|
||||
|
||||
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-reference-test")
|
||||
.setNativeStructures(new KList<IrisNativeStructure>().qadd(source));
|
||||
return new NativeStructureStartPlan(
|
||||
placement,
|
||||
source,
|
||||
origin.x(),
|
||||
origin.z(),
|
||||
baseY
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static ChunkPos outsideReferenceEnvelopeWithinScan(
|
||||
NativeStructureOwnershipRecord ownership) {
|
||||
int originX = ownership.originChunkX();
|
||||
int originZ = ownership.originChunkZ();
|
||||
List<ChunkPos> candidates = List.of(
|
||||
new ChunkPos(ownership.referenceMinChunkX() - 1, originZ),
|
||||
new ChunkPos(ownership.referenceMaxChunkX() + 1, originZ),
|
||||
new ChunkPos(originX, ownership.referenceMinChunkZ() - 1),
|
||||
new ChunkPos(originX, ownership.referenceMaxChunkZ() + 1));
|
||||
for (ChunkPos candidate : candidates) {
|
||||
if (!ownership.covers(candidate.x(), candidate.z())
|
||||
&& Math.abs(candidate.x() - originX)
|
||||
<= NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS
|
||||
&& Math.abs(candidate.z() - originZ)
|
||||
<= NativeStructureOwnershipRecord.MAX_REFERENCE_DISTANCE_CHUNKS) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ProtoChunk emptyChunk(ChunkPos position) {
|
||||
return new ProtoChunk(
|
||||
position,
|
||||
UpgradeData.EMPTY,
|
||||
LevelHeightAccessor.create(0, 0),
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Registry<Structure> structureRegistry(
|
||||
Structure structure, String structureKey) {
|
||||
Identifier identifier = Identifier.parse(structureKey);
|
||||
InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> {
|
||||
if (method.getName().equals("getKey")) {
|
||||
return arguments != null && arguments.length == 1
|
||||
&& arguments[0] == structure ? identifier : null;
|
||||
}
|
||||
return defaultProxyValue(proxy, method, arguments);
|
||||
};
|
||||
return (Registry<Structure>) Proxy.newProxyInstance(
|
||||
Registry.class.getClassLoader(),
|
||||
new Class<?>[]{Registry.class},
|
||||
handler);
|
||||
}
|
||||
|
||||
private static WorldGenLevel worldGenLevel(
|
||||
Registry<Structure> registry,
|
||||
ServerLevel serverLevel,
|
||||
ChunkAccess originChunk,
|
||||
ChunkAccess emptyChunk) {
|
||||
RegistryAccess registryAccess = registryAccess(registry);
|
||||
ChunkPos origin = originChunk.getPos();
|
||||
InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> {
|
||||
return switch (method.getName()) {
|
||||
case "registryAccess" -> registryAccess;
|
||||
case "getLevel" -> serverLevel;
|
||||
case "getChunk" -> arguments != null
|
||||
&& arguments.length >= 2
|
||||
&& ((Integer) arguments[0]) == origin.x()
|
||||
&& ((Integer) arguments[1]) == origin.z()
|
||||
? originChunk : emptyChunk;
|
||||
default -> defaultProxyValue(proxy, method, arguments);
|
||||
};
|
||||
};
|
||||
return (WorldGenLevel) Proxy.newProxyInstance(
|
||||
WorldGenLevel.class.getClassLoader(),
|
||||
new Class<?>[]{WorldGenLevel.class},
|
||||
handler);
|
||||
}
|
||||
|
||||
private static RegistryAccess registryAccess(Registry<Structure> registry) {
|
||||
InvocationHandler handler = (Object proxy, Method method, Object[] arguments) -> {
|
||||
return switch (method.getName()) {
|
||||
case "lookupOrThrow" -> registry;
|
||||
case "lookup" -> Optional.of(registry);
|
||||
case "registries" -> Stream.empty();
|
||||
default -> defaultProxyValue(proxy, method, arguments);
|
||||
};
|
||||
};
|
||||
return (RegistryAccess) Proxy.newProxyInstance(
|
||||
RegistryAccess.class.getClassLoader(),
|
||||
new Class<?>[]{RegistryAccess.class},
|
||||
handler);
|
||||
}
|
||||
|
||||
private static Engine engine(IrisDimension dimension, IrisData data) throws Exception {
|
||||
TestEngine engine = allocateWithoutConstructor(TestEngine.class);
|
||||
engine.dimension = dimension;
|
||||
engine.data = data;
|
||||
return engine;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void installOwnership(
|
||||
Engine engine, NativeStructureOwnershipRecord ownership) throws Exception {
|
||||
NativeStructureOwnershipBundle bundle =
|
||||
NativeStructureOwnershipBundle.empty().with(ownership);
|
||||
Class<?> storageType = Class.forName(
|
||||
"art.arcane.iris.engine.framework.NativeStructureOwnershipStore$Storage");
|
||||
InvocationHandler storageHandler =
|
||||
(Object proxy, Method method, Object[] arguments) -> {
|
||||
return switch (method.getName()) {
|
||||
case "read" -> arguments != null
|
||||
&& arguments.length == 2
|
||||
&& ((Integer) arguments[0]) == ownership.originChunkX()
|
||||
&& ((Integer) arguments[1]) == ownership.originChunkZ()
|
||||
? bundle : null;
|
||||
case "write", "remove" -> null;
|
||||
default -> defaultProxyValue(proxy, method, arguments);
|
||||
};
|
||||
};
|
||||
Object storage = Proxy.newProxyInstance(
|
||||
storageType.getClassLoader(),
|
||||
new Class<?>[]{storageType},
|
||||
storageHandler);
|
||||
Class<?> stateType = Class.forName(
|
||||
"art.arcane.iris.engine.framework.NativeStructureOwnershipStore$State");
|
||||
Constructor<?> constructor = stateType.getDeclaredConstructor(Engine.class, storageType);
|
||||
constructor.setAccessible(true);
|
||||
Object state = constructor.newInstance(engine, storage);
|
||||
Field statesField = NativeStructureOwnershipStore.class.getDeclaredField("STATES");
|
||||
statesField.setAccessible(true);
|
||||
Cache<Engine, Object> states = (Cache<Engine, Object>) statesField.get(null);
|
||||
states.put(engine, state);
|
||||
}
|
||||
|
||||
private static <T> T allocateWithoutConstructor(Class<T> type) throws Exception {
|
||||
Class<?> unsafeType = Class.forName("sun.misc.Unsafe");
|
||||
Field unsafeField = unsafeType.getDeclaredField("theUnsafe");
|
||||
unsafeField.setAccessible(true);
|
||||
Object unsafe = unsafeField.get(null);
|
||||
Method allocateInstance = unsafeType.getMethod("allocateInstance", Class.class);
|
||||
return type.cast(allocateInstance.invoke(unsafe, type));
|
||||
}
|
||||
|
||||
private static Object defaultProxyValue(
|
||||
Object proxy, Method method, Object[] arguments) {
|
||||
return switch (method.getName()) {
|
||||
case "equals" -> arguments != null && arguments.length == 1
|
||||
&& proxy == arguments[0];
|
||||
case "hashCode" -> System.identityHashCode(proxy);
|
||||
case "toString" -> proxy.getClass().getInterfaces()[0].getSimpleName() + "Proxy";
|
||||
default -> primitiveDefault(method.getReturnType());
|
||||
};
|
||||
}
|
||||
|
||||
private static Object primitiveDefault(Class<?> returnType) {
|
||||
if (!returnType.isPrimitive() || returnType == void.class) {
|
||||
return null;
|
||||
}
|
||||
if (returnType == boolean.class) {
|
||||
return false;
|
||||
}
|
||||
if (returnType == char.class) {
|
||||
return '\0';
|
||||
}
|
||||
if (returnType == byte.class) {
|
||||
return (byte) 0;
|
||||
}
|
||||
if (returnType == short.class) {
|
||||
return (short) 0;
|
||||
}
|
||||
if (returnType == int.class) {
|
||||
return 0;
|
||||
}
|
||||
if (returnType == long.class) {
|
||||
return 0L;
|
||||
}
|
||||
if (returnType == float.class) {
|
||||
return 0.0F;
|
||||
}
|
||||
return 0.0D;
|
||||
}
|
||||
|
||||
private static final class TestEngine extends IrisEngine {
|
||||
private IrisDimension dimension;
|
||||
private IrisData data;
|
||||
|
||||
private TestEngine() {
|
||||
super(null, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisDimension getDimension() {
|
||||
return dimension;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IrisData getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosing() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,8 @@ nmsBindings.each { key, value ->
|
||||
rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/IrisChunkGenerator.java").absolutePath)
|
||||
systemProperty('iris.nativeStructurePostProcessorSource',
|
||||
rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java').absolutePath)
|
||||
systemProperty('iris.nativeStructureStartInjectorSource',
|
||||
rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java').absolutePath)
|
||||
systemProperty('iris.customBiomeSource',
|
||||
rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/CustomBiomeSource.java").absolutePath)
|
||||
systemProperty('iris.vanillaStructureBiomesSource',
|
||||
|
||||
@@ -119,6 +119,10 @@ dependencies {
|
||||
testRuntimeOnly(libs.paper.api)
|
||||
}
|
||||
|
||||
tasks.named('test').configure {
|
||||
maxHeapSize = '1g'
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
|
||||
@@ -2,14 +2,18 @@ art/arcane/iris/core/IrisRuntimeSchedulerMode.java
|
||||
art/arcane/iris/core/IrisWorldStorage.java
|
||||
art/arcane/iris/core/IrisWorlds.java
|
||||
art/arcane/iris/core/ServerConfigurator.java
|
||||
art/arcane/iris/core/WorldCreatorCompat.java
|
||||
art/arcane/iris/core/WorldRemovalPathPolicy.java
|
||||
art/arcane/iris/core/datapack/DatapackIngestService.java
|
||||
art/arcane/iris/core/edit/BlockSignal.java
|
||||
art/arcane/iris/core/edit/DustRevealer.java
|
||||
art/arcane/iris/core/events/IrisEngineEvent.java
|
||||
art/arcane/iris/core/events/IrisEngineHotloadEvent.java
|
||||
art/arcane/iris/core/events/IrisLootEvent.java
|
||||
art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java
|
||||
art/arcane/iris/core/lifecycle/BukkitPublicBackend.java
|
||||
art/arcane/iris/core/lifecycle/CapabilitySnapshot.java
|
||||
art/arcane/iris/core/lifecycle/IrisWorldRemovalService.java
|
||||
art/arcane/iris/core/lifecycle/PaperLibBootstrap.java
|
||||
art/arcane/iris/core/lifecycle/PaperLikeRuntimeBackend.java
|
||||
art/arcane/iris/core/lifecycle/WorldLifecycleBackend.java
|
||||
@@ -115,7 +119,6 @@ art/arcane/iris/engine/object/IrisVanillaLootTable.java
|
||||
art/arcane/iris/engine/object/LegacyTileData.java
|
||||
art/arcane/iris/engine/object/PotionEffectTypes.java
|
||||
art/arcane/iris/engine/object/TileData.java
|
||||
art/arcane/iris/engine/object/annotations/functions/LootTableKeyFunction.java
|
||||
art/arcane/iris/engine/platform/BukkitChunkGenerator.java
|
||||
art/arcane/iris/engine/platform/DummyBiomeProvider.java
|
||||
art/arcane/iris/engine/platform/DummyChunkGenerator.java
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record DatapackInstallResult(Status status) {
|
||||
public DatapackInstallResult {
|
||||
Objects.requireNonNull(status, "status");
|
||||
}
|
||||
|
||||
public static DatapackInstallResult failedResult() {
|
||||
return new DatapackInstallResult(Status.FAILED);
|
||||
}
|
||||
|
||||
public static DatapackInstallResult unchangedResult() {
|
||||
return new DatapackInstallResult(Status.UNCHANGED);
|
||||
}
|
||||
|
||||
public static DatapackInstallResult readyResult() {
|
||||
return new DatapackInstallResult(Status.READY);
|
||||
}
|
||||
|
||||
public static DatapackInstallResult restartRequiredResult() {
|
||||
return new DatapackInstallResult(Status.RESTART_REQUIRED);
|
||||
}
|
||||
|
||||
public boolean succeeded() {
|
||||
return status != Status.FAILED;
|
||||
}
|
||||
|
||||
public boolean changed() {
|
||||
return status == Status.READY || status == Status.RESTART_REQUIRED;
|
||||
}
|
||||
|
||||
public boolean restartRequired() {
|
||||
return status == Status.RESTART_REQUIRED;
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
FAILED,
|
||||
UNCHANGED,
|
||||
READY,
|
||||
RESTART_REQUIRED
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package art.arcane.iris.core;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
@@ -10,8 +12,12 @@ import art.arcane.volmlib.util.collection.KSet;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
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.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
@@ -19,6 +25,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -49,9 +56,7 @@ public final class IrisDatapackCompiler {
|
||||
throw new IOException("No Iris datapack output roots were provided");
|
||||
}
|
||||
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
Files.createDirectories(datapackRoot.toPath());
|
||||
}
|
||||
resetOutputRoots(datapackRoots);
|
||||
IrisDimension.clearGeneratedBiomeTags(datapackRoots);
|
||||
|
||||
DimensionHeight height = new DimensionHeight(fixer);
|
||||
@@ -59,6 +64,7 @@ public final class IrisDatapackCompiler {
|
||||
int packCount = 0;
|
||||
int dimensionCount = 0;
|
||||
for (File packRoot : packRoots) {
|
||||
PackDirectoryResolver.requireSafePackTree(packRoot);
|
||||
if (!hasDimensions(packRoot.toPath())) {
|
||||
continue;
|
||||
}
|
||||
@@ -93,42 +99,42 @@ public final class IrisDatapackCompiler {
|
||||
}
|
||||
|
||||
IrisDimension.writeShared(datapackRoots, height, packFormat, adjustVanillaHeight);
|
||||
validateOutputs(datapackRoots);
|
||||
validateOutputs(datapackRoots, dimensionCount);
|
||||
return new CompilationResult(packCount, dimensionCount, countBiomes(biomes));
|
||||
}
|
||||
|
||||
private static void collectInstalledPackRoots(Path packsRoot, Map<Path, File> roots) throws IOException {
|
||||
if (!Files.isDirectory(packsRoot)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(packsRoot)) {
|
||||
List<Path> candidates = stream
|
||||
.filter(Files::isDirectory)
|
||||
.sorted(Comparator.comparing(Path::toString))
|
||||
.toList();
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots);
|
||||
}
|
||||
List<File> candidates = PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot.toFile());
|
||||
for (File candidate : candidates) {
|
||||
addPackRoot(candidate.toPath(), roots);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectWorldPackRoots(Path dimensionsRoot, Map<Path, File> roots) throws IOException {
|
||||
if (!Files.isDirectory(dimensionsRoot)) {
|
||||
if (Files.isSymbolicLink(dimensionsRoot)
|
||||
|| !Files.isDirectory(dimensionsRoot, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.find(
|
||||
dimensionsRoot,
|
||||
WORLD_PACK_SCAN_DEPTH,
|
||||
(path, attributes) -> attributes.isDirectory()
|
||||
&& "pack".equals(path.getFileName().toString())
|
||||
&& path.getParent() != null
|
||||
&& "iris".equals(path.getParent().getFileName().toString())
|
||||
&& hasDimensions(path)
|
||||
)) {
|
||||
List<Path> candidates = stream.sorted(Comparator.comparing(Path::toString)).toList();
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots);
|
||||
List<Path> candidates = new ArrayList<>();
|
||||
Files.walkFileTree(dimensionsRoot, Set.of(), WORLD_PACK_SCAN_DEPTH, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
|
||||
if (!directory.equals(dimensionsRoot)
|
||||
&& PackDirectoryResolver.containsHiddenPathSegment(dimensionsRoot, directory)) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
if ("pack".equals(directory.getFileName().toString())
|
||||
&& directory.getParent() != null
|
||||
&& "iris".equals(directory.getParent().getFileName().toString())
|
||||
&& hasDimensions(directory)) {
|
||||
candidates.add(directory);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
candidates.sort(Comparator.comparing(Path::toString));
|
||||
for (Path candidate : candidates) {
|
||||
addPackRoot(candidate, roots);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,29 +143,52 @@ public final class IrisDatapackCompiler {
|
||||
return;
|
||||
}
|
||||
Path normalized = root.toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(normalized)) {
|
||||
return;
|
||||
}
|
||||
PackDirectoryResolver.requireSafePackTree(normalized.toFile());
|
||||
Path identity = normalized.toRealPath();
|
||||
roots.putIfAbsent(identity, normalized.toFile());
|
||||
}
|
||||
|
||||
private static boolean hasDimensions(Path root) {
|
||||
Path dimensions = root.resolve("dimensions");
|
||||
if (!Files.isDirectory(dimensions)) {
|
||||
if (Files.isSymbolicLink(dimensions)
|
||||
|| !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(dimensions)) {
|
||||
return stream.anyMatch(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".json"));
|
||||
return stream.anyMatch(path -> !Files.isSymbolicLink(path)
|
||||
&& Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)
|
||||
&& path.getFileName().toString().endsWith(".json"));
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateOutputs(Collection<File> datapackRoots) throws IOException {
|
||||
private static void resetOutputRoots(Collection<File> datapackRoots) throws IOException {
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
Path root = datapackRoot.toPath().toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(root)) {
|
||||
throw new IOException("Iris datapack output root is a symbolic link: " + root);
|
||||
}
|
||||
if (Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Iris datapack output root is not a directory: " + root);
|
||||
}
|
||||
AtomicDirectoryPublisher.deleteTree(root);
|
||||
}
|
||||
Files.createDirectories(root);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateOutputs(Collection<File> datapackRoots, int dimensionCount) throws IOException {
|
||||
for (File datapackRoot : datapackRoots) {
|
||||
Path root = datapackRoot.toPath();
|
||||
if (!Files.isRegularFile(root.resolve("pack.mcmeta"))) {
|
||||
throw new IOException("Iris datapack metadata was not generated at " + root);
|
||||
}
|
||||
if (!Files.isDirectory(root.resolve("data/iris/dimension_type"))) {
|
||||
if (dimensionCount > 0 && !Files.isDirectory(root.resolve("data/iris/dimension_type"))) {
|
||||
throw new IOException("Iris dimension types were not generated at " + root);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -50,6 +51,10 @@ public final class IrisWorldStorage {
|
||||
return cached;
|
||||
}
|
||||
|
||||
public static String configuredLevelName() {
|
||||
return levelNameFromProperties(new File("server.properties"));
|
||||
}
|
||||
|
||||
static String levelNameFromProperties(File serverProperties) {
|
||||
Properties properties = new Properties();
|
||||
if (Objects.requireNonNull(serverProperties, "serverProperties").isFile()) {
|
||||
@@ -87,6 +92,39 @@ public final class IrisWorldStorage {
|
||||
return keyFromName(worldName, levelRoot().getName());
|
||||
}
|
||||
|
||||
public static NamespacedKey managedKeyFromName(String worldName) {
|
||||
String requestedName = Objects.requireNonNull(worldName, "worldName").trim();
|
||||
if (requestedName.contains(":")) {
|
||||
return managedKeyFromName(requestedName, DEFAULT_LEVEL_NAME);
|
||||
}
|
||||
return managedKeyFromName(requestedName, levelRoot().getName());
|
||||
}
|
||||
|
||||
public static NamespacedKey managedKeyFromName(String worldName, String levelName) {
|
||||
String requestedName = Objects.requireNonNull(worldName, "worldName").trim();
|
||||
if (requestedName.isEmpty()) {
|
||||
throw new IllegalArgumentException("World name cannot be empty.");
|
||||
}
|
||||
if (requestedName.contains("/") || requestedName.contains("\\") || requestedName.contains("..")) {
|
||||
throw new IllegalArgumentException("World name must be a safe single path segment.");
|
||||
}
|
||||
|
||||
NamespacedKey key;
|
||||
if (requestedName.contains(":")) {
|
||||
key = NamespacedKey.fromString(requestedName.toLowerCase(Locale.ENGLISH));
|
||||
if (key == null) {
|
||||
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
|
||||
}
|
||||
} else {
|
||||
key = keyFromName(requestedName, levelName);
|
||||
}
|
||||
|
||||
if (!IRIS_NAMESPACE.equals(key.getNamespace()) || !key.getKey().matches("[a-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException("Only Iris-managed dimension worlds can be changed.");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
static NamespacedKey keyFromName(String worldName, String levelName) {
|
||||
String name = Objects.requireNonNull(worldName, "worldName").trim();
|
||||
String mainLevelName = Objects.requireNonNull(levelName, "levelName").trim();
|
||||
@@ -112,7 +150,11 @@ public final class IrisWorldStorage {
|
||||
}
|
||||
|
||||
public static String logicalName(NamespacedKey key) {
|
||||
return logicalName(key, levelRoot().getName());
|
||||
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
||||
if (IRIS_NAMESPACE.equals(worldKey.getNamespace())) {
|
||||
return worldKey.getKey();
|
||||
}
|
||||
return logicalName(worldKey, levelRoot().getName());
|
||||
}
|
||||
|
||||
static String logicalName(NamespacedKey key, String levelName) {
|
||||
@@ -147,6 +189,31 @@ public final class IrisWorldStorage {
|
||||
return dimensionRoot(levelRoot(), key);
|
||||
}
|
||||
|
||||
public static File requireSafeManagedDimensionRoot(NamespacedKey key) {
|
||||
return requireSafeManagedDimensionRoot(levelRoot(), key);
|
||||
}
|
||||
|
||||
public static File requireSafeManagedDimensionRoot(File levelRoot, NamespacedKey key) {
|
||||
NamespacedKey worldKey = Objects.requireNonNull(key, "key");
|
||||
if (!IRIS_NAMESPACE.equals(worldKey.getNamespace()) || !worldKey.getKey().matches("[a-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException("Only safe Iris-managed dimension worlds can be changed.");
|
||||
}
|
||||
|
||||
Path root = Objects.requireNonNull(levelRoot, "levelRoot").toPath().toAbsolutePath().normalize();
|
||||
Path dimensions = root.resolve("dimensions");
|
||||
Path namespace = dimensions.resolve(IRIS_NAMESPACE);
|
||||
Path target = namespace.resolve(worldKey.getKey()).normalize();
|
||||
if (!Objects.equals(target.getParent(), namespace)) {
|
||||
throw new IllegalArgumentException("World target escapes the Iris namespace root.");
|
||||
}
|
||||
for (Path path : new Path[]{dimensions, namespace, target}) {
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IllegalArgumentException("World storage path contains a symbolic link: " + path);
|
||||
}
|
||||
}
|
||||
return target.toFile();
|
||||
}
|
||||
|
||||
public static File dimensionRoot(File levelRoot, NamespacedKey key) {
|
||||
Path dimensionsRoot = Objects.requireNonNull(levelRoot, "levelRoot")
|
||||
.toPath()
|
||||
|
||||
@@ -22,8 +22,15 @@ import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -62,9 +69,42 @@ public class IrisWorlds {
|
||||
});
|
||||
}
|
||||
|
||||
public void put(String identity, String type) {
|
||||
put0(identity, type);
|
||||
save();
|
||||
public synchronized void put(String identity, String type) {
|
||||
String canonicalIdentity = WorldIdentity.parse(identity).toString();
|
||||
String requiredType = Objects.requireNonNull(type, "type");
|
||||
String previous = worlds.put(canonicalIdentity, requiredType);
|
||||
if (requiredType.equals(previous)) {
|
||||
return;
|
||||
}
|
||||
dirty = true;
|
||||
try {
|
||||
saveOrThrow();
|
||||
} catch (IOException e) {
|
||||
if (previous == null) {
|
||||
worlds.remove(canonicalIdentity);
|
||||
} else {
|
||||
worlds.put(canonicalIdentity, previous);
|
||||
}
|
||||
dirty = true;
|
||||
throw new UncheckedIOException("Failed to persist Iris world registry entry for " + canonicalIdentity, e);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean remove(String identity) {
|
||||
String canonicalIdentity = WorldIdentity.parse(identity).toString();
|
||||
String previous = worlds.remove(canonicalIdentity);
|
||||
if (previous == null) {
|
||||
return false;
|
||||
}
|
||||
dirty = true;
|
||||
try {
|
||||
saveOrThrow();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
worlds.put(canonicalIdentity, previous);
|
||||
dirty = true;
|
||||
throw new UncheckedIOException("Failed to remove Iris world registry entry for " + canonicalIdentity, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void put0(String identity, String type) {
|
||||
@@ -74,7 +114,7 @@ public class IrisWorlds {
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
public KMap<String, String> getWorlds() {
|
||||
public synchronized KMap<String, String> getWorlds() {
|
||||
clean();
|
||||
KMap<String, String> result = new KMap<>();
|
||||
readBukkitWorlds().forEach((name, type) -> result.put(IrisWorldStorage.keyFromName(name).toString(), type));
|
||||
@@ -95,7 +135,7 @@ public class IrisWorlds {
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
public void clean() {
|
||||
public synchronized void clean() {
|
||||
boolean removed = worlds.entrySet().removeIf(entry -> {
|
||||
try {
|
||||
File packRoot = IrisWorldStorage.packRoot(WorldIdentity.parse(entry.getKey()));
|
||||
@@ -108,18 +148,43 @@ public class IrisWorlds {
|
||||
}
|
||||
|
||||
public synchronized void save() {
|
||||
clean();
|
||||
if (!dirty) return;
|
||||
try {
|
||||
IO.write(IrisPlatforms.get().dataFile("worlds.json"), OutputStreamWriter::new, writer -> GSON.toJson(worlds, TYPE, writer));
|
||||
dirty = false;
|
||||
saveOrThrow();
|
||||
} catch (IOException e) {
|
||||
IrisLogging.error("Failed to save worlds.json!");
|
||||
e.printStackTrace();
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveOrThrow() throws IOException {
|
||||
clean();
|
||||
if (!dirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path target = IrisPlatforms.get().dataFile("worlds.json").toPath().toAbsolutePath().normalize();
|
||||
Path parent = target.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("worlds.json target has no parent: " + target);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
Path staged = Files.createTempFile(parent, ".iris-worlds-", ".json");
|
||||
try {
|
||||
Files.writeString(staged, GSON.toJson(worlds, TYPE), StandardCharsets.UTF_8);
|
||||
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(staged, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
dirty = false;
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
public static Long readBukkitWorldSeed(String world) {
|
||||
YamlConfiguration bukkit = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
|
||||
ConfigurationSection worlds = bukkit.getConfigurationSection("worlds");
|
||||
|
||||
@@ -22,10 +22,14 @@ import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.core.loader.ResourceLoader;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.datapack.DataVersion;
|
||||
import art.arcane.iris.core.nms.datapack.IDataFixer;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
@@ -47,16 +51,27 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
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.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -64,6 +79,8 @@ import art.arcane.iris.core.localization.BukkitRuntimeMessages;
|
||||
import art.arcane.iris.core.localization.IrisLanguage;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
public class ServerConfigurator {
|
||||
private static final Object DATAPACK_INSTALL_LOCK = new Object();
|
||||
|
||||
public static void configure() {
|
||||
IrisSettings.IrisSettingsAutoconfiguration s = IrisSettings.get().getAutoConfiguration();
|
||||
if (s.isConfigureSpigotTimeoutTime()) {
|
||||
@@ -77,7 +94,10 @@ public class ServerConfigurator {
|
||||
if (DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()) {
|
||||
IrisLogging.info("Paper loaded the Iris datapack during bootstrap; skipping the legacy startup install.");
|
||||
} else {
|
||||
installDataPacks(true);
|
||||
DatapackInstallResult result = installDataPacks(true);
|
||||
if (result.restartRequired() && IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall()) {
|
||||
restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,20 +144,20 @@ public class ServerConfigurator {
|
||||
return roots;
|
||||
}
|
||||
|
||||
public static boolean installDataPacks(boolean fullInstall) {
|
||||
IDataFixer fixer = DataVersion.getDefault();
|
||||
if (fixer == null) {
|
||||
DataVersion fallback = DataVersion.getLatest();
|
||||
IrisLogging.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion());
|
||||
fixer = fallback.get();
|
||||
}
|
||||
return installDataPacks(fixer, fullInstall);
|
||||
public static DatapackInstallResult installDataPacks(boolean fullInstall) {
|
||||
return installDataPacks(resolveDataFixer(), fullInstall);
|
||||
}
|
||||
|
||||
public static boolean installDataPacks(IDataFixer fixer, boolean fullInstall) {
|
||||
public static DatapackInstallResult installDataPacks(IDataFixer fixer, boolean fullInstall) {
|
||||
synchronized (DATAPACK_INSTALL_LOCK) {
|
||||
return installDataPacksLocked(fixer, fullInstall);
|
||||
}
|
||||
}
|
||||
|
||||
private static DatapackInstallResult installDataPacksLocked(IDataFixer fixer, boolean fullInstall) {
|
||||
if (fixer == null) {
|
||||
IrisLogging.error("Unable to install datapacks, fixer is null!");
|
||||
return false;
|
||||
return DatapackInstallResult.failedResult();
|
||||
}
|
||||
if (fullInstall) {
|
||||
IrisLogging.info("Checking Data Packs...");
|
||||
@@ -145,7 +165,10 @@ public class ServerConfigurator {
|
||||
IrisLogging.debug("Checking Data Packs...");
|
||||
}
|
||||
KList<File> datapacksFolders = getDatapacksFolder();
|
||||
DatapackIngestService.reapplyFromStaging(datapacksFolders);
|
||||
if (!DatapackIngestService.reapplyFromStaging(datapacksFolders)) {
|
||||
IrisLogging.error("Unable to compile Iris datapacks while external datapack recovery is incomplete.");
|
||||
return DatapackInstallResult.failedResult();
|
||||
}
|
||||
List<File> packRoots;
|
||||
try (Stream<IrisData> stream = allPacks()) {
|
||||
packRoots = stream
|
||||
@@ -155,17 +178,58 @@ public class ServerConfigurator {
|
||||
.toList();
|
||||
}
|
||||
|
||||
KList<File> liveRoots = getIrisDatapackRoots();
|
||||
KList<File> stagedRoots = new KList<>();
|
||||
List<Path> stagedPaths = new ArrayList<>(liveRoots.size());
|
||||
List<AtomicDirectoryPublisher.Publication> publications = new ArrayList<>(liveRoots.size());
|
||||
try {
|
||||
for (File liveRoot : liveRoots) {
|
||||
Path target = liveRoot.toPath().toAbsolutePath().normalize();
|
||||
Path parent = target.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("Iris datapack root has no parent: " + target);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
Path staged = parent.resolve(".iris-compile-" + UUID.randomUUID());
|
||||
Files.createDirectories(staged);
|
||||
stagedPaths.add(staged);
|
||||
stagedRoots.add(staged.toFile());
|
||||
}
|
||||
IrisDatapackCompiler.compile(
|
||||
packRoots,
|
||||
getIrisDatapackRoots(),
|
||||
stagedRoots,
|
||||
fixer,
|
||||
BukkitPlatform.dataPackFormat(),
|
||||
IrisSettings.get().getGeneral().adjustVanillaHeight
|
||||
);
|
||||
} catch (IOException e) {
|
||||
for (int i = 0; i < liveRoots.size(); i++) {
|
||||
publications.add(AtomicDirectoryPublisher.publish(
|
||||
stagedRoots.get(i).toPath(),
|
||||
liveRoots.get(i).toPath()
|
||||
));
|
||||
}
|
||||
for (AtomicDirectoryPublisher.Publication publication : publications) {
|
||||
publication.commit();
|
||||
try {
|
||||
publication.cleanupBackup();
|
||||
} catch (IOException cleanupFailure) {
|
||||
IrisLogging.warn("Iris datapack was committed but its backup could not be removed: "
|
||||
+ cleanupFailure.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
closePublications(publications, e);
|
||||
IrisLogging.reportError("Unable to compile Iris datapacks", e);
|
||||
return false;
|
||||
return DatapackInstallResult.failedResult();
|
||||
} finally {
|
||||
for (Path stagedPath : stagedPaths) {
|
||||
try {
|
||||
AtomicDirectoryPublisher.deleteTree(stagedPath);
|
||||
} catch (IOException cleanupFailure) {
|
||||
IrisLogging.warn("Failed to clean Iris datapack compilation stage " + stagedPath + ": "
|
||||
+ cleanupFailure.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fullInstall) {
|
||||
IrisLogging.info("Data Packs Setup!");
|
||||
@@ -173,74 +237,205 @@ public class ServerConfigurator {
|
||||
IrisLogging.debug("Data Packs Setup!");
|
||||
}
|
||||
|
||||
return fullInstall && verifyDataPacksPost(IrisSettings.get().getAutoConfiguration().isAutoRestartOnCustomBiomeInstall());
|
||||
boolean restartRequired = fullInstall && verifyDataPacksPost();
|
||||
return restartRequired
|
||||
? DatapackInstallResult.restartRequiredResult()
|
||||
: DatapackInstallResult.readyResult();
|
||||
}
|
||||
|
||||
public static boolean installDataPacksIfChanged(boolean fullInstall) {
|
||||
File packsDir = IrisPlatforms.get().dataFolder("packs");
|
||||
String current = computePackFingerprint(packsDir);
|
||||
File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint");
|
||||
String cached = "";
|
||||
if (cacheFile.exists()) {
|
||||
private static IDataFixer resolveDataFixer() {
|
||||
IDataFixer fixer = DataVersion.getDefault();
|
||||
if (fixer != null) {
|
||||
return fixer;
|
||||
}
|
||||
DataVersion fallback = DataVersion.getLatest();
|
||||
IrisLogging.warn("Primary datapack fixer was null, forcing latest fixer: " + fallback.getVersion());
|
||||
return fallback.get();
|
||||
}
|
||||
|
||||
private static void closePublications(List<AtomicDirectoryPublisher.Publication> publications, Throwable failure) {
|
||||
for (int i = publications.size() - 1; i >= 0; i--) {
|
||||
try {
|
||||
cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim();
|
||||
} catch (IOException e) {
|
||||
cached = "";
|
||||
publications.get(i).close();
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
if (!current.isEmpty() && current.equals(cached)) {
|
||||
IrisLogging.debug("Data packs unchanged, skipping install.");
|
||||
return false;
|
||||
}
|
||||
|
||||
public static DatapackInstallResult installDataPacksIfChanged(boolean fullInstall) {
|
||||
synchronized (DATAPACK_INSTALL_LOCK) {
|
||||
File packsDir = IrisPlatforms.get().dataFolder("packs");
|
||||
String current;
|
||||
try {
|
||||
current = computePackFingerprint(packsDir);
|
||||
} catch (RuntimeException exception) {
|
||||
IrisLogging.reportError("Unable to fingerprint Iris packs safely", exception);
|
||||
return DatapackInstallResult.failedResult();
|
||||
}
|
||||
File cacheFile = new File(IrisPlatforms.get().dataFolder("cache"), "datapack-fingerprint");
|
||||
String cached = "";
|
||||
if (cacheFile.exists()) {
|
||||
try {
|
||||
cached = Files.readString(cacheFile.toPath(), StandardCharsets.UTF_8).trim();
|
||||
} catch (IOException e) {
|
||||
cached = "";
|
||||
}
|
||||
}
|
||||
if (!current.isEmpty() && current.equals(cached)) {
|
||||
IrisLogging.debug("Data packs unchanged, skipping install.");
|
||||
return DatapackInstallResult.unchangedResult();
|
||||
}
|
||||
DatapackInstallResult result = installDataPacksLocked(resolveDataFixer(), fullInstall);
|
||||
if (result.succeeded()) {
|
||||
try {
|
||||
writeFingerprintAtomic(cacheFile.toPath(), current);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
boolean result = installDataPacks(fullInstall);
|
||||
try {
|
||||
cacheFile.getParentFile().mkdirs();
|
||||
Files.writeString(cacheFile.toPath(), current, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
IrisLogging.warn("Failed to write datapack fingerprint cache: " + e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static String computePackFingerprint(File packsDir) {
|
||||
if (packsDir == null || !packsDir.isDirectory()) {
|
||||
if (packsDir == null) {
|
||||
return "";
|
||||
}
|
||||
Path root = packsDir.toPath().toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(root)) {
|
||||
throw new IllegalArgumentException("Iris packs root is a symbolic link: " + root);
|
||||
}
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
List<String> entries = new ArrayList<>();
|
||||
collectFingerprintEntries(packsDir, packsDir.getAbsolutePath(), entries);
|
||||
Collections.sort(entries);
|
||||
for (String entry : entries) {
|
||||
digest.update(entry.getBytes(StandardCharsets.UTF_8));
|
||||
List<FingerprintEntry> entries = collectFingerprintEntries(root);
|
||||
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
|
||||
byte[] buffer = new byte[8192];
|
||||
for (FingerprintEntry entry : entries) {
|
||||
byte[] relativePath = entry.relativePath().getBytes(StandardCharsets.UTF_8);
|
||||
updateDigestInt(digest, relativePath.length);
|
||||
digest.update(relativePath);
|
||||
updateDigestLong(digest, Files.size(entry.source()));
|
||||
try (InputStream input = Files.newInputStream(entry.source())) {
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
if (read > 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
byte[] hash = digest.digest();
|
||||
StringBuilder sb = new StringBuilder(hash.length * 2);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
} catch (IOException exception) {
|
||||
throw new UncheckedIOException("Unable to fingerprint Iris packs at " + root, exception);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 not available", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectFingerprintEntries(File dir, String rootPath, List<String> entries) {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) {
|
||||
return;
|
||||
private static void writeFingerprintAtomic(Path target, String fingerprint) throws IOException {
|
||||
Path absoluteTarget = target.toAbsolutePath().normalize();
|
||||
Path parent = absoluteTarget.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("Datapack fingerprint target has no parent: " + absoluteTarget);
|
||||
}
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
if (file.getName().startsWith(".")) {
|
||||
Files.createDirectories(parent);
|
||||
Path staged = Files.createTempFile(parent, ".datapack-fingerprint-", ".tmp");
|
||||
try {
|
||||
Files.writeString(staged, fingerprint, StandardCharsets.UTF_8);
|
||||
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<FingerprintEntry> collectFingerprintEntries(Path root) throws IOException {
|
||||
List<FingerprintEntry> entries = new ArrayList<>();
|
||||
try (Stream<Path> children = Files.list(root)) {
|
||||
for (Path child : children.toList()) {
|
||||
String childName = child.getFileName().toString();
|
||||
if (PackDirectoryResolver.isHiddenName(childName)) {
|
||||
continue;
|
||||
}
|
||||
collectFingerprintEntries(file, rootPath, entries);
|
||||
} else {
|
||||
String relative = file.getAbsolutePath().substring(rootPath.length());
|
||||
entries.add(relative + "|" + file.length() + "|" + file.lastModified());
|
||||
if (Files.isSymbolicLink(child)) {
|
||||
if (!Files.isDirectory(child)) {
|
||||
throw new IOException("Iris pack fingerprint rejected symbolic link: " + child);
|
||||
}
|
||||
PackDirectoryResolver.requireSafePackTree(child.toFile());
|
||||
collectFingerprintTree(child.toRealPath(), childName, entries);
|
||||
} else if (Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
collectFingerprintTree(child, childName, entries);
|
||||
} else if (Files.isRegularFile(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
entries.add(new FingerprintEntry(child, childName));
|
||||
} else {
|
||||
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static void collectFingerprintTree(
|
||||
Path treeRoot,
|
||||
String logicalRoot,
|
||||
List<FingerprintEntry> entries
|
||||
) throws IOException {
|
||||
Files.walkFileTree(treeRoot, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
|
||||
if (!directory.equals(treeRoot)
|
||||
&& PackDirectoryResolver.isHiddenName(directory.getFileName().toString())) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
if (PackDirectoryResolver.isHiddenName(file.getFileName().toString())) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
|
||||
throw new IOException("Iris pack fingerprint rejected symbolic link: " + file);
|
||||
}
|
||||
if (!attributes.isRegularFile()) {
|
||||
throw new IOException("Iris pack fingerprint rejected unsupported entry: " + file);
|
||||
}
|
||||
String relative = treeRoot.relativize(file).toString().replace(File.separatorChar, '/');
|
||||
entries.add(new FingerprintEntry(file, logicalRoot + "/" + relative));
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
|
||||
throw new IOException("Unable to inspect Iris pack entry: " + file, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private record FingerprintEntry(Path source, String relativePath) {
|
||||
}
|
||||
|
||||
private static void updateDigestInt(MessageDigest digest, int value) {
|
||||
for (int shift = Integer.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
|
||||
digest.update((byte) (value >>> shift));
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateDigestLong(MessageDigest digest, long value) {
|
||||
for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
|
||||
digest.update((byte) (value >>> shift));
|
||||
}
|
||||
}
|
||||
|
||||
public static File resolveDatapacksFolder(File worldFolder) {
|
||||
@@ -255,12 +450,12 @@ public class ServerConfigurator {
|
||||
return IrisWorldStorage.levelRoot(worldFolder);
|
||||
}
|
||||
|
||||
private static boolean verifyDataPacksPost(boolean allowRestarting) {
|
||||
private static boolean verifyDataPacksPost() {
|
||||
try (Stream<IrisData> stream = allPacks()) {
|
||||
boolean bad = stream
|
||||
.map(data -> {
|
||||
IrisLogging.debug("Checking Pack: " + data.getDataFolder().getPath());
|
||||
var loader = data.getDimensionLoader();
|
||||
ResourceLoader<IrisDimension> loader = data.getDimensionLoader();
|
||||
return loader.loadAll(loader.getPossibleKeys())
|
||||
.stream()
|
||||
.filter(Objects::nonNull)
|
||||
@@ -270,13 +465,13 @@ public class ServerConfigurator {
|
||||
})
|
||||
.toList()
|
||||
.contains(true);
|
||||
if (!bad) return false;
|
||||
if (!bad) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (allowRestarting) {
|
||||
restart();
|
||||
} else if (INMS.get().supportsDataPacks()) {
|
||||
if (INMS.get().supportsDataPacks()) {
|
||||
IrisLogging.error("============================================================================");
|
||||
IrisLogging.error(C.ITALIC + "You need to restart your server to properly generate custom biomes.");
|
||||
IrisLogging.error(C.ITALIC + "By continuing, Iris will use backup biomes in place of the custom biomes.");
|
||||
@@ -292,22 +487,23 @@ public class ServerConfigurator {
|
||||
}
|
||||
}
|
||||
|
||||
J.sleep(3000);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void restart() {
|
||||
J.s(() -> {
|
||||
IrisLogging.warn("New data pack entries have been installed in Iris! Restarting server!");
|
||||
IrisLogging.warn("This will only happen when your pack changes (updates/first time setup)");
|
||||
IrisLogging.warn("(You can disable this auto restart in iris settings)");
|
||||
restart("New data pack entries have been installed in Iris.");
|
||||
}
|
||||
|
||||
public static void restart(String reason) {
|
||||
LifecycleOperationCoordinator.get().quiesceForRestart(() -> J.s(() -> {
|
||||
IrisLogging.warn(reason + " Restarting server to restore a safe lifecycle boundary.");
|
||||
J.s(() -> {
|
||||
IrisLogging.warn("Looks like the restart command didn't work. Stopping the server instead!");
|
||||
Bukkit.shutdown();
|
||||
}, 100);
|
||||
Bukkit.dispatchCommand(Bukkit.getConsoleSender(), "restart");
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
public static boolean verifyDataPackInstalled(IrisDimension dimension) {
|
||||
@@ -360,12 +556,12 @@ public class ServerConfigurator {
|
||||
}
|
||||
|
||||
public static Stream<IrisData> allPacks() {
|
||||
File[] packs = IrisPlatforms.get().dataFolder("packs").listFiles(File::isDirectory);
|
||||
Stream<File> locals = packs == null ? Stream.empty() : Arrays.stream(packs);
|
||||
Stream<File> locals = PackDirectoryResolver.listVisiblePackDirectories(
|
||||
IrisPlatforms.get().dataFolder("packs")
|
||||
).stream();
|
||||
return Stream.concat(locals
|
||||
.filter(base -> !base.getName().contains(".importing-"))
|
||||
.filter( base -> {
|
||||
var content = new File(base, "dimensions").listFiles();
|
||||
.filter(base -> {
|
||||
File[] content = new File(base, "dimensions").listFiles();
|
||||
return content != null && content.length > 0;
|
||||
})
|
||||
.map(IrisData::get), IrisWorlds.get().getPacks());
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import org.bukkit.NamespacedKey;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class WorldRemovalPathPolicy {
|
||||
private WorldRemovalPathPolicy() {
|
||||
}
|
||||
|
||||
public static Target resolve(String identifier, String configuredMainWorld, Path levelRoot) {
|
||||
return resolve(identifier, configuredMainWorld, List.of(configuredMainWorld), levelRoot);
|
||||
}
|
||||
|
||||
public static Target resolve(
|
||||
String identifier,
|
||||
String currentMainWorld,
|
||||
Collection<String> protectedWorldNames,
|
||||
Path levelRoot
|
||||
) {
|
||||
String requestedIdentifier = requireIdentifier(identifier);
|
||||
String mainWorld = requireIdentifier(currentMainWorld);
|
||||
for (String protectedWorldName : Objects.requireNonNull(protectedWorldNames, "protectedWorldNames")) {
|
||||
if (protectedWorldName != null
|
||||
&& !protectedWorldName.isBlank()
|
||||
&& requestedIdentifier.equalsIgnoreCase(protectedWorldName.trim())) {
|
||||
throw new Rejection(RejectionReason.CONFIGURED_MAIN_WORLD,
|
||||
"A current or configured main world cannot be removed.");
|
||||
}
|
||||
}
|
||||
if (requestedIdentifier.toLowerCase(Locale.ENGLISH).startsWith(NamespacedKey.MINECRAFT + ":")) {
|
||||
throw new Rejection(RejectionReason.MINECRAFT_NAMESPACE,
|
||||
"Minecraft namespace worlds cannot be removed.");
|
||||
}
|
||||
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(requestedIdentifier, mainWorld);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw classifyIdentifierFailure(requestedIdentifier, failure);
|
||||
}
|
||||
|
||||
Path normalizedLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(normalizedLevelRoot)) {
|
||||
throw new Rejection(RejectionReason.SYMBOLIC_LINK,
|
||||
"World storage path contains a symbolic link: " + normalizedLevelRoot);
|
||||
}
|
||||
Path target;
|
||||
try {
|
||||
target = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
normalizedLevelRoot.toFile(),
|
||||
worldKey
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
||||
}
|
||||
validateStoragePath(normalizedLevelRoot, worldKey, target);
|
||||
return new Target(
|
||||
requestedIdentifier,
|
||||
worldKey,
|
||||
IrisWorldStorage.logicalName(worldKey, mainWorld),
|
||||
normalizedLevelRoot,
|
||||
target
|
||||
);
|
||||
}
|
||||
|
||||
public static void validateStoragePath(Path levelRoot, NamespacedKey worldKey, Path candidate) {
|
||||
Path normalizedLevelRoot = Objects.requireNonNull(levelRoot, "levelRoot").toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(normalizedLevelRoot)) {
|
||||
throw new Rejection(RejectionReason.SYMBOLIC_LINK,
|
||||
"World storage path contains a symbolic link: " + normalizedLevelRoot);
|
||||
}
|
||||
Path expected;
|
||||
try {
|
||||
expected = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
normalizedLevelRoot.toFile(),
|
||||
Objects.requireNonNull(worldKey, "worldKey")
|
||||
).toPath().toAbsolutePath().normalize();
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw classifyStorageFailure(normalizedLevelRoot, worldKey, failure);
|
||||
}
|
||||
Path normalizedCandidate = Objects.requireNonNull(candidate, "candidate").toAbsolutePath().normalize();
|
||||
if (!normalizedCandidate.equals(expected)) {
|
||||
throw new Rejection(RejectionReason.OUTSIDE_STORAGE_ROOT,
|
||||
"The world directory is outside its exact Iris dimension storage root.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Rejection classifyIdentifierFailure(String identifier, IllegalArgumentException failure) {
|
||||
NamespacedKey parsed = identifier.contains(":")
|
||||
? NamespacedKey.fromString(identifier.toLowerCase(Locale.ENGLISH))
|
||||
: null;
|
||||
RejectionReason reason = parsed != null && !"iris".equals(parsed.getNamespace())
|
||||
? RejectionReason.NOT_IRIS_NAMESPACE
|
||||
: RejectionReason.INVALID_IDENTIFIER;
|
||||
return new Rejection(reason, failure.getMessage(), failure);
|
||||
}
|
||||
|
||||
private static Rejection classifyStorageFailure(
|
||||
Path levelRoot,
|
||||
NamespacedKey worldKey,
|
||||
IllegalArgumentException failure
|
||||
) {
|
||||
Path dimensions = levelRoot.resolve("dimensions");
|
||||
Path namespace = dimensions.resolve(worldKey.getNamespace());
|
||||
Path target = namespace.resolve(worldKey.getKey());
|
||||
RejectionReason reason = Files.isSymbolicLink(dimensions)
|
||||
|| Files.isSymbolicLink(namespace)
|
||||
|| Files.isSymbolicLink(target)
|
||||
? RejectionReason.SYMBOLIC_LINK
|
||||
: RejectionReason.OUTSIDE_STORAGE_ROOT;
|
||||
return new Rejection(reason, failure.getMessage(), failure);
|
||||
}
|
||||
|
||||
private static String requireIdentifier(String identifier) {
|
||||
if (identifier == null || identifier.isBlank()) {
|
||||
throw new Rejection(RejectionReason.INVALID_IDENTIFIER, "The world identifier cannot be empty.");
|
||||
}
|
||||
return identifier.trim();
|
||||
}
|
||||
|
||||
public record Target(
|
||||
String requestedIdentifier,
|
||||
NamespacedKey worldKey,
|
||||
String logicalName,
|
||||
Path levelRoot,
|
||||
Path worldDirectory
|
||||
) {
|
||||
public Target {
|
||||
Objects.requireNonNull(requestedIdentifier, "requestedIdentifier");
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(logicalName, "logicalName");
|
||||
Objects.requireNonNull(levelRoot, "levelRoot");
|
||||
Objects.requireNonNull(worldDirectory, "worldDirectory");
|
||||
}
|
||||
}
|
||||
|
||||
public enum RejectionReason {
|
||||
INVALID_IDENTIFIER,
|
||||
CONFIGURED_MAIN_WORLD,
|
||||
MINECRAFT_NAMESPACE,
|
||||
NOT_IRIS_NAMESPACE,
|
||||
OUTSIDE_STORAGE_ROOT,
|
||||
SYMBOLIC_LINK
|
||||
}
|
||||
|
||||
public static final class Rejection extends IllegalArgumentException {
|
||||
private final RejectionReason reason;
|
||||
|
||||
private Rejection(RejectionReason reason, String message) {
|
||||
super(message);
|
||||
this.reason = Objects.requireNonNull(reason, "reason");
|
||||
}
|
||||
|
||||
private Rejection(RejectionReason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.reason = Objects.requireNonNull(reason, "reason");
|
||||
}
|
||||
|
||||
public RejectionReason reason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,14 +23,17 @@ import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.Reader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -39,6 +42,7 @@ public final class ModrinthResolver {
|
||||
private static final String API = "https://api.modrinth.com/v2";
|
||||
private static final String USER_AGENT = "VolmitSoftware/Iris (datapack-ingest)";
|
||||
private static final String DATAPACK_LOADER = "datapack";
|
||||
private static final int MAX_API_RESPONSE_CHARS = 8 * 1024 * 1024;
|
||||
|
||||
private ModrinthResolver() {
|
||||
}
|
||||
@@ -63,9 +67,19 @@ public final class ModrinthResolver {
|
||||
? selectLatestDatapackVersion(versions, serverMcVersion)
|
||||
: selectVersionByToken(versions, ref.versionToken);
|
||||
if (version == null) {
|
||||
String detail = ref.versionToken == null ? "no datapack-loader version" : "no version matching '" + ref.versionToken + "'";
|
||||
String detail = ref.versionToken == null
|
||||
? (serverMcVersion == null || serverMcVersion.isBlank()
|
||||
? "no datapack-loader version"
|
||||
: "no datapack-loader version compatible with Minecraft " + serverMcVersion)
|
||||
: "no version matching '" + ref.versionToken + "'";
|
||||
throw new IOException("Modrinth project '" + ref.slug + "' has " + detail);
|
||||
}
|
||||
if (!isDatapack(version)) {
|
||||
throw new IOException("Modrinth version for '" + ref.slug + "' is not published for the datapack loader");
|
||||
}
|
||||
if (serverMcVersion != null && !serverMcVersion.isBlank() && !gameVersionsContains(version, serverMcVersion)) {
|
||||
throw new IOException("Modrinth version for '" + ref.slug + "' is not compatible with Minecraft " + serverMcVersion);
|
||||
}
|
||||
|
||||
JsonObject file = selectFile(version);
|
||||
if (file == null) {
|
||||
@@ -76,41 +90,30 @@ public final class ModrinthResolver {
|
||||
}
|
||||
|
||||
private static ModrinthRef parse(String url) {
|
||||
if (!url.toLowerCase(Locale.ROOT).contains("modrinth.com/")) {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(url);
|
||||
} catch (URISyntaxException e) {
|
||||
return null;
|
||||
}
|
||||
String host = uri.getHost();
|
||||
if (host == null || !(host.equalsIgnoreCase("modrinth.com") || host.equalsIgnoreCase("www.modrinth.com"))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String path = url.replaceFirst("^[a-zA-Z][a-zA-Z0-9+.-]*://", "");
|
||||
int query = path.indexOf('?');
|
||||
if (query >= 0) {
|
||||
path = path.substring(0, query);
|
||||
}
|
||||
int fragment = path.indexOf('#');
|
||||
if (fragment >= 0) {
|
||||
path = path.substring(0, fragment);
|
||||
}
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (String segment : path.split("/")) {
|
||||
for (String segment : uri.getPath().split("/")) {
|
||||
if (!segment.isBlank()) {
|
||||
parts.add(segment);
|
||||
}
|
||||
}
|
||||
|
||||
int base = -1;
|
||||
for (int i = 0; i < parts.size(); i++) {
|
||||
if (parts.get(i).equalsIgnoreCase("modrinth.com")) {
|
||||
base = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (base < 0 || parts.size() < base + 3) {
|
||||
if (parts.size() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String slug = parts.get(base + 2);
|
||||
String slug = parts.get(1);
|
||||
String token = null;
|
||||
for (int i = base + 3; i + 1 < parts.size(); i++) {
|
||||
for (int i = 2; i + 1 < parts.size(); i++) {
|
||||
if (parts.get(i).equalsIgnoreCase("version")) {
|
||||
token = parts.get(i + 1);
|
||||
break;
|
||||
@@ -125,6 +128,9 @@ public final class ModrinthResolver {
|
||||
String normalizedToken = normalizeVersion(token);
|
||||
|
||||
for (JsonElement element : versions) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject version = element.getAsJsonObject();
|
||||
String id = optString(version, "id");
|
||||
String number = optString(version, "version_number");
|
||||
@@ -146,25 +152,24 @@ public final class ModrinthResolver {
|
||||
return datapackMatch != null ? datapackMatch : anyMatch;
|
||||
}
|
||||
|
||||
private static JsonObject selectLatestDatapackVersion(JsonArray versions, String serverMcVersion) {
|
||||
JsonObject firstDatapack = null;
|
||||
static JsonObject selectLatestDatapackVersion(JsonArray versions, String serverMcVersion) {
|
||||
for (JsonElement element : versions) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject version = element.getAsJsonObject();
|
||||
if (!isDatapack(version)) {
|
||||
continue;
|
||||
}
|
||||
if (firstDatapack == null) {
|
||||
firstDatapack = version;
|
||||
}
|
||||
if (serverMcVersion != null && !serverMcVersion.isBlank() && gameVersionsContains(version, serverMcVersion)) {
|
||||
if (serverMcVersion == null || serverMcVersion.isBlank() || gameVersionsContains(version, serverMcVersion)) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
return firstDatapack;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JsonObject selectFile(JsonObject version) {
|
||||
JsonArray files = version.getAsJsonArray("files");
|
||||
static JsonObject selectFile(JsonObject version) {
|
||||
JsonArray files = optArray(version, "files");
|
||||
if (files == null || files.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -174,11 +179,14 @@ public final class ModrinthResolver {
|
||||
JsonObject primary = null;
|
||||
JsonObject first = null;
|
||||
for (JsonElement element : files) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
JsonObject file = element.getAsJsonObject();
|
||||
if (first == null) {
|
||||
first = file;
|
||||
}
|
||||
boolean isPrimary = file.has("primary") && file.get("primary").getAsBoolean();
|
||||
boolean isPrimary = optBoolean(file, "primary");
|
||||
boolean isZip = optString(file, "filename").toLowerCase(Locale.ROOT).endsWith(".zip");
|
||||
if (isPrimary && primary == null) {
|
||||
primary = file;
|
||||
@@ -203,14 +211,20 @@ public final class ModrinthResolver {
|
||||
return first;
|
||||
}
|
||||
|
||||
private static ResolvedDatapack toResolved(JsonObject version, JsonObject file, String slug) {
|
||||
private static ResolvedDatapack toResolved(JsonObject version, JsonObject file, String slug) throws IOException {
|
||||
String downloadUrl = optString(file, "url");
|
||||
String filename = optString(file, "filename");
|
||||
if (downloadUrl.isBlank() || filename.isBlank()) {
|
||||
throw new IOException("Modrinth version for '" + slug + "' has an invalid downloadable file");
|
||||
}
|
||||
String sha1 = null;
|
||||
if (file.has("hashes") && file.get("hashes").isJsonObject()) {
|
||||
sha1 = optString(file.getAsJsonObject("hashes"), "sha1");
|
||||
if (!sha1.isBlank() && !sha1.matches("(?i)[0-9a-f]{40}")) {
|
||||
throw new IOException("Modrinth version for '" + slug + "' has an invalid SHA-1 checksum");
|
||||
}
|
||||
}
|
||||
return new ResolvedDatapack(downloadUrl, filename, sha1, optString(version, "id"), optString(version, "version_number"), slug);
|
||||
return new ResolvedDatapack(downloadUrl, filename, sha1, optString(version, "id"), optString(version, "version_number"), slug, false);
|
||||
}
|
||||
|
||||
private static ResolvedDatapack directResolve(String url) {
|
||||
@@ -226,16 +240,45 @@ public final class ModrinthResolver {
|
||||
if (filename.isBlank()) {
|
||||
filename = "datapack.zip";
|
||||
}
|
||||
return new ResolvedDatapack(url, filename, null, "direct", "direct", null);
|
||||
String identity = directIdentity(url);
|
||||
return new ResolvedDatapack(url, filename, null, "direct-" + identity, "direct", null, true);
|
||||
}
|
||||
|
||||
static String directIdentity(String url) {
|
||||
String normalized = url;
|
||||
try {
|
||||
URI parsed = new URI(url).normalize();
|
||||
normalized = new URI(
|
||||
parsed.getScheme() == null ? null : parsed.getScheme().toLowerCase(Locale.ROOT),
|
||||
parsed.getUserInfo(),
|
||||
parsed.getHost() == null ? null : parsed.getHost().toLowerCase(Locale.ROOT),
|
||||
parsed.getPort(),
|
||||
parsed.getPath(),
|
||||
parsed.getQuery(),
|
||||
null
|
||||
).toASCIIString();
|
||||
} catch (URISyntaxException ignored) {
|
||||
}
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(normalized.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder builder = new StringBuilder(16);
|
||||
for (int i = 0; i < 8; i++) {
|
||||
builder.append(String.format("%02x", hash[i]));
|
||||
}
|
||||
return builder.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 algorithm unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isDatapack(JsonObject version) {
|
||||
JsonArray loaders = version.getAsJsonArray("loaders");
|
||||
JsonArray loaders = optArray(version, "loaders");
|
||||
if (loaders == null) {
|
||||
return false;
|
||||
}
|
||||
for (JsonElement loader : loaders) {
|
||||
if (DATAPACK_LOADER.equalsIgnoreCase(loader.getAsString())) {
|
||||
if (isString(loader) && DATAPACK_LOADER.equalsIgnoreCase(loader.getAsString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -243,12 +286,12 @@ public final class ModrinthResolver {
|
||||
}
|
||||
|
||||
private static boolean gameVersionsContains(JsonObject version, String mc) {
|
||||
JsonArray gameVersions = version.getAsJsonArray("game_versions");
|
||||
JsonArray gameVersions = optArray(version, "game_versions");
|
||||
if (gameVersions == null) {
|
||||
return false;
|
||||
}
|
||||
for (JsonElement gv : gameVersions) {
|
||||
if (mc.equalsIgnoreCase(gv.getAsString())) {
|
||||
if (isString(gv) && mc.equalsIgnoreCase(gv.getAsString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -267,19 +310,42 @@ public final class ModrinthResolver {
|
||||
}
|
||||
|
||||
private static String optString(JsonObject object, String key) {
|
||||
if (object == null || !object.has(key) || object.get(key).isJsonNull()) {
|
||||
if (object == null || !object.has(key) || !isString(object.get(key))) {
|
||||
return "";
|
||||
}
|
||||
return object.get(key).getAsString();
|
||||
}
|
||||
|
||||
private static JsonArray optArray(JsonObject object, String key) {
|
||||
if (object == null || !object.has(key) || !object.get(key).isJsonArray()) {
|
||||
return null;
|
||||
}
|
||||
return object.getAsJsonArray(key);
|
||||
}
|
||||
|
||||
private static boolean optBoolean(JsonObject object, String key) {
|
||||
if (object == null || !object.has(key) || !object.get(key).isJsonPrimitive()
|
||||
|| !object.get(key).getAsJsonPrimitive().isBoolean()) {
|
||||
return false;
|
||||
}
|
||||
return object.get(key).getAsBoolean();
|
||||
}
|
||||
|
||||
private static boolean isString(JsonElement element) {
|
||||
return element != null && element.isJsonPrimitive() && element.getAsJsonPrimitive().isString();
|
||||
}
|
||||
|
||||
private static JsonArray getJsonArray(String url) throws IOException {
|
||||
String body = httpGet(url);
|
||||
JsonElement parsed = JsonParser.parseString(body);
|
||||
if (!parsed.isJsonArray()) {
|
||||
throw new IOException("Unexpected response from " + url);
|
||||
try {
|
||||
JsonElement parsed = JsonParser.parseString(body);
|
||||
if (!parsed.isJsonArray()) {
|
||||
throw new IOException("Unexpected response from " + url);
|
||||
}
|
||||
return parsed.getAsJsonArray();
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid JSON response from " + url, e);
|
||||
}
|
||||
return parsed.getAsJsonArray();
|
||||
}
|
||||
|
||||
private static String httpGet(String url) throws IOException {
|
||||
@@ -298,17 +364,36 @@ public final class ModrinthResolver {
|
||||
throw new IOException("HTTP " + code + " from " + url);
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (InputStream input = connection.getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
builder.append(line);
|
||||
}
|
||||
InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
|
||||
return readBoundedApiResponse(reader, url, MAX_API_RESPONSE_CHARS);
|
||||
} finally {
|
||||
connection.disconnect();
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
static String readBoundedApiResponse(Reader reader, String source, int maxChars) throws IOException {
|
||||
if (maxChars < 1) {
|
||||
throw new IllegalArgumentException("Response character limit must be positive");
|
||||
}
|
||||
int bufferSize = maxChars < 8192 ? maxChars + 1 : 8192;
|
||||
char[] buffer = new char[bufferSize];
|
||||
StringBuilder builder = new StringBuilder(Math.min(maxChars, 8192));
|
||||
while (true) {
|
||||
int remaining = maxChars - builder.length();
|
||||
int requested = remaining >= buffer.length ? buffer.length : remaining + 1;
|
||||
int length = reader.read(buffer, 0, requested);
|
||||
if (length == -1) {
|
||||
return builder.toString();
|
||||
}
|
||||
if (length == 0) {
|
||||
continue;
|
||||
}
|
||||
if (length > remaining) {
|
||||
throw new IOException("Oversized response from " + source);
|
||||
}
|
||||
builder.append(buffer, 0, length);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ModrinthRef {
|
||||
@@ -328,14 +413,16 @@ public final class ModrinthResolver {
|
||||
private final String versionId;
|
||||
private final String versionNumber;
|
||||
private final String projectSlug;
|
||||
private final boolean direct;
|
||||
|
||||
public ResolvedDatapack(String downloadUrl, String fileName, String sha1, String versionId, String versionNumber, String projectSlug) {
|
||||
public ResolvedDatapack(String downloadUrl, String fileName, String sha1, String versionId, String versionNumber, String projectSlug, boolean direct) {
|
||||
this.downloadUrl = downloadUrl;
|
||||
this.fileName = fileName;
|
||||
this.sha1 = sha1;
|
||||
this.versionId = versionId;
|
||||
this.versionNumber = versionNumber;
|
||||
this.projectSlug = projectSlug;
|
||||
this.direct = direct;
|
||||
}
|
||||
|
||||
public String getDownloadUrl() {
|
||||
@@ -361,5 +448,9 @@ public final class ModrinthResolver {
|
||||
public String getProjectSlug() {
|
||||
return projectSlug;
|
||||
}
|
||||
|
||||
public boolean isDirect() {
|
||||
return direct;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ final class BukkitPublicBackend implements WorldLifecycleBackend {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.Objects;
|
||||
import java.util.ArrayList;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class BukkitWorldConfiguration {
|
||||
private static final Object MUTATION_LOCK = new Object();
|
||||
|
||||
private BukkitWorldConfiguration() {
|
||||
}
|
||||
|
||||
public static Registration register(File configurationFile, String worldName, String dimension, Long seed) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
String requiredDimension = requireName(dimension, "Dimension");
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null) {
|
||||
worlds = configuration.createSection("worlds");
|
||||
}
|
||||
|
||||
ConfigurationSection existing = worlds.getConfigurationSection(requiredWorldName);
|
||||
String generator = "Iris:" + requiredDimension;
|
||||
if (existing != null) {
|
||||
String existingGenerator = existing.getString("generator");
|
||||
Long existingSeed = existing.contains("seed") ? existing.getLong("seed") : null;
|
||||
if (!generator.equals(existingGenerator) || !Objects.equals(seed, existingSeed)) {
|
||||
throw new IOException("bukkit.yml already contains a different definition for world \""
|
||||
+ requiredWorldName + "\".");
|
||||
}
|
||||
return Registration.UNCHANGED;
|
||||
}
|
||||
|
||||
ConfigurationSection created = worlds.createSection(requiredWorldName);
|
||||
created.set("generator", generator);
|
||||
if (seed != null) {
|
||||
created.set("seed", seed);
|
||||
}
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return Registration.CREATED;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean remove(File configurationFile, String worldName) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null || worlds.get(requiredWorldName) == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
worlds.set(requiredWorldName, null);
|
||||
if (worlds.getKeys(false).isEmpty()) {
|
||||
configuration.set("worlds", null);
|
||||
}
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean removeIfMatching(
|
||||
File configurationFile,
|
||||
String worldName,
|
||||
String dimension,
|
||||
Long seed
|
||||
) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
String requiredWorldName = requireWorldName(worldName);
|
||||
String requiredDimension = requireName(dimension, "Dimension");
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ConfigurationSection existing = worlds.getConfigurationSection(requiredWorldName);
|
||||
if (existing == null) {
|
||||
return false;
|
||||
}
|
||||
String expectedGenerator = "Iris:" + requiredDimension;
|
||||
String actualGenerator = existing.getString("generator");
|
||||
Long actualSeed = existing.contains("seed") ? existing.getLong("seed") : null;
|
||||
if (!expectedGenerator.equals(actualGenerator) || !Objects.equals(seed, actualSeed)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
worlds.set(requiredWorldName, null);
|
||||
if (worlds.getKeys(false).isEmpty()) {
|
||||
configuration.set("worlds", null);
|
||||
}
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static int removeMatching(File configurationFile, Predicate<String> matcher) throws IOException {
|
||||
Objects.requireNonNull(configurationFile, "configurationFile");
|
||||
Predicate<String> requiredMatcher = Objects.requireNonNull(matcher, "matcher");
|
||||
synchronized (MUTATION_LOCK) {
|
||||
YamlConfiguration configuration = load(configurationFile);
|
||||
ConfigurationSection worlds = configuration.getConfigurationSection("worlds");
|
||||
if (worlds == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int removed = 0;
|
||||
for (String worldName : new ArrayList<>(worlds.getKeys(false))) {
|
||||
if (!requiredMatcher.test(worldName)) {
|
||||
continue;
|
||||
}
|
||||
worlds.set(worldName, null);
|
||||
removed++;
|
||||
}
|
||||
if (removed == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (worlds.getKeys(false).isEmpty()) {
|
||||
configuration.set("worlds", null);
|
||||
}
|
||||
saveAtomic(configurationFile.toPath(), configuration);
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
static void saveAtomic(Path target, YamlConfiguration configuration) throws IOException {
|
||||
Path absoluteTarget = target.toAbsolutePath().normalize();
|
||||
Path parent = absoluteTarget.getParent();
|
||||
if (parent == null) {
|
||||
throw new IOException("bukkit.yml target has no parent: " + absoluteTarget);
|
||||
}
|
||||
Files.createDirectories(parent);
|
||||
Path staged = Files.createTempFile(parent, ".bukkit-worlds-", ".yml");
|
||||
try {
|
||||
configuration.save(staged.toFile());
|
||||
try (FileChannel channel = FileChannel.open(staged, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(staged, absoluteTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(staged);
|
||||
}
|
||||
}
|
||||
|
||||
private static YamlConfiguration load(File configurationFile) throws IOException {
|
||||
YamlConfiguration configuration = new YamlConfiguration();
|
||||
try {
|
||||
configuration.load(configurationFile);
|
||||
return configuration;
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("bukkit.yml is invalid and was not changed.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireWorldName(String value) {
|
||||
String worldName = requireName(value, "World name");
|
||||
if (!worldName.matches("[a-z0-9_-]+")) {
|
||||
throw new IllegalArgumentException("World name must contain only lowercase letters, numbers, underscores, or hyphens.");
|
||||
}
|
||||
return worldName;
|
||||
}
|
||||
|
||||
private static String requireName(String value, String label) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(label + " cannot be empty.");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
public enum Registration {
|
||||
CREATED,
|
||||
UNCHANGED
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,275 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collections;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class LifecycleOperationCoordinator {
|
||||
private static final LifecycleOperationCoordinator INSTANCE = new LifecycleOperationCoordinator();
|
||||
|
||||
private final Object monitor;
|
||||
private final ArrayDeque<Runnable> idleCallbacks;
|
||||
private ActiveOperation mutationOperation;
|
||||
private ActiveOperation restartOperation;
|
||||
private Runnable pendingRestartCallback;
|
||||
private long nextOperationId;
|
||||
|
||||
LifecycleOperationCoordinator() {
|
||||
monitor = new Object();
|
||||
idleCallbacks = new ArrayDeque<>();
|
||||
nextOperationId = 1L;
|
||||
}
|
||||
|
||||
public static LifecycleOperationCoordinator get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public Lease acquire(Domain domain, OperationKind kind, String target) {
|
||||
Objects.requireNonNull(domain, "domain");
|
||||
Objects.requireNonNull(kind, "kind");
|
||||
String normalizedTarget = Objects.requireNonNull(target, "target").trim();
|
||||
if (normalizedTarget.isEmpty()) {
|
||||
throw new IllegalArgumentException("target must not be blank");
|
||||
}
|
||||
|
||||
synchronized (monitor) {
|
||||
if (domain == Domain.SERVER_LIFECYCLE) {
|
||||
throw new IllegalArgumentException("SERVER_LIFECYCLE is reserved for terminal operations");
|
||||
}
|
||||
|
||||
ActiveOperation currentOperation = restartOperation == null ? mutationOperation : restartOperation;
|
||||
if (currentOperation != null) {
|
||||
throw new BusyException(currentOperation);
|
||||
}
|
||||
|
||||
ActiveOperation operation = new ActiveOperation(nextOperationId++, domain, kind, normalizedTarget);
|
||||
mutationOperation = operation;
|
||||
return new LeaseImpl(this, operation);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean quiesceForRestart(Runnable callback) {
|
||||
Runnable restartCallback = Objects.requireNonNull(callback, "callback");
|
||||
ActiveOperation operation;
|
||||
boolean dispatch;
|
||||
synchronized (monitor) {
|
||||
if (restartOperation != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
operation = new ActiveOperation(
|
||||
nextOperationId++,
|
||||
Domain.SERVER_LIFECYCLE,
|
||||
OperationKind.SERVER_RESTART,
|
||||
"server");
|
||||
restartOperation = operation;
|
||||
dispatch = mutationOperation == null;
|
||||
if (!dispatch) {
|
||||
pendingRestartCallback = restartCallback;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
runRestartCallback(restartCallback, operation);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Optional<ActiveOperation> active(Domain domain) {
|
||||
Objects.requireNonNull(domain, "domain");
|
||||
synchronized (monitor) {
|
||||
if (domain == Domain.SERVER_LIFECYCLE) {
|
||||
return Optional.ofNullable(restartOperation);
|
||||
}
|
||||
if (mutationOperation != null && mutationOperation.domain() == domain) {
|
||||
return Optional.of(mutationOperation);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public Map<Domain, ActiveOperation> snapshot() {
|
||||
synchronized (monitor) {
|
||||
EnumMap<Domain, ActiveOperation> operations = new EnumMap<>(Domain.class);
|
||||
if (mutationOperation != null) {
|
||||
operations.put(mutationOperation.domain(), mutationOperation);
|
||||
}
|
||||
if (restartOperation != null) {
|
||||
operations.put(Domain.SERVER_LIFECYCLE, restartOperation);
|
||||
}
|
||||
return Collections.unmodifiableMap(operations);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isIdle() {
|
||||
synchronized (monitor) {
|
||||
return mutationOperation == null && restartOperation == null;
|
||||
}
|
||||
}
|
||||
|
||||
public void whenIdle(Runnable callback) {
|
||||
Runnable idleCallback = Objects.requireNonNull(callback, "callback");
|
||||
synchronized (monitor) {
|
||||
if (mutationOperation != null || restartOperation != null) {
|
||||
idleCallbacks.addLast(idleCallback);
|
||||
return;
|
||||
}
|
||||
runIdleCallback(idleCallback);
|
||||
}
|
||||
}
|
||||
|
||||
private void release(ActiveOperation operation) {
|
||||
Runnable restartCallback = null;
|
||||
ActiveOperation terminalOperation = null;
|
||||
synchronized (monitor) {
|
||||
if (!operation.equals(mutationOperation)) {
|
||||
throw new IllegalStateException("Lifecycle operation lease is not active: " + operation.id());
|
||||
}
|
||||
|
||||
mutationOperation = null;
|
||||
if (restartOperation != null && pendingRestartCallback != null) {
|
||||
terminalOperation = restartOperation;
|
||||
restartCallback = pendingRestartCallback;
|
||||
pendingRestartCallback = null;
|
||||
} else {
|
||||
runIdleCallbacks();
|
||||
}
|
||||
}
|
||||
|
||||
if (restartCallback != null) {
|
||||
runRestartCallback(restartCallback, terminalOperation);
|
||||
}
|
||||
}
|
||||
|
||||
private void runIdleCallbacks() {
|
||||
while (mutationOperation == null && restartOperation == null && !idleCallbacks.isEmpty()) {
|
||||
runIdleCallback(idleCallbacks.removeFirst());
|
||||
}
|
||||
}
|
||||
|
||||
private void runIdleCallback(Runnable callback) {
|
||||
try {
|
||||
callback.run();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Lifecycle idle callback failed.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void runRestartCallback(Runnable callback, ActiveOperation operation) {
|
||||
try {
|
||||
callback.run();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Lifecycle restart dispatch failed for operation " + operation.id() + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
public enum Domain {
|
||||
WORLD_MUTATION,
|
||||
PACK_MUTATION,
|
||||
SERVER_LIFECYCLE
|
||||
}
|
||||
|
||||
public enum OperationKind {
|
||||
WORLD_CREATE,
|
||||
WORLD_LOAD,
|
||||
WORLD_UNLOAD,
|
||||
WORLD_REMOVE,
|
||||
WORLD_PROMOTE,
|
||||
STUDIO_OPEN,
|
||||
STUDIO_CLOSE,
|
||||
PACK_CREATE,
|
||||
PACK_DOWNLOAD,
|
||||
PACK_PUBLISH,
|
||||
DATAPACK_COMPILE,
|
||||
SERVER_RESTART
|
||||
}
|
||||
|
||||
public record ActiveOperation(long id, Domain domain, OperationKind kind, String target) {
|
||||
public ActiveOperation {
|
||||
if (id < 1L) {
|
||||
throw new IllegalArgumentException("id must be positive");
|
||||
}
|
||||
Objects.requireNonNull(domain, "domain");
|
||||
Objects.requireNonNull(kind, "kind");
|
||||
target = Objects.requireNonNull(target, "target").trim();
|
||||
if (target.isEmpty()) {
|
||||
throw new IllegalArgumentException("target must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static final class BusyException extends IllegalStateException {
|
||||
private final ActiveOperation currentOperation;
|
||||
|
||||
private BusyException(ActiveOperation currentOperation) {
|
||||
super("Lifecycle domain " + currentOperation.domain()
|
||||
+ " is busy with " + currentOperation.kind()
|
||||
+ " target=" + currentOperation.target()
|
||||
+ " id=" + currentOperation.id());
|
||||
this.currentOperation = currentOperation;
|
||||
}
|
||||
|
||||
public ActiveOperation currentOperation() {
|
||||
return currentOperation;
|
||||
}
|
||||
|
||||
public long operationId() {
|
||||
return currentOperation.id();
|
||||
}
|
||||
|
||||
public Domain domain() {
|
||||
return currentOperation.domain();
|
||||
}
|
||||
|
||||
public OperationKind operationKind() {
|
||||
return currentOperation.kind();
|
||||
}
|
||||
|
||||
public String target() {
|
||||
return currentOperation.target();
|
||||
}
|
||||
}
|
||||
|
||||
public interface Lease extends AutoCloseable {
|
||||
ActiveOperation operation();
|
||||
|
||||
boolean isClosed();
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
|
||||
private static final class LeaseImpl implements Lease {
|
||||
private final LifecycleOperationCoordinator coordinator;
|
||||
private final ActiveOperation operation;
|
||||
private final AtomicBoolean closed;
|
||||
|
||||
private LeaseImpl(LifecycleOperationCoordinator coordinator, ActiveOperation operation) {
|
||||
this.coordinator = coordinator;
|
||||
this.operation = operation;
|
||||
closed = new AtomicBoolean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ActiveOperation operation() {
|
||||
return operation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return closed.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (closed.compareAndSet(false, true)) {
|
||||
coordinator.release(operation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,8 @@ final class PaperLikeRuntimeBackend implements WorldLifecycleBackend {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -9,7 +9,7 @@ public interface WorldLifecycleBackend {
|
||||
|
||||
CompletableFuture<World> create(WorldLifecycleRequest request);
|
||||
|
||||
boolean unload(World world, boolean save);
|
||||
CompletableFuture<Boolean> unloadAsync(World world, boolean save);
|
||||
|
||||
String backendName();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package art.arcane.iris.core.lifecycle;
|
||||
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -8,25 +10,43 @@ import org.bukkit.World;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class WorldLifecycleService {
|
||||
private static final long UNLOAD_TIMEOUT_SECONDS = 120L;
|
||||
private static volatile WorldLifecycleService instance;
|
||||
|
||||
private final CapabilitySnapshot capabilities;
|
||||
private final WorldsProviderBackend worldsProviderBackend;
|
||||
private final PaperLikeRuntimeBackend paperLikeRuntimeBackend;
|
||||
private final BukkitPublicBackend bukkitPublicBackend;
|
||||
private final WorldLifecycleBackend worldsProviderBackend;
|
||||
private final WorldLifecycleBackend paperLikeRuntimeBackend;
|
||||
private final WorldLifecycleBackend bukkitPublicBackend;
|
||||
private final List<WorldLifecycleBackend> backends;
|
||||
private final Map<String, String> worldBackendByKey;
|
||||
|
||||
public WorldLifecycleService(CapabilitySnapshot capabilities) {
|
||||
this.capabilities = capabilities;
|
||||
this.worldsProviderBackend = new WorldsProviderBackend(capabilities);
|
||||
this.paperLikeRuntimeBackend = new PaperLikeRuntimeBackend(capabilities);
|
||||
this.bukkitPublicBackend = new BukkitPublicBackend(capabilities);
|
||||
this(
|
||||
capabilities,
|
||||
new WorldsProviderBackend(capabilities),
|
||||
new PaperLikeRuntimeBackend(capabilities),
|
||||
new BukkitPublicBackend(capabilities)
|
||||
);
|
||||
}
|
||||
|
||||
WorldLifecycleService(
|
||||
CapabilitySnapshot capabilities,
|
||||
WorldLifecycleBackend worldsProviderBackend,
|
||||
WorldLifecycleBackend paperLikeRuntimeBackend,
|
||||
WorldLifecycleBackend bukkitPublicBackend
|
||||
) {
|
||||
this.capabilities = Objects.requireNonNull(capabilities, "capabilities");
|
||||
this.worldsProviderBackend = Objects.requireNonNull(worldsProviderBackend, "worldsProviderBackend");
|
||||
this.paperLikeRuntimeBackend = Objects.requireNonNull(paperLikeRuntimeBackend, "paperLikeRuntimeBackend");
|
||||
this.bukkitPublicBackend = Objects.requireNonNull(bukkitPublicBackend, "bukkitPublicBackend");
|
||||
this.backends = List.of(worldsProviderBackend, paperLikeRuntimeBackend, bukkitPublicBackend);
|
||||
this.worldBackendByKey = new ConcurrentHashMap<>();
|
||||
}
|
||||
@@ -89,47 +109,83 @@ public final class WorldLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean unload(World world, boolean save) {
|
||||
if (!J.isPrimaryThread()) {
|
||||
CompletableFuture<Boolean> future = new CompletableFuture<>();
|
||||
J.s(() -> {
|
||||
try {
|
||||
future.complete(unloadDirect(world, save));
|
||||
} catch (Throwable e) {
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
});
|
||||
return future.join();
|
||||
}
|
||||
|
||||
return unloadDirect(world, save);
|
||||
}
|
||||
|
||||
private boolean unloadDirect(World world, boolean save) {
|
||||
String worldIdentity = WorldIdentity.serialize(world);
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
World requiredWorld = Objects.requireNonNull(world, "world");
|
||||
String worldIdentity = WorldIdentity.serialize(requiredWorld);
|
||||
String worldName = requiredWorld.getName();
|
||||
WorldLifecycleBackend backend = selectUnloadBackend(worldIdentity);
|
||||
IrisLogging.info("WorldLifecycle unload: world=%s, backend=%s",
|
||||
world.getName(),
|
||||
worldName,
|
||||
backend.backendName());
|
||||
boolean unloaded;
|
||||
|
||||
CompletableFuture<Boolean> unloadFuture;
|
||||
try {
|
||||
unloaded = backend.unload(world, save);
|
||||
unloadFuture = backend.unloadAsync(requiredWorld, save);
|
||||
if (unloadFuture == null) {
|
||||
throw new IllegalStateException("World lifecycle backend returned no unload completion future.");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + world.getName()
|
||||
+ "\", backend=" + backend.backendName()
|
||||
+ ", family=" + capabilities.serverFamily().id() + ".", e);
|
||||
if (e instanceof RuntimeException runtimeException) {
|
||||
unloadFuture = CompletableFuture.failedFuture(e);
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> guardedFuture = guardUnloadCompletion(worldName, unloadFuture);
|
||||
return guardedFuture.whenComplete((unloaded, throwable) -> {
|
||||
if (throwable != null) {
|
||||
Throwable cause = WorldLifecycleSupport.unwrap(throwable);
|
||||
IrisLogging.reportError("WorldLifecycle unload failed: world=\"" + worldName
|
||||
+ "\", backend=" + backend.backendName()
|
||||
+ ", family=" + capabilities.serverFamily().id() + ".", cause);
|
||||
return;
|
||||
}
|
||||
if (Boolean.TRUE.equals(unloaded)) {
|
||||
worldBackendByKey.remove(worldIdentity, backend.backendName());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Boolean> guardUnloadCompletion(
|
||||
String worldName,
|
||||
CompletableFuture<Boolean> unloadFuture
|
||||
) {
|
||||
CompletableFuture<Boolean> guarded = new CompletableFuture<>();
|
||||
unloadFuture.whenComplete((unloaded, throwable) -> {
|
||||
if (throwable == null) {
|
||||
guarded.complete(Boolean.TRUE.equals(unloaded));
|
||||
} else {
|
||||
guarded.completeExceptionally(WorldLifecycleSupport.unwrap(throwable));
|
||||
}
|
||||
});
|
||||
if (!guarded.isDone()) {
|
||||
CompletableFuture.delayedExecutor(UNLOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
|
||||
TimeoutException timeout = new TimeoutException(
|
||||
"World unload did not settle within " + UNLOAD_TIMEOUT_SECONDS + " seconds for \""
|
||||
+ worldName + "\".");
|
||||
if (!guarded.completeExceptionally(timeout) || IrisToolbelt.isServerStopping()) {
|
||||
return;
|
||||
}
|
||||
ServerConfigurator.restart("World unload timed out for \"" + worldName + "\".");
|
||||
});
|
||||
}
|
||||
return guarded;
|
||||
}
|
||||
|
||||
public boolean unload(World world, boolean save) {
|
||||
if (J.isPrimaryThread() || (J.isFolia() && WorldLifecycleSupport.isGlobalTickThread())) {
|
||||
throw new IllegalStateException("WorldLifecycle unload cannot block the primary/global tick thread; use unloadAsync instead.");
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(unloadAsync(world, save).join());
|
||||
} catch (CompletionException e) {
|
||||
Throwable cause = WorldLifecycleSupport.unwrap(e);
|
||||
if (cause instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
if (e instanceof Error error) {
|
||||
if (cause instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IllegalStateException(e);
|
||||
throw new IllegalStateException(cause);
|
||||
}
|
||||
if (unloaded) {
|
||||
worldBackendByKey.remove(worldIdentity);
|
||||
}
|
||||
return unloaded;
|
||||
}
|
||||
|
||||
public String backendNameForWorld(NamespacedKey worldKey) {
|
||||
|
||||
@@ -5,8 +5,10 @@ import art.arcane.iris.core.link.Identifier;
|
||||
import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.volmlib.util.scheduling.FoliaScheduler;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.NamespacedKey;
|
||||
@@ -414,36 +416,83 @@ final class WorldLifecycleSupport {
|
||||
}
|
||||
}
|
||||
|
||||
static boolean unloadWorld(CapabilitySnapshot capabilities, World world, boolean save) {
|
||||
static CompletableFuture<Boolean> unloadWorldAsync(CapabilitySnapshot capabilities, World world, boolean save) {
|
||||
if (world == null) {
|
||||
return false;
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
|
||||
CompletableFuture<Boolean> asyncUnload = unloadWorldViaAsyncApi(capabilities, world, save);
|
||||
if (asyncUnload != null) {
|
||||
return resolveAsyncUnload(asyncUnload);
|
||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||
Runnable invokeTask = () -> beginUnload(capabilities, world, save, result);
|
||||
boolean folia = J.isFolia();
|
||||
if ((!folia && J.isPrimaryThread()) || (folia && isGlobalTickThread())) {
|
||||
invokeTask.run();
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
return Bukkit.unloadWorld(world, save);
|
||||
} catch (UnsupportedOperationException unsupported) {
|
||||
if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) {
|
||||
throw unsupported;
|
||||
CompletableFuture<Void> scheduled = runGlobalAsync(invokeTask);
|
||||
scheduled.whenComplete((unused, throwable) -> {
|
||||
if (throwable != null) {
|
||||
result.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void beginUnload(
|
||||
CapabilitySnapshot capabilities,
|
||||
World world,
|
||||
boolean save,
|
||||
CompletableFuture<Boolean> result
|
||||
) {
|
||||
CompletableFuture<Boolean> operation;
|
||||
try {
|
||||
operation = unloadWorldViaAsyncApi(capabilities, world, save);
|
||||
if (operation == null) {
|
||||
operation = unloadWorldWithoutAsyncApi(capabilities, world, save);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(unwrap(e));
|
||||
return;
|
||||
}
|
||||
|
||||
operation.whenComplete((unloaded, throwable) -> {
|
||||
if (throwable == null) {
|
||||
result.complete(Boolean.TRUE.equals(unloaded));
|
||||
} else {
|
||||
result.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static CompletableFuture<Boolean> unloadWorldWithoutAsyncApi(
|
||||
CapabilitySnapshot capabilities,
|
||||
World world,
|
||||
boolean save
|
||||
) {
|
||||
String worldName = world.getName();
|
||||
try {
|
||||
try {
|
||||
return CompletableFuture.completedFuture(Bukkit.unloadWorld(world, save));
|
||||
} catch (UnsupportedOperationException unsupported) {
|
||||
if (capabilities.minecraftServer() == null || capabilities.removeLevelMethod() == null) {
|
||||
return CompletableFuture.failedFuture(unsupported);
|
||||
}
|
||||
}
|
||||
|
||||
if (save) {
|
||||
world.save();
|
||||
}
|
||||
|
||||
Method getHandleMethod = world.getClass().getMethod("getHandle");
|
||||
Object serverLevel = getHandleMethod.invoke(world);
|
||||
closeServerLevel(world, serverLevel);
|
||||
detachServerLevel(capabilities, serverLevel, world);
|
||||
return WorldIdentity.resolve(WorldIdentity.key(world)).isEmpty();
|
||||
CompletableFuture<Boolean> operation = closeServerLevelAsync(world, serverLevel)
|
||||
.thenCompose(unused -> detachServerLevelAsync(capabilities, serverLevel, world))
|
||||
.thenApply(unused -> WorldIdentity.resolve(WorldIdentity.key(world)).isEmpty());
|
||||
return contextualizeUnloadFailure(worldName, operation);
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to unload world \"" + world.getName() + "\" through the selected world lifecycle backend.", unwrap(e));
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Failed to unload world \"" + worldName + "\" through the selected world lifecycle backend.",
|
||||
unwrap(e)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,63 +501,70 @@ final class WorldLifecycleSupport {
|
||||
return null;
|
||||
}
|
||||
|
||||
return invokeAsyncUnload(
|
||||
capabilities.bukkitServer(),
|
||||
capabilities.unloadWorldAsyncMethod(),
|
||||
world,
|
||||
save
|
||||
);
|
||||
}
|
||||
|
||||
static CompletableFuture<Boolean> invokeAsyncUnload(
|
||||
Object bukkitServer,
|
||||
Method unloadWorldAsyncMethod,
|
||||
World world,
|
||||
boolean save
|
||||
) {
|
||||
CompletableFuture<Boolean> callbackFuture = new CompletableFuture<>();
|
||||
Runnable invokeTask = () -> {
|
||||
Consumer<Boolean> callback = result -> callbackFuture.complete(Boolean.TRUE.equals(result));
|
||||
try {
|
||||
capabilities.unloadWorldAsyncMethod().invoke(capabilities.bukkitServer(), world, save, callback);
|
||||
} catch (Throwable e) {
|
||||
callbackFuture.completeExceptionally(unwrap(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (J.isFolia() && !isGlobalTickThread()) {
|
||||
CompletableFuture<Void> scheduled = J.sfut(invokeTask);
|
||||
if (scheduled == null) {
|
||||
callbackFuture.completeExceptionally(new IllegalStateException("Failed to schedule global unload task."));
|
||||
return callbackFuture;
|
||||
}
|
||||
scheduled.whenComplete((unused, throwable) -> {
|
||||
if (throwable != null) {
|
||||
callbackFuture.completeExceptionally(unwrap(throwable));
|
||||
}
|
||||
});
|
||||
return callbackFuture;
|
||||
Consumer<Boolean> callback = unloaded -> callbackFuture.complete(Boolean.TRUE.equals(unloaded));
|
||||
try {
|
||||
unloadWorldAsyncMethod.invoke(bukkitServer, world, save, callback);
|
||||
} catch (Throwable e) {
|
||||
callbackFuture.completeExceptionally(unwrap(e));
|
||||
}
|
||||
|
||||
invokeTask.run();
|
||||
return callbackFuture;
|
||||
}
|
||||
|
||||
private static boolean resolveAsyncUnload(CompletableFuture<Boolean> asyncUnload) {
|
||||
if (J.isPrimaryThread()) {
|
||||
if (!asyncUnload.isDone()) {
|
||||
return true;
|
||||
private static CompletableFuture<Boolean> contextualizeUnloadFailure(
|
||||
String worldName,
|
||||
CompletableFuture<Boolean> operation
|
||||
) {
|
||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||
operation.whenComplete((unloaded, throwable) -> {
|
||||
if (throwable == null) {
|
||||
result.complete(Boolean.TRUE.equals(unloaded));
|
||||
} else {
|
||||
result.completeExceptionally(new IllegalStateException(
|
||||
"Failed to unload world \"" + worldName + "\" through the selected world lifecycle backend.",
|
||||
unwrap(throwable)
|
||||
));
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(asyncUnload.join());
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed to consume async world unload result.", unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean.TRUE.equals(asyncUnload.get(120, TimeUnit.SECONDS));
|
||||
} catch (Throwable e) {
|
||||
throw new IllegalStateException("Failed while waiting for async world unload result.", unwrap(e));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void closeServerLevel(World world, Object serverLevel) throws Throwable {
|
||||
Method closeMethod = CapabilityResolution.resolveMethod(serverLevel.getClass(), "close", method -> method.getParameterCount() == 0);
|
||||
private static CompletableFuture<Void> closeServerLevelAsync(World world, Object serverLevel) {
|
||||
Method closeMethod;
|
||||
try {
|
||||
closeMethod = CapabilityResolution.resolveMethod(
|
||||
serverLevel.getClass(),
|
||||
"close",
|
||||
method -> method.getParameterCount() == 0
|
||||
);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(unwrap(e));
|
||||
}
|
||||
if (closeMethod == null) {
|
||||
return;
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
if (!J.isFolia()) {
|
||||
closeMethod.invoke(serverLevel);
|
||||
return;
|
||||
try {
|
||||
closeMethod.invoke(serverLevel);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
Location spawn = world.getSpawnLocation();
|
||||
@@ -524,9 +580,11 @@ final class WorldLifecycleSupport {
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
throw new IllegalStateException("Failed to schedule region close task for world \"" + world.getName() + "\".");
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Failed to schedule region close task for world \"" + world.getName() + "\"."
|
||||
));
|
||||
}
|
||||
closeFuture.get(90, TimeUnit.SECONDS);
|
||||
return closeFuture.orTimeout(90L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
@@ -545,7 +603,11 @@ final class WorldLifecycleSupport {
|
||||
}
|
||||
}
|
||||
|
||||
private static void detachServerLevel(CapabilitySnapshot capabilities, Object serverLevel, World world) throws Throwable {
|
||||
private static CompletableFuture<Void> detachServerLevelAsync(
|
||||
CapabilitySnapshot capabilities,
|
||||
Object serverLevel,
|
||||
World world
|
||||
) {
|
||||
Runnable detachTask = () -> {
|
||||
try {
|
||||
capabilities.removeLevelMethod().invoke(capabilities.minecraftServer(), serverLevel);
|
||||
@@ -556,15 +618,41 @@ final class WorldLifecycleSupport {
|
||||
};
|
||||
|
||||
if (!J.isFolia() || isGlobalTickThread()) {
|
||||
detachTask.run();
|
||||
return;
|
||||
try {
|
||||
detachTask.run();
|
||||
return CompletableFuture.completedFuture(null);
|
||||
} catch (Throwable e) {
|
||||
return CompletableFuture.failedFuture(unwrap(e));
|
||||
}
|
||||
}
|
||||
|
||||
CompletableFuture<Void> detachFuture = J.sfut(detachTask);
|
||||
if (detachFuture == null) {
|
||||
throw new IllegalStateException("Failed to schedule global detach task for world \"" + world.getName() + "\".");
|
||||
CompletableFuture<Void> detachFuture = runGlobalAsync(detachTask);
|
||||
return detachFuture.orTimeout(15L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> runGlobalAsync(Runnable task) {
|
||||
if (!J.isFolia()) {
|
||||
return J.sfut(task);
|
||||
}
|
||||
detachFuture.get(15, TimeUnit.SECONDS);
|
||||
|
||||
CompletableFuture<Void> result = new CompletableFuture<>();
|
||||
Runnable settlement = () -> {
|
||||
try {
|
||||
task.run();
|
||||
result.complete(null);
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(unwrap(e));
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (!FoliaScheduler.runGlobal(BukkitPlatform.plugin(), settlement)) {
|
||||
result.completeExceptionally(new IllegalStateException("Failed to schedule global world lifecycle task."));
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
result.completeExceptionally(unwrap(e));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static boolean isGlobalTickThread() {
|
||||
|
||||
@@ -46,8 +46,8 @@ final class WorldsProviderBackend implements WorldLifecycleBackend {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unload(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorld(capabilities, world, save);
|
||||
public CompletableFuture<Boolean> unloadAsync(World world, boolean save) {
|
||||
return WorldLifecycleSupport.unloadWorldAsync(capabilities, world, save);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -22,28 +22,36 @@ import lombok.SneakyThrows;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.World;
|
||||
import org.mvplugins.multiverse.core.MultiverseCoreApi;
|
||||
import org.mvplugins.multiverse.core.utils.result.Attempt;
|
||||
import org.mvplugins.multiverse.core.world.MultiverseWorld;
|
||||
import org.mvplugins.multiverse.core.world.WorldManager;
|
||||
import org.mvplugins.multiverse.core.world.options.ImportWorldOptions;
|
||||
import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
|
||||
import org.mvplugins.multiverse.core.world.reasons.RemoveFailureReason;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
|
||||
public class MultiverseCoreLink {
|
||||
public void removeFromConfig(World world) {
|
||||
removeFromConfig(world.getName());
|
||||
public boolean removeFromConfig(World world) {
|
||||
return removeFromConfig(world.getName());
|
||||
}
|
||||
|
||||
public void removeFromConfig(String world) {
|
||||
public boolean removeFromConfig(String world) {
|
||||
if (!isActive()) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
WorldManager manager = worldManager();
|
||||
MultiverseWorld multiverseWorld = manager.getWorld(world).getOrElse((MultiverseWorld) null);
|
||||
if (multiverseWorld == null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
manager.removeWorld(RemoveWorldOptions.world(multiverseWorld)).onSuccess(ignored -> manager.saveWorldsConfig());
|
||||
Attempt<String, RemoveFailureReason> removal = manager.removeWorld(RemoveWorldOptions.world(multiverseWorld));
|
||||
if (removal.isFailure()) {
|
||||
throw new IllegalStateException("Multiverse refused to remove world \"" + world + "\": "
|
||||
+ removal.getFailureMessage());
|
||||
}
|
||||
manager.saveWorldsConfig().get();
|
||||
return true;
|
||||
}
|
||||
|
||||
@SneakyThrows
|
||||
@@ -72,7 +80,7 @@ public class MultiverseCoreLink {
|
||||
.invoke(config, generator);
|
||||
}
|
||||
|
||||
manager.saveWorldsConfig();
|
||||
manager.saveWorldsConfig().get();
|
||||
}
|
||||
|
||||
private WorldManager worldManager() {
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.engine.data.cache.AtomicCache;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.IrisStructureLocator;
|
||||
@@ -244,15 +245,14 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
|
||||
}
|
||||
}
|
||||
|
||||
for (File i : Objects.requireNonNull(IrisPlatforms.get().dataFolder("packs").listFiles())) {
|
||||
if (i.isDirectory()) {
|
||||
IrisData dm = get(i);
|
||||
if (dm == nearest) continue;
|
||||
T t = dm.load(type, key, false);
|
||||
for (File i : PackDirectoryResolver.listVisiblePackDirectories(
|
||||
IrisPlatforms.get().dataFolder("packs"))) {
|
||||
IrisData dm = get(i);
|
||||
if (dm == nearest) continue;
|
||||
T t = dm.load(type, key, false);
|
||||
|
||||
if (t != null) {
|
||||
return t;
|
||||
}
|
||||
if (t != null) {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import art.arcane.iris.util.project.hunk.Hunk;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
@@ -106,6 +107,18 @@ public interface INMSBinding {
|
||||
return new KList<>();
|
||||
}
|
||||
|
||||
default JigsawSourceMetadata getJigsawSourceMetadata(String structureKey) {
|
||||
throw new UnsupportedOperationException("The active NMS binding does not expose registered jigsaw metadata");
|
||||
}
|
||||
|
||||
default int getTemplatePoolHorizontalSpan(String templatePoolKey) {
|
||||
throw new UnsupportedOperationException("The active NMS binding does not expose registered template pool spans");
|
||||
}
|
||||
|
||||
default int getJigsawStartPoolHorizontalSpan(String structureKey, String templatePoolKey) {
|
||||
return getTemplatePoolHorizontalSpan(templatePoolKey);
|
||||
}
|
||||
|
||||
KList<String> getStructureSetKeys();
|
||||
|
||||
KList<String> getReachableStructureKeys(World world);
|
||||
|
||||
@@ -6,7 +6,7 @@ import java.lang.reflect.Method;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class MinecraftVersion {
|
||||
public final class MinecraftVersion {
|
||||
private static final Pattern DECORATED_VERSION_PATTERN = Pattern.compile("\\(MC: ([0-9]+(?:\\.[0-9]+){0,2})\\)");
|
||||
|
||||
private final String value;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class AtomicDirectoryPublisher {
|
||||
private AtomicDirectoryPublisher() {
|
||||
}
|
||||
|
||||
public static Publication publish(Path stagedDirectory, Path targetDirectory) throws IOException {
|
||||
Path staged = Objects.requireNonNull(stagedDirectory, "stagedDirectory").toAbsolutePath().normalize();
|
||||
Path target = Objects.requireNonNull(targetDirectory, "targetDirectory").toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(staged) || Files.isSymbolicLink(staged)) {
|
||||
throw new IOException("Staged directory is missing or unsafe: " + staged);
|
||||
}
|
||||
if (!Objects.equals(staged.getParent(), target.getParent())) {
|
||||
throw new IOException("Staged and target directories must have the same parent.");
|
||||
}
|
||||
|
||||
Path backup = null;
|
||||
if (Files.exists(target) || Files.isSymbolicLink(target)) {
|
||||
backup = target.resolveSibling("." + target.getFileName() + ".backup-" + UUID.randomUUID());
|
||||
move(target, backup);
|
||||
}
|
||||
try {
|
||||
move(staged, target);
|
||||
return new Publication(target, backup);
|
||||
} catch (IOException failure) {
|
||||
if (backup != null && (Files.exists(backup) || Files.isSymbolicLink(backup))) {
|
||||
try {
|
||||
move(backup, target);
|
||||
} catch (IOException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
private static void move(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException exception) {
|
||||
Files.move(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Publication implements AutoCloseable {
|
||||
private final Path target;
|
||||
private final Path backup;
|
||||
private boolean committed;
|
||||
private boolean closed;
|
||||
|
||||
private Publication(Path target, Path backup) {
|
||||
this.target = target;
|
||||
this.backup = backup;
|
||||
}
|
||||
|
||||
public synchronized void commit() {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Directory publication is already closed.");
|
||||
}
|
||||
committed = true;
|
||||
}
|
||||
|
||||
public synchronized void cleanupBackup() throws IOException {
|
||||
if (!committed) {
|
||||
throw new IllegalStateException("Directory publication is not committed.");
|
||||
}
|
||||
if (backup != null) {
|
||||
deleteTree(backup);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() throws IOException {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
if (committed) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleteTree(target);
|
||||
if (backup != null && (Files.exists(backup) || Files.isSymbolicLink(backup))) {
|
||||
move(backup, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void deleteTree(Path path) throws IOException {
|
||||
if (!Files.exists(path) && !Files.isSymbolicLink(path)) {
|
||||
return;
|
||||
}
|
||||
if (Files.isSymbolicLink(path) || !Files.isDirectory(path)) {
|
||||
Files.delete(path);
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> stream = Files.walk(path)) {
|
||||
for (Path entry : stream.sorted(Comparator.reverseOrder()).toList()) {
|
||||
Files.deleteIfExists(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
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.attribute.BasicFileAttributes;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class PackDirectoryResolver {
|
||||
private PackDirectoryResolver() {
|
||||
@@ -18,9 +25,103 @@ public final class PackDirectoryResolver {
|
||||
if (!root.equals(candidate.getParent())) {
|
||||
return null;
|
||||
}
|
||||
if (Files.isSymbolicLink(candidate) || !Files.isDirectory(candidate, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!isVisiblePackDirectory(candidate.toFile())) {
|
||||
return null;
|
||||
}
|
||||
return candidate.toFile();
|
||||
}
|
||||
|
||||
public static List<File> listVisiblePackDirectories(File packsRoot) {
|
||||
try {
|
||||
return listVisiblePackDirectoriesOrThrow(packsRoot);
|
||||
} catch (IOException exception) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<File> listVisiblePackDirectoriesOrThrow(File packsRoot) throws IOException {
|
||||
if (packsRoot == null) {
|
||||
return List.of();
|
||||
}
|
||||
Path root = packsRoot.toPath().toAbsolutePath().normalize();
|
||||
if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return List.of();
|
||||
}
|
||||
if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack workspace is missing or unsafe: " + root);
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(root)) {
|
||||
return stream
|
||||
.map(Path::toFile)
|
||||
.filter(PackDirectoryResolver::isVisiblePackDirectory)
|
||||
.sorted(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER)
|
||||
.thenComparing(File::getName))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isVisiblePackDirectory(File candidate) {
|
||||
if (candidate == null || isHiddenName(candidate.getName())) {
|
||||
return false;
|
||||
}
|
||||
Path path = candidate.toPath();
|
||||
return Files.isDirectory(path);
|
||||
}
|
||||
|
||||
public static void requireSafePackTree(File candidate) throws IOException {
|
||||
if (!isVisiblePackDirectory(candidate)) {
|
||||
throw new IOException("Pack directory is missing or unsafe: " + candidate);
|
||||
}
|
||||
Path root = candidate.toPath().toAbsolutePath().normalize().toRealPath();
|
||||
if (Files.isSymbolicLink(root) || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack directory target is missing or unsafe: " + candidate);
|
||||
}
|
||||
Files.walkFileTree(root, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) {
|
||||
if (!directory.equals(root) && isHiddenName(directory.getFileName().toString())) {
|
||||
return FileVisitResult.SKIP_SUBTREE;
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) throws IOException {
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
|
||||
throw new IOException("Pack directory contains a symbolic link: " + file);
|
||||
}
|
||||
if (!attributes.isRegularFile()) {
|
||||
throw new IOException("Pack directory contains an unsupported entry: " + file);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException {
|
||||
throw new IOException("Unable to inspect pack entry: " + file, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static boolean containsHiddenPathSegment(Path root, Path candidate) {
|
||||
if (root == null || candidate == null) {
|
||||
return true;
|
||||
}
|
||||
Path normalizedRoot = root.toAbsolutePath().normalize();
|
||||
Path normalizedCandidate = candidate.toAbsolutePath().normalize();
|
||||
if (!normalizedCandidate.startsWith(normalizedRoot)) {
|
||||
return true;
|
||||
}
|
||||
Path relative = normalizedRoot.relativize(normalizedCandidate);
|
||||
for (Path segment : relative) {
|
||||
if (isHiddenName(segment.toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isHiddenName(String name) {
|
||||
return name != null && name.startsWith(".");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,18 +23,33 @@ import art.arcane.iris.core.localization.PackDownloadMessages;
|
||||
import art.arcane.iris.core.loader.IrisData;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.util.common.misc.WebCache;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.volmlib.util.localization.MessageArgument;
|
||||
import org.zeroturnaround.zip.ZipUtil;
|
||||
import org.zeroturnaround.zip.commons.FileUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
public final class PackDownloader {
|
||||
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
|
||||
@@ -43,7 +58,14 @@ public final class PackDownloader {
|
||||
private static final Pattern GITHUB_REPOSITORY = Pattern.compile("[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+");
|
||||
private static final Pattern GITHUB_REF = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._/-]*");
|
||||
private static final Pattern COMMIT_SHA = Pattern.compile("[0-9a-fA-F]{40}");
|
||||
private static final ConcurrentHashMap<String, Object> DOWNLOAD_LOCKS = new ConcurrentHashMap<>();
|
||||
private static final Pattern PACK_KEY = Pattern.compile("[a-z0-9_-]+");
|
||||
private static final ArchiveLimits ARCHIVE_LIMITS = new ArchiveLimits(
|
||||
512L * 1024L * 1024L,
|
||||
100_000,
|
||||
2L * 1024L * 1024L * 1024L,
|
||||
256L * 1024L * 1024L
|
||||
);
|
||||
private static final ConcurrentHashMap<String, DownloadLock> DOWNLOAD_LOCKS = new ConcurrentHashMap<>();
|
||||
|
||||
private PackDownloader() {
|
||||
}
|
||||
@@ -63,15 +85,37 @@ public final class PackDownloader {
|
||||
* partial import (an interrupted copy) and counts as absent so it can be replaced.
|
||||
*/
|
||||
public static boolean isPackPresent(File packsFolder, String key) {
|
||||
if (packsFolder == null || key == null || key.isBlank()) {
|
||||
if (packsFolder == null || !isSafePackKey(key)) {
|
||||
return false;
|
||||
}
|
||||
Path packsRoot = packsFolder.toPath().toAbsolutePath().normalize();
|
||||
File resolvedPack = PackDirectoryResolver.resolveExisting(packsFolder, key);
|
||||
if (resolvedPack == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
PackDirectoryResolver.requireSafePackTree(resolvedPack);
|
||||
} catch (IOException exception) {
|
||||
return false;
|
||||
}
|
||||
Path pack = resolvedPack.toPath().toAbsolutePath().normalize();
|
||||
Path dimensions = pack.resolve("dimensions");
|
||||
if (!Objects.equals(pack.getParent(), packsRoot)
|
||||
|| Files.isSymbolicLink(dimensions)
|
||||
|| !Files.isDirectory(pack)
|
||||
|| !Files.isDirectory(dimensions, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return false;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(dimensions)) {
|
||||
return entries.anyMatch(path -> path.getFileName().toString().endsWith(".json")
|
||||
&& !Files.isSymbolicLink(path)
|
||||
&& Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS));
|
||||
} catch (IOException exception) {
|
||||
return false;
|
||||
}
|
||||
File[] dimensions = new File(new File(packsFolder, key), "dimensions")
|
||||
.listFiles((File dir, String name) -> name.endsWith(".json"));
|
||||
return dimensions != null && dimensions.length > 0;
|
||||
}
|
||||
|
||||
public static String downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
|
||||
public static PackInstallResult downloadDefaultOverworld(File packsFolder, boolean forceOverwrite, Consumer<String> feedback) throws IOException {
|
||||
return download(packsFolder, DEFAULT_OVERWORLD_REPOSITORY, defaultOverworldReleaseUrl(), forceOverwrite, true, DEFAULT_OVERWORLD_PACK, feedback);
|
||||
}
|
||||
|
||||
@@ -82,136 +126,414 @@ public final class PackDownloader {
|
||||
* per-repo lock keeps concurrent startup triggers (async default-pack install racing world
|
||||
* resolution) from downloading the same archive twice.
|
||||
*/
|
||||
public static String download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, String expectedKey, Consumer<String> feedback) throws IOException {
|
||||
// Lock on the destination pack key when known: concurrent triggers for the same pack can
|
||||
// arrive with different refs (release URL vs listing branch) and must still serialize.
|
||||
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
|
||||
Object lock = DOWNLOAD_LOCKS.computeIfAbsent(lockKey, key -> new Object());
|
||||
synchronized (lock) {
|
||||
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
|
||||
return expectedKey;
|
||||
}
|
||||
return downloadLocked(packsFolder, repo, ref, forceOverwrite, directUrl, feedback);
|
||||
public static PackInstallResult download(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, String expectedKey, Consumer<String> feedback) throws IOException {
|
||||
Objects.requireNonNull(packsFolder, "packsFolder");
|
||||
Consumer<String> output = feedback == null ? ignored -> {
|
||||
} : feedback;
|
||||
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
|
||||
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
|
||||
}
|
||||
String lockKey = expectedKey != null && !expectedKey.isBlank() ? "key:" + expectedKey : "ref:" + repo + "|" + ref;
|
||||
return withDownloadLock(lockKey, () -> {
|
||||
if (!forceOverwrite && isPackPresent(packsFolder, expectedKey)) {
|
||||
sendFeedback(output, IrisLanguage.plain(PackDownloadMessages.ALREADY_INSTALLED, MessageArgument.untrusted("key", expectedKey)));
|
||||
return new PackInstallResult(expectedKey, false, false);
|
||||
}
|
||||
return downloadLocked(packsFolder, repo, ref, forceOverwrite, directUrl, expectedKey, lockKey, output);
|
||||
});
|
||||
}
|
||||
|
||||
private static String downloadLocked(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl, Consumer<String> feedback) throws IOException {
|
||||
private static PackInstallResult downloadLocked(File packsFolder, String repo, String ref, boolean forceOverwrite, boolean directUrl,
|
||||
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException {
|
||||
String url = directUrl ? ref : resolveGithubArchiveUrl(repo, ref);
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " "); //The extra space stops a bug in adventure API from repeating the last letter of the URL
|
||||
File zip = WebCache.getNonCachedFile("pack-" + repo, url);
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.DOWNLOADING, MessageArgument.untrusted("url", url)) + " ");
|
||||
File zip = WebCache.getNonCachedFile("pack-" + repo, url, ARCHIVE_LIMITS.maxArchiveBytes());
|
||||
File temp = WebCache.getTemp();
|
||||
File work = new File(temp, "dl-" + UUID.randomUUID());
|
||||
|
||||
if (zip == null || !zip.exists()) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url)));
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.CHECK_REPOSITORY_AND_BRANCH));
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.EXAMPLE_COMMAND));
|
||||
return null;
|
||||
}
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", repo)));
|
||||
try {
|
||||
ZipUtil.unpack(zip, work);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED));
|
||||
IO.delete(work);
|
||||
return null;
|
||||
}
|
||||
File dir = null;
|
||||
File[] zipFiles = work.listFiles();
|
||||
|
||||
if (zipFiles == null) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
dir = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null;
|
||||
} catch (NullPointerException e) {
|
||||
IrisLogging.reportError(e);
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.HOME_DIRECTORY_ERROR));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dir == null) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
|
||||
return null;
|
||||
}
|
||||
|
||||
IrisData data = IrisData.get(dir);
|
||||
String[] dimensions = data.getDimensionLoader().getPossibleKeys();
|
||||
|
||||
if (dimensions == null || dimensions.length == 0) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.NO_DIMENSION_FILE));
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dimensions.length != 1) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
|
||||
return null;
|
||||
}
|
||||
|
||||
IrisDimension d = data.getDimensionLoader().load(dimensions[0]);
|
||||
data.close();
|
||||
|
||||
if (d == null) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
|
||||
return null;
|
||||
}
|
||||
|
||||
String key = d.getLoadKey();
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.IMPORTING, MessageArgument.untrusted("name", d.getName()), MessageArgument.untrusted("key", key)));
|
||||
File packEntry = new File(packsFolder, key);
|
||||
File[] staleStaging = packsFolder.listFiles((File parent, String name) -> name.startsWith(key + ".importing-"));
|
||||
if (staleStaging != null) {
|
||||
for (File stale : staleStaging) {
|
||||
IO.delete(stale);
|
||||
}
|
||||
}
|
||||
|
||||
if (forceOverwrite) {
|
||||
IO.delete(packEntry);
|
||||
}
|
||||
|
||||
if (IrisData.loadAnyDimension(key, null) != null) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.DIMENSION_KEY_CONFLICT, MessageArgument.untrusted("key", key)));
|
||||
return null;
|
||||
}
|
||||
|
||||
File[] existingEntries = packEntry.listFiles();
|
||||
if (packEntry.exists() && existingEntries != null && existingEntries.length > 0) {
|
||||
if (isPackPresent(packsFolder, key)) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.PACK_KEY_CONFLICT, MessageArgument.untrusted("key", key)));
|
||||
if (zip == null || !zip.exists()) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.FAILED_TO_FIND, MessageArgument.untrusted("url", url)));
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_REPOSITORY_AND_BRANCH));
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.EXAMPLE_COMMAND));
|
||||
return null;
|
||||
}
|
||||
// Non-empty but no dimension file: a partial import from an interrupted copy.
|
||||
// Replace it instead of refusing forever.
|
||||
IrisLogging.warn("Replacing partial pack folder " + packEntry.getPath() + " (no dimension files found).");
|
||||
IO.delete(packEntry);
|
||||
}
|
||||
|
||||
// Stage inside the packs folder and move into place so packs/<key> is never partial:
|
||||
// an interrupted copy previously left a folder without dimensions/, which then blocked
|
||||
// every future import as a key conflict.
|
||||
File staging = new File(packsFolder, key + ".importing-" + UUID.randomUUID());
|
||||
try {
|
||||
FileUtils.copyDirectory(dir, staging);
|
||||
if (!staging.renameTo(packEntry)) {
|
||||
throw new IOException("Unable to move imported pack into place: " + packEntry.getPath());
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACKING, MessageArgument.untrusted("repository", repo)));
|
||||
try {
|
||||
unpackArchive(zip.toPath(), work.toPath(), ARCHIVE_LIMITS);
|
||||
} catch (IOException exception) {
|
||||
IrisLogging.reportError(exception);
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.UNPACK_FAILED));
|
||||
return null;
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
IO.delete(staging);
|
||||
throw e;
|
||||
File[] zipFiles = work.listFiles();
|
||||
if (zipFiles == null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_EXTRACTED_FILES));
|
||||
return null;
|
||||
}
|
||||
File directory;
|
||||
try {
|
||||
directory = zipFiles.length > 1 ? work : zipFiles[0].isDirectory() ? zipFiles[0] : null;
|
||||
} catch (NullPointerException exception) {
|
||||
IrisLogging.reportError(exception);
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.HOME_DIRECTORY_ERROR));
|
||||
return null;
|
||||
}
|
||||
if (directory == null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_ARCHIVE_FORMAT));
|
||||
return null;
|
||||
}
|
||||
return installExtractedPack(packsFolder, directory, forceOverwrite, expectedKey, heldLockKey, feedback);
|
||||
} finally {
|
||||
deleteDirectory(work);
|
||||
}
|
||||
}
|
||||
|
||||
static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite,
|
||||
String expectedKey, Consumer<String> feedback) throws IOException {
|
||||
Objects.requireNonNull(packsFolder, "packsFolder");
|
||||
Objects.requireNonNull(extractedPack, "extractedPack");
|
||||
Consumer<String> output = feedback == null ? ignored -> {
|
||||
} : feedback;
|
||||
return installExtractedPack(packsFolder, extractedPack, forceOverwrite, expectedKey, null, output);
|
||||
}
|
||||
|
||||
private static PackInstallResult installExtractedPack(File packsFolder, File extractedPack, boolean forceOverwrite,
|
||||
String expectedKey, String heldLockKey, Consumer<String> feedback) throws IOException {
|
||||
if (expectedKey != null && !expectedKey.isBlank() && !isSafePackKey(expectedKey)) {
|
||||
throw new IllegalArgumentException("Invalid expected pack key '" + expectedKey + "'");
|
||||
}
|
||||
Path packsRoot = packsFolder.toPath().toAbsolutePath().normalize();
|
||||
Files.createDirectories(packsRoot);
|
||||
Path staging = packsRoot.resolve(".iris-import-" + UUID.randomUUID());
|
||||
try {
|
||||
FileUtils.copyDirectory(extractedPack, staging.toFile());
|
||||
PreparedPack prepared = prepareStagedPack(staging.toFile(), expectedKey, feedback);
|
||||
if (prepared == null) {
|
||||
return null;
|
||||
}
|
||||
String destinationLockKey = "key:" + prepared.key();
|
||||
if (destinationLockKey.equals(heldLockKey)) {
|
||||
return publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback);
|
||||
}
|
||||
return withDownloadLock(destinationLockKey,
|
||||
() -> publishPreparedPack(packsFolder, packsRoot, staging, prepared, forceOverwrite, feedback));
|
||||
} finally {
|
||||
deleteDirectory(staging.toFile());
|
||||
}
|
||||
}
|
||||
|
||||
private static PreparedPack prepareStagedPack(File staging, String expectedKey,
|
||||
Consumer<String> feedback) throws IOException {
|
||||
IrisData data = IrisData.openDatapackCompiler(staging);
|
||||
String key;
|
||||
String name;
|
||||
try {
|
||||
String[] dimensions = data.getDimensionLoader().getPossibleKeys();
|
||||
if (dimensions == null || dimensions.length == 0) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.NO_DIMENSION_FILE));
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.CHECK_GITHUB));
|
||||
return null;
|
||||
}
|
||||
if (dimensions.length != 1) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.ONE_DIMENSION_REQUIRED));
|
||||
return null;
|
||||
}
|
||||
IrisDimension dimension = data.getDimensionLoader().load(dimensions[0]);
|
||||
if (dimension == null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(PackDownloadMessages.INVALID_DIMENSION));
|
||||
return null;
|
||||
}
|
||||
key = dimension.getLoadKey();
|
||||
name = dimension.getName();
|
||||
} finally {
|
||||
data.close();
|
||||
}
|
||||
if (!isSafePackKey(key)) {
|
||||
throw new IOException("Downloaded pack has unsafe dimension key '" + key + "'");
|
||||
}
|
||||
if (expectedKey != null && !expectedKey.isBlank() && !expectedKey.equals(key)) {
|
||||
throw new IOException("Downloaded pack key '" + key + "' does not match requested key '" + expectedKey + "'");
|
||||
}
|
||||
|
||||
IrisData.getLoaded(packEntry)
|
||||
.ifPresent(IrisData::hotloaded);
|
||||
PackValidationResult stagedValidation;
|
||||
try {
|
||||
stagedValidation = PackValidator.validate(staging);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new IOException("Pack validation failed before publication for '" + key + "'", exception);
|
||||
}
|
||||
PackValidationResult validation = new PackValidationResult(
|
||||
key,
|
||||
stagedValidation.getBlockingErrors(),
|
||||
stagedValidation.getWarnings(),
|
||||
stagedValidation.getValidatedAtMillis()
|
||||
);
|
||||
if (!validation.isLoadable()) {
|
||||
sendValidationFeedback(validation, feedback);
|
||||
return null;
|
||||
}
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.IMPORTING,
|
||||
MessageArgument.untrusted("name", name),
|
||||
MessageArgument.untrusted("key", key)
|
||||
));
|
||||
return new PreparedPack(key, name, validation);
|
||||
}
|
||||
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.ACQUIRED, MessageArgument.untrusted("name", d.getName())));
|
||||
validateDownloaded(packEntry, feedback);
|
||||
return key;
|
||||
private static PackInstallResult publishPreparedPack(File packsFolder, Path packsRoot, Path staging, PreparedPack prepared,
|
||||
boolean forceOverwrite, Consumer<String> feedback) throws IOException {
|
||||
Path target = packsRoot.resolve(prepared.key()).normalize();
|
||||
if (!Objects.equals(target.getParent(), packsRoot)) {
|
||||
throw new IOException("Pack target escapes the packs folder: " + target);
|
||||
}
|
||||
if (Files.isSymbolicLink(target)) {
|
||||
sendFeedback(feedback, "Pack '" + prepared.key() + "' is a symbolic-link source and cannot be replaced by Iris.");
|
||||
return null;
|
||||
}
|
||||
|
||||
Path conflictingPack = findConflictingPack(packsRoot, staging, target, prepared.key());
|
||||
if (conflictingPack != null) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.DIMENSION_KEY_CONFLICT,
|
||||
MessageArgument.untrusted("key", prepared.key())
|
||||
));
|
||||
return null;
|
||||
}
|
||||
if (!forceOverwrite && isPackPresent(packsRoot.toFile(), prepared.key())) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.PACK_KEY_CONFLICT,
|
||||
MessageArgument.untrusted("key", prepared.key())
|
||||
));
|
||||
return null;
|
||||
}
|
||||
if (!forceOverwrite && Files.exists(target) && !isPackPresent(packsRoot.toFile(), prepared.key())) {
|
||||
IrisLogging.warn("Replacing partial pack folder " + target + " (no dimension files found).");
|
||||
}
|
||||
|
||||
Optional<IrisData> loadedData = IrisData.getLoaded(new File(packsFolder, prepared.key()));
|
||||
if (loadedData.isEmpty()) {
|
||||
loadedData = IrisData.getLoaded(target.toFile());
|
||||
}
|
||||
if (loadedData.isPresent()) {
|
||||
sendFeedback(
|
||||
feedback,
|
||||
"Pack '" + prepared.key() + "' is active and cannot be replaced safely. Unload its worlds before retrying."
|
||||
);
|
||||
return null;
|
||||
}
|
||||
try (AtomicDirectoryPublisher.Publication publication = AtomicDirectoryPublisher.publish(staging, target)) {
|
||||
publication.commit();
|
||||
try {
|
||||
publication.cleanupBackup();
|
||||
} catch (IOException exception) {
|
||||
IrisLogging.reportError(
|
||||
"Pack '" + prepared.key() + "' was published, but its transaction backup could not be cleaned.",
|
||||
exception
|
||||
);
|
||||
}
|
||||
}
|
||||
PackValidationRegistry.publish(prepared.validation());
|
||||
sendValidationFeedback(prepared.validation(), feedback);
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.ACQUIRED,
|
||||
MessageArgument.untrusted("name", prepared.name())
|
||||
));
|
||||
return new PackInstallResult(prepared.key(), true, false);
|
||||
}
|
||||
|
||||
private static Path findConflictingPack(Path packsRoot, Path staging, Path target, String key) throws IOException {
|
||||
Set<Path> roots = new LinkedHashSet<>();
|
||||
roots.add(packsRoot);
|
||||
if (IrisPlatforms.isBound()) {
|
||||
roots.add(IrisPlatforms.get().dataFolder("packs").toPath().toAbsolutePath().normalize());
|
||||
}
|
||||
for (Path root : roots) {
|
||||
if (!Files.isDirectory(root)) {
|
||||
continue;
|
||||
}
|
||||
try (Stream<Path> entries = Files.list(root)) {
|
||||
List<Path> candidates = entries.toList();
|
||||
for (Path candidate : candidates) {
|
||||
Path normalized = candidate.toAbsolutePath().normalize();
|
||||
String candidateName = candidate.getFileName().toString();
|
||||
if (normalized.equals(staging)
|
||||
|| normalized.equals(target)
|
||||
|| PackDirectoryResolver.isHiddenName(candidateName)
|
||||
|| !PackDirectoryResolver.isVisiblePackDirectory(candidate.toFile())) {
|
||||
continue;
|
||||
}
|
||||
Path dimension = candidate.resolve("dimensions").resolve(key + ".json");
|
||||
if (Files.isRegularFile(dimension)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isSafePackKey(String key) {
|
||||
return key != null && PACK_KEY.matcher(key).matches();
|
||||
}
|
||||
|
||||
private static PackInstallResult withDownloadLock(String key, DownloadOperation operation) throws IOException {
|
||||
DownloadLock lock = DOWNLOAD_LOCKS.compute(key, (ignored, existing) -> {
|
||||
DownloadLock selected = existing == null ? new DownloadLock() : existing;
|
||||
selected.references++;
|
||||
return selected;
|
||||
});
|
||||
try {
|
||||
synchronized (lock) {
|
||||
return operation.run();
|
||||
}
|
||||
} finally {
|
||||
DOWNLOAD_LOCKS.computeIfPresent(key, (ignored, existing) -> {
|
||||
if (existing != lock) {
|
||||
return existing;
|
||||
}
|
||||
existing.references--;
|
||||
return existing.references == 0 ? null : existing;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static int downloadLockCount() {
|
||||
return DOWNLOAD_LOCKS.size();
|
||||
}
|
||||
|
||||
static void unpackArchive(Path archive, Path destination, ArchiveLimits limits) throws IOException {
|
||||
Path source = Objects.requireNonNull(archive, "archive").toAbsolutePath().normalize();
|
||||
Path root = Objects.requireNonNull(destination, "destination").toAbsolutePath().normalize();
|
||||
ArchiveLimits safety = Objects.requireNonNull(limits, "limits");
|
||||
if (Files.isSymbolicLink(source) || !Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack archive is missing or unsafe: " + source);
|
||||
}
|
||||
if (Files.size(source) > safety.maxArchiveBytes()) {
|
||||
throw new IOException("Pack archive exceeds the compressed size limit.");
|
||||
}
|
||||
if (Files.exists(root, LinkOption.NOFOLLOW_LINKS) && !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Pack extraction target is not a directory: " + root);
|
||||
}
|
||||
Files.createDirectories(root);
|
||||
if (Files.isSymbolicLink(root)) {
|
||||
throw new IOException("Pack extraction target is unsafe: " + root);
|
||||
}
|
||||
|
||||
int entryCount = 0;
|
||||
long expandedBytes = 0L;
|
||||
Set<String> paths = new HashSet<>();
|
||||
try (InputStream input = Files.newInputStream(source); ZipInputStream zip = new ZipInputStream(input)) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
entryCount++;
|
||||
if (entryCount > safety.maxEntries()) {
|
||||
throw new IOException("Pack archive contains too many entries.");
|
||||
}
|
||||
String normalizedName = normalizeArchiveEntry(entry.getName());
|
||||
String collisionKey = normalizedName.toLowerCase(Locale.ROOT);
|
||||
if (!paths.add(collisionKey)) {
|
||||
throw new IOException("Pack archive contains a duplicate path: " + normalizedName);
|
||||
}
|
||||
Path output = root.resolve(normalizedName).normalize();
|
||||
if (!output.startsWith(root)) {
|
||||
throw new IOException("Pack archive entry escapes extraction: " + entry.getName());
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(output);
|
||||
zip.closeEntry();
|
||||
continue;
|
||||
}
|
||||
long declaredSize = entry.getSize();
|
||||
if (declaredSize > safety.maxEntryBytes()) {
|
||||
throw new IOException("Pack archive entry exceeds the file size limit: " + normalizedName);
|
||||
}
|
||||
Files.createDirectories(output.getParent());
|
||||
long entryBytes = 0L;
|
||||
try (OutputStream file = Files.newOutputStream(output, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = zip.read(buffer)) != -1) {
|
||||
if (read == 0) {
|
||||
continue;
|
||||
}
|
||||
entryBytes += read;
|
||||
expandedBytes += read;
|
||||
if (entryBytes > safety.maxEntryBytes()) {
|
||||
throw new IOException("Pack archive entry exceeds the file size limit: " + normalizedName);
|
||||
}
|
||||
if (expandedBytes > safety.maxExpandedBytes()) {
|
||||
throw new IOException("Pack archive expands beyond the safety limit.");
|
||||
}
|
||||
file.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
if (entryCount == 0) {
|
||||
throw new IOException("Pack archive is empty.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeArchiveEntry(String rawName) throws IOException {
|
||||
if (rawName == null || rawName.isBlank() || rawName.indexOf('\0') >= 0
|
||||
|| rawName.startsWith("/") || rawName.startsWith("\\")) {
|
||||
throw new IOException("Pack archive contains an invalid path.");
|
||||
}
|
||||
String slashNormalized = rawName.replace('\\', '/');
|
||||
if (slashNormalized.matches("^[A-Za-z]:.*")) {
|
||||
throw new IOException("Pack archive contains an unsafe path: " + rawName);
|
||||
}
|
||||
Path normalized = Path.of(slashNormalized).normalize();
|
||||
String result = normalized.toString().replace('\\', '/');
|
||||
if (normalized.isAbsolute() || normalized.startsWith("..") || result.isBlank() || ".".equals(result)) {
|
||||
throw new IOException("Pack archive contains an unsafe path: " + rawName);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void deleteDirectory(File directory) {
|
||||
try {
|
||||
AtomicDirectoryPublisher.deleteTree(directory.toPath());
|
||||
} catch (IOException exception) {
|
||||
IrisLogging.reportError("Failed to clean temporary pack directory '" + directory.getPath() + "'", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendFeedback(Consumer<String> feedback, String message) {
|
||||
try {
|
||||
feedback.accept(message);
|
||||
} catch (RuntimeException exception) {
|
||||
IrisLogging.reportError("Pack download feedback delivery failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static void sendValidationFeedback(PackValidationResult result, Consumer<String> feedback) {
|
||||
if (!result.isLoadable()) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATION_FAILED,
|
||||
MessageArgument.untrusted("pack", result.getPackName())
|
||||
));
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATION_REASON,
|
||||
MessageArgument.untrusted("reason", reason)
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!result.getWarnings().isEmpty()) {
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATED_WITH_WARNINGS,
|
||||
MessageArgument.untrusted("pack", result.getPackName()),
|
||||
MessageArgument.trusted("count", result.getWarnings().size())
|
||||
));
|
||||
return;
|
||||
}
|
||||
sendFeedback(feedback, IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATED,
|
||||
MessageArgument.untrusted("pack", result.getPackName())
|
||||
));
|
||||
}
|
||||
|
||||
static String resolveGithubArchiveUrl(String repo, String ref) {
|
||||
@@ -229,6 +551,9 @@ public final class PackDownloader {
|
||||
if (COMMIT_SHA.matcher(ref).matches()) {
|
||||
return "https://github.com/" + repo + "/archive/" + ref + ".zip";
|
||||
}
|
||||
if ("HEAD".equals(ref)) {
|
||||
return "https://github.com/" + repo + "/archive/HEAD.zip";
|
||||
}
|
||||
if (ref.startsWith("refs/") && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/")) {
|
||||
throw new IllegalArgumentException("Unsupported GitHub reference '" + ref + "'");
|
||||
}
|
||||
@@ -269,27 +594,26 @@ public final class PackDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateDownloaded(File packEntry, Consumer<String> feedback) {
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(packEntry);
|
||||
PackValidationRegistry.publish(result);
|
||||
private record PreparedPack(String key, String name, PackValidationResult validation) {
|
||||
}
|
||||
|
||||
if (!result.isLoadable()) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATION_FAILED, MessageArgument.untrusted("pack", result.getPackName())));
|
||||
for (String reason : result.getBlockingErrors()) {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATION_REASON, MessageArgument.untrusted("reason", reason)));
|
||||
}
|
||||
} else if (!result.getWarnings().isEmpty()) {
|
||||
feedback.accept(IrisLanguage.plain(
|
||||
PackDownloadMessages.VALIDATED_WITH_WARNINGS,
|
||||
MessageArgument.untrusted("pack", result.getPackName()),
|
||||
MessageArgument.trusted("count", result.getWarnings().size())
|
||||
));
|
||||
} else {
|
||||
feedback.accept(IrisLanguage.plain(PackDownloadMessages.VALIDATED, MessageArgument.untrusted("pack", result.getPackName())));
|
||||
public record PackInstallResult(String key, boolean changed, boolean restartRequired) {
|
||||
}
|
||||
|
||||
record ArchiveLimits(long maxArchiveBytes, int maxEntries, long maxExpandedBytes, long maxEntryBytes) {
|
||||
ArchiveLimits {
|
||||
if (maxArchiveBytes < 1L || maxEntries < 1 || maxExpandedBytes < 1L || maxEntryBytes < 1L) {
|
||||
throw new IllegalArgumentException("Archive limits must be positive.");
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError("Pack validation failed for '" + packEntry.getName() + "'", e);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface DownloadOperation {
|
||||
PackInstallResult run() throws IOException;
|
||||
}
|
||||
|
||||
private static final class DownloadLock {
|
||||
private int references;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,10 @@ final class PackObjectSurfaceValidator {
|
||||
}
|
||||
|
||||
static List<String> validateStructureGraph(File packFolder) {
|
||||
return validateStructureGraph(packFolder, true);
|
||||
}
|
||||
|
||||
static List<String> validateStructureGraph(File packFolder, boolean validateLiveRegistries) {
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
if (packFolder == null || !packFolder.isDirectory()) {
|
||||
return blockingErrors;
|
||||
@@ -131,7 +135,8 @@ final class PackObjectSurfaceValidator {
|
||||
Set<String> pieceKeys = ContentKeyValidator.deriveRegistrantKeysExact(piecesFolder);
|
||||
Set<String> objectKeys = ContentKeyValidator.deriveObjectKeysExact(objectsFolder);
|
||||
|
||||
PackStructurePlacementValidator.validateStructurePlacements(packFolder, structureKeys, blockingErrors);
|
||||
PackStructurePlacementValidator.validateStructurePlacements(
|
||||
packFolder, structureKeys, validateLiveRegistries, blockingErrors);
|
||||
PackStructurePlacementValidator.validateStructureStartPools(structuresFolder, poolKeys, blockingErrors);
|
||||
PackStructurePlacementValidator.validateJigsawPools(poolsFolder, poolKeys, pieceKeys, blockingErrors);
|
||||
PackStructurePlacementValidator.validateJigsawPieces(piecesFolder, poolKeys, objectKeys, blockingErrors);
|
||||
|
||||
+495
-55
@@ -18,7 +18,10 @@
|
||||
|
||||
package art.arcane.iris.core.pack;
|
||||
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks;
|
||||
import art.arcane.iris.spi.PlatformStructureHooks.JigsawSourceMetadata;
|
||||
import art.arcane.volmlib.util.json.JSONArray;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
@@ -26,22 +29,32 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
final class PackStructurePlacementValidator {
|
||||
private static final Set<String> TERRAIN_ENVELOPE_MODES = Set.of(
|
||||
"BORE", "FORCE_CARVE", "VACUUM", "ENCASE");
|
||||
|
||||
private PackStructurePlacementValidator() {
|
||||
}
|
||||
|
||||
static void validateStructurePlacements(File packFolder,
|
||||
Set<String> structureKeys,
|
||||
boolean validateLiveRegistries,
|
||||
List<String> blockingErrors) {
|
||||
Set<String> registeredStructures = registeredStructureKeys();
|
||||
Set<String> registeredJigsaws = registeredJigsawKeys();
|
||||
Set<String> registeredPools = registeredTemplatePoolKeys();
|
||||
RegistrySnapshot registries = registrySnapshot(validateLiveRegistries, blockingErrors);
|
||||
if (registries == null) {
|
||||
return;
|
||||
}
|
||||
Map<String, String> placementIds = new HashMap<>();
|
||||
Map<String, String> anonymousGridIdentities = new HashMap<>();
|
||||
for (String folderName : PackValidator.STRUCTURE_HOST_FOLDERS) {
|
||||
File resourceFolder = new File(packFolder, folderName);
|
||||
if (!resourceFolder.isDirectory()) {
|
||||
@@ -55,22 +68,42 @@ final class PackStructurePlacementValidator {
|
||||
if (resource == null) {
|
||||
continue;
|
||||
}
|
||||
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
|
||||
if (PackValidator.DIMENSIONS_FOLDER.equals(folderName)) {
|
||||
validateImportedStructureAdjustmentEnvelopes(
|
||||
resourceKey, resource, registries, blockingErrors);
|
||||
}
|
||||
Object rawPlacements = resource.opt("structures");
|
||||
JSONArray placements = resource.optJSONArray("structures");
|
||||
if (placements == null) {
|
||||
if (resource.has("structures") && rawPlacements != JSONObject.NULL) {
|
||||
blockingErrors.add(resourceType + " '" + resourceKey + "'.structures must be an array.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String resourceKey = PackValidationIo.deriveKey(resourceFolder, resourceFile);
|
||||
for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) {
|
||||
JSONObject placement = placements.optJSONObject(placementIndex);
|
||||
if (placement == null) {
|
||||
continue;
|
||||
}
|
||||
JSONArray references = placement.optJSONArray("structures");
|
||||
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
|
||||
boolean hasIrisStructures = references != null && references.length() > 0;
|
||||
boolean hasNativeStructures = nativeStructures != null && nativeStructures.length() > 0;
|
||||
String placementPath = resourceType + " '" + resourceKey + "' structures["
|
||||
+ placementIndex + "]";
|
||||
if (placement == null) {
|
||||
blockingErrors.add(placementPath + " must be an object.");
|
||||
continue;
|
||||
}
|
||||
validatePlacementConfiguration(placementPath, placement, blockingErrors);
|
||||
validatePlacementIdentity(
|
||||
placementPath, placement, placementIds, anonymousGridIdentities, blockingErrors);
|
||||
JSONArray references = placement.optJSONArray("structures");
|
||||
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
|
||||
if (placement.has("structures") && placement.opt("structures") != JSONObject.NULL
|
||||
&& references == null) {
|
||||
blockingErrors.add(placementPath + ".structures must be an array.");
|
||||
}
|
||||
if (placement.has("nativeStructures") && placement.opt("nativeStructures") != JSONObject.NULL
|
||||
&& nativeStructures == null) {
|
||||
blockingErrors.add(placementPath + ".nativeStructures must be an array.");
|
||||
}
|
||||
boolean hasIrisStructures = references != null && references.length() > 0;
|
||||
boolean hasNativeStructures = nativeStructures != null && nativeStructures.length() > 0;
|
||||
if (hasIrisStructures == hasNativeStructures) {
|
||||
blockingErrors.add(placementPath
|
||||
+ " must declare exactly one non-empty backend: structures or nativeStructures.");
|
||||
@@ -78,14 +111,24 @@ final class PackStructurePlacementValidator {
|
||||
}
|
||||
if (hasNativeStructures) {
|
||||
validateNativeStructures(
|
||||
placementPath, placement, nativeStructures,
|
||||
registeredStructures, registeredJigsaws,
|
||||
registeredPools, blockingErrors);
|
||||
placementPath, nativeStructures,
|
||||
registries.structures(), registries.jigsaws(),
|
||||
registries.pools(), registries.jigsawMetadataResolver(),
|
||||
registries.hooks(), blockingErrors);
|
||||
continue;
|
||||
}
|
||||
Set<String> editableStructureKeys = new HashSet<>();
|
||||
for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) {
|
||||
Object rawReference = references.opt(referenceIndex);
|
||||
if (!(rawReference instanceof String structureKey) || structureKey.isBlank()) {
|
||||
blockingErrors.add(placementPath + ".structures[" + referenceIndex
|
||||
+ "] must name a non-blank Iris structure.");
|
||||
continue;
|
||||
}
|
||||
String normalizedStructureKey = structureKey.trim().toLowerCase(Locale.ROOT);
|
||||
if (!editableStructureKeys.add(normalizedStructureKey)) {
|
||||
blockingErrors.add(placementPath + ".structures[" + referenceIndex
|
||||
+ "] duplicates Iris structure '" + structureKey + "'.");
|
||||
continue;
|
||||
}
|
||||
if (!structureKeys.contains(structureKey)) {
|
||||
@@ -99,66 +142,240 @@ final class PackStructurePlacementValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> registeredJigsawKeys() {
|
||||
private static RegistrySnapshot registrySnapshot(boolean validateLiveRegistries,
|
||||
List<String> blockingErrors) {
|
||||
if (!validateLiveRegistries || !IrisPlatforms.isBound()) {
|
||||
return new RegistrySnapshot(Set.of(), Set.of(), Set.of(), null, null);
|
||||
}
|
||||
try {
|
||||
List<String> registered = IrisPlatforms.get().structureHooks().jigsawStructureKeys();
|
||||
if (registered == null || registered.isEmpty()) {
|
||||
return Set.of();
|
||||
PlatformStructureHooks hooks = IrisPlatforms.get().structureHooks();
|
||||
if (hooks == null) {
|
||||
throw new IllegalStateException("The active platform did not provide structure registry hooks");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (String key : registered) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
keys.add(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
Set<String> structures = normalizeRegistryKeys(
|
||||
hooks.structureKeys(), "structure");
|
||||
Set<String> jigsaws = normalizeRegistryKeys(
|
||||
hooks.jigsawStructureKeys(), "jigsaw structure");
|
||||
Set<String> pools = normalizeRegistryKeys(
|
||||
hooks.templatePoolKeys(), "template pool");
|
||||
if (structures.isEmpty()) {
|
||||
throw new IllegalStateException("The active structure registry is empty");
|
||||
}
|
||||
return Set.copyOf(keys);
|
||||
} catch (Throwable ignored) {
|
||||
return Set.of();
|
||||
if (jigsaws.isEmpty()) {
|
||||
throw new IllegalStateException("The active jigsaw structure registry is empty");
|
||||
}
|
||||
if (pools.isEmpty()) {
|
||||
throw new IllegalStateException("The active template pool registry is empty");
|
||||
}
|
||||
return new RegistrySnapshot(
|
||||
structures, jigsaws, pools, new JigsawMetadataResolver(hooks), hooks);
|
||||
} catch (RuntimeException | LinkageError e) {
|
||||
IrisLogging.reportError("Could not read the live structure registries during pack validation", e);
|
||||
blockingErrors.add("Could not validate native structure references against the live registries: "
|
||||
+ failureMessage(e) + ".");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> registeredStructureKeys() {
|
||||
try {
|
||||
List<String> registered = IrisPlatforms.get().structureHooks().structureKeys();
|
||||
if (registered == null || registered.isEmpty()) {
|
||||
return Set.of();
|
||||
private static Set<String> normalizeRegistryKeys(List<String> registered, String registryName) {
|
||||
if (registered == null) {
|
||||
throw new IllegalStateException("The active " + registryName + " registry returned null");
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (String key : registered) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
keys.add(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (String key : registered) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
keys.add(key.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return Set.copyOf(keys);
|
||||
}
|
||||
|
||||
private static String failureMessage(Throwable throwable) {
|
||||
String message = throwable.getMessage();
|
||||
return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
private static void validatePlacementConfiguration(String path, JSONObject placement,
|
||||
List<String> blockingErrors) {
|
||||
PackJsonFieldChecks.validateOptionalEnum(path, placement, "distribution",
|
||||
Set.of("RANDOM_SPREAD", "DENSITY", "CONCENTRIC_RINGS"), blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path, placement, "spacing", 1, 4096, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "separation", 0, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "salt", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalDoubleRange(path, placement, "density", 0D, 1D, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "ringCount", 1, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "ringDistance", 1, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "ringSpread", 1, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "minHeight", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path, placement, "maxHeight", Integer.MIN_VALUE, Integer.MAX_VALUE, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalBoolean(path, placement, "underground", blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalBoolean(path, placement, "underwater", blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalEnum(path, placement, "nativeSuppression",
|
||||
Set.of("NONE", "REPLACE_SOURCE"), blockingErrors);
|
||||
|
||||
Integer spacing = integerValue(placement, "spacing", 32);
|
||||
Integer separation = integerValue(placement, "separation", 8);
|
||||
if (spacing != null && separation != null && separation >= spacing) {
|
||||
blockingErrors.add(path + ".separation must be smaller than spacing.");
|
||||
}
|
||||
Integer minimumHeight = integerValue(placement, "minHeight", -2032);
|
||||
Integer maximumHeight = integerValue(placement, "maxHeight", 2032);
|
||||
if (minimumHeight != null && maximumHeight != null && minimumHeight > maximumHeight) {
|
||||
blockingErrors.add(path + " has an inverted height band: minHeight must not exceed maxHeight.");
|
||||
}
|
||||
Integer ringCount = integerValue(placement, "ringCount", 128);
|
||||
Integer ringDistance = integerValue(placement, "ringDistance", 32);
|
||||
Integer ringSpread = integerValue(placement, "ringSpread", 3);
|
||||
if (ringCount != null && ringDistance != null && ringSpread != null
|
||||
&& ringCount > 0 && ringDistance > 0 && ringSpread > 0) {
|
||||
long ringRadius = (long) Math.ceilDiv(ringCount, ringSpread) * ringDistance;
|
||||
if (ringRadius > Integer.MAX_VALUE) {
|
||||
blockingErrors.add(path + " concentric ring radius exceeds the supported chunk coordinate range.");
|
||||
}
|
||||
return Set.copyOf(keys);
|
||||
} catch (Throwable ignored) {
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
if (placement.has("placementId")) {
|
||||
Object rawPlacementId = placement.opt("placementId");
|
||||
if (!(rawPlacementId instanceof String placementId)) {
|
||||
blockingErrors.add(path + ".placementId must be a string.");
|
||||
} else if (!placementId.isEmpty() && placementId.isBlank()) {
|
||||
blockingErrors.add(path + ".placementId must be omitted or non-blank.");
|
||||
} else if (!placementId.equals(placementId.trim())) {
|
||||
blockingErrors.add(path + ".placementId must not contain leading or trailing whitespace.");
|
||||
}
|
||||
}
|
||||
validateStilt(path, placement, blockingErrors);
|
||||
validateNativeTerrain(path, placement, blockingErrors);
|
||||
}
|
||||
|
||||
private static void validatePlacementIdentity(String path, JSONObject placement,
|
||||
Map<String, String> placementIds,
|
||||
Map<String, String> anonymousGridIdentities,
|
||||
List<String> blockingErrors) {
|
||||
Object rawPlacementId = placement.opt("placementId");
|
||||
if (rawPlacementId instanceof String placementId && !placementId.isBlank()) {
|
||||
String normalizedId = placementId.trim();
|
||||
String existing = placementIds.putIfAbsent(normalizedId, path);
|
||||
if (existing != null) {
|
||||
blockingErrors.add(path + ".placementId duplicates '" + normalizedId
|
||||
+ "' already declared by " + existing + ".");
|
||||
}
|
||||
return;
|
||||
}
|
||||
String gridIdentity = anonymousGridIdentity(placement);
|
||||
String existing = anonymousGridIdentities.putIfAbsent(gridIdentity, path);
|
||||
if (existing != null) {
|
||||
blockingErrors.add(path + " duplicates an anonymous placement grid already declared by "
|
||||
+ existing + "; give distinct placements unique placementId values.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> registeredTemplatePoolKeys() {
|
||||
try {
|
||||
List<String> registered = IrisPlatforms.get().structureHooks().templatePoolKeys();
|
||||
if (registered == null || registered.isEmpty()) {
|
||||
return Set.of();
|
||||
private static String anonymousGridIdentity(JSONObject placement) {
|
||||
StringBuilder identity = new StringBuilder();
|
||||
appendIdentity(identity, placement.optString("distribution", "RANDOM_SPREAD"));
|
||||
appendIdentity(identity, integerValue(placement, "salt", 165745296));
|
||||
appendIdentity(identity, integerValue(placement, "spacing", 32));
|
||||
appendIdentity(identity, integerValue(placement, "separation", 8));
|
||||
Object rawDensity = placement.has("density") ? placement.opt("density") : 0.02D;
|
||||
appendIdentity(identity, rawDensity instanceof Number number
|
||||
? Double.doubleToLongBits(number.doubleValue()) : rawDensity);
|
||||
appendIdentity(identity, integerValue(placement, "ringCount", 128));
|
||||
appendIdentity(identity, integerValue(placement, "ringDistance", 32));
|
||||
appendIdentity(identity, integerValue(placement, "ringSpread", 3));
|
||||
appendIdentity(identity, integerValue(placement, "minHeight", -2032));
|
||||
appendIdentity(identity, integerValue(placement, "maxHeight", 2032));
|
||||
appendIdentity(identity, placement.opt("underground") instanceof Boolean underground && underground);
|
||||
appendIdentity(identity, placement.opt("underwater") instanceof Boolean underwater && underwater);
|
||||
JSONArray structures = placement.optJSONArray("structures");
|
||||
JSONArray nativeStructures = placement.optJSONArray("nativeStructures");
|
||||
if (structures != null && structures.length() > 0) {
|
||||
appendIdentity(identity, "iris");
|
||||
List<String> orderedStructures = new ArrayList<>(structures.length());
|
||||
for (int index = 0; index < structures.length(); index++) {
|
||||
orderedStructures.add(String.valueOf(structures.opt(index)));
|
||||
}
|
||||
Set<String> keys = new HashSet<>();
|
||||
for (String key : registered) {
|
||||
if (key != null && !key.isBlank()) {
|
||||
keys.add(key.toLowerCase(Locale.ROOT));
|
||||
orderedStructures.sort(String::compareTo);
|
||||
for (String structure : orderedStructures) {
|
||||
appendIdentity(identity, structure);
|
||||
}
|
||||
} else {
|
||||
appendIdentity(identity, "native");
|
||||
if (nativeStructures != null) {
|
||||
List<String> orderedSources = new ArrayList<>(nativeStructures.length());
|
||||
for (int index = 0; index < nativeStructures.length(); index++) {
|
||||
JSONObject source = nativeStructures.optJSONObject(index);
|
||||
String sourceKey = source == null ? "null" : source.optString("structure", "");
|
||||
Integer weight = source == null ? null : integerValue(source, "weight", 1);
|
||||
orderedSources.add(sourceKey.length() + ":" + sourceKey + ":" + weight);
|
||||
}
|
||||
orderedSources.sort(String::compareTo);
|
||||
for (String source : orderedSources) {
|
||||
appendIdentity(identity, source);
|
||||
}
|
||||
}
|
||||
return Set.copyOf(keys);
|
||||
} catch (Throwable ignored) {
|
||||
return Set.of();
|
||||
}
|
||||
return identity.toString();
|
||||
}
|
||||
|
||||
private static void appendIdentity(StringBuilder identity, Object value) {
|
||||
String text = String.valueOf(value);
|
||||
identity.append(text.length()).append(':').append(text).append('|');
|
||||
}
|
||||
|
||||
private static void validateStilt(String path, JSONObject placement, List<String> blockingErrors) {
|
||||
if (!placement.has("stilt") || placement.opt("stilt") == JSONObject.NULL) {
|
||||
return;
|
||||
}
|
||||
JSONObject stilt = placement.optJSONObject("stilt");
|
||||
if (stilt == null) {
|
||||
blockingErrors.add(path + ".stilt must be an object.");
|
||||
return;
|
||||
}
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path + ".stilt", stilt, "maxDepth", 1, 4064, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(
|
||||
path + ".stilt", stilt, "spacing", 1, 64, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalBoolean(
|
||||
path + ".stilt", stilt, "supportNonOccluding", blockingErrors);
|
||||
if (stilt.has("palette") && stilt.opt("palette") != JSONObject.NULL
|
||||
&& stilt.optJSONObject("palette") == null) {
|
||||
blockingErrors.add(path + ".stilt.palette must be an object.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateNativeStructures(String placementPath, JSONObject placement,
|
||||
private static Integer integerValue(JSONObject object, String field, int defaultValue) {
|
||||
if (!object.has(field)) {
|
||||
return defaultValue;
|
||||
}
|
||||
Object rawValue = object.opt(field);
|
||||
if (!(rawValue instanceof Number number)) {
|
||||
return null;
|
||||
}
|
||||
double doubleValue = number.doubleValue();
|
||||
if (!Double.isFinite(doubleValue) || Math.rint(doubleValue) != doubleValue
|
||||
|| doubleValue < Integer.MIN_VALUE || doubleValue > Integer.MAX_VALUE) {
|
||||
return null;
|
||||
}
|
||||
return number.intValue();
|
||||
}
|
||||
|
||||
private static void validateNativeStructures(String placementPath,
|
||||
JSONArray nativeStructures,
|
||||
Set<String> registeredStructures,
|
||||
Set<String> registeredJigsaws,
|
||||
Set<String> registeredPools,
|
||||
JigsawMetadataResolver jigsawMetadataResolver,
|
||||
PlatformStructureHooks hooks,
|
||||
List<String> blockingErrors) {
|
||||
Set<String> sourceKeys = new HashSet<>();
|
||||
long totalWeight = 0L;
|
||||
for (int sourceIndex = 0; sourceIndex < nativeStructures.length(); sourceIndex++) {
|
||||
String sourcePath = placementPath + ".nativeStructures[" + sourceIndex + "]";
|
||||
JSONObject source = nativeStructures.optJSONObject(sourceIndex);
|
||||
@@ -174,22 +391,199 @@ final class PackStructurePlacementValidator {
|
||||
blockingErrors.add(sourcePath + ".structure '" + structureKey
|
||||
+ "' is not a registered structure.");
|
||||
}
|
||||
if (!structureKey.isEmpty() && !sourceKeys.add(structureKey.toLowerCase(Locale.ROOT))) {
|
||||
blockingErrors.add(sourcePath + ".structure duplicates native source '" + structureKey + "'.");
|
||||
}
|
||||
Integer weight = PackLootValidator.lootInteger(source, "weight", 1, sourcePath, blockingErrors);
|
||||
PackLootValidator.requireMinimum(sourcePath + ".weight", weight, 1, blockingErrors);
|
||||
if (weight != null && weight > 0) {
|
||||
totalWeight += weight;
|
||||
}
|
||||
String normalizedStructureKey = structureKey.toLowerCase(Locale.ROOT);
|
||||
boolean registeredJigsaw = registeredJigsaws.contains(normalizedStructureKey);
|
||||
JSONObject jigsaw = source.optJSONObject("jigsaw");
|
||||
if (source.has("jigsaw") && source.opt("jigsaw") != JSONObject.NULL && jigsaw == null) {
|
||||
blockingErrors.add(sourcePath + ".jigsaw must be an object.");
|
||||
} else if (jigsaw != null) {
|
||||
if (!registeredJigsaws.isEmpty()
|
||||
&& !registeredJigsaws.contains(structureKey.toLowerCase(Locale.ROOT))) {
|
||||
if (!registeredJigsaws.isEmpty() && !registeredJigsaw) {
|
||||
blockingErrors.add(sourcePath
|
||||
+ ".jigsaw requires a registered jigsaw structure.");
|
||||
}
|
||||
validateJigsawAssembly(
|
||||
sourcePath + ".jigsaw", jigsaw, registeredPools, blockingErrors);
|
||||
}
|
||||
JigsawSourceMetadata sourceMetadata = registeredJigsaw
|
||||
? resolveJigsawMetadata(sourcePath, normalizedStructureKey,
|
||||
jigsawMetadataResolver, blockingErrors)
|
||||
: null;
|
||||
if (registeredJigsaw && sourceMetadata == null) {
|
||||
continue;
|
||||
}
|
||||
if (registeredJigsaw || jigsaw != null) {
|
||||
validateReferenceEnvelope(
|
||||
sourcePath, normalizedStructureKey, jigsaw,
|
||||
sourceMetadata, hooks, blockingErrors);
|
||||
}
|
||||
}
|
||||
validateNativeTerrain(placementPath, placement, blockingErrors);
|
||||
if (totalWeight > Integer.MAX_VALUE) {
|
||||
blockingErrors.add(placementPath + ".nativeStructures total weight exceeds "
|
||||
+ Integer.MAX_VALUE + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateReferenceEnvelope(String sourcePath, String structureKey,
|
||||
JSONObject jigsaw, JigsawSourceMetadata sourceMetadata,
|
||||
PlatformStructureHooks hooks,
|
||||
List<String> blockingErrors) {
|
||||
boolean overriddenDistance = jigsaw != null && jigsaw.has("maxDistanceHorizontal");
|
||||
Integer maximumDistance = overriddenDistance
|
||||
? integerValue(jigsaw, "maxDistanceHorizontal", -1)
|
||||
: sourceMetadata == null ? null : sourceMetadata.maxDistanceHorizontal();
|
||||
if (maximumDistance == null || maximumDistance < 0) {
|
||||
return;
|
||||
}
|
||||
Integer startElementSpan = effectiveStartElementSpan(
|
||||
sourcePath, structureKey, jigsaw, sourceMetadata, hooks, blockingErrors);
|
||||
if (startElementSpan == null) {
|
||||
return;
|
||||
}
|
||||
long assemblySpan = Math.max(maximumDistance, startElementSpan);
|
||||
if (assemblySpan > 128L) {
|
||||
String distancePath = overriddenDistance
|
||||
? ".jigsaw.maxDistanceHorizontal" : " registered jigsaw source max-distance";
|
||||
blockingErrors.add(sourcePath + distancePath + " and its actual structure content"
|
||||
+ " (" + startElementSpan + "-block maximum start element and "
|
||||
+ maximumDistance + "-block maximum assembly distance) must not exceed Minecraft's"
|
||||
+ " 128-block (8-chunk) structure reference range.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer effectiveStartElementSpan(String sourcePath, String structureKey,
|
||||
JSONObject jigsaw,
|
||||
JigsawSourceMetadata sourceMetadata,
|
||||
PlatformStructureHooks hooks,
|
||||
List<String> blockingErrors) {
|
||||
int sourceSpan = sourceMetadata == null ? 0 : sourceMetadata.maxStartElementHorizontalSpan();
|
||||
if (jigsaw == null || !jigsaw.has("startPool")) {
|
||||
return sourceSpan;
|
||||
}
|
||||
String startPool = jigsaw.optString("startPool", "").trim();
|
||||
if (startPool.isEmpty() || hooks == null) {
|
||||
return sourceSpan;
|
||||
}
|
||||
try {
|
||||
int resolvedSpan = hooks.jigsawStartPoolHorizontalSpan(structureKey, startPool);
|
||||
if (resolvedSpan < 0) {
|
||||
throw new IllegalStateException("negative horizontal span " + resolvedSpan);
|
||||
}
|
||||
return resolvedSpan;
|
||||
} catch (RuntimeException | LinkageError error) {
|
||||
IrisLogging.reportError("Could not resolve the effective native jigsaw start-pool span", error);
|
||||
blockingErrors.add(sourcePath + ".jigsaw.startPool '" + startPool
|
||||
+ "' could not resolve a bounded live horizontal span: "
|
||||
+ failureMessage(error) + ".");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateImportedStructureAdjustmentEnvelopes(
|
||||
String dimensionKey, JSONObject dimension, RegistrySnapshot registries,
|
||||
List<String> blockingErrors) {
|
||||
JSONObject policy = dimension.optJSONObject("importedStructures");
|
||||
if (policy == null) {
|
||||
return;
|
||||
}
|
||||
JSONArray adjustments = policy.optJSONArray("adjustments");
|
||||
if (adjustments == null) {
|
||||
return;
|
||||
}
|
||||
List<String> orderedStructureKeys = new ArrayList<>(registries.structures());
|
||||
orderedStructureKeys.sort(String::compareTo);
|
||||
Map<String, EffectiveTerrainAdjustment> effective = new HashMap<>();
|
||||
for (int adjustmentIndex = 0; adjustmentIndex < adjustments.length(); adjustmentIndex++) {
|
||||
JSONObject adjustment = adjustments.optJSONObject(adjustmentIndex);
|
||||
if (adjustment == null || adjustment.optJSONObject("terrain") == null) {
|
||||
continue;
|
||||
}
|
||||
JSONArray matches = adjustment.optJSONArray("match");
|
||||
if (matches == null || matches.length() == 0) {
|
||||
continue;
|
||||
}
|
||||
String path = "Dimension '" + dimensionKey
|
||||
+ "' importedStructures.adjustments[" + adjustmentIndex + "]";
|
||||
for (String structureKey : orderedStructureKeys) {
|
||||
if (matchesStructure(matches, structureKey)) {
|
||||
effective.put(structureKey, new EffectiveTerrainAdjustment(path, adjustment));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String structureKey : orderedStructureKeys) {
|
||||
EffectiveTerrainAdjustment adjustment = effective.get(structureKey);
|
||||
if (adjustment == null) {
|
||||
continue;
|
||||
}
|
||||
JSONObject terrain = adjustment.adjustment().optJSONObject("terrain");
|
||||
if (terrain == null || !TERRAIN_ENVELOPE_MODES.contains(
|
||||
terrain.optString("mode", "SOURCE").toUpperCase(Locale.ROOT))) {
|
||||
continue;
|
||||
}
|
||||
String sourcePath = adjustment.path() + " matched registered structure '"
|
||||
+ structureKey + "'";
|
||||
if (registries.jigsaws().contains(structureKey)) {
|
||||
JigsawSourceMetadata metadata = resolveJigsawMetadata(
|
||||
sourcePath, structureKey,
|
||||
registries.jigsawMetadataResolver(), blockingErrors);
|
||||
if (metadata == null) {
|
||||
continue;
|
||||
}
|
||||
validateReferenceEnvelope(
|
||||
sourcePath, structureKey, null, metadata,
|
||||
registries.hooks(), blockingErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean matchesStructure(JSONArray matches, String structureKey) {
|
||||
for (int matchIndex = 0; matchIndex < matches.length(); matchIndex++) {
|
||||
Object rawPattern = matches.opt(matchIndex);
|
||||
if (rawPattern instanceof String pattern
|
||||
&& matchesStructurePattern(pattern, structureKey)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JigsawSourceMetadata resolveJigsawMetadata(
|
||||
String sourcePath, String structureKey,
|
||||
JigsawMetadataResolver resolver, List<String> blockingErrors) {
|
||||
if (resolver == null) {
|
||||
return null;
|
||||
}
|
||||
MetadataResolution resolution = resolver.resolve(structureKey);
|
||||
if (resolution.metadata() != null) {
|
||||
return resolution.metadata();
|
||||
}
|
||||
blockingErrors.add(sourcePath + " could not resolve live metadata for registered jigsaw source '"
|
||||
+ structureKey + "': " + resolution.failure() + ".");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean matchesStructurePattern(String pattern, String structureKey) {
|
||||
String normalizedPattern = pattern == null ? "" : pattern.trim().toLowerCase(Locale.ROOT);
|
||||
String normalizedKey = structureKey == null ? "" : structureKey.trim().toLowerCase(Locale.ROOT);
|
||||
if (normalizedPattern.isEmpty() || !normalizedKey.startsWith(normalizedPattern)) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedKey.length() == normalizedPattern.length()) {
|
||||
return true;
|
||||
}
|
||||
char patternEnd = normalizedPattern.charAt(normalizedPattern.length() - 1);
|
||||
if (patternEnd == ':' || patternEnd == '/' || patternEnd == '_') {
|
||||
return true;
|
||||
}
|
||||
char boundary = normalizedKey.charAt(normalizedPattern.length());
|
||||
return boundary == '/' || boundary == '_';
|
||||
}
|
||||
|
||||
private static void validateJigsawAssembly(String path, JSONObject assembly,
|
||||
@@ -234,6 +628,8 @@ final class PackStructurePlacementValidator {
|
||||
}
|
||||
PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "mode",
|
||||
Set.of("SOURCE", "PRESERVE", "BORE", "FORCE_CARVE", "VACUUM", "ENCASE"), blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalEnum(path + ".terrain", terrain, "shape",
|
||||
Set.of("BOX", "ROUNDED", "ERODED"), blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
|
||||
"horizontalPadding", 0, 128, blockingErrors);
|
||||
PackJsonFieldChecks.validateOptionalIntegerRange(path + ".terrain", terrain,
|
||||
@@ -386,4 +782,48 @@ final class PackStructurePlacementValidator {
|
||||
default -> "Resource";
|
||||
};
|
||||
}
|
||||
|
||||
private record RegistrySnapshot(Set<String> structures, Set<String> jigsaws, Set<String> pools,
|
||||
JigsawMetadataResolver jigsawMetadataResolver,
|
||||
PlatformStructureHooks hooks) {
|
||||
}
|
||||
|
||||
private static final class JigsawMetadataResolver {
|
||||
private final PlatformStructureHooks hooks;
|
||||
private final Map<String, MetadataResolution> resolutions = new HashMap<>();
|
||||
|
||||
private JigsawMetadataResolver(PlatformStructureHooks hooks) {
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
private MetadataResolution resolve(String structureKey) {
|
||||
MetadataResolution cached = resolutions.get(structureKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
MetadataResolution resolved;
|
||||
try {
|
||||
JigsawSourceMetadata metadata = hooks.jigsawSourceMetadata(structureKey);
|
||||
if (metadata == null) {
|
||||
throw new IllegalStateException(
|
||||
"The active structure registry returned null jigsaw metadata for '"
|
||||
+ structureKey + "'");
|
||||
}
|
||||
resolved = new MetadataResolution(metadata, null);
|
||||
} catch (RuntimeException | LinkageError error) {
|
||||
IrisLogging.reportError(
|
||||
"Could not resolve live jigsaw metadata for registered structure '"
|
||||
+ structureKey + "' during pack validation", error);
|
||||
resolved = new MetadataResolution(null, failureMessage(error));
|
||||
}
|
||||
resolutions.put(structureKey, resolved);
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
private record MetadataResolution(JigsawSourceMetadata metadata, String failure) {
|
||||
}
|
||||
|
||||
private record EffectiveTerrainAdjustment(String path, JSONObject adjustment) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ public final class PackValidator {
|
||||
}
|
||||
|
||||
public static PackValidationResult validate(File packFolder) {
|
||||
return validate(packFolder, true);
|
||||
}
|
||||
|
||||
public static PackValidationResult validateForDatapackBootstrap(File packFolder) {
|
||||
return validate(packFolder, false);
|
||||
}
|
||||
|
||||
private static PackValidationResult validate(File packFolder, boolean validateLiveRegistries) {
|
||||
String packName = packFolder == null ? "<unknown>" : packFolder.getName();
|
||||
List<String> blockingErrors = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
@@ -72,7 +80,8 @@ public final class PackValidator {
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateRemovedWorldgenFields(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateObjectSurfaceSupport(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateUnsupportedStructureTransforms(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateStructureGraph(packFolder));
|
||||
blockingErrors.addAll(PackObjectSurfaceValidator.validateStructureGraph(
|
||||
packFolder, validateLiveRegistries));
|
||||
StructureGraphPackValidator.Validation compiledStructures =
|
||||
StructureGraphPackValidator.validate(
|
||||
packFolder.toPath(), PackObjectSurfaceValidator.collectPlacedStructureKeys(packFolder));
|
||||
|
||||
@@ -18,52 +18,213 @@
|
||||
|
||||
package art.arcane.iris.core.project;
|
||||
|
||||
import art.arcane.iris.core.pack.PackDirectoryResolver;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import art.arcane.volmlib.util.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
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.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class IrisProjectCopier {
|
||||
private IrisProjectCopier() {
|
||||
}
|
||||
|
||||
public static void copyProject(File sourcePack, File targetPack, String sourceKey, String targetKey) throws IOException {
|
||||
Path source = sourcePack.toPath();
|
||||
try (Stream<Path> walk = Files.walk(source)) {
|
||||
for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) {
|
||||
String relative = source.relativize(path).toString();
|
||||
if (relative.isEmpty() || relative.equals(".git") || relative.startsWith(".git" + File.separator) || relative.endsWith(".code-workspace")) {
|
||||
continue;
|
||||
}
|
||||
Path destination = targetPack.toPath().resolve(relative);
|
||||
if (Files.isDirectory(path)) {
|
||||
Files.createDirectories(destination);
|
||||
} else {
|
||||
Files.createDirectories(destination.getParent());
|
||||
Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
public static void copyProject(File sourcePack, File targetWorkspace, String sourceKey, String targetKey) throws IOException {
|
||||
copyProject(sourcePack, targetWorkspace, sourceKey, targetKey, (source, target) -> {
|
||||
});
|
||||
}
|
||||
|
||||
File oldDimension = new File(targetPack, "dimensions/" + sourceKey + ".json");
|
||||
File newDimension = new File(targetPack, "dimensions/" + targetKey + ".json");
|
||||
if (oldDimension.isFile() && !oldDimension.equals(newDimension)) {
|
||||
Files.copy(oldDimension.toPath(), newDimension.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.delete(oldDimension.toPath());
|
||||
}
|
||||
if (newDimension.isFile()) {
|
||||
JSONObject json = new JSONObject(IO.readAll(newDimension));
|
||||
if (json.has("name")) {
|
||||
json.put("name", Form.capitalizeWords(targetKey.replaceAll("\\Q-\\E", " ")));
|
||||
IO.writeAll(newDimension, json.toString(4));
|
||||
static void copyProject(
|
||||
File sourcePack,
|
||||
File targetWorkspace,
|
||||
String sourceKey,
|
||||
String targetKey,
|
||||
CopyHook copyHook
|
||||
) throws IOException {
|
||||
String validatedSourceKey = requireSafeKey(sourceKey, "source");
|
||||
String validatedTargetKey = requireSafeKey(targetKey, "target");
|
||||
Path source = requireSafeSource(
|
||||
Objects.requireNonNull(sourcePack, "sourcePack").toPath().toAbsolutePath().normalize(),
|
||||
validatedSourceKey
|
||||
);
|
||||
Path workspace = requireTargetWorkspace(targetWorkspace);
|
||||
Path target = workspace.resolve(validatedTargetKey).normalize();
|
||||
requireAvailableTarget(target, workspace, validatedTargetKey);
|
||||
|
||||
Path stage = Files.createTempDirectory(workspace, "." + validatedTargetKey + ".importing-");
|
||||
boolean published = false;
|
||||
Throwable operationFailure = null;
|
||||
try {
|
||||
copyTree(source, stage, Objects.requireNonNull(copyHook, "copyHook"));
|
||||
transformDimension(stage, validatedSourceKey, validatedTargetKey);
|
||||
requireAvailableTarget(target, workspace, validatedTargetKey);
|
||||
publish(stage, target);
|
||||
published = true;
|
||||
} catch (IOException | RuntimeException | Error e) {
|
||||
operationFailure = e;
|
||||
throw e;
|
||||
} finally {
|
||||
if (!published) {
|
||||
try {
|
||||
deleteTree(stage);
|
||||
} catch (IOException cleanupFailure) {
|
||||
if (operationFailure != null) {
|
||||
operationFailure.addSuppressed(cleanupFailure);
|
||||
} else {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireSafeKey(String value, String purpose) throws IOException {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IOException("Project " + purpose + " key cannot be empty.");
|
||||
}
|
||||
|
||||
Path key;
|
||||
try {
|
||||
key = Path.of(value);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IOException("Invalid project " + purpose + " key: " + value, e);
|
||||
}
|
||||
if (key.isAbsolute()
|
||||
|| key.getNameCount() != 1
|
||||
|| !key.normalize().equals(key)
|
||||
|| ".".equals(value)
|
||||
|| "..".equals(value)
|
||||
|| value.indexOf('/') >= 0
|
||||
|| value.indexOf('\\') >= 0) {
|
||||
throw new IOException("Invalid project " + purpose + " key: " + value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private static Path requireTargetWorkspace(File targetWorkspace) throws IOException {
|
||||
Path workspace = Objects.requireNonNull(targetWorkspace, "targetWorkspace").toPath().toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(workspace)
|
||||
|| !Files.isDirectory(workspace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Project workspace is missing or unsafe: " + workspace);
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private static Path requireSafeSource(Path source, String sourceKey) throws IOException {
|
||||
if (!Files.isDirectory(source)) {
|
||||
throw new IOException("Source project is missing or unsafe: " + source);
|
||||
}
|
||||
PackDirectoryResolver.requireSafePackTree(source.toFile());
|
||||
Path resolvedSource = source.toRealPath();
|
||||
|
||||
Path dimension = resolvedSource.resolve("dimensions").resolve(sourceKey + ".json").normalize();
|
||||
if (!dimension.startsWith(resolvedSource)
|
||||
|| Files.isSymbolicLink(dimension)
|
||||
|| !Files.isRegularFile(dimension, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Source project dimension is missing or unsafe: " + dimension);
|
||||
}
|
||||
return resolvedSource;
|
||||
}
|
||||
|
||||
private static void requireAvailableTarget(Path target, Path workspace, String targetKey) throws IOException {
|
||||
if (!workspace.equals(target.getParent()) || !targetKey.equals(target.getFileName().toString())) {
|
||||
throw new IOException("Target project must be a direct child of the workspace: " + target);
|
||||
}
|
||||
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) {
|
||||
throw new FileAlreadyExistsException(target.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyTree(Path source, Path stage, CopyHook copyHook) throws IOException {
|
||||
try (Stream<Path> walk = Files.walk(source)) {
|
||||
for (Path path : walk.sorted(Comparator.naturalOrder()).toList()) {
|
||||
Path relative = source.relativize(path);
|
||||
if (relative.toString().isEmpty() || shouldSkip(relative)) {
|
||||
continue;
|
||||
}
|
||||
if (Files.isSymbolicLink(path)) {
|
||||
throw new IOException("Source project contains a symbolic link: " + path);
|
||||
}
|
||||
|
||||
Path destination = stage.resolve(relative).normalize();
|
||||
if (!destination.startsWith(stage)) {
|
||||
throw new IOException("Source project entry escapes staging: " + relative);
|
||||
}
|
||||
copyHook.beforeCopy(path, destination);
|
||||
if (Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(destination);
|
||||
} else if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) {
|
||||
Files.createDirectories(destination.getParent());
|
||||
Files.copy(path, destination);
|
||||
} else {
|
||||
throw new IOException("Source project contains an unsupported entry: " + path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean shouldSkip(Path relative) {
|
||||
return ".git".equals(relative.getName(0).toString())
|
||||
|| relative.getFileName().toString().endsWith(".code-workspace");
|
||||
}
|
||||
|
||||
private static void transformDimension(Path stage, String sourceKey, String targetKey) throws IOException {
|
||||
Path oldDimension = stage.resolve("dimensions").resolve(sourceKey + ".json");
|
||||
Path newDimension = stage.resolve("dimensions").resolve(targetKey + ".json");
|
||||
if (!oldDimension.equals(newDimension)) {
|
||||
Files.move(oldDimension, newDimension);
|
||||
}
|
||||
|
||||
JSONObject json = new JSONObject(IO.readAll(newDimension.toFile()));
|
||||
if (json.has("name")) {
|
||||
json.put("name", Form.capitalizeWords(targetKey.replace('-', ' ')));
|
||||
IO.writeAll(newDimension.toFile(), json.toString(4));
|
||||
}
|
||||
}
|
||||
|
||||
private static void publish(Path stage, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(stage, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
Files.move(stage, target);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteTree(Path root) throws IOException {
|
||||
if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
IOException failure = null;
|
||||
for (Path path : walk.sorted(Comparator.reverseOrder()).toList()) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
if (failure == null) {
|
||||
failure = e;
|
||||
} else {
|
||||
failure.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface CopyHook {
|
||||
void beforeCopy(Path source, Path target) throws IOException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -536,7 +536,7 @@ public class SchemaBuilder {
|
||||
} else if (SchemaKeyedTypes.isKeyed(k.getType())) {
|
||||
fancyType = addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity());
|
||||
} else if (k.getType().isEnum()) {
|
||||
fancyType = addEnum(k.getType(), prop, description, k.getType().getEnumConstants(), o -> ((Enum<?>) o).name());
|
||||
fancyType = addEnum(k.getType(), prop, description, enumNames(k.getType()), Function.identity());
|
||||
}
|
||||
}
|
||||
case "object" -> {
|
||||
@@ -738,7 +738,7 @@ public class SchemaBuilder {
|
||||
} else if (SchemaKeyedTypes.isKeyed(t.type())) {
|
||||
fancyType = addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity());
|
||||
} else if (t.type().isEnum()) {
|
||||
fancyType = addEnumList(prop, description, t, t.type().getEnumConstants(), o -> ((Enum<?>) o).name());
|
||||
fancyType = addEnumList(prop, description, t, enumNames(t.type()), Function.identity());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +851,22 @@ public class SchemaBuilder {
|
||||
return "List of " + s + "s";
|
||||
}
|
||||
|
||||
private static String[] enumNames(Class<?> enumType) {
|
||||
try {
|
||||
Object[] constants = enumType.getEnumConstants();
|
||||
String[] names = new String[constants.length];
|
||||
for (int index = 0; index < constants.length; index++) {
|
||||
names[index] = ((Enum<?>) constants[index]).name();
|
||||
}
|
||||
return names;
|
||||
} catch (LinkageError error) {
|
||||
return Arrays.stream(enumType.getDeclaredFields())
|
||||
.filter(Field::isEnumConstant)
|
||||
.map(Field::getName)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private <T> String addEnum(Class<?> type, JSONObject prop, KList<String> description, T[] values, Function<T, String> function) {
|
||||
JSONArray a = new JSONArray();
|
||||
|
||||
@@ -2,9 +2,13 @@ package art.arcane.iris.core.runtime;
|
||||
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.link.MultiverseCoreLink;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.core.lifecycle.WorldLifecycleService;
|
||||
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.core.project.IrisProject;
|
||||
import art.arcane.iris.core.project.IrisCodeWorkspace;
|
||||
import art.arcane.iris.core.tools.IrisCreator;
|
||||
@@ -14,7 +18,6 @@ import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.exceptions.IrisException;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import art.arcane.volmlib.util.io.IO;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
@@ -22,17 +25,27 @@ import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
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.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public final class StudioOpenCoordinator {
|
||||
private static final long STUDIO_CLOSE_TIMEOUT_SECONDS = 120L;
|
||||
private static volatile StudioOpenCoordinator instance;
|
||||
|
||||
private StudioOpenCoordinator() {
|
||||
@@ -61,31 +74,20 @@ public final class StudioOpenCoordinator {
|
||||
}
|
||||
|
||||
public CompletableFuture<StudioCloseResult> closeProject(IrisProject project) {
|
||||
CompletableFuture<StudioCloseResult> future = new CompletableFuture<>();
|
||||
J.aBukkit(() -> future.complete(executeClose(project)));
|
||||
return future;
|
||||
}
|
||||
|
||||
private StudioCloseResult executeClose(IrisProject project) {
|
||||
if (project == null) {
|
||||
return new StudioCloseResult(null, true, true, false, null);
|
||||
return CompletableFuture.completedFuture(new StudioCloseResult(null, true, true, false, null));
|
||||
}
|
||||
|
||||
PlatformChunkGenerator provider = project.getActiveProvider();
|
||||
if (provider == null) {
|
||||
return new StudioCloseResult(null, true, true, false, null);
|
||||
return CompletableFuture.completedFuture(new StudioCloseResult(null, true, true, false, null));
|
||||
}
|
||||
|
||||
World world = BukkitWorldBinding.world(provider.getTarget().getWorld());
|
||||
String worldName = world == null
|
||||
? IrisWorldStorage.logicalName(WorldIdentity.parse(provider.getTarget().getWorld().identity()))
|
||||
: IrisWorldStorage.logicalName(world);
|
||||
try {
|
||||
return closeWorld(provider, worldName, world, true, project);
|
||||
} catch (Throwable e) {
|
||||
project.setActiveProvider(null);
|
||||
return new StudioCloseResult(worldName, false, false, false, e);
|
||||
}
|
||||
return closeWorldCoordinated(provider, worldName, world, true, project);
|
||||
}
|
||||
|
||||
private void executeOpen(StudioOpenRequest request, CompletableFuture<StudioOpenResult> future) {
|
||||
@@ -200,7 +202,16 @@ public final class StudioOpenCoordinator {
|
||||
if (!request.retainOnFailure()) {
|
||||
try {
|
||||
updateStage(request, "cleanup", 1.00D);
|
||||
closeWorld(provider, request.worldName(), world, true, request.project());
|
||||
StudioCloseResult cleanupResult = closeWorldCoordinated(
|
||||
provider,
|
||||
request.worldName(),
|
||||
world,
|
||||
true,
|
||||
request.project()
|
||||
).get(45L, TimeUnit.SECONDS);
|
||||
if (cleanupResult.failureCause() != null) {
|
||||
throw cleanupResult.failureCause();
|
||||
}
|
||||
} catch (Throwable cleanupError) {
|
||||
IrisLogging.reportError("Studio cleanup failed for world \"" + request.worldName() + "\".", cleanupError);
|
||||
}
|
||||
@@ -240,164 +251,344 @@ public final class StudioOpenCoordinator {
|
||||
return loaded;
|
||||
}
|
||||
|
||||
private StudioCloseResult closeWorld(
|
||||
private CompletableFuture<StudioCloseResult> closeWorldCoordinated(
|
||||
PlatformChunkGenerator provider,
|
||||
String worldName,
|
||||
World world,
|
||||
boolean deleteFolder,
|
||||
IrisProject project
|
||||
) {
|
||||
Throwable failure = null;
|
||||
boolean unloadCompletedLive = world == null || !isWorldFamilyLoaded(worldName);
|
||||
boolean folderDeletionCompletedLive = !deleteFolder;
|
||||
boolean startupCleanupQueued = false;
|
||||
CompletableFuture<Void> closeFuture = CompletableFuture.completedFuture(null);
|
||||
|
||||
if (world != null) {
|
||||
try {
|
||||
evacuatePlayers(world);
|
||||
} catch (Throwable e) {
|
||||
failure = e;
|
||||
}
|
||||
String operationTarget = worldName == null || worldName.isBlank() ? "unknown-studio-world" : worldName;
|
||||
LifecycleOperationCoordinator.Lease lease;
|
||||
try {
|
||||
lease = LifecycleOperationCoordinator.get().acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.STUDIO_CLOSE,
|
||||
operationTarget
|
||||
);
|
||||
} catch (Throwable failure) {
|
||||
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
|
||||
return CompletableFuture.completedFuture(new StudioCloseResult(
|
||||
worldName,
|
||||
false,
|
||||
false,
|
||||
queued,
|
||||
failure
|
||||
));
|
||||
}
|
||||
|
||||
CompletableFuture<StudioCloseResult> closeFuture;
|
||||
try {
|
||||
closeFuture = closeWorldReserved(provider, worldName, world, deleteFolder, project);
|
||||
} catch (Throwable failure) {
|
||||
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
|
||||
closeFuture = CompletableFuture.completedFuture(new StudioCloseResult(
|
||||
worldName,
|
||||
false,
|
||||
false,
|
||||
queued,
|
||||
failure
|
||||
));
|
||||
}
|
||||
return closeFuture.whenComplete((result, throwable) -> lease.close());
|
||||
}
|
||||
|
||||
private CompletableFuture<StudioCloseResult> closeWorldReserved(
|
||||
PlatformChunkGenerator provider,
|
||||
String worldName,
|
||||
World world,
|
||||
boolean deleteFolder,
|
||||
IrisProject project
|
||||
) {
|
||||
AtomicBoolean unloadConfirmed = new AtomicBoolean(false);
|
||||
AtomicBoolean folderDeleted = new AtomicBoolean(!deleteFolder);
|
||||
AtomicBoolean terminalTimeout = new AtomicBoolean(false);
|
||||
if (world != null) {
|
||||
IrisToolbelt.beginWorldMaintenance(world, "studio-close", true);
|
||||
}
|
||||
|
||||
try {
|
||||
if (project != null) {
|
||||
project.setActiveProvider(null);
|
||||
}
|
||||
if (provider != null) {
|
||||
closeFuture = provider.closeAsync();
|
||||
}
|
||||
|
||||
if (worldName != null && !worldName.isBlank()) {
|
||||
requestWorldFamilyUnload(worldName);
|
||||
}
|
||||
|
||||
if (worldName != null && !worldName.isBlank()) {
|
||||
long unloadDeadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(20L);
|
||||
CompletableFuture<Void> unloadFuture = waitForWorldFamilyUnload(worldName, unloadDeadline);
|
||||
try {
|
||||
unloadFuture.get(Math.max(1000L, unloadDeadline - System.currentTimeMillis()), TimeUnit.MILLISECONDS);
|
||||
unloadCompletedLive = true;
|
||||
} catch (TimeoutException e) {
|
||||
unloadCompletedLive = !isWorldFamilyLoaded(worldName);
|
||||
} catch (Throwable e) {
|
||||
failure = failure == null ? unwrapFailure(e) : failure;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
closeFuture.get(20L, TimeUnit.SECONDS);
|
||||
} catch (Throwable e) {
|
||||
Throwable cause = unwrapFailure(e);
|
||||
if (failure == null) {
|
||||
failure = cause;
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteFolder && worldName != null && !worldName.isBlank()) {
|
||||
WorldFamilyDeleteResult deleteResult = deleteWorldFamily(worldName, unloadCompletedLive);
|
||||
folderDeletionCompletedLive = deleteResult.liveDeleted();
|
||||
startupCleanupQueued = deleteResult.startupCleanupQueued();
|
||||
}
|
||||
} finally {
|
||||
CompletableFuture<Void> sequence = sequenceStudioClose(
|
||||
() -> evacuateWorldFamily(worldName, world),
|
||||
() -> unloadWorldFamily(worldName, world).thenRun(() -> {
|
||||
if (terminalTimeout.get()) {
|
||||
throw new CompletionException(new TimeoutException(
|
||||
"Studio close stopped after its terminal timeout."));
|
||||
}
|
||||
unloadConfirmed.set(true);
|
||||
if (project != null) {
|
||||
project.setActiveProvider(null);
|
||||
}
|
||||
}),
|
||||
() -> provider == null ? CompletableFuture.completedFuture(null) : provider.closeAsync(),
|
||||
() -> deleteFolder
|
||||
? deleteWorldFamily(worldName).thenRun(() -> folderDeleted.set(true))
|
||||
: CompletableFuture.completedFuture(null),
|
||||
terminalTimeout::get
|
||||
);
|
||||
CompletableFuture<StudioCloseResult> operation = guardCloseCompletion(
|
||||
sequence,
|
||||
terminalTimeout,
|
||||
worldName)
|
||||
.thenApply(ignored -> new StudioCloseResult(
|
||||
worldName,
|
||||
true,
|
||||
folderDeleted.get(),
|
||||
false,
|
||||
null
|
||||
))
|
||||
.exceptionally(throwable -> {
|
||||
Throwable failure = unwrapFailure(throwable);
|
||||
boolean queued = deleteFolder && queueStartupCleanup(worldName, failure);
|
||||
return new StudioCloseResult(
|
||||
worldName,
|
||||
unloadConfirmed.get(),
|
||||
folderDeleted.get(),
|
||||
queued,
|
||||
failure
|
||||
);
|
||||
});
|
||||
return operation.whenComplete((result, throwable) -> {
|
||||
if (world != null) {
|
||||
IrisToolbelt.endWorldMaintenance(world, "studio-close");
|
||||
}
|
||||
}
|
||||
|
||||
return new StudioCloseResult(worldName, unloadCompletedLive, folderDeletionCompletedLive, startupCleanupQueued, failure);
|
||||
});
|
||||
}
|
||||
|
||||
private void evacuatePlayers(World world) throws Exception {
|
||||
if (world == null) {
|
||||
return;
|
||||
static CompletableFuture<Void> sequenceStudioClose(
|
||||
Supplier<CompletableFuture<Void>> evacuate,
|
||||
Supplier<CompletableFuture<Void>> unload,
|
||||
Supplier<CompletableFuture<Void>> closeGenerator,
|
||||
Supplier<CompletableFuture<Void>> deleteFolders
|
||||
) {
|
||||
return sequenceStudioClose(evacuate, unload, closeGenerator, deleteFolders, () -> false);
|
||||
}
|
||||
|
||||
static CompletableFuture<Void> sequenceStudioClose(
|
||||
Supplier<CompletableFuture<Void>> evacuate,
|
||||
Supplier<CompletableFuture<Void>> unload,
|
||||
Supplier<CompletableFuture<Void>> closeGenerator,
|
||||
Supplier<CompletableFuture<Void>> deleteFolders,
|
||||
BooleanSupplier terminalTimeout
|
||||
) {
|
||||
return invokePhase(evacuate)
|
||||
.thenCompose(ignored -> invokePhaseUnlessTimedOut(unload, terminalTimeout))
|
||||
.thenCompose(ignored -> invokePhaseUnlessTimedOut(closeGenerator, terminalTimeout))
|
||||
.thenCompose(ignored -> invokePhaseUnlessTimedOut(deleteFolders, terminalTimeout));
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> invokePhase(Supplier<CompletableFuture<Void>> phase) {
|
||||
try {
|
||||
CompletableFuture<Void> future = phase.get();
|
||||
if (future == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Studio close phase returned no completion future."));
|
||||
}
|
||||
return future;
|
||||
} catch (Throwable failure) {
|
||||
return CompletableFuture.failedFuture(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> invokePhaseUnlessTimedOut(
|
||||
Supplier<CompletableFuture<Void>> phase,
|
||||
BooleanSupplier terminalTimeout
|
||||
) {
|
||||
if (terminalTimeout.getAsBoolean()) {
|
||||
return CompletableFuture.failedFuture(new TimeoutException(
|
||||
"Studio close stopped after its terminal timeout."));
|
||||
}
|
||||
return invokePhase(phase);
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> guardCloseCompletion(
|
||||
CompletableFuture<Void> source,
|
||||
AtomicBoolean terminalTimeout,
|
||||
String worldName
|
||||
) {
|
||||
CompletableFuture<Void> guarded = new CompletableFuture<>();
|
||||
AtomicBoolean settled = new AtomicBoolean(false);
|
||||
source.whenComplete((ignored, throwable) -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (throwable == null) {
|
||||
guarded.complete(null);
|
||||
} else {
|
||||
guarded.completeExceptionally(throwable);
|
||||
}
|
||||
});
|
||||
CompletableFuture.delayedExecutor(STUDIO_CLOSE_TIMEOUT_SECONDS, TimeUnit.SECONDS).execute(() -> {
|
||||
if (!settled.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
terminalTimeout.set(true);
|
||||
TimeoutException timeout = new TimeoutException(
|
||||
"Studio close did not settle within " + STUDIO_CLOSE_TIMEOUT_SECONDS
|
||||
+ " seconds for \"" + worldName + "\".");
|
||||
ServerConfigurator.restart("Studio close timed out for \"" + worldName + "\".");
|
||||
guarded.completeExceptionally(timeout);
|
||||
});
|
||||
return guarded;
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> evacuateWorldFamily(String worldName, World primaryWorld) {
|
||||
List<World> loadedWorlds = loadedWorldFamily(worldName, primaryWorld);
|
||||
if (loadedWorlds.isEmpty()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
CompletableFuture<Void> future = J.sfut(() -> {
|
||||
IrisToolbelt.evacuate(world);
|
||||
ArrayList<CompletableFuture<Void>> evacuations = new ArrayList<>(loadedWorlds.size());
|
||||
for (World loadedWorld : loadedWorlds) {
|
||||
CompletableFuture<Void> evacuation = J.sfut(() -> IrisToolbelt.evacuateAsync(loadedWorld))
|
||||
.thenCompose(evacuationFuture -> evacuationFuture)
|
||||
.thenCompose(evacuated -> Boolean.TRUE.equals(evacuated)
|
||||
? CompletableFuture.completedFuture(null)
|
||||
: CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Studio player evacuation failed for \"" + loadedWorld.getName() + "\".")));
|
||||
evacuations.add(evacuation);
|
||||
}
|
||||
return CompletableFuture.allOf(evacuations.toArray(CompletableFuture[]::new));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> unloadWorldFamily(String worldName, World primaryWorld) {
|
||||
List<World> loadedWorlds = loadedWorldFamily(worldName, primaryWorld);
|
||||
ArrayList<CompletableFuture<Boolean>> unloads = new ArrayList<>(loadedWorlds.size());
|
||||
for (World loadedWorld : loadedWorlds) {
|
||||
CompletableFuture<Boolean> unload = J.sfut(() ->
|
||||
IrisServices.get(MultiverseCoreLink.class)
|
||||
.removeFromConfig(loadedWorld))
|
||||
.thenCompose(ignored -> WorldLifecycleService.get().unloadAsync(loadedWorld, false));
|
||||
unloads.add(unload);
|
||||
}
|
||||
|
||||
return CompletableFuture.allOf(unloads.toArray(CompletableFuture[]::new)).thenApply(ignored -> {
|
||||
for (CompletableFuture<Boolean> unload : unloads) {
|
||||
if (!Boolean.TRUE.equals(unload.join())) {
|
||||
throw new CompletionException(new IllegalStateException(
|
||||
"Studio world family unload returned false for \"" + worldName + "\"."));
|
||||
}
|
||||
}
|
||||
if (isWorldFamilyLoaded(worldName)) {
|
||||
throw new CompletionException(new IllegalStateException(
|
||||
"Studio world family remained loaded after confirmed unload for \"" + worldName + "\"."));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (future != null) {
|
||||
future.get(10L, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestWorldFamilyUnload(String worldName) {
|
||||
private List<World> loadedWorldFamily(String worldName, World primaryWorld) {
|
||||
LinkedHashSet<World> worlds = new LinkedHashSet<>();
|
||||
if (primaryWorld != null) {
|
||||
worlds.add(primaryWorld);
|
||||
}
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return;
|
||||
return List.copyOf(worlds);
|
||||
}
|
||||
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
World familyWorld = WorldIdentity.resolve(IrisWorldStorage.keyFromName(familyWorldName)).orElse(null);
|
||||
if (familyWorld == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
IrisServices.get(art.arcane.iris.core.link.MultiverseCoreLink.class).removeFromConfig(familyWorld);
|
||||
WorldLifecycleService.get().unload(familyWorld, false);
|
||||
WorldIdentity.resolve(IrisWorldStorage.keyFromName(familyWorldName)).ifPresent(worlds::add);
|
||||
}
|
||||
return List.copyOf(worlds);
|
||||
}
|
||||
|
||||
private WorldFamilyDeleteResult deleteWorldFamily(String worldName, boolean unloadCompletedLive) {
|
||||
private CompletableFuture<Void> deleteWorldFamily(String worldName) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return new WorldFamilyDeleteResult(true, false);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
if (isWorldFamilyLoaded(worldName)) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException(
|
||||
"Refusing to delete a loaded studio world family for \"" + worldName + "\"."));
|
||||
}
|
||||
|
||||
boolean liveDeleted = true;
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
File folder = IrisWorldStorage.dimensionRoot(familyWorldName);
|
||||
if (!folder.exists()) {
|
||||
continue;
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
for (String familyWorldName : TransientWorldCleanupSupport.worldFamilyNames(worldName)) {
|
||||
try {
|
||||
if (isWorldFamilyLoaded(worldName)) {
|
||||
throw new IOException("Studio world family became loaded before deletion for \""
|
||||
+ worldName + "\".");
|
||||
}
|
||||
File folder = IrisWorldStorage.requireSafeManagedDimensionRoot(
|
||||
IrisWorldStorage.managedKeyFromName(familyWorldName));
|
||||
AtomicDirectoryPublisher.deleteTree(folder.toPath());
|
||||
} catch (IOException | IllegalArgumentException failure) {
|
||||
throw new CompletionException(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
deleteWorldFolderAsync(folder, 40).get(15L, TimeUnit.SECONDS);
|
||||
} catch (Throwable e) {
|
||||
liveDeleted = false;
|
||||
IrisLogging.reportError("Studio folder deletion retries failed for \"" + folder.getAbsolutePath() + "\".", unwrapFailure(e));
|
||||
}
|
||||
|
||||
if (folder.exists()) {
|
||||
liveDeleted = false;
|
||||
}
|
||||
private boolean queueStartupCleanup(String worldName, Throwable failure) {
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (liveDeleted) {
|
||||
return new WorldFamilyDeleteResult(true, false);
|
||||
}
|
||||
|
||||
try {
|
||||
IrisServices.get(WorldDeletionQueue.class).queueForStartupDeletion(Collections.singleton(worldName));
|
||||
return new WorldFamilyDeleteResult(false, true);
|
||||
} catch (IOException e) {
|
||||
if (unloadCompletedLive) {
|
||||
IrisLogging.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", e);
|
||||
IrisServices.get(WorldDeletionQueue.class).queueFamilyForStartupDeletion(Collections.singleton(worldName));
|
||||
return true;
|
||||
} catch (Throwable queueFailure) {
|
||||
if (failure != null) {
|
||||
failure.addSuppressed(queueFailure);
|
||||
}
|
||||
return new WorldFamilyDeleteResult(false, false);
|
||||
IrisLogging.reportError("Failed to queue deferred deletion for world \"" + worldName + "\".", queueFailure);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupStaleTransientWorlds(String worldName) {
|
||||
LinkedHashSet<String> staleWorldNames = TransientWorldCleanupSupport.collectTransientStudioWorldNames(IrisWorldStorage.levelRoot());
|
||||
LinkedHashSet<String> staleWorldNames = collectSafeTransientWorldNames();
|
||||
String requestedBaseName = TransientWorldCleanupSupport.transientStudioBaseWorldName(worldName);
|
||||
if (requestedBaseName != null) {
|
||||
staleWorldNames.add(requestedBaseName);
|
||||
}
|
||||
|
||||
for (String staleWorldName : staleWorldNames) {
|
||||
if (WorldIdentity.resolve(IrisWorldStorage.keyFromName(staleWorldName)).isPresent()) {
|
||||
continue;
|
||||
try {
|
||||
StudioCloseResult cleanupResult = closeWorldCoordinated(
|
||||
null,
|
||||
staleWorldName,
|
||||
null,
|
||||
true,
|
||||
null
|
||||
).get(30L, TimeUnit.SECONDS);
|
||||
if (cleanupResult.failureCause() != null) {
|
||||
IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", cleanupResult.failureCause());
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Stale studio world cleanup failed for \"" + staleWorldName + "\".", unwrapFailure(failure));
|
||||
}
|
||||
|
||||
deleteWorldFamily(staleWorldName, true);
|
||||
}
|
||||
}
|
||||
|
||||
private LinkedHashSet<String> collectSafeTransientWorldNames() {
|
||||
LinkedHashSet<String> worldNames = new LinkedHashSet<>();
|
||||
Path irisNamespace = IrisWorldStorage.levelRoot()
|
||||
.toPath()
|
||||
.toAbsolutePath()
|
||||
.normalize()
|
||||
.resolve("dimensions")
|
||||
.resolve("iris");
|
||||
if (!Files.exists(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return worldNames;
|
||||
}
|
||||
if (Files.isSymbolicLink(irisNamespace) || !Files.isDirectory(irisNamespace, LinkOption.NOFOLLOW_LINKS)) {
|
||||
IrisLogging.warn("Skipping stale studio cleanup because Iris dimension storage is unsafe: " + irisNamespace);
|
||||
return worldNames;
|
||||
}
|
||||
|
||||
try (DirectoryStream<Path> children = Files.newDirectoryStream(irisNamespace)) {
|
||||
for (Path child : children) {
|
||||
if (Files.isSymbolicLink(child) || !Files.isDirectory(child, LinkOption.NOFOLLOW_LINKS)) {
|
||||
continue;
|
||||
}
|
||||
String transientName = TransientWorldCleanupSupport.transientStudioBaseWorldName(
|
||||
child.getFileName().toString());
|
||||
if (transientName != null) {
|
||||
worldNames.add(transientName);
|
||||
}
|
||||
}
|
||||
} catch (IOException failure) {
|
||||
IrisLogging.reportError("Failed to inspect stale studio worlds in \"" + irisNamespace + "\".", failure);
|
||||
}
|
||||
return worldNames;
|
||||
}
|
||||
|
||||
private void updateStage(StudioOpenRequest request, String stage, double progress) {
|
||||
if (request.progressConsumer() != null) {
|
||||
request.progressConsumer().accept(new StudioOpenProgress(progress, stage));
|
||||
@@ -419,37 +610,6 @@ public final class StudioOpenCoordinator {
|
||||
};
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> waitForWorldFamilyUnload(String worldName, long deadline) {
|
||||
if (worldName == null || !isWorldFamilyLoaded(worldName) || System.currentTimeMillis() >= deadline) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
return delayFuture(100L).thenCompose(ignored -> waitForWorldFamilyUnload(worldName, deadline));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> deleteWorldFolderAsync(File folder, int attemptsRemaining) {
|
||||
if (folder == null || !folder.exists()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
IO.delete(folder);
|
||||
if (!folder.exists()) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
if (attemptsRemaining <= 1) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("World folder still exists after deletion retries: " + folder.getAbsolutePath()));
|
||||
}
|
||||
|
||||
return delayFuture(250L).thenCompose(ignored -> deleteWorldFolderAsync(folder, attemptsRemaining - 1));
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> delayFuture(long delayMillis) {
|
||||
long safeDelay = Math.max(0L, delayMillis);
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
}, CompletableFuture.delayedExecutor(safeDelay, TimeUnit.MILLISECONDS));
|
||||
}
|
||||
|
||||
private Throwable unwrapFailure(Throwable throwable) {
|
||||
Throwable cursor = throwable;
|
||||
while (cursor instanceof CompletionException || cursor instanceof ExecutionException) {
|
||||
@@ -538,7 +698,4 @@ public final class StudioOpenCoordinator {
|
||||
return failureCause == null;
|
||||
}
|
||||
}
|
||||
|
||||
private record WorldFamilyDeleteResult(boolean liveDeleted, boolean startupCleanupQueued) {
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user