mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
content
This commit is contained in:
+183
-13
@@ -1,7 +1,10 @@
|
||||
package art.arcane.iris.core.nms.v26_2_R1;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
|
||||
import art.arcane.iris.engine.framework.NativeStructureFrequencyScale;
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderOwner;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
@@ -30,6 +33,21 @@ final class DatapackStructureStateFilter {
|
||||
DatapackStructureScopeIndex scopeIndex,
|
||||
Set<String> declaredSources
|
||||
) {
|
||||
return filter(structureSets, scopeIndex, declaredSources, new IrisImportedStructureControl());
|
||||
}
|
||||
|
||||
static Selection filter(
|
||||
List<Holder<StructureSet>> structureSets,
|
||||
DatapackStructureScopeIndex scopeIndex,
|
||||
Set<String> declaredSources,
|
||||
IrisImportedStructureControl importedStructures
|
||||
) {
|
||||
if (scopeIndex.isEmpty()) {
|
||||
return new Selection(
|
||||
scaleFrequencyOverrides(structureSets, importedStructures),
|
||||
0,
|
||||
0);
|
||||
}
|
||||
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
|
||||
for (Holder<StructureSet> holder : structureSets) {
|
||||
String key = structureSetKey(holder);
|
||||
@@ -64,7 +82,7 @@ final class DatapackStructureStateFilter {
|
||||
filteredSets.add(scopedHolder);
|
||||
}
|
||||
return new Selection(
|
||||
List.copyOf(filteredSets),
|
||||
scaleFrequencyOverrides(List.copyOf(filteredSets), importedStructures),
|
||||
retainedManagedSets,
|
||||
excludedManagedSets);
|
||||
}
|
||||
@@ -138,7 +156,8 @@ final class DatapackStructureStateFilter {
|
||||
scopedByIdentity.put(holder, holder);
|
||||
return holder;
|
||||
}
|
||||
Holder<StructureSet> scoped = Holder.direct(new StructureSet(entries, placement));
|
||||
Holder<StructureSet> scoped = replacementHolder(
|
||||
holder, new StructureSet(entries, placement));
|
||||
scopedByIdentity.put(holder, scoped);
|
||||
return scoped;
|
||||
} finally {
|
||||
@@ -193,12 +212,138 @@ final class DatapackStructureStateFilter {
|
||||
if (scopedTarget == currentZone.get().otherSet()) {
|
||||
return placement;
|
||||
}
|
||||
return copyPlacement(placement, scopedZone);
|
||||
return copyPlacement(placement, scopedZone, 1D);
|
||||
}
|
||||
|
||||
private static List<Holder<StructureSet>> scaleFrequencyOverrides(
|
||||
List<Holder<StructureSet>> structureSets,
|
||||
IrisImportedStructureControl importedStructures
|
||||
) {
|
||||
if (!importedStructures.hasFrequencyOverrides()) {
|
||||
return structureSets;
|
||||
}
|
||||
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
|
||||
for (Holder<StructureSet> holder : structureSets) {
|
||||
String key = structureSetKey(holder);
|
||||
if (key != null) {
|
||||
holdersByKey.putIfAbsent(key, holder);
|
||||
}
|
||||
}
|
||||
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity = new IdentityHashMap<>();
|
||||
List<Holder<StructureSet>> scaledSets = new ArrayList<>(structureSets.size());
|
||||
boolean changed = false;
|
||||
for (Holder<StructureSet> holder : structureSets) {
|
||||
Holder<StructureSet> scaled = scaleFrequencyHolder(
|
||||
holder, importedStructures, holdersByKey, scaledByIdentity);
|
||||
scaledSets.add(scaled);
|
||||
changed |= scaled != holder;
|
||||
}
|
||||
return changed ? List.copyOf(scaledSets) : structureSets;
|
||||
}
|
||||
|
||||
private static Holder<StructureSet> scaleFrequencyHolder(
|
||||
Holder<StructureSet> holder,
|
||||
IrisImportedStructureControl importedStructures,
|
||||
Map<String, Holder<StructureSet>> holdersByKey,
|
||||
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity
|
||||
) {
|
||||
if (scaledByIdentity.containsKey(holder)) {
|
||||
return scaledByIdentity.get(holder);
|
||||
}
|
||||
if (!dependsOnFrequencyOverride(holder, importedStructures, holdersByKey)) {
|
||||
scaledByIdentity.put(holder, holder);
|
||||
return holder;
|
||||
}
|
||||
|
||||
ResourceKey<StructureSet> holderKey = structureSetResourceKey(holder).orElseThrow(() ->
|
||||
new IllegalStateException(
|
||||
"An affected native structure-set exclusion graph has an unkeyed holder"));
|
||||
ReboundStructureSetHolder scaledHolder = new ReboundStructureSetHolder(holderKey);
|
||||
scaledByIdentity.put(holder, scaledHolder);
|
||||
|
||||
StructureSet originalSet = holder.value();
|
||||
StructurePlacement originalPlacement = originalSet.placement();
|
||||
Optional<StructurePlacement.ExclusionZone> originalZone = exclusionZone(originalPlacement);
|
||||
Optional<StructurePlacement.ExclusionZone> scaledZone = originalZone;
|
||||
if (originalZone.isPresent()) {
|
||||
Holder<StructureSet> target = canonicalHolder(
|
||||
originalZone.get().otherSet(), holdersByKey);
|
||||
Holder<StructureSet> scaledTarget = scaleFrequencyHolder(
|
||||
target, importedStructures, holdersByKey, scaledByIdentity);
|
||||
if (scaledTarget != originalZone.get().otherSet()) {
|
||||
scaledZone = Optional.of(new StructurePlacement.ExclusionZone(
|
||||
scaledTarget, originalZone.get().chunkCount()));
|
||||
}
|
||||
}
|
||||
|
||||
double multiplier = importedStructures.frequencyMultiplier(structureSetKey(holder));
|
||||
StructurePlacement scaledPlacement = multiplier == 1D && scaledZone.equals(originalZone)
|
||||
? originalPlacement
|
||||
: copyPlacement(originalPlacement, scaledZone, multiplier);
|
||||
scaledHolder.bind(new StructureSet(originalSet.structures(), scaledPlacement));
|
||||
return scaledHolder;
|
||||
}
|
||||
|
||||
private static boolean dependsOnFrequencyOverride(
|
||||
Holder<StructureSet> holder,
|
||||
IrisImportedStructureControl importedStructures,
|
||||
Map<String, Holder<StructureSet>> holdersByKey
|
||||
) {
|
||||
Set<Holder<StructureSet>> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Holder<StructureSet> current = holder;
|
||||
while (visited.add(current)) {
|
||||
if (importedStructures.frequencyMultiplier(structureSetKey(current)) != 1D) {
|
||||
return true;
|
||||
}
|
||||
Optional<StructurePlacement.ExclusionZone> zone =
|
||||
exclusionZone(current.value().placement());
|
||||
if (zone.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
current = canonicalHolder(zone.get().otherSet(), holdersByKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Holder<StructureSet> canonicalHolder(
|
||||
Holder<StructureSet> holder,
|
||||
Map<String, Holder<StructureSet>> holdersByKey
|
||||
) {
|
||||
String key = structureSetKey(holder);
|
||||
return key == null ? holder : holdersByKey.getOrDefault(key, holder);
|
||||
}
|
||||
|
||||
private static Holder<StructureSet> replacementHolder(
|
||||
Holder<StructureSet> original,
|
||||
StructureSet replacement
|
||||
) {
|
||||
Optional<ResourceKey<StructureSet>> key = structureSetResourceKey(original);
|
||||
if (key.isEmpty()) {
|
||||
return Holder.direct(replacement);
|
||||
}
|
||||
ReboundStructureSetHolder holder = new ReboundStructureSetHolder(key.get());
|
||||
holder.bind(replacement);
|
||||
return holder;
|
||||
}
|
||||
|
||||
private static Optional<ResourceKey<StructureSet>> structureSetResourceKey(
|
||||
Holder<StructureSet> holder
|
||||
) {
|
||||
Optional<ResourceKey<StructureSet>> key = holder.unwrapKey();
|
||||
if (key.isPresent()) {
|
||||
return key;
|
||||
}
|
||||
if (holder.value().placement()
|
||||
instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) {
|
||||
return Optional.of(keyedPlacement.key);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static StructurePlacement copyPlacement(
|
||||
StructurePlacement placement,
|
||||
Optional<StructurePlacement.ExclusionZone> exclusionZone
|
||||
Optional<StructurePlacement.ExclusionZone> exclusionZone,
|
||||
double frequencyMultiplier
|
||||
) {
|
||||
Vec3i locateOffset = (Vec3i) declaredFieldValue(
|
||||
StructurePlacement.class, placement, Vec3i.class);
|
||||
@@ -212,34 +357,47 @@ final class DatapackStructureStateFilter {
|
||||
int salt = (int) declaredFieldValue(
|
||||
StructurePlacement.class, placement, int.class);
|
||||
|
||||
if (placement instanceof ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement) {
|
||||
if (placement.getClass()
|
||||
== ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement.class) {
|
||||
ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement keyedPlacement =
|
||||
(ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement) placement;
|
||||
NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread(
|
||||
frequency, keyedPlacement.spacing(), keyedPlacement.separation(), frequencyMultiplier);
|
||||
return new ChunkGeneratorStructureState.KeyedRandomSpreadStructurePlacement(
|
||||
keyedPlacement.key,
|
||||
locateOffset,
|
||||
frequencyReductionMethod,
|
||||
frequency,
|
||||
scale.frequency(),
|
||||
salt,
|
||||
exclusionZone,
|
||||
keyedPlacement.spacing(),
|
||||
scale.spacing(),
|
||||
keyedPlacement.separation(),
|
||||
keyedPlacement.spreadType());
|
||||
}
|
||||
if (placement instanceof RandomSpreadStructurePlacement randomSpread) {
|
||||
if (placement.getClass() == RandomSpreadStructurePlacement.class) {
|
||||
RandomSpreadStructurePlacement randomSpread =
|
||||
(RandomSpreadStructurePlacement) placement;
|
||||
NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread(
|
||||
frequency, randomSpread.spacing(), randomSpread.separation(), frequencyMultiplier);
|
||||
return new RandomSpreadStructurePlacement(
|
||||
locateOffset,
|
||||
frequencyReductionMethod,
|
||||
frequency,
|
||||
scale.frequency(),
|
||||
salt,
|
||||
exclusionZone,
|
||||
randomSpread.spacing(),
|
||||
scale.spacing(),
|
||||
randomSpread.separation(),
|
||||
randomSpread.spreadType());
|
||||
}
|
||||
if (placement instanceof ConcentricRingsStructurePlacement rings) {
|
||||
if (placement.getClass() == ConcentricRingsStructurePlacement.class) {
|
||||
ConcentricRingsStructurePlacement rings =
|
||||
(ConcentricRingsStructurePlacement) placement;
|
||||
float scaledFrequency = NativeStructureFrequencyScale.probability(
|
||||
frequency, frequencyMultiplier);
|
||||
return new ConcentricRingsStructurePlacement(
|
||||
locateOffset,
|
||||
frequencyReductionMethod,
|
||||
frequency,
|
||||
scaledFrequency,
|
||||
salt,
|
||||
exclusionZone,
|
||||
rings.distance(),
|
||||
@@ -247,7 +405,8 @@ final class DatapackStructureStateFilter {
|
||||
rings.count(),
|
||||
rings.preferredBiomes());
|
||||
}
|
||||
throw new IllegalStateException("Unsupported structure placement with an exclusion zone: "
|
||||
throw new IllegalStateException("Unsupported native structure placement in an affected "
|
||||
+ "frequency-override or datapack-scope graph: "
|
||||
+ placement.getClass().getName());
|
||||
}
|
||||
|
||||
@@ -286,4 +445,15 @@ final class DatapackStructureStateFilter {
|
||||
int excludedManagedSets
|
||||
) {
|
||||
}
|
||||
|
||||
private static final class ReboundStructureSetHolder extends Holder.Reference<StructureSet> {
|
||||
private ReboundStructureSetHolder(ResourceKey<StructureSet> key) {
|
||||
super(Type.STAND_ALONE, new HolderOwner<>() {
|
||||
}, key, null);
|
||||
}
|
||||
|
||||
private void bind(StructureSet structureSet) {
|
||||
bindValue(structureSet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -788,7 +788,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
throw new IllegalStateException("Iris cannot generate native structures in chunk "
|
||||
+ disabledChunk.x() + "," + disabledChunk.z()
|
||||
+ " because structure generation is disabled outside the pack; enable native structure generation "
|
||||
+ "and deny individual structures through importedStructures.disabled");
|
||||
+ "and deny families through importedStructures.disabled or complete keys through importedStructures.disabledExact");
|
||||
}
|
||||
ChunkPos chunkPos = chunk.getPos();
|
||||
SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY());
|
||||
|
||||
+4
-2
@@ -6,6 +6,7 @@ import ca.spottedleaf.moonrise.patches.chunk_system.scheduling.NewChunkHolder;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
|
||||
import art.arcane.iris.core.nms.DatapackStructureScopeResult;
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.core.nms.INMSBinding;
|
||||
import art.arcane.iris.core.nms.MinecraftVersion;
|
||||
@@ -1221,7 +1222,8 @@ public class NMSBinding implements INMSBinding {
|
||||
public DatapackStructureScopeResult scopeDatapackStructures(
|
||||
World world,
|
||||
DatapackStructureScopeIndex scopeIndex,
|
||||
Set<String> declaredSources
|
||||
Set<String> declaredSources,
|
||||
IrisImportedStructureControl importedStructures
|
||||
) throws NoSuchFieldException, IllegalAccessException {
|
||||
ServerLevel level = ((CraftWorld) world).getHandle();
|
||||
ChunkMap chunkMap = level.getChunkSource().chunkMap;
|
||||
@@ -1229,7 +1231,7 @@ public class NMSBinding implements INMSBinding {
|
||||
net.minecraft.world.level.chunk.ChunkGenerator generator = level.getChunkSource().getGenerator();
|
||||
ChunkGeneratorStructureState scopedState = createStructureState(level, generator, currentState);
|
||||
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
|
||||
scopedState.possibleStructureSets(), scopeIndex, declaredSources);
|
||||
scopedState.possibleStructureSets(), scopeIndex, declaredSources, importedStructures);
|
||||
|
||||
Field possibleSetsField = getField(scopedState.getClass(), List.class);
|
||||
possibleSetsField.setAccessible(true);
|
||||
|
||||
+201
@@ -2,6 +2,9 @@ package art.arcane.iris.core.nms.v26_2_R1;
|
||||
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.core.datapack.DatapackStructureScopeIndex;
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.iris.engine.object.IrisStructureSetFrequencyOverride;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import com.mojang.datafixers.util.Either;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.Holder;
|
||||
@@ -18,6 +21,7 @@ import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacementType;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import org.junit.Test;
|
||||
import org.junit.BeforeClass;
|
||||
@@ -33,7 +37,9 @@ import java.util.stream.Stream;
|
||||
|
||||
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 NMSBindingDatapackStructureScopeTest {
|
||||
@@ -126,6 +132,161 @@ public class NMSBindingDatapackStructureScopeTest {
|
||||
DatapackStructureStateFilter.structureSetKey(Holder.direct(structureSet)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void exactFrequencyOverridesScaleOnlyTheNamedNativeStructureSet() {
|
||||
Holder<StructureSet> complexes = structureSetHolder(
|
||||
"minecraft:nether_complexes",
|
||||
new RandomSpreadStructurePlacement(27, 4, RandomSpreadType.LINEAR, 30084232),
|
||||
structureHolder("minecraft:fortress"));
|
||||
Holder<StructureSet> fossils = structureSetHolder(
|
||||
"minecraft:nether_fossils",
|
||||
new RandomSpreadStructurePlacement(2, 1, RandomSpreadType.LINEAR, 14357921),
|
||||
structureHolder("minecraft:nether_fossil"));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("minecraft:nether_complexes")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
|
||||
List.of(complexes, fossils), index(List.of(), List.of()), Set.of(), control);
|
||||
|
||||
RandomSpreadStructurePlacement scaledComplexes =
|
||||
(RandomSpreadStructurePlacement) selection.structureSets().get(0).value().placement();
|
||||
RandomSpreadStructurePlacement unchangedFossils =
|
||||
(RandomSpreadStructurePlacement) selection.structureSets().get(1).value().placement();
|
||||
assertEquals(26, scaledComplexes.spacing());
|
||||
assertEquals(4, scaledComplexes.separation());
|
||||
assertEquals(2, unchangedFossils.spacing());
|
||||
assertSame(fossils, selection.structureSets().get(1));
|
||||
assertEquals(27, ((RandomSpreadStructurePlacement) complexes.value().placement()).spacing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frequencyOnlyPassLeavesUnrelatedCustomPlacementUntouched() {
|
||||
Holder<StructureSet> exclusionTarget = structureSetHolder(
|
||||
"example:target", structureHolder("example:target"));
|
||||
Holder<StructureSet> custom = structureSetHolder(
|
||||
"example:custom",
|
||||
new UnsupportedPlacement(Optional.of(
|
||||
new StructurePlacement.ExclusionZone(exclusionTarget, 1))),
|
||||
structureHolder("example:custom"));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("minecraft:nether_complexes")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
|
||||
List.of(custom), index(List.of(), List.of()), Set.of(), control);
|
||||
|
||||
assertSame(custom, selection.structureSets().getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frequencyOnlyPassRebindsDependentExclusionTarget() {
|
||||
Holder<StructureSet> target = structureSetHolder(
|
||||
"example:target",
|
||||
new RandomSpreadStructurePlacement(27, 4, RandomSpreadType.LINEAR, 30084232),
|
||||
structureHolder("example:target"));
|
||||
RandomSpreadStructurePlacement dependentPlacement = new RandomSpreadStructurePlacement(
|
||||
Vec3i.ZERO,
|
||||
StructurePlacement.FrequencyReductionMethod.DEFAULT,
|
||||
1.0F,
|
||||
4567,
|
||||
Optional.of(new StructurePlacement.ExclusionZone(target, 1)),
|
||||
32,
|
||||
8,
|
||||
RandomSpreadType.LINEAR);
|
||||
Holder<StructureSet> dependent = structureSetHolder(
|
||||
"example:dependent",
|
||||
dependentPlacement,
|
||||
structureHolder("example:dependent"));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("example:target")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
|
||||
List.of(dependent, target), index(List.of(), List.of()), Set.of(), control);
|
||||
|
||||
Holder<StructureSet> scaledDependent = selection.structureSets().get(0);
|
||||
Holder<StructureSet> scaledTarget = selection.structureSets().get(1);
|
||||
assertNotSame(dependent, scaledDependent);
|
||||
assertNotSame(target, scaledTarget);
|
||||
assertSame(scaledTarget, DatapackStructureStateFilter.exclusionZone(
|
||||
scaledDependent.value().placement()).orElseThrow().otherSet());
|
||||
assertEquals(26, ((RandomSpreadStructurePlacement)
|
||||
scaledTarget.value().placement()).spacing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frequencyOnlyPassRebindsAffectedExclusionCycle() {
|
||||
MutableStructureSetHolder first = new MutableStructureSetHolder("example:first");
|
||||
MutableStructureSetHolder second = new MutableStructureSetHolder("example:second");
|
||||
first.bind(structureSet(
|
||||
new RandomSpreadStructurePlacement(
|
||||
Vec3i.ZERO,
|
||||
StructurePlacement.FrequencyReductionMethod.DEFAULT,
|
||||
1.0F,
|
||||
101,
|
||||
Optional.of(new StructurePlacement.ExclusionZone(second, 1)),
|
||||
32,
|
||||
8,
|
||||
RandomSpreadType.LINEAR),
|
||||
"example:first"));
|
||||
second.bind(structureSet(
|
||||
new RandomSpreadStructurePlacement(
|
||||
Vec3i.ZERO,
|
||||
StructurePlacement.FrequencyReductionMethod.DEFAULT,
|
||||
1.0F,
|
||||
102,
|
||||
Optional.of(new StructurePlacement.ExclusionZone(first, 1)),
|
||||
27,
|
||||
4,
|
||||
RandomSpreadType.LINEAR),
|
||||
"example:second"));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("example:second")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
DatapackStructureStateFilter.Selection selection = DatapackStructureStateFilter.filter(
|
||||
List.of(first, second), index(List.of(), List.of()), Set.of(), control);
|
||||
|
||||
Holder<StructureSet> scaledFirst = selection.structureSets().get(0);
|
||||
Holder<StructureSet> scaledSecond = selection.structureSets().get(1);
|
||||
assertSame(scaledSecond, DatapackStructureStateFilter.exclusionZone(
|
||||
scaledFirst.value().placement()).orElseThrow().otherSet());
|
||||
assertSame(scaledFirst, DatapackStructureStateFilter.exclusionZone(
|
||||
scaledSecond.value().placement()).orElseThrow().otherSet());
|
||||
assertEquals(26, ((RandomSpreadStructurePlacement)
|
||||
scaledSecond.value().placement()).spacing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void affectedRandomSpreadSubclassFailsInsteadOfLosingSubtypeBehavior() {
|
||||
Holder<StructureSet> custom = structureSetHolder(
|
||||
"example:custom",
|
||||
new CustomRandomSpreadPlacement(),
|
||||
structureHolder("example:custom"));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("example:custom")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> DatapackStructureStateFilter.filter(
|
||||
List.of(custom), index(List.of(), List.of()), Set.of(), control));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void excludedManagedSetCannotSuppressAnAllowedSetThroughExclusionZone() {
|
||||
Holder<StructureSet> managedSet = structureSetHolder(
|
||||
@@ -273,6 +434,13 @@ public class NMSBindingDatapackStructureScopeTest {
|
||||
value);
|
||||
}
|
||||
|
||||
private static StructureSet structureSet(StructurePlacement placement, String structureKey) {
|
||||
return new StructureSet(
|
||||
List.of(new StructureSet.StructureSelectionEntry(
|
||||
structureHolder(structureKey), 1)),
|
||||
placement);
|
||||
}
|
||||
|
||||
private static final class KeyedHolder<T> implements Holder<T> {
|
||||
private final ResourceKey<T> key;
|
||||
private final T value;
|
||||
@@ -352,4 +520,37 @@ public class NMSBindingDatapackStructureScopeTest {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class UnsupportedPlacement extends StructurePlacement {
|
||||
private UnsupportedPlacement(Optional<ExclusionZone> exclusionZone) {
|
||||
super(Vec3i.ZERO, FrequencyReductionMethod.DEFAULT, 1F, 1, exclusionZone);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isPlacementChunk(ChunkGeneratorStructureState state, int x, int z) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StructurePlacementType<?> type() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableStructureSetHolder extends Holder.Reference<StructureSet> {
|
||||
private MutableStructureSetHolder(String key) {
|
||||
super(Type.STAND_ALONE, new HolderOwner<>() {
|
||||
}, ResourceKey.create(Registries.STRUCTURE_SET, Identifier.parse(key)), null);
|
||||
}
|
||||
|
||||
private void bind(StructureSet structureSet) {
|
||||
bindValue(structureSet);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CustomRandomSpreadPlacement extends RandomSpreadStructurePlacement {
|
||||
private CustomRandomSpreadPlacement() {
|
||||
super(27, 4, RandomSpreadType.LINEAR, 30084232);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import art.arcane.iris.core.IrisStartupAdmissionListener;
|
||||
import art.arcane.iris.core.BukkitWorldReconciler;
|
||||
import art.arcane.iris.core.IrisWorldGeneratorResolver;
|
||||
import art.arcane.iris.core.PendingWorldDeleteQueue;
|
||||
import art.arcane.iris.core.PendingWorldReplacementManager;
|
||||
import art.arcane.iris.core.SettingsHotloadWatch;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.datapack.DatapackIngestService;
|
||||
@@ -167,6 +168,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
private final IrisWorldGeneratorResolver generatorResolver = new IrisWorldGeneratorResolver(this);
|
||||
private final BukkitWorldReconciler worldReconciler = new BukkitWorldReconciler(this);
|
||||
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
|
||||
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
|
||||
private volatile SettingsHotloadWatch settingsHotloadWatch;
|
||||
|
||||
public static VolmitSender getSender() {
|
||||
@@ -595,6 +597,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
services.values().forEach(IrisService::onEnable);
|
||||
services.values().forEach(this::registerListener);
|
||||
addShutdownHook();
|
||||
pendingWorldReplacements.processPendingStartupReplacements();
|
||||
pendingWorldDeletes.processPendingStartupWorldDeletes();
|
||||
WorldLifecycleService.get();
|
||||
WorldRuntimeControlService.get();
|
||||
@@ -604,6 +607,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
J.s(() -> {
|
||||
pendingWorldReplacements.verifyLoadedPublishedWorlds();
|
||||
J.a(this::bstats);
|
||||
J.ar(() -> settingsHotloadWatch.checkConfigHotload(configHotloadEngine), 60);
|
||||
J.sr(this::tickQueue, 0);
|
||||
@@ -642,6 +646,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
return worldReconciler;
|
||||
}
|
||||
|
||||
public PendingWorldReplacementManager pendingWorldReplacements() {
|
||||
return pendingWorldReplacements;
|
||||
}
|
||||
|
||||
private void autoStartStudio() {
|
||||
if (IrisSettings.get().getStudio().isAutoStartDefaultStudio()) {
|
||||
Iris.info("Starting up auto Studio!");
|
||||
@@ -683,6 +691,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
IrisPlatforms.bind(new BukkitPlatform());
|
||||
IrisStartupValidation.begin();
|
||||
Bukkit.getPluginManager().registerEvents(new IrisStartupAdmissionListener(), this);
|
||||
Bukkit.getPluginManager().registerEvents(pendingWorldReplacements, this);
|
||||
enable();
|
||||
BukkitGuiHost.install();
|
||||
super.onEnable();
|
||||
|
||||
+8
-2
@@ -741,13 +741,19 @@ public final class BukkitWorldReconciler {
|
||||
|
||||
@Override
|
||||
public void requireDimensionLoadable(NamespacedKey worldKey, String dimension) {
|
||||
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
|
||||
boolean snapshotPresent = snapshotRoot.isDirectory();
|
||||
if (snapshotPresent) {
|
||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(snapshotRoot);
|
||||
}
|
||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
||||
IrisDimension irisDimension = IrisWorldGeneratorResolver.loadDimension(worldName, dimension);
|
||||
if (irisDimension == null) {
|
||||
throw new IllegalStateException("Could not resolve the Iris dimension \"" + dimension + "\".");
|
||||
}
|
||||
PackValidationRegistry.requireLoadable(
|
||||
irisDimension.getLoader().getDataFolder().getName());
|
||||
if (!snapshotPresent) {
|
||||
PackValidationRegistry.requireLoadable(irisDimension.getLoader().getDataFolder().getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-3
@@ -55,6 +55,8 @@ import java.util.function.Supplier;
|
||||
* Bukkit plugin entry points delegate to.
|
||||
*/
|
||||
public final class IrisWorldGeneratorResolver {
|
||||
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
|
||||
|
||||
private final VolmitPlugin plugin;
|
||||
|
||||
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
|
||||
@@ -134,6 +136,35 @@ public final class IrisWorldGeneratorResolver {
|
||||
IrisStartupValidation.markPacksReady();
|
||||
}
|
||||
|
||||
static PackValidationResult requireSnapshotLoadable(File packRoot) {
|
||||
Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize();
|
||||
PackValidationResult result = PackValidationRegistry.get(normalizedRoot);
|
||||
if (result == null) {
|
||||
synchronized (SNAPSHOT_VALIDATION_LOCK) {
|
||||
result = PackValidationRegistry.get(normalizedRoot);
|
||||
if (result == null) {
|
||||
try {
|
||||
result = PackValidator.validate(normalizedRoot.toFile());
|
||||
} catch (Throwable exception) {
|
||||
Iris.reportError("Snapshot pack validation failed for '" + normalizedRoot + "'", exception);
|
||||
String detail = exception.getMessage();
|
||||
if (detail == null || detail.isBlank()) {
|
||||
detail = exception.getClass().getSimpleName();
|
||||
}
|
||||
result = new PackValidationResult(
|
||||
normalizedRoot.getFileName().toString(),
|
||||
List.of("Pack validation failed with " + exception.getClass().getSimpleName()
|
||||
+ ": " + detail),
|
||||
List.of(),
|
||||
System.currentTimeMillis());
|
||||
}
|
||||
PackValidationRegistry.publish(normalizedRoot, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
return PackValidationRegistry.requireLoadable(normalizedRoot);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static IrisDimension loadDimension(@NonNull String worldName, @NonNull String id) {
|
||||
File pack = IrisWorldStorage.packRoot(IrisWorldStorage.keyFromName(worldName));
|
||||
@@ -188,9 +219,17 @@ public final class IrisWorldGeneratorResolver {
|
||||
if (dim == null) {
|
||||
throw new RuntimeException("Can't find dimension " + id + "!");
|
||||
}
|
||||
String packName = dim.getLoader().getDataFolder().getName();
|
||||
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
|
||||
File snapshotRoot = IrisWorldStorage.packRoot(worldKey);
|
||||
File dimensionPackRoot = dim.getLoader().getDataFolder();
|
||||
String packName = dimensionPackRoot.getName();
|
||||
try {
|
||||
PackValidationRegistry.requireLoadable(packName);
|
||||
if (snapshotRoot.toPath().toAbsolutePath().normalize()
|
||||
.equals(dimensionPackRoot.toPath().toAbsolutePath().normalize())) {
|
||||
requireSnapshotLoadable(snapshotRoot);
|
||||
} else {
|
||||
PackValidationRegistry.requireLoadable(packName);
|
||||
}
|
||||
} catch (BrokenPackException exception) {
|
||||
Iris.error("Refusing to create world '" + worldName + "' using broken pack '" + packName + "':");
|
||||
for (String reason : exception.getReasons()) {
|
||||
@@ -200,7 +239,6 @@ public final class IrisWorldGeneratorResolver {
|
||||
}
|
||||
|
||||
Iris.debug("Assuming IrisDimension: " + dim.getName());
|
||||
NamespacedKey worldKey = IrisWorldStorage.keyFromName(worldName);
|
||||
|
||||
IrisWorld w = IrisWorld.builder()
|
||||
.platformIdentity(worldKey.toString())
|
||||
@@ -225,6 +263,7 @@ public final class IrisWorldGeneratorResolver {
|
||||
} else {
|
||||
dim = installedDimension;
|
||||
}
|
||||
requireSnapshotLoadable(ff);
|
||||
|
||||
return new BukkitChunkGenerator(w, false, ff, dim.getLoadKey());
|
||||
}
|
||||
|
||||
+799
@@ -0,0 +1,799 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.core.ExactWorldSlotPathPolicy.SlotKind;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.GeneratorReplacement;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.WorldGeneratorSnapshot;
|
||||
import art.arcane.iris.core.lifecycle.LifecycleOperationCoordinator;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementFilesystem.ReplacementPaths;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.service.StudioSVC;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisEnvironment;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.util.common.misc.ServerProperties;
|
||||
import art.arcane.iris.util.common.plugin.VolmitSender;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.bukkit.WorldIdentity;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.world.WorldLoadEvent;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Properties;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public final class PendingWorldReplacementManager implements Listener {
|
||||
private static final String JOURNAL_DIRECTORY = "pending-world-replacements";
|
||||
private static final String JOURNAL_SUFFIX = ".properties";
|
||||
|
||||
private final Iris plugin;
|
||||
|
||||
public PendingWorldReplacementManager(Iris plugin) {
|
||||
this.plugin = Objects.requireNonNull(plugin, "plugin");
|
||||
}
|
||||
|
||||
public NamespacedKey resolveRequestedWorldKey(String requestedName) {
|
||||
String requested = Objects.requireNonNull(requestedName, "requestedName").trim();
|
||||
if (requested.isEmpty()) {
|
||||
throw new IllegalArgumentException("World name cannot be empty.");
|
||||
}
|
||||
if (requested.contains("/") || requested.contains("\\") || requested.contains("..")) {
|
||||
throw new IllegalArgumentException("World name must be a safe single path segment.");
|
||||
}
|
||||
NamespacedKey worldKey = requested.contains(":")
|
||||
? NamespacedKey.fromString(requested.toLowerCase(Locale.ENGLISH))
|
||||
: IrisWorldStorage.keyFromName(requested);
|
||||
if (worldKey == null) {
|
||||
throw new IllegalArgumentException("World identifier is invalid: " + requestedName);
|
||||
}
|
||||
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
|
||||
return worldKey;
|
||||
}
|
||||
|
||||
public synchronized StagedReplacement stageReplacement(
|
||||
VolmitSender sender,
|
||||
NamespacedKey worldKey,
|
||||
IrisDimension dimension,
|
||||
long seed
|
||||
) throws IOException {
|
||||
VolmitSender requiredSender = Objects.requireNonNull(sender, "sender");
|
||||
NamespacedKey requiredWorldKey = Objects.requireNonNull(worldKey, "worldKey");
|
||||
IrisDimension requiredDimension = Objects.requireNonNull(dimension, "dimension");
|
||||
IrisStartupValidation.requireWorldCreationReady();
|
||||
PackValidationRegistry.requireLoadable(requiredDimension.getLoader().getDataFolder().getName());
|
||||
ExactWorldSlotPathPolicy.Target resolvedTarget = ExactWorldSlotPathPolicy.resolve(
|
||||
IrisWorldStorage.levelRoot().toPath(),
|
||||
requiredWorldKey
|
||||
);
|
||||
requireCompatibleEnvironment(resolvedTarget.slotKind(), requiredDimension.getEnvironment());
|
||||
long effectiveSeed = resolveEffectiveSeed(resolvedTarget.slotKind(), seed);
|
||||
String worldName = IrisWorldStorage.logicalName(requiredWorldKey);
|
||||
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
|
||||
try (LifecycleOperationCoordinator.Lease ignored = coordinator.acquire(
|
||||
LifecycleOperationCoordinator.Domain.WORLD_MUTATION,
|
||||
LifecycleOperationCoordinator.OperationKind.WORLD_REPLACE,
|
||||
requiredWorldKey.toString()
|
||||
)) {
|
||||
if (findTransaction(requiredWorldKey) != null) {
|
||||
throw new IOException("A replacement is already pending for " + requiredWorldKey + ".");
|
||||
}
|
||||
ExactWorldSlotPathPolicy.Target target = prepareTarget(requiredWorldKey);
|
||||
DatapackInstallResult datapacks = ServerConfigurator.installDataPacksIfChanged(true);
|
||||
if (!datapacks.succeeded()) {
|
||||
throw new IOException("Iris could not compile the dimension datapacks.");
|
||||
}
|
||||
|
||||
UUID transactionId = UUID.randomUUID();
|
||||
ReplacementPaths paths = replacementPaths(target, transactionId);
|
||||
boolean targetPresent = Files.exists(paths.target(), LinkOption.NOFOLLOW_LINKS);
|
||||
WorldGeneratorSnapshot originalConfiguration = BukkitWorldConfiguration.snapshot(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
worldName
|
||||
);
|
||||
Transaction transaction = null;
|
||||
boolean journalWritten = false;
|
||||
boolean configurationApplied = false;
|
||||
try {
|
||||
Files.createDirectory(paths.stage());
|
||||
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(
|
||||
requiredSender,
|
||||
requiredDimension,
|
||||
paths.stage().toFile()
|
||||
);
|
||||
if (installed == null) {
|
||||
throw new IOException("Iris could not stage the dimension pack.");
|
||||
}
|
||||
File stagedPack = paths.stage().resolve("iris/pack").toFile();
|
||||
IrisWorldGeneratorResolver.requireSnapshotLoadable(stagedPack);
|
||||
String packFingerprint = WorldReplacementFilesystem.fingerprintPack(stagedPack.toPath());
|
||||
transaction = new Transaction(
|
||||
transactionId,
|
||||
requiredWorldKey,
|
||||
installed.getLoadKey(),
|
||||
effectiveSeed,
|
||||
packFingerprint,
|
||||
originalConfiguration,
|
||||
targetPresent,
|
||||
Phase.PREPARED
|
||||
);
|
||||
writeTransaction(transaction);
|
||||
journalWritten = true;
|
||||
GeneratorReplacement replacement = BukkitWorldConfiguration.replaceIfMatching(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
worldName,
|
||||
originalConfiguration,
|
||||
installed.getLoadKey(),
|
||||
effectiveSeed
|
||||
);
|
||||
if (!replacement.applied()) {
|
||||
throw new IOException("bukkit.yml changed while the replacement was being staged.");
|
||||
}
|
||||
configurationApplied = true;
|
||||
transaction = transaction.withPhase(Phase.ARMED);
|
||||
writeTransaction(transaction);
|
||||
return new StagedReplacement(
|
||||
requiredWorldKey,
|
||||
worldName,
|
||||
installed.getLoadKey(),
|
||||
effectiveSeed,
|
||||
targetPresent,
|
||||
datapacks.restartRequired()
|
||||
);
|
||||
} catch (Throwable failure) {
|
||||
if (configurationApplied && transaction != null) {
|
||||
try {
|
||||
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
|
||||
if (!BukkitWorldConfiguration.restoreIfMatching(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
worldName,
|
||||
replacement,
|
||||
originalConfiguration
|
||||
)) {
|
||||
failure.addSuppressed(new IOException(
|
||||
"bukkit.yml changed before the failed replacement could be restored."));
|
||||
}
|
||||
} catch (Throwable restoreFailure) {
|
||||
failure.addSuppressed(restoreFailure);
|
||||
}
|
||||
}
|
||||
if (!configurationApplied || configurationMatches(originalConfiguration, worldName)) {
|
||||
try {
|
||||
WorldReplacementFilesystem.discardStage(paths);
|
||||
if (journalWritten) {
|
||||
deleteJournal(transactionId);
|
||||
}
|
||||
} catch (Throwable cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
if (failure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
throw new IOException("Failed to stage replacement for " + requiredWorldKey + ".", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void processPendingStartupReplacements() {
|
||||
ArrayList<String> failures = new ArrayList<>();
|
||||
List<Transaction> transactions;
|
||||
try {
|
||||
transactions = loadTransactions();
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to read pending Iris world replacements.", failure);
|
||||
IrisStartupValidation.markPacksInvalid(List.of(
|
||||
"Pending Iris world replacement journal validation failed: " + detail(failure)));
|
||||
return;
|
||||
}
|
||||
for (Transaction transaction : transactions) {
|
||||
try {
|
||||
processStartupTransaction(transaction);
|
||||
} catch (Throwable failure) {
|
||||
String message = "Pending replacement for " + transaction.worldKey()
|
||||
+ " failed safely: " + detail(failure);
|
||||
failures.add(message);
|
||||
Iris.reportError(message, failure);
|
||||
}
|
||||
}
|
||||
if (!failures.isEmpty()) {
|
||||
IrisStartupValidation.markPacksInvalid(failures);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void verifyLoadedPublishedWorlds() {
|
||||
try {
|
||||
for (Transaction transaction : loadTransactions()) {
|
||||
if (transaction.phase() != Phase.PUBLISHED) {
|
||||
continue;
|
||||
}
|
||||
WorldIdentity.resolve(transaction.worldKey()).ifPresent(world -> verifyPublishedWorld(world, transaction));
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to inspect published Iris world replacements.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onWorldLoad(WorldLoadEvent event) {
|
||||
World world = event.getWorld();
|
||||
J.s(() -> verifyPublishedWorldIfPending(world), 1);
|
||||
}
|
||||
|
||||
private synchronized void verifyPublishedWorldIfPending(World world) {
|
||||
try {
|
||||
Transaction transaction = findTransaction(WorldIdentity.key(world));
|
||||
if (transaction != null && transaction.phase() == Phase.PUBLISHED) {
|
||||
verifyPublishedWorld(world, transaction);
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to verify a published Iris world replacement.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyPublishedWorld(World world, Transaction transaction) {
|
||||
try {
|
||||
if (!transaction.worldKey().equals(WorldIdentity.key(world))) {
|
||||
throw new IOException("Loaded world identity does not match the replacement journal.");
|
||||
}
|
||||
if (!IrisToolbelt.isIrisWorld(world)) {
|
||||
throw new IOException("The replaced world did not load with an Iris generator.");
|
||||
}
|
||||
if (world.getSeed() != transaction.seed()) {
|
||||
throw new IOException("The replaced world loaded with an unexpected seed.");
|
||||
}
|
||||
World.Environment expectedEnvironment = expectedEnvironment(transaction.worldKey());
|
||||
if (expectedEnvironment != null && world.getEnvironment() != expectedEnvironment) {
|
||||
throw new IOException("The replaced world loaded with an unexpected environment.");
|
||||
}
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
if (generator == null || !transaction.dimension().equals(
|
||||
generator.getTarget().getDimension().getLoadKey())) {
|
||||
throw new IOException("The replaced world loaded an unexpected Iris dimension.");
|
||||
}
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
|
||||
IrisWorldStorage.levelRoot().toPath(),
|
||||
transaction.worldKey()
|
||||
);
|
||||
ReplacementPaths paths = replacementPaths(target, transaction.id());
|
||||
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
|
||||
paths.target().resolve("iris/pack"));
|
||||
if (!transaction.packFingerprint().equals(fingerprint)) {
|
||||
throw new IOException("The replacement pack changed before runtime verification.");
|
||||
}
|
||||
WorldReplacementFilesystem.cleanupBackup(paths);
|
||||
deleteJournal(transaction.id());
|
||||
Iris.success("Committed Iris world replacement for " + transaction.worldKey() + ".");
|
||||
} catch (Throwable failure) {
|
||||
initiateRollback(transaction, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void initiateRollback(Transaction transaction, Throwable failure) {
|
||||
Iris.reportError("Iris world replacement verification failed for " + transaction.worldKey()
|
||||
+ "; the retained world will be restored on restart.", failure);
|
||||
try {
|
||||
Transaction rollback = transaction.withPhase(Phase.ROLLBACK_PENDING);
|
||||
writeTransaction(rollback);
|
||||
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
|
||||
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
transaction.worldName()
|
||||
);
|
||||
if (current.matchesGeneratorAndSeed(replacement)) {
|
||||
if (!BukkitWorldConfiguration.restoreIfMatching(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
transaction.worldName(),
|
||||
replacement,
|
||||
transaction.originalConfiguration()
|
||||
)) {
|
||||
throw new IOException("bukkit.yml changed during replacement rollback.");
|
||||
}
|
||||
} else if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||
throw new IOException("bukkit.yml no longer matches either side of the replacement.");
|
||||
}
|
||||
ServerConfigurator.restart("An Iris world replacement failed verification and will be rolled back.");
|
||||
} catch (Throwable rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
IrisStartupValidation.markPacksInvalid(List.of(
|
||||
"Iris could not arm rollback for " + transaction.worldKey() + ": " + detail(rollbackFailure)));
|
||||
Iris.reportError("Failed to arm Iris world replacement rollback for "
|
||||
+ transaction.worldKey() + ". Stop the server and preserve the replacement artifacts.", rollbackFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private void processStartupTransaction(Transaction transaction) throws IOException {
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(
|
||||
IrisWorldStorage.levelRoot().toPath(),
|
||||
transaction.worldKey()
|
||||
);
|
||||
ReplacementPaths paths = replacementPaths(target, transaction.id());
|
||||
WorldGeneratorSnapshot current = BukkitWorldConfiguration.snapshot(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
transaction.worldName()
|
||||
);
|
||||
WorldGeneratorSnapshot replacement = replacementSnapshot(transaction);
|
||||
if (transaction.phase() == Phase.ROLLBACK_PENDING) {
|
||||
processRollback(transaction, paths, current, replacement);
|
||||
return;
|
||||
}
|
||||
if (transaction.phase() == Phase.PREPARED) {
|
||||
if (current.matchesGeneratorAndSeed(replacement)) {
|
||||
transaction = transaction.withPhase(Phase.ARMED);
|
||||
writeTransaction(transaction);
|
||||
} else if (current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||
WorldReplacementFilesystem.discardStage(paths);
|
||||
deleteJournal(transaction.id());
|
||||
Iris.warn("Cancelled incomplete Iris world replacement for " + transaction.worldKey() + ".");
|
||||
return;
|
||||
} else {
|
||||
throw new IOException("bukkit.yml does not match the prepared replacement or its original state.");
|
||||
}
|
||||
}
|
||||
if (transaction.phase() == Phase.ARMED) {
|
||||
if (!current.matchesGeneratorAndSeed(replacement)) {
|
||||
throw new IOException("bukkit.yml no longer authorizes the armed replacement.");
|
||||
}
|
||||
WorldReplacementFilesystem.publish(
|
||||
paths,
|
||||
transaction.originalTargetPresent(),
|
||||
transaction.packFingerprint()
|
||||
);
|
||||
transaction = transaction.withPhase(Phase.PUBLISHED);
|
||||
writeTransaction(transaction);
|
||||
Iris.success("Published Iris world replacement for " + transaction.worldKey()
|
||||
+ "; waiting for runtime verification.");
|
||||
}
|
||||
if (transaction.phase() == Phase.PUBLISHED) {
|
||||
if (!current.matchesGeneratorAndSeed(replacement)) {
|
||||
throw new IOException("bukkit.yml changed after the replacement was published.");
|
||||
}
|
||||
String fingerprint = WorldReplacementFilesystem.fingerprintPack(
|
||||
paths.target().resolve("iris/pack"));
|
||||
if (!transaction.packFingerprint().equals(fingerprint)) {
|
||||
throw new IOException("Published replacement pack fingerprint does not match its journal.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void processRollback(
|
||||
Transaction transaction,
|
||||
ReplacementPaths paths,
|
||||
WorldGeneratorSnapshot current,
|
||||
WorldGeneratorSnapshot replacement
|
||||
) throws IOException {
|
||||
if (current.matchesGeneratorAndSeed(replacement)) {
|
||||
if (!BukkitWorldConfiguration.restoreIfMatching(
|
||||
ServerProperties.BUKKIT_YML,
|
||||
transaction.worldName(),
|
||||
replacement,
|
||||
transaction.originalConfiguration()
|
||||
)) {
|
||||
throw new IOException("bukkit.yml changed during startup rollback.");
|
||||
}
|
||||
} else if (!current.matchesGeneratorAndSeed(transaction.originalConfiguration())) {
|
||||
throw new IOException("bukkit.yml conflicts with the pending world rollback.");
|
||||
}
|
||||
WorldReplacementFilesystem.rollback(paths, transaction.originalTargetPresent());
|
||||
deleteJournal(transaction.id());
|
||||
Iris.success("Restored the retained world for " + transaction.worldKey() + ".");
|
||||
}
|
||||
|
||||
private ExactWorldSlotPathPolicy.Target prepareTarget(NamespacedKey worldKey) throws IOException {
|
||||
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
|
||||
ExactWorldSlotPathPolicy.Target target = ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
|
||||
Path dimensions = target.levelRoot().resolve("dimensions");
|
||||
createDirectoryIfMissing(dimensions);
|
||||
createDirectoryIfMissing(target.namespaceRoot());
|
||||
return ExactWorldSlotPathPolicy.resolve(levelRoot, worldKey);
|
||||
}
|
||||
|
||||
private static void createDirectoryIfMissing(Path directory) throws IOException {
|
||||
if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("World storage parent is unsafe: " + directory);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Files.createDirectory(directory);
|
||||
}
|
||||
|
||||
private Transaction findTransaction(NamespacedKey worldKey) throws IOException {
|
||||
for (Transaction transaction : loadTransactions()) {
|
||||
if (transaction.worldKey().equals(worldKey)) {
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<Transaction> loadTransactions() throws IOException {
|
||||
Path directory = journalDirectory(false);
|
||||
if (directory == null) {
|
||||
return List.of();
|
||||
}
|
||||
ArrayList<Transaction> transactions = new ArrayList<>();
|
||||
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "*" + JOURNAL_SUFFIX)) {
|
||||
for (Path file : files) {
|
||||
if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Replacement journal entry is unsafe: " + file);
|
||||
}
|
||||
transactions.add(readTransaction(file));
|
||||
}
|
||||
}
|
||||
transactions.sort(Comparator.comparing(transaction -> transaction.id().toString()));
|
||||
return List.copyOf(transactions);
|
||||
}
|
||||
|
||||
private Transaction readTransaction(Path file) throws IOException {
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
properties.load(input);
|
||||
}
|
||||
UUID id = UUID.fromString(required(properties, "id"));
|
||||
if (!file.getFileName().toString().equals(id + JOURNAL_SUFFIX)) {
|
||||
throw new IOException("Replacement journal filename does not match its transaction id.");
|
||||
}
|
||||
NamespacedKey worldKey = NamespacedKey.fromString(required(properties, "worldKey"));
|
||||
if (worldKey == null) {
|
||||
throw new IOException("Replacement journal contains an invalid world key.");
|
||||
}
|
||||
ExactWorldSlotPathPolicy.resolve(IrisWorldStorage.levelRoot().toPath(), worldKey);
|
||||
String dimension = required(properties, "dimension");
|
||||
if (!safeDimension(dimension)) {
|
||||
throw new IOException("Replacement journal contains an invalid dimension key.");
|
||||
}
|
||||
long seed = parseLong(properties, "seed");
|
||||
String packFingerprint = required(properties, "packFingerprint");
|
||||
if (!packFingerprint.matches("[0-9a-f]{64}")) {
|
||||
throw new IOException("Replacement journal contains an invalid pack fingerprint.");
|
||||
}
|
||||
WorldGeneratorSnapshot original = readSnapshot(properties, "original.");
|
||||
boolean originalTargetPresent = parseBoolean(properties, "originalTargetPresent");
|
||||
Phase phase;
|
||||
try {
|
||||
phase = Phase.valueOf(required(properties, "phase"));
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Replacement journal contains an invalid phase.", failure);
|
||||
}
|
||||
return new Transaction(
|
||||
id,
|
||||
worldKey,
|
||||
dimension,
|
||||
seed,
|
||||
packFingerprint,
|
||||
original,
|
||||
originalTargetPresent,
|
||||
phase
|
||||
);
|
||||
}
|
||||
|
||||
private void writeTransaction(Transaction transaction) throws IOException {
|
||||
Path directory = Objects.requireNonNull(journalDirectory(true));
|
||||
Path target = directory.resolve(transaction.id() + JOURNAL_SUFFIX);
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("id", transaction.id().toString());
|
||||
properties.setProperty("worldKey", transaction.worldKey().toString());
|
||||
properties.setProperty("dimension", transaction.dimension());
|
||||
properties.setProperty("seed", Long.toString(transaction.seed()));
|
||||
properties.setProperty("packFingerprint", transaction.packFingerprint());
|
||||
properties.setProperty("originalTargetPresent", Boolean.toString(transaction.originalTargetPresent()));
|
||||
properties.setProperty("phase", transaction.phase().name());
|
||||
writeSnapshot(properties, "original.", transaction.originalConfiguration());
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
properties.store(output, null);
|
||||
writeAtomic(target, output.toByteArray());
|
||||
}
|
||||
|
||||
private void deleteJournal(UUID id) throws IOException {
|
||||
Path directory = journalDirectory(false);
|
||||
if (directory == null) {
|
||||
return;
|
||||
}
|
||||
Files.deleteIfExists(directory.resolve(id + JOURNAL_SUFFIX));
|
||||
forceDirectory(directory);
|
||||
}
|
||||
|
||||
private Path journalDirectory(boolean create) throws IOException {
|
||||
Path directory = plugin.getDataFile(JOURNAL_DIRECTORY).toPath().toAbsolutePath().normalize();
|
||||
if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
if (!create) {
|
||||
return null;
|
||||
}
|
||||
Files.createDirectories(directory);
|
||||
}
|
||||
if (Files.isSymbolicLink(directory) || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) {
|
||||
throw new IOException("Replacement journal storage is unsafe: " + directory);
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
private static void writeAtomic(Path target, byte[] content) throws IOException {
|
||||
Path parent = Objects.requireNonNull(target.getParent(), "journal parent");
|
||||
Path temporary = parent.resolve("." + target.getFileName() + ".tmp-" + UUID.randomUUID());
|
||||
try {
|
||||
try (FileChannel channel = FileChannel.open(
|
||||
temporary,
|
||||
StandardOpenOption.CREATE_NEW,
|
||||
StandardOpenOption.WRITE
|
||||
)) {
|
||||
ByteBuffer buffer = ByteBuffer.wrap(content);
|
||||
while (buffer.hasRemaining()) {
|
||||
channel.write(buffer);
|
||||
}
|
||||
channel.force(true);
|
||||
}
|
||||
try {
|
||||
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (AtomicMoveNotSupportedException failure) {
|
||||
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
forceDirectory(parent);
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceDirectory(Path directory) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static ReplacementPaths replacementPaths(
|
||||
ExactWorldSlotPathPolicy.Target target,
|
||||
UUID id
|
||||
) {
|
||||
String artifactBase = ".iris-replace-" + target.worldKey().getKey() + "-" + id;
|
||||
return new ReplacementPaths(
|
||||
target.worldDirectory(),
|
||||
target.namespaceRoot().resolve(artifactBase + ".stage"),
|
||||
target.namespaceRoot().resolve(artifactBase + ".backup")
|
||||
);
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot replacementSnapshot(Transaction transaction) {
|
||||
return new WorldGeneratorSnapshot(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"Iris:" + transaction.dimension(),
|
||||
true,
|
||||
transaction.seed()
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean configurationMatches(WorldGeneratorSnapshot expected, String worldName) {
|
||||
try {
|
||||
return BukkitWorldConfiguration.snapshot(ServerProperties.BUKKIT_YML, worldName)
|
||||
.matchesGeneratorAndSeed(expected);
|
||||
} catch (IOException failure) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireCompatibleEnvironment(SlotKind slotKind, IrisEnvironment environment) {
|
||||
IrisEnvironment expected = switch (slotKind) {
|
||||
case VANILLA_OVERWORLD -> IrisEnvironment.NORMAL;
|
||||
case VANILLA_NETHER -> IrisEnvironment.NETHER;
|
||||
case VANILLA_END -> IrisEnvironment.THE_END;
|
||||
case IRIS_MANAGED -> null;
|
||||
};
|
||||
if (expected != null && environment != expected) {
|
||||
throw new IllegalArgumentException("The " + slotKind.name().toLowerCase(Locale.ENGLISH)
|
||||
+ " slot requires a pack environment of " + expected.name() + ".");
|
||||
}
|
||||
}
|
||||
|
||||
private static long resolveEffectiveSeed(SlotKind slotKind, long requestedSeed) throws IOException {
|
||||
if (slotKind == SlotKind.IRIS_MANAGED) {
|
||||
return requestedSeed;
|
||||
}
|
||||
CompletableFuture<VanillaLevelContext> contextFuture = J.sfut(() -> new VanillaLevelContext(
|
||||
WorldIdentity.resolve(NamespacedKey.minecraft("overworld"))
|
||||
.orElseThrow(() -> new IllegalStateException("The configured primary world is not loaded."))
|
||||
.getSeed(),
|
||||
Iris.instance.getServer().getAllowNether(),
|
||||
Iris.instance.getServer().getAllowEnd()
|
||||
));
|
||||
if (contextFuture == null) {
|
||||
throw new IOException("Could not schedule primary level-seed resolution.");
|
||||
}
|
||||
try {
|
||||
VanillaLevelContext context = contextFuture.get(30L, TimeUnit.SECONDS);
|
||||
if (slotKind == SlotKind.VANILLA_NETHER && !context.allowNether()) {
|
||||
throw new IOException("allow-nether must be true before the vanilla Nether can be replaced.");
|
||||
}
|
||||
if (slotKind == SlotKind.VANILLA_END && !context.allowEnd()) {
|
||||
throw new IOException("Bukkit allow-end must be true before the vanilla End can be replaced.");
|
||||
}
|
||||
return context.seed();
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Primary level-seed resolution was interrupted.", failure);
|
||||
} catch (ExecutionException | TimeoutException failure) {
|
||||
throw new IOException("Could not resolve the authoritative primary level seed.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static World.Environment expectedEnvironment(NamespacedKey worldKey) {
|
||||
if (NamespacedKey.minecraft("overworld").equals(worldKey)) {
|
||||
return World.Environment.NORMAL;
|
||||
}
|
||||
if (NamespacedKey.minecraft("the_nether").equals(worldKey)) {
|
||||
return World.Environment.NETHER;
|
||||
}
|
||||
if (NamespacedKey.minecraft("the_end").equals(worldKey)) {
|
||||
return World.Environment.THE_END;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void writeSnapshot(Properties properties, String prefix, WorldGeneratorSnapshot snapshot) {
|
||||
properties.setProperty(prefix + "worldsSectionPresent", Boolean.toString(snapshot.worldsSectionPresent()));
|
||||
properties.setProperty(prefix + "worldSectionPresent", Boolean.toString(snapshot.worldSectionPresent()));
|
||||
properties.setProperty(prefix + "generatorPresent", Boolean.toString(snapshot.generatorPresent()));
|
||||
if (snapshot.generatorPresent()) {
|
||||
properties.setProperty(prefix + "generator", snapshot.generator());
|
||||
}
|
||||
properties.setProperty(prefix + "seedPresent", Boolean.toString(snapshot.seedPresent()));
|
||||
if (snapshot.seedPresent()) {
|
||||
properties.setProperty(prefix + "seed", Long.toString(snapshot.seed()));
|
||||
}
|
||||
}
|
||||
|
||||
private static WorldGeneratorSnapshot readSnapshot(Properties properties, String prefix) throws IOException {
|
||||
boolean worldsPresent = parseBoolean(properties, prefix + "worldsSectionPresent");
|
||||
boolean worldPresent = parseBoolean(properties, prefix + "worldSectionPresent");
|
||||
boolean generatorPresent = parseBoolean(properties, prefix + "generatorPresent");
|
||||
String generator = generatorPresent ? required(properties, prefix + "generator") : null;
|
||||
boolean seedPresent = parseBoolean(properties, prefix + "seedPresent");
|
||||
Long seed = seedPresent ? parseLong(properties, prefix + "seed") : null;
|
||||
try {
|
||||
return new WorldGeneratorSnapshot(
|
||||
worldsPresent,
|
||||
worldPresent,
|
||||
generatorPresent,
|
||||
generator,
|
||||
seedPresent,
|
||||
seed
|
||||
);
|
||||
} catch (IllegalArgumentException failure) {
|
||||
throw new IOException("Replacement journal contains an invalid configuration snapshot.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean safeDimension(String value) {
|
||||
if (value.isEmpty() || value.length() > 256 || value.startsWith(".") || value.contains("..")) {
|
||||
return false;
|
||||
}
|
||||
String[] segments = value.split("/", -1);
|
||||
if (segments.length > 16) {
|
||||
return false;
|
||||
}
|
||||
for (String segment : segments) {
|
||||
if (segment.isEmpty() || !segment.matches("[A-Za-z0-9_-]+")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String required(Properties properties, String key) throws IOException {
|
||||
String value = properties.getProperty(key);
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IOException("Replacement journal is missing " + key + ".");
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static boolean parseBoolean(Properties properties, String key) throws IOException {
|
||||
String value = required(properties, key);
|
||||
if (!"true".equals(value) && !"false".equals(value)) {
|
||||
throw new IOException("Replacement journal contains an invalid boolean for " + key + ".");
|
||||
}
|
||||
return Boolean.parseBoolean(value);
|
||||
}
|
||||
|
||||
private static long parseLong(Properties properties, String key) throws IOException {
|
||||
try {
|
||||
return Long.parseLong(required(properties, key));
|
||||
} catch (NumberFormatException failure) {
|
||||
throw new IOException("Replacement journal contains an invalid integer for " + key + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static String detail(Throwable failure) {
|
||||
String message = failure.getMessage();
|
||||
return message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
public record StagedReplacement(
|
||||
NamespacedKey worldKey,
|
||||
String worldName,
|
||||
String dimension,
|
||||
long seed,
|
||||
boolean replacedExistingTarget,
|
||||
boolean datapackRestartRequired
|
||||
) {
|
||||
public StagedReplacement {
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(worldName, "worldName");
|
||||
Objects.requireNonNull(dimension, "dimension");
|
||||
}
|
||||
}
|
||||
|
||||
private record Transaction(
|
||||
UUID id,
|
||||
NamespacedKey worldKey,
|
||||
String dimension,
|
||||
long seed,
|
||||
String packFingerprint,
|
||||
WorldGeneratorSnapshot originalConfiguration,
|
||||
boolean originalTargetPresent,
|
||||
Phase phase
|
||||
) {
|
||||
private Transaction {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(worldKey, "worldKey");
|
||||
Objects.requireNonNull(dimension, "dimension");
|
||||
Objects.requireNonNull(packFingerprint, "packFingerprint");
|
||||
Objects.requireNonNull(originalConfiguration, "originalConfiguration");
|
||||
Objects.requireNonNull(phase, "phase");
|
||||
}
|
||||
|
||||
private String worldName() {
|
||||
return IrisWorldStorage.logicalName(worldKey);
|
||||
}
|
||||
|
||||
private Transaction withPhase(Phase nextPhase) {
|
||||
return new Transaction(
|
||||
id,
|
||||
worldKey,
|
||||
dimension,
|
||||
seed,
|
||||
packFingerprint,
|
||||
originalConfiguration,
|
||||
originalTargetPresent,
|
||||
nextPhase
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private enum Phase {
|
||||
PREPARED,
|
||||
ARMED,
|
||||
PUBLISHED,
|
||||
ROLLBACK_PENDING
|
||||
}
|
||||
|
||||
private record VanillaLevelContext(long seed, boolean allowNether, boolean allowEnd) {
|
||||
}
|
||||
}
|
||||
+36
-4
@@ -26,6 +26,7 @@ import art.arcane.iris.core.IrisStartupValidation;
|
||||
import art.arcane.iris.core.DatapackInstallResult;
|
||||
import art.arcane.iris.core.IrisWorldStorage;
|
||||
import art.arcane.iris.core.IrisWorlds;
|
||||
import art.arcane.iris.core.PendingWorldReplacementManager;
|
||||
import art.arcane.iris.core.ServerConfigurator;
|
||||
import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration;
|
||||
import art.arcane.iris.core.lifecycle.IrisWorldRemovalService;
|
||||
@@ -124,17 +125,27 @@ public class CommandIris implements DirectorExecutor {
|
||||
@Param(description = "The seed to generate the world with", descriptionKey = "iris.director.commandiris.param.seed_generate_world_with", defaultValue = "1337")
|
||||
long seed,
|
||||
@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
|
||||
boolean main,
|
||||
@Param(name = "overwrite", aliases = "force", description = "Replace the exact existing world slot on the next restart", descriptionKey = "iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart", defaultValue = "false")
|
||||
boolean overwrite
|
||||
) {
|
||||
NamespacedKey worldKey;
|
||||
try {
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(name);
|
||||
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||
if (overwrite) {
|
||||
worldKey = Iris.instance.pendingWorldReplacements().resolveRequestedWorldKey(name);
|
||||
} else {
|
||||
worldKey = IrisWorldStorage.managedKeyFromName(name);
|
||||
IrisWorldStorage.requireSafeManagedDimensionRoot(worldKey);
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
sender().sendMessage(C.RED + e.getMessage());
|
||||
return;
|
||||
}
|
||||
String worldName = IrisWorldStorage.logicalName(worldKey);
|
||||
if (overwrite && main && !NamespacedKey.minecraft("overworld").equals(worldKey)) {
|
||||
sender().sendMessage(C.RED + "overwrite=true with main=true must target the configured main-world name.");
|
||||
return;
|
||||
}
|
||||
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));
|
||||
@@ -147,7 +158,7 @@ public class CommandIris implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
if (IrisWorldStorage.dimensionRoot(worldName).exists()) {
|
||||
if (!overwrite && IrisWorldStorage.dimensionRoot(worldName).exists()) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_THAT_FOLDER_ALREADY_EXISTS));
|
||||
return;
|
||||
}
|
||||
@@ -164,6 +175,27 @@ public class CommandIris implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
if (overwrite) {
|
||||
try {
|
||||
PendingWorldReplacementManager.StagedReplacement staged = Iris.instance
|
||||
.pendingWorldReplacements()
|
||||
.stageReplacement(sender(), worldKey, dimension, seed);
|
||||
if (staged.seed() != seed) {
|
||||
sender().sendMessage(C.YELLOW + "Exact vanilla slots preserve the shared level seed; using "
|
||||
+ staged.seed() + " instead of " + seed + ".");
|
||||
}
|
||||
sender().sendMessage(C.GREEN + "Staged Iris replacement for " + staged.worldKey()
|
||||
+ ". Restart once to publish it. The current dimension is retained until Iris verifies the replacement.");
|
||||
} catch (Throwable failure) {
|
||||
Iris.reportError("Failed to stage Iris world replacement for " + worldKey + ".", failure);
|
||||
String detail = failure.getMessage() == null || failure.getMessage().isBlank()
|
||||
? failure.getClass().getSimpleName()
|
||||
: failure.getMessage();
|
||||
sender().sendMessage(C.RED + "Could not stage the world replacement: " + detail);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (J.isFolia()) {
|
||||
if (stageFoliaWorldCreation(worldName, dimension, seed, main)) {
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_IRIS_WORLD_STAGING_COMPLETED_RESTART_SERVER_GENERATE_LOAD, MessageArgument.untrusted("worldName", worldName)));
|
||||
|
||||
+26
-10
@@ -7,6 +7,7 @@ import art.arcane.iris.core.nms.INMS;
|
||||
import art.arcane.iris.core.tools.IrisToolbelt;
|
||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import art.arcane.iris.engine.platform.PlatformChunkGenerator;
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.plugin.IrisService;
|
||||
import org.bukkit.Bukkit;
|
||||
@@ -31,11 +32,11 @@ public final class DatapackStructureScopeSVC implements IrisService {
|
||||
throw new IllegalStateException(
|
||||
"Iris could not establish ownership for installed datapack structure sets", e);
|
||||
}
|
||||
if (scopeIndex.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
applyScope(world);
|
||||
IrisImportedStructureControl importedStructures = importedStructures(world);
|
||||
if (!scopeIndex.isEmpty() || importedStructures != null && importedStructures.hasFrequencyOverrides()) {
|
||||
applyScope(world, importedStructures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,8 +53,10 @@ public final class DatapackStructureScopeSVC implements IrisService {
|
||||
boolean studioEntryBootstrap = event.getWorld().getGenerator()
|
||||
instanceof BukkitChunkGenerator generator
|
||||
&& generator.isStudioEntryBootstrapActive();
|
||||
if (shouldApplyScope(scopeIndex.isEmpty(), studioEntryBootstrap)) {
|
||||
applyScope(event.getWorld());
|
||||
IrisImportedStructureControl importedStructures = importedStructures(event.getWorld());
|
||||
if (shouldApplyScope(scopeIndex.isEmpty(), studioEntryBootstrap,
|
||||
importedStructures != null && importedStructures.hasFrequencyOverrides())) {
|
||||
applyScope(event.getWorld(), importedStructures);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,15 +65,19 @@ public final class DatapackStructureScopeSVC implements IrisService {
|
||||
INMS.get().abandonStudioStructureBootstrap(event.getWorld());
|
||||
}
|
||||
|
||||
static boolean shouldApplyScope(boolean scopeIndexEmpty, boolean studioEntryBootstrap) {
|
||||
return !scopeIndexEmpty || studioEntryBootstrap;
|
||||
static boolean shouldApplyScope(boolean scopeIndexEmpty, boolean studioEntryBootstrap,
|
||||
boolean hasFrequencyOverrides) {
|
||||
return !scopeIndexEmpty || studioEntryBootstrap || hasFrequencyOverrides;
|
||||
}
|
||||
|
||||
private void applyScope(World world) {
|
||||
private void applyScope(World world, IrisImportedStructureControl importedStructures) {
|
||||
Set<String> declaredSources = declaredSources(world);
|
||||
IrisImportedStructureControl activeControl = importedStructures == null
|
||||
? new IrisImportedStructureControl()
|
||||
: importedStructures;
|
||||
try {
|
||||
DatapackStructureScopeResult result = INMS.get().scopeDatapackStructures(
|
||||
world, scopeIndex, declaredSources);
|
||||
world, scopeIndex, declaredSources, activeControl);
|
||||
IrisLogging.info("Scoped Iris-managed datapack structure sets for world '"
|
||||
+ world.getName() + "': " + result.retainedManagedSets() + " retained, "
|
||||
+ result.excludedManagedSets() + " excluded.");
|
||||
@@ -80,6 +87,15 @@ public final class DatapackStructureScopeSVC implements IrisService {
|
||||
}
|
||||
}
|
||||
|
||||
private IrisImportedStructureControl importedStructures(World world) {
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
if (generator == null || generator.getTarget() == null
|
||||
|| generator.getTarget().getDimension() == null) {
|
||||
return null;
|
||||
}
|
||||
return generator.getTarget().getDimension().getImportedStructures();
|
||||
}
|
||||
|
||||
private Set<String> declaredSources(World world) {
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
if (generator == null) {
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package art.arcane.iris.core;
|
||||
|
||||
import art.arcane.iris.core.pack.BrokenPackException;
|
||||
import art.arcane.iris.core.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.core.pack.PackValidationResult;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisWorldGeneratorResolverTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@After
|
||||
public void clearValidationRegistry() {
|
||||
PackValidationRegistry.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void snapshotValidationIsLazyAndExactRootScoped() throws Exception {
|
||||
File packRoot = temporaryFolder.newFolder("world", "iris", "pack");
|
||||
writeValidPack(packRoot.toPath());
|
||||
PackValidationResult unrelatedNamedFailure = new PackValidationResult(
|
||||
"pack", List.of("unrelated basename failure"), List.of(), 1L);
|
||||
PackValidationRegistry.publish(unrelatedNamedFailure);
|
||||
|
||||
PackValidationResult result = IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot);
|
||||
|
||||
assertTrue(result.isLoadable());
|
||||
assertEquals(result, PackValidationRegistry.get(packRoot.toPath()));
|
||||
assertEquals(unrelatedNamedFailure, PackValidationRegistry.get("pack"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidatedSnapshotIsValidatedAgainBeforeAuthorization() throws Exception {
|
||||
File packRoot = temporaryFolder.newFolder("replace", "iris", "pack");
|
||||
writeValidPack(packRoot.toPath());
|
||||
assertTrue(IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot).isLoadable());
|
||||
|
||||
Files.writeString(
|
||||
packRoot.toPath().resolve("dimensions/main.json"),
|
||||
"{",
|
||||
StandardCharsets.UTF_8);
|
||||
PackValidationRegistry.remove(packRoot.toPath());
|
||||
|
||||
assertThrows(BrokenPackException.class,
|
||||
() -> IrisWorldGeneratorResolver.requireSnapshotLoadable(packRoot));
|
||||
PackValidationResult invalid = PackValidationRegistry.get(packRoot.toPath());
|
||||
assertNotNull(invalid);
|
||||
assertFalse(invalid.getBlockingErrors().toString(), invalid.isLoadable());
|
||||
}
|
||||
|
||||
private static void writeValidPack(Path packRoot) throws Exception {
|
||||
Files.createDirectories(packRoot.resolve("dimensions"));
|
||||
Files.createDirectories(packRoot.resolve("regions"));
|
||||
Files.createDirectories(packRoot.resolve("biomes"));
|
||||
Files.writeString(
|
||||
packRoot.resolve("dimensions/main.json"),
|
||||
"{\"regions\":[\"region\"]}",
|
||||
StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
packRoot.resolve("regions/region.json"),
|
||||
"{\"landBiomes\":[\"biome\"]}",
|
||||
StandardCharsets.UTF_8);
|
||||
Files.writeString(
|
||||
packRoot.resolve("biomes/biome.json"),
|
||||
"{\"name\":\"Biome\"}",
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package art.arcane.iris.core.commands;
|
||||
|
||||
import art.arcane.volmlib.util.director.annotations.Param;
|
||||
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.assertTrue;
|
||||
|
||||
public class CommandIrisCreateOverwriteContractTest {
|
||||
@Test
|
||||
public void createExposesOptInRestartReplacementFlag() throws Exception {
|
||||
Method command = CommandIris.class.getDeclaredMethod(
|
||||
"create",
|
||||
String.class,
|
||||
String.class,
|
||||
long.class,
|
||||
boolean.class,
|
||||
boolean.class
|
||||
);
|
||||
Parameter overwriteParameter = command.getParameters()[4];
|
||||
Param overwrite = overwriteParameter.getAnnotation(Param.class);
|
||||
|
||||
assertEquals("overwrite", overwrite.name());
|
||||
assertEquals("false", overwrite.defaultValue());
|
||||
assertTrue(Arrays.asList(overwrite.aliases()).contains("force"));
|
||||
assertEquals(
|
||||
"iris.director.commandiris.param.replace_exact_existing_world_slot_next_restart",
|
||||
overwrite.descriptionKey()
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -34,8 +34,9 @@ public class DatapackStructureScopeSVCTest {
|
||||
|
||||
@Test
|
||||
public void emptyScopeStillAppliesToJigsawStudioBootstrap() {
|
||||
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, true));
|
||||
assertFalse(DatapackStructureScopeSVC.shouldApplyScope(true, false));
|
||||
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(false, false));
|
||||
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, true, false));
|
||||
assertFalse(DatapackStructureScopeSVC.shouldApplyScope(true, false, false));
|
||||
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(false, false, false));
|
||||
assertTrue(DatapackStructureScopeSVC.shouldApplyScope(true, false, true));
|
||||
}
|
||||
}
|
||||
|
||||
+19
-4
@@ -26,6 +26,7 @@ import art.arcane.iris.engine.framework.GenerationSessionException;
|
||||
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.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.iris.nativegen.NativeStructureStartInjector;
|
||||
import art.arcane.iris.nativegen.NativeStructureReferenceRepair;
|
||||
import art.arcane.iris.nativegen.NativeStructureVanillaLocator;
|
||||
@@ -284,8 +285,21 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
|
||||
@Override
|
||||
public ChunkGeneratorStructureState createState(HolderLookup<StructureSet> structureSets, RandomState randomState, long seed) {
|
||||
return ChunkGeneratorStructureState.createForNormal(
|
||||
ChunkGeneratorStructureState state = ChunkGeneratorStructureState.createForNormal(
|
||||
randomState, seed, structureBiomeSource.forStructureState(structureSets), structureSets);
|
||||
return ModdedStructureSetFrequencyOverrides.apply(state, configuredImportedStructures());
|
||||
}
|
||||
|
||||
private IrisImportedStructureControl configuredImportedStructures() {
|
||||
Engine current = engine;
|
||||
if (current != null && !current.isClosed() && !current.isClosing()) {
|
||||
IrisDimension dimension = current.getDimension();
|
||||
if (dimension != null && dimension.getImportedStructures() != null) {
|
||||
return dimension.getImportedStructures();
|
||||
}
|
||||
}
|
||||
IrisImportedStructureControl importedStructures = configuredPack().dimension().getImportedStructures();
|
||||
return importedStructures == null ? new IrisImportedStructureControl() : importedStructures;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -444,10 +458,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator {
|
||||
return;
|
||||
}
|
||||
String remedy = integratedEnvironment()
|
||||
? "enable 'Generate Structures' for this world; Iris requires it, and individual structures "
|
||||
+ "are denied through importedStructures.disabled"
|
||||
? "enable 'Generate Structures' for this world; Iris requires it, then deny families through "
|
||||
+ "importedStructures.disabled or complete keys through importedStructures.disabledExact"
|
||||
: "set generate-structures=true in server.properties, restart the server, "
|
||||
+ "and deny individual structures through importedStructures.disabled";
|
||||
+ "then deny families through importedStructures.disabled or complete keys through "
|
||||
+ "importedStructures.disabledExact";
|
||||
throw new IllegalStateException("Iris generator '" + dimensionKey
|
||||
+ "' cannot bind while generate-structures=false; " + remedy);
|
||||
}
|
||||
|
||||
+2
-1
@@ -287,7 +287,8 @@ final class ModdedNativeStructureStage {
|
||||
+ " because generate-structures=false disables them outside the pack. That flag is fixed when "
|
||||
+ "the world is created (server.properties generate-structures, or the Generate Structures "
|
||||
+ "toggle in singleplayer), so it cannot be changed for this world: create a new world with "
|
||||
+ "structures enabled, and deny individual structures through importedStructures.disabled");
|
||||
+ "structures enabled, then deny families through importedStructures.disabled or complete keys "
|
||||
+ "through importedStructures.disabledExact");
|
||||
}
|
||||
ChunkPos chunkPos = chunk.getPos();
|
||||
SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY());
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.engine.framework.NativeStructureFrequencyScale;
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderOwner;
|
||||
import net.minecraft.core.Vec3i;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
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.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
final class ModdedStructureSetFrequencyOverrides {
|
||||
private ModdedStructureSetFrequencyOverrides() {
|
||||
}
|
||||
|
||||
static ChunkGeneratorStructureState apply(
|
||||
ChunkGeneratorStructureState state,
|
||||
IrisImportedStructureControl importedStructures
|
||||
) {
|
||||
if (!importedStructures.hasFrequencyOverrides()) {
|
||||
return state;
|
||||
}
|
||||
List<Holder<StructureSet>> original = state.possibleStructureSets();
|
||||
List<Holder<StructureSet>> scaled = scaleSets(original, importedStructures);
|
||||
if (scaled == original) {
|
||||
return state;
|
||||
}
|
||||
Field possibleSetsField = declaredField(ChunkGeneratorStructureState.class, List.class);
|
||||
try {
|
||||
possibleSetsField.set(state, scaled);
|
||||
return state;
|
||||
} catch (IllegalAccessException error) {
|
||||
throw new IllegalStateException(
|
||||
"Could not apply native structure-set frequency overrides", error);
|
||||
}
|
||||
}
|
||||
|
||||
static List<Holder<StructureSet>> scaleSets(
|
||||
List<Holder<StructureSet>> structureSets,
|
||||
IrisImportedStructureControl importedStructures
|
||||
) {
|
||||
Map<String, Holder<StructureSet>> holdersByKey = new HashMap<>();
|
||||
for (Holder<StructureSet> holder : structureSets) {
|
||||
String key = structureSetKey(holder);
|
||||
if (key != null) {
|
||||
holdersByKey.putIfAbsent(key, holder);
|
||||
}
|
||||
}
|
||||
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity = new IdentityHashMap<>();
|
||||
List<Holder<StructureSet>> scaledSets = new ArrayList<>(structureSets.size());
|
||||
boolean changed = false;
|
||||
for (Holder<StructureSet> holder : structureSets) {
|
||||
Holder<StructureSet> scaled = scaleHolder(
|
||||
holder, importedStructures, holdersByKey, scaledByIdentity);
|
||||
scaledSets.add(scaled);
|
||||
changed |= scaled != holder;
|
||||
}
|
||||
return changed ? List.copyOf(scaledSets) : structureSets;
|
||||
}
|
||||
|
||||
private static Holder<StructureSet> scaleHolder(
|
||||
Holder<StructureSet> holder,
|
||||
IrisImportedStructureControl importedStructures,
|
||||
Map<String, Holder<StructureSet>> holdersByKey,
|
||||
Map<Holder<StructureSet>, Holder<StructureSet>> scaledByIdentity
|
||||
) {
|
||||
if (scaledByIdentity.containsKey(holder)) {
|
||||
return scaledByIdentity.get(holder);
|
||||
}
|
||||
if (!dependsOnOverride(holder, importedStructures, holdersByKey)) {
|
||||
scaledByIdentity.put(holder, holder);
|
||||
return holder;
|
||||
}
|
||||
ResourceKey<StructureSet> holderKey = holder.unwrapKey().orElseThrow(() ->
|
||||
new IllegalStateException("An affected native structure-set exclusion graph has an unkeyed holder"));
|
||||
ScaledStructureSetHolder scaledHolder = new ScaledStructureSetHolder(holderKey);
|
||||
scaledByIdentity.put(holder, scaledHolder);
|
||||
|
||||
StructureSet originalSet = holder.value();
|
||||
StructurePlacement originalPlacement = originalSet.placement();
|
||||
Optional<StructurePlacement.ExclusionZone> originalZone = exclusionZone(originalPlacement);
|
||||
Optional<StructurePlacement.ExclusionZone> scaledZone = originalZone;
|
||||
if (originalZone.isPresent()) {
|
||||
Holder<StructureSet> target = canonicalHolder(
|
||||
originalZone.get().otherSet(), holdersByKey);
|
||||
Holder<StructureSet> scaledTarget = scaleHolder(
|
||||
target, importedStructures, holdersByKey, scaledByIdentity);
|
||||
if (scaledTarget != originalZone.get().otherSet()) {
|
||||
scaledZone = Optional.of(new StructurePlacement.ExclusionZone(
|
||||
scaledTarget, originalZone.get().chunkCount()));
|
||||
}
|
||||
}
|
||||
double multiplier = importedStructures.frequencyMultiplier(structureSetKey(holder));
|
||||
StructurePlacement scaledPlacement = multiplier == 1D && scaledZone.equals(originalZone)
|
||||
? originalPlacement
|
||||
: scalePlacement(originalPlacement, originalZone, scaledZone, multiplier);
|
||||
scaledHolder.bind(new StructureSet(originalSet.structures(), scaledPlacement));
|
||||
return scaledHolder;
|
||||
}
|
||||
|
||||
private static boolean dependsOnOverride(
|
||||
Holder<StructureSet> holder,
|
||||
IrisImportedStructureControl importedStructures,
|
||||
Map<String, Holder<StructureSet>> holdersByKey
|
||||
) {
|
||||
Set<Holder<StructureSet>> visited = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
Holder<StructureSet> current = holder;
|
||||
while (visited.add(current)) {
|
||||
if (importedStructures.frequencyMultiplier(structureSetKey(current)) != 1D) {
|
||||
return true;
|
||||
}
|
||||
Optional<StructurePlacement.ExclusionZone> zone =
|
||||
exclusionZone(current.value().placement());
|
||||
if (zone.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
current = canonicalHolder(zone.get().otherSet(), holdersByKey);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Holder<StructureSet> canonicalHolder(
|
||||
Holder<StructureSet> holder,
|
||||
Map<String, Holder<StructureSet>> holdersByKey
|
||||
) {
|
||||
String key = structureSetKey(holder);
|
||||
return key == null ? holder : holdersByKey.getOrDefault(key, holder);
|
||||
}
|
||||
|
||||
private static StructurePlacement scalePlacement(
|
||||
StructurePlacement placement,
|
||||
Optional<StructurePlacement.ExclusionZone> originalZone,
|
||||
Optional<StructurePlacement.ExclusionZone> scaledZone,
|
||||
double multiplier
|
||||
) {
|
||||
Vec3i locateOffset = (Vec3i) declaredFieldValue(
|
||||
StructurePlacement.class, placement, Vec3i.class);
|
||||
StructurePlacement.FrequencyReductionMethod reductionMethod =
|
||||
(StructurePlacement.FrequencyReductionMethod) declaredFieldValue(
|
||||
StructurePlacement.class,
|
||||
placement,
|
||||
StructurePlacement.FrequencyReductionMethod.class);
|
||||
float frequency = (float) declaredFieldValue(
|
||||
StructurePlacement.class, placement, float.class);
|
||||
int salt = (int) declaredFieldValue(
|
||||
StructurePlacement.class, placement, int.class);
|
||||
|
||||
if (placement.getClass() == RandomSpreadStructurePlacement.class) {
|
||||
RandomSpreadStructurePlacement randomSpread =
|
||||
(RandomSpreadStructurePlacement) placement;
|
||||
NativeStructureFrequencyScale scale = NativeStructureFrequencyScale.randomSpread(
|
||||
frequency, randomSpread.spacing(), randomSpread.separation(), multiplier);
|
||||
if (scale.frequency() == frequency
|
||||
&& scale.spacing() == randomSpread.spacing()
|
||||
&& scaledZone.equals(originalZone)) {
|
||||
return placement;
|
||||
}
|
||||
return new RandomSpreadStructurePlacement(
|
||||
locateOffset,
|
||||
reductionMethod,
|
||||
scale.frequency(),
|
||||
salt,
|
||||
scaledZone,
|
||||
scale.spacing(),
|
||||
randomSpread.separation(),
|
||||
randomSpread.spreadType());
|
||||
}
|
||||
if (placement.getClass() == ConcentricRingsStructurePlacement.class) {
|
||||
ConcentricRingsStructurePlacement rings =
|
||||
(ConcentricRingsStructurePlacement) placement;
|
||||
float scaledFrequency = NativeStructureFrequencyScale.probability(frequency, multiplier);
|
||||
if (scaledFrequency == frequency && scaledZone.equals(originalZone)) {
|
||||
return placement;
|
||||
}
|
||||
return new ConcentricRingsStructurePlacement(
|
||||
locateOffset,
|
||||
reductionMethod,
|
||||
scaledFrequency,
|
||||
salt,
|
||||
scaledZone,
|
||||
rings.distance(),
|
||||
rings.spread(),
|
||||
rings.count(),
|
||||
rings.preferredBiomes());
|
||||
}
|
||||
throw new IllegalStateException("Unsupported native structure placement: "
|
||||
+ placement.getClass().getName());
|
||||
}
|
||||
|
||||
private static Optional<StructurePlacement.ExclusionZone> exclusionZone(
|
||||
StructurePlacement placement
|
||||
) {
|
||||
Object value = declaredFieldValue(StructurePlacement.class, placement, Optional.class);
|
||||
if (value instanceof Optional<?> optional && (optional.isEmpty()
|
||||
|| optional.get() instanceof StructurePlacement.ExclusionZone)) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Optional<StructurePlacement.ExclusionZone> resolved =
|
||||
(Optional<StructurePlacement.ExclusionZone>) optional;
|
||||
return resolved;
|
||||
}
|
||||
throw new IllegalStateException("Could not read native structure exclusion zone from "
|
||||
+ placement.getClass().getName());
|
||||
}
|
||||
|
||||
private static String structureSetKey(Holder<StructureSet> holder) {
|
||||
Optional<ResourceKey<StructureSet>> key = holder.unwrapKey();
|
||||
return key.map(resourceKey -> resourceKey.identifier().toString()).orElse(null);
|
||||
}
|
||||
|
||||
private static Object declaredFieldValue(
|
||||
Class<?> declaringType,
|
||||
Object target,
|
||||
Class<?> fieldType
|
||||
) {
|
||||
Field field = declaredField(declaringType, fieldType);
|
||||
try {
|
||||
return field.get(target);
|
||||
} catch (IllegalAccessException error) {
|
||||
throw new IllegalStateException("Could not read " + fieldType.getName()
|
||||
+ " field on " + declaringType.getName(), error);
|
||||
}
|
||||
}
|
||||
|
||||
private static Field declaredField(Class<?> declaringType, Class<?> fieldType) {
|
||||
Field match = null;
|
||||
for (Field field : declaringType.getDeclaredFields()) {
|
||||
if (Modifier.isStatic(field.getModifiers()) || field.getType() != fieldType) {
|
||||
continue;
|
||||
}
|
||||
if (match != null) {
|
||||
throw new IllegalStateException("Ambiguous " + fieldType.getName() + " field on "
|
||||
+ declaringType.getName());
|
||||
}
|
||||
match = field;
|
||||
}
|
||||
if (match == null || !match.trySetAccessible()) {
|
||||
throw new IllegalStateException("Missing accessible " + fieldType.getName()
|
||||
+ " field on " + declaringType.getName());
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
private static final class ScaledStructureSetHolder extends Holder.Reference<StructureSet> {
|
||||
private ScaledStructureSetHolder(ResourceKey<StructureSet> key) {
|
||||
super(Type.STAND_ALONE, new HolderOwner<>() {
|
||||
}, key, null);
|
||||
}
|
||||
|
||||
private void bind(StructureSet structureSet) {
|
||||
bindValue(structureSet);
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.engine.object.IrisImportedStructureControl;
|
||||
import art.arcane.iris.engine.object.IrisStructureSetFrequencyOverride;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import net.minecraft.SharedConstants;
|
||||
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.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.biome.BiomeSource;
|
||||
import net.minecraft.world.level.biome.Climate;
|
||||
import net.minecraft.world.level.chunk.ChunkGeneratorStructureState;
|
||||
import net.minecraft.world.level.levelgen.RandomState;
|
||||
import net.minecraft.world.level.levelgen.structure.StructureSet;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement;
|
||||
import net.minecraft.world.level.levelgen.structure.placement.StructurePlacementType;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public class ModdedStructureSetFrequencyOverridesTest {
|
||||
@BeforeClass
|
||||
public static void bootstrapMinecraftRegistries() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createStateReplacementScalesOnlyTheExactSetAndRetainsEntries() throws Exception {
|
||||
Holder<StructureSet> complexes = structureSet(
|
||||
"minecraft:nether_complexes", 27, 4, 30084232);
|
||||
Holder<StructureSet> fossils = structureSet(
|
||||
"minecraft:nether_fossils", 2, 1, 14357921);
|
||||
ChunkGeneratorStructureState state = state(List.of(complexes, fossils));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("minecraft:nether_complexes")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
ChunkGeneratorStructureState returned =
|
||||
ModdedStructureSetFrequencyOverrides.apply(state, control);
|
||||
|
||||
assertSame(state, returned);
|
||||
List<Holder<StructureSet>> scaledSets = state.possibleStructureSets();
|
||||
RandomSpreadStructurePlacement scaledComplexes =
|
||||
(RandomSpreadStructurePlacement) scaledSets.get(0).value().placement();
|
||||
assertEquals(26, scaledComplexes.spacing());
|
||||
assertEquals(4, scaledComplexes.separation());
|
||||
assertSame(complexes.value().structures(), scaledSets.get(0).value().structures());
|
||||
assertSame(fossils, scaledSets.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedCustomPlacementTypeRemainsUntouched() {
|
||||
Holder<StructureSet> custom = new BoundStructureSetHolder(
|
||||
"example:custom",
|
||||
new StructureSet(List.of(), new UnsupportedPlacement()));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("minecraft:nether_complexes")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
List<Holder<StructureSet>> scaled =
|
||||
ModdedStructureSetFrequencyOverrides.scaleSets(List.of(custom), control);
|
||||
|
||||
assertSame(custom, scaled.getFirst());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedExclusionCycleRemainsUntouched() {
|
||||
BoundStructureSetHolder first = new BoundStructureSetHolder("example:first");
|
||||
BoundStructureSetHolder second = new BoundStructureSetHolder("example:second");
|
||||
first.bind(new StructureSet(List.of(), placement(32, 8, 1, second)));
|
||||
second.bind(new StructureSet(List.of(), placement(40, 10, 2, first)));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("minecraft:nether_complexes")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
List<Holder<StructureSet>> scaled = ModdedStructureSetFrequencyOverrides.scaleSets(
|
||||
List.of(first, second), control);
|
||||
|
||||
assertSame(first, scaled.get(0));
|
||||
assertSame(second, scaled.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void affectedExclusionCycleUsesBoundScaledHolders() {
|
||||
BoundStructureSetHolder first = new BoundStructureSetHolder("example:first");
|
||||
BoundStructureSetHolder second = new BoundStructureSetHolder("example:second");
|
||||
first.bind(new StructureSet(List.of(), placement(32, 8, 1, second)));
|
||||
second.bind(new StructureSet(List.of(), placement(40, 10, 2, first)));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("example:first")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
List<Holder<StructureSet>> scaled = ModdedStructureSetFrequencyOverrides.scaleSets(
|
||||
List.of(first, second), control);
|
||||
|
||||
assertEquals(31, ((RandomSpreadStructurePlacement)
|
||||
scaled.get(0).value().placement()).spacing());
|
||||
assertEquals(40, ((RandomSpreadStructurePlacement)
|
||||
scaled.get(1).value().placement()).spacing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void affectedRandomSpreadSubclassFailsInsteadOfLosingSubtypeBehavior() {
|
||||
Holder<StructureSet> custom = new BoundStructureSetHolder(
|
||||
"example:custom",
|
||||
new StructureSet(List.of(), new CustomRandomSpreadPlacement()));
|
||||
KList<IrisStructureSetFrequencyOverride> overrides = new KList<>();
|
||||
overrides.add(new IrisStructureSetFrequencyOverride()
|
||||
.setStructureSet("example:custom")
|
||||
.setMultiplier(1.1D));
|
||||
IrisImportedStructureControl control = new IrisImportedStructureControl()
|
||||
.setFrequencyOverrides(overrides);
|
||||
|
||||
assertThrows(IllegalStateException.class, () ->
|
||||
ModdedStructureSetFrequencyOverrides.scaleSets(List.of(custom), control));
|
||||
}
|
||||
|
||||
private static Holder<StructureSet> structureSet(
|
||||
String key,
|
||||
int spacing,
|
||||
int separation,
|
||||
int salt
|
||||
) {
|
||||
StructureSet value = new StructureSet(
|
||||
List.of(),
|
||||
new RandomSpreadStructurePlacement(
|
||||
spacing, separation, RandomSpreadType.LINEAR, salt));
|
||||
return new BoundStructureSetHolder(key, value);
|
||||
}
|
||||
|
||||
private static RandomSpreadStructurePlacement placement(
|
||||
int spacing,
|
||||
int separation,
|
||||
int salt,
|
||||
Holder<StructureSet> exclusionTarget
|
||||
) {
|
||||
return new RandomSpreadStructurePlacement(
|
||||
Vec3i.ZERO,
|
||||
StructurePlacement.FrequencyReductionMethod.DEFAULT,
|
||||
1F,
|
||||
salt,
|
||||
Optional.of(new StructurePlacement.ExclusionZone(exclusionTarget, 1)),
|
||||
spacing,
|
||||
separation,
|
||||
RandomSpreadType.LINEAR);
|
||||
}
|
||||
|
||||
private static ChunkGeneratorStructureState state(
|
||||
List<Holder<StructureSet>> sets
|
||||
) throws Exception {
|
||||
Constructor<ChunkGeneratorStructureState> constructor =
|
||||
ChunkGeneratorStructureState.class.getDeclaredConstructor(
|
||||
RandomState.class,
|
||||
BiomeSource.class,
|
||||
long.class,
|
||||
long.class,
|
||||
List.class);
|
||||
constructor.setAccessible(true);
|
||||
return constructor.newInstance(
|
||||
null,
|
||||
new EmptyBiomeSource(),
|
||||
1L,
|
||||
1L,
|
||||
sets);
|
||||
}
|
||||
|
||||
private static final class BoundStructureSetHolder extends Holder.Reference<StructureSet> {
|
||||
private BoundStructureSetHolder(String key) {
|
||||
this(key, null);
|
||||
}
|
||||
|
||||
private BoundStructureSetHolder(String key, StructureSet value) {
|
||||
super(Type.STAND_ALONE, new HolderOwner<>() {
|
||||
}, ResourceKey.create(Registries.STRUCTURE_SET, Identifier.parse(key)), value);
|
||||
}
|
||||
|
||||
private void bind(StructureSet value) {
|
||||
bindValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class EmptyBiomeSource extends BiomeSource {
|
||||
@Override
|
||||
public Holder<Biome> getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected com.mojang.serialization.MapCodec<? extends BiomeSource> codec() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected java.util.stream.Stream<Holder<Biome>> collectPossibleBiomes() {
|
||||
return HolderSet.<Biome>empty().stream();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class UnsupportedPlacement extends StructurePlacement {
|
||||
private UnsupportedPlacement() {
|
||||
super(Vec3i.ZERO, FrequencyReductionMethod.DEFAULT, 1F, 1, Optional.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isPlacementChunk(ChunkGeneratorStructureState state, int x, int z) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public StructurePlacementType<?> type() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CustomRandomSpreadPlacement extends RandomSpreadStructurePlacement {
|
||||
private CustomRandomSpreadPlacement() {
|
||||
super(27, 4, RandomSpreadType.LINEAR, 30084232);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -53,6 +53,7 @@ public class NativeStructureFailureContractTest {
|
||||
assertTrue(error.getMessage().contains("overworld:overworld"));
|
||||
assertTrue(error.getMessage().contains("generate-structures=false"));
|
||||
assertTrue(error.getMessage().contains("importedStructures.disabled"));
|
||||
assertTrue(error.getMessage().contains("importedStructures.disabledExact"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user