diff --git a/README.md b/README.md index 31f0c3300..21ffdb69c 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ Consider supporting development by buying Iris on Spigot. | Folia | plugin jar | 26.2 | Region-safe scheduling throughout | | Spigot / CraftBukkit | plugin jar | 26.2 | Full feature set | | Fabric | mod jar | 26.2 | Server worldgen + client HUD; requires Fabric Loader 0.19.3+ | -| Forge | mod jar | 26.2 | Server worldgen + client HUD; requires Forge 65.0.0+ | -| NeoForge | mod jar | 26.2 | Server worldgen + client HUD; requires NeoForge 26.2+ | +| Forge | mod jar | 26.2 | Server worldgen + client HUD; requires Forge 65.0.4+ | +| NeoForge | mod jar | 26.2 | Server worldgen + client HUD; requires NeoForge 26.2.0.12-beta+ | Java 25 is required on every platform. diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 161217e07..ca589913b 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -1,14 +1,17 @@ package art.arcane.iris.core.nms.v26_2_R1; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import com.mojang.serialization.MapCodec; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.RNG; import net.minecraft.core.Holder; +import net.minecraft.core.QuartPos; import net.minecraft.core.Registry; import net.minecraft.core.RegistryAccess; import net.minecraft.core.registries.Registries; @@ -26,12 +29,14 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.IdentityHashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.stream.Stream; public class CustomBiomeSource extends BiomeSource { @@ -42,11 +47,13 @@ public class CustomBiomeSource extends BiomeSource { private final Registry biomeCustomRegistry; private final Registry biomeRegistry; private final AtomicCache registryAccess = new AtomicCache<>(); - private final KMap> customBiomes; - private final Map> vanillaSpawnBiomes; private final Holder fallbackBiome; private final ConcurrentHashMap> noiseBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> surfaceStructureBiomeCache = new ConcurrentHashMap<>(); + private volatile KMap> customBiomes; + private volatile Map> vanillaSpawnBiomes; + private volatile IrisDimension cacheDimension; public CustomBiomeSource(long seed, Engine engine, World world) { this.engine = engine; @@ -56,20 +63,19 @@ public class CustomBiomeSource extends BiomeSource { this.fallbackBiome = resolveFallbackBiome(this.biomeRegistry, this.biomeCustomRegistry); this.customBiomes = fillCustomBiomes(this.biomeCustomRegistry, engine, this.fallbackBiome); this.vanillaSpawnBiomes = fillVanillaSpawnBiomes(this.biomeCustomRegistry, this.biomeRegistry, engine); + this.cacheDimension = engine.getDimension(); } private static List> getAllBiomes(Registry customRegistry, Registry registry, Engine engine, Holder fallback) { LinkedHashSet> biomes = new LinkedHashSet<>(); - if (fallback != null) { - biomes.add(fallback); - } + boolean resolutionFailed = false; for (IrisBiome i : engine.getAllBiomes()) { Holder vanillaHolder = NMSBinding.biomeToBiomeBase(registry, i.getVanillaDerivative()); if (vanillaHolder != null) { biomes.add(vanillaHolder); - } else if (!i.isCustom() && fallback != null) { - biomes.add(fallback); + } else if (!i.isCustom()) { + resolutionFailed = true; } if (i.isCustom()) { @@ -77,13 +83,17 @@ public class CustomBiomeSource extends BiomeSource { Holder customHolder = resolveCustomBiomeHolder(customRegistry, engine, j.getId()); if (customHolder != null) { biomes.add(customHolder); - } else if (fallback != null) { - biomes.add(fallback); + } else { + resolutionFailed = true; } } } } + if ((resolutionFailed || biomes.isEmpty()) && fallback != null) { + biomes.add(fallback); + } + return new ArrayList<>(biomes); } @@ -135,12 +145,21 @@ public class CustomBiomeSource extends BiomeSource { @Override protected Stream> collectPossibleBiomes() { - return getAllBiomes( - ((RegistryAccess) getFor(RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())) - .lookup(Registries.BIOME).orElse(null), - ((CraftWorld) engine.getWorld().realWorld()).getHandle().registryAccess().lookup(Registries.BIOME).orElse(null), - engine, - fallbackBiome).stream(); + return possibleStructureBiomes().stream(); + } + + Set> possibleStructureBiomes() { + ensureCachesCurrent(); + World world = BukkitWorldBinding.world(engine.getWorld()); + if (world == null) { + throw new IllegalStateException("Iris biome source has no bound Bukkit world"); + } + Registry customRegistry = ((RegistryAccess) getFor( + RegistryAccess.Frozen.class, ((CraftServer) Bukkit.getServer()).getHandle().getServer())) + .lookup(Registries.BIOME).orElse(null); + Registry worldRegistry = ((CraftWorld) world).getHandle().registryAccess() + .lookup(Registries.BIOME).orElse(null); + return Set.copyOf(getAllBiomes(customRegistry, worldRegistry, engine, fallbackBiome)); } private KMap> fillCustomBiomes(Registry customRegistry, Engine engine, Holder fallback) { @@ -194,6 +213,7 @@ public class CustomBiomeSource extends BiomeSource { } Holder getVanillaSpawnBiome(Holder biome) { + ensureCachesCurrent(); if (biome == null) { return null; } @@ -211,6 +231,11 @@ public class CustomBiomeSource extends BiomeSource { @Override public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { + ensureCachesCurrent(); + if (isGuaranteedSurfaceBiome(y)) { + return getSurfaceStructureBiomeHolder(x, z); + } + long cacheKey = packNoiseKey(x, y, z); Holder cachedHolder = structureBiomeCache.get(cacheKey); if (cachedHolder != null) { @@ -230,7 +255,69 @@ public class CustomBiomeSource extends BiomeSource { return resolvedHolder; } + @Override + public Set> getBiomesWithin(int x, int y, int z, int radius, Climate.Sampler sampler) { + ensureCachesCurrent(); + int minQuartY = QuartPos.fromBlock(y - radius); + boolean monumentQuery = radius == 29 + && y == engine.getMinHeight() + engine.getDimension().getFluidHeight(); + if (!monumentQuery && !isGuaranteedSurfaceBiome(minQuartY)) { + return super.getBiomesWithin(x, y, z, radius, sampler); + } + int minQuartX = QuartPos.fromBlock(x - radius); + int maxQuartX = QuartPos.fromBlock(x + radius); + int minQuartZ = QuartPos.fromBlock(z - radius); + int maxQuartZ = QuartPos.fromBlock(z + radius); + int columns = (maxQuartX - minQuartX + 1) * (maxQuartZ - minQuartZ + 1); + Set> biomes = new HashSet<>(columns); + for (int quartZ = minQuartZ; quartZ <= maxQuartZ; quartZ++) { + for (int quartX = minQuartX; quartX <= maxQuartX; quartX++) { + biomes.add(getSurfaceStructureBiomeHolder(quartX, quartZ)); + } + } + return biomes; + } + + private Holder getSurfaceStructureBiomeHolder(int x, int z) { + long columnKey = packColumnKey(x, z); + Holder surfaceHolder = surfaceStructureBiomeCache.get(columnKey); + if (surfaceHolder != null) { + return surfaceHolder; + } + Holder resolvedSurfaceHolder = resolveSurfaceStructureBiomeHolder(x, z); + Holder existingSurfaceHolder = surfaceStructureBiomeCache.putIfAbsent(columnKey, resolvedSurfaceHolder); + if (existingSurfaceHolder != null) { + return existingSurfaceHolder; + } + if (surfaceStructureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + surfaceStructureBiomeCache.clear(); + } + return resolvedSurfaceHolder; + } + + private boolean isGuaranteedSurfaceBiome(int quartY) { + if (engine == null || engine.isClosed() || engine.getComplex() == null) { + return false; + } + int worldMinHeight = engine.getWorld().minHeight(); + int internalY = (quartY << 2) - worldMinHeight; + int caveSwitchInternalY = Math.max(-8 - worldMinHeight, 40); + return internalY > caveSwitchInternalY; + } + + private Holder resolveSurfaceStructureBiomeHolder(int x, int z) { + int blockX = x << 2; + int blockZ = z << 2; + IrisBiome irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + if (irisBiome == null) { + return getFallbackBiome(); + } + Holder holder = NMSBinding.biomeToBiomeBase(biomeRegistry, irisBiome.getVanillaDerivative()); + return holder == null ? getFallbackBiome() : holder; + } + public Holder getVisibleNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { + ensureCachesCurrent(); long cacheKey = packNoiseKey(x, y, z); Holder cachedHolder = noiseBiomeCache.get(cacheKey); if (cachedHolder != null) { @@ -250,6 +337,28 @@ public class CustomBiomeSource extends BiomeSource { return resolvedHolder; } + private void ensureCachesCurrent() { + IrisDimension dimension = engine.getDimension(); + if (cacheDimension == dimension) { + return; + } + synchronized (this) { + if (cacheDimension == dimension) { + return; + } + KMap> refreshedCustomBiomes = fillCustomBiomes( + biomeCustomRegistry, engine, fallbackBiome); + Map> refreshedSpawnBiomes = fillVanillaSpawnBiomes( + biomeCustomRegistry, biomeRegistry, engine); + noiseBiomeCache.clear(); + structureBiomeCache.clear(); + surfaceStructureBiomeCache.clear(); + customBiomes = refreshedCustomBiomes; + vanillaSpawnBiomes = refreshedSpawnBiomes; + cacheDimension = dimension; + } + } + private Holder resolveStructureBiomeHolder(int x, int y, int z) { BiomeResolution resolution = resolveBiomeResolution(x, y, z); if (resolution == null) { @@ -315,14 +424,19 @@ public class CustomBiomeSource extends BiomeSource { int blockY = y << 2; int worldMinHeight = engine.getWorld().minHeight(); int internalY = blockY - worldMinHeight; - int surfaceInternalY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue(); int caveSwitchInternalY = Math.max(-8 - worldMinHeight, 40); boolean deepUnderground = internalY <= caveSwitchInternalY; - boolean belowSurface = internalY <= surfaceInternalY - 8; - boolean underground = deepUnderground && belowSurface; - IrisBiome irisBiome = underground - ? engine.getCaveBiome(blockX, internalY, blockZ) - : engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + boolean underground = false; + IrisBiome irisBiome; + if (deepUnderground) { + int surfaceInternalY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue(); + underground = internalY <= surfaceInternalY - 8; + irisBiome = underground + ? engine.getCaveBiome(blockX, internalY, blockZ) + : engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + } else { + irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + } if (irisBiome == null && underground) { irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); } @@ -357,6 +471,10 @@ public class CustomBiomeSource extends BiomeSource { | ((long) y & 4095L); } + private static long packColumnKey(int x, int z) { + return ((long) x << 32) ^ ((long) z & 4294967295L); + } + private static Holder resolveCustomBiomeHolder(Registry customRegistry, Engine engine, String customBiomeId) { if (customRegistry == null || engine == null || customBiomeId == null || customBiomeId.isBlank()) { return null; diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java index 3fe3adcdd..ccd21deb7 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGenerator.java @@ -1,5 +1,20 @@ package art.arcane.iris.core.nms.v26_2_R1; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisImportedStructureControl; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisVanillaStructureStiltSettings; +import art.arcane.iris.nativegen.NativeStructurePostProcessor; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.IrisCustomData; +import art.arcane.iris.util.common.reflect.WrappedField; +import art.arcane.iris.util.common.reflect.WrappedReturningMethod; +import art.arcane.volmlib.util.math.RNG; +import com.mojang.datafixers.util.Pair; +import com.mojang.serialization.MapCodec; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.HolderLookup; @@ -7,6 +22,13 @@ import net.minecraft.core.HolderSet; import net.minecraft.core.Registry; import net.minecraft.core.RegistryAccess; import net.minecraft.core.SectionPos; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.WorldGenRegion; +import net.minecraft.util.random.Weighted; +import net.minecraft.util.random.WeightedList; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; import net.minecraft.world.level.LevelHeightAccessor; @@ -23,26 +45,6 @@ import net.minecraft.world.level.levelgen.RandomState; import net.minecraft.world.level.levelgen.RandomSupport; import net.minecraft.world.level.levelgen.WorldgenRandom; import net.minecraft.world.level.levelgen.XoroshiroRandomSource; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import com.mojang.datafixers.util.Pair; -import com.mojang.serialization.MapCodec; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.object.IrisImportedStructureControl; -import art.arcane.iris.util.common.reflect.WrappedField; -import art.arcane.iris.util.common.reflect.WrappedReturningMethod; -import net.minecraft.resources.Identifier; -import net.minecraft.resources.ResourceKey; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.WorldGenRegion; -import net.minecraft.util.random.Weighted; -import net.minecraft.util.random.WeightedList; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MobCategory; import net.minecraft.world.level.block.Blocks; @@ -52,35 +54,44 @@ import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.chunk.status.ChunkStatus; import net.minecraft.world.level.levelgen.blending.Blender; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.StructureSet; import net.minecraft.world.level.levelgen.structure.StructureStart; -import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; -import net.minecraft.core.registries.Registries; -import java.util.stream.Collectors; -import art.arcane.iris.engine.framework.IrisStructureLocator; import org.bukkit.World; import org.bukkit.craftbukkit.CraftWorld; +import org.bukkit.craftbukkit.block.data.CraftBlockData; import org.bukkit.craftbukkit.generator.CustomChunkGenerator; +import org.bukkit.block.data.BlockData; import org.spigotmc.SpigotWorldConfig; import javax.annotation.Nullable; import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; public class IrisChunkGenerator extends CustomChunkGenerator { + private static final String NATIVE_MONUMENT_KEY = "minecraft:monument"; private static final WrappedField BIOME_SOURCE; private static final WrappedReturningMethod SET_HEIGHT; private final ChunkGenerator delegate; private final Engine engine; private final CustomBiomeSource customBiomeSource; + private final int runtimeMinY; + private final int runtimeHeight; private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); - private volatile Set reachableStructureKeysCache; + private volatile ReachableStructureCache reachableStructureCache; + private volatile StructureStepCache structureStepCache; public IrisChunkGenerator(ChunkGenerator delegate, long seed, Engine engine, World world) { this(delegate, engine, world, new CustomBiomeSource(seed, engine, world)); @@ -91,6 +102,9 @@ public class IrisChunkGenerator extends CustomChunkGenerator { this.delegate = delegate; this.engine = engine; this.customBiomeSource = customBiomeSource; + ServerLevel level = ((CraftWorld) world).getHandle(); + this.runtimeMinY = level.getMinY(); + this.runtimeHeight = level.getHeight(); } @Override @@ -105,16 +119,20 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (id == null) { continue; } - int[] at = IrisStructureLocator.locate(engine, id.toString(), pos.getX(), pos.getZ(), Math.max(1, radius)); - if (at == null) { + IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( + engine, id.toString(), pos.getX(), pos.getZ(), Math.max(1, radius)); + if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { continue; } - long dx = (long) at[0] - pos.getX(); - long dz = (long) at[2] - pos.getZ(); + if (!result.found()) { + continue; + } + long dx = (long) result.originX() - pos.getX(); + long dz = (long) result.originZ() - pos.getZ(); long d = dx * dx + dz * dz; if (d < bestDist) { bestDist = d; - best = new BlockPos(at[0], at[1], at[2]); + best = new BlockPos(result.originX(), result.baseY(), result.originZ()); bestHolder = holder; } } @@ -122,7 +140,8 @@ public class IrisChunkGenerator extends CustomChunkGenerator { return Pair.of(best, bestHolder); } } catch (Throwable e) { - IrisLogging.reportError(e); + IrisLogging.reportError("Iris-placed structure lookup failed near " + + pos.getX() + ", " + pos.getZ() + ".", e); } if (!importedControl().active()) { return null; @@ -134,28 +153,30 @@ public class IrisChunkGenerator extends CustomChunkGenerator { try { return delegate.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); } catch (Throwable e) { - IrisLogging.error("Vanilla structure locate failed near " + pos.getX() + ", " + pos.getZ() + ": " + e); - IrisLogging.reportError(e); + IrisLogging.reportError("Vanilla structure locate failed near " + + pos.getX() + ", " + pos.getZ() + ".", e); return null; } } private HolderSet filterReachableStructures(ServerLevel level, HolderSet holders) { - Set reachable = reachableStructureKeysCache; - if (reachable == null) { - reachable = VanillaStructureBiomes.reachableStructureKeys(level, delegate.getBiomeSource()); - reachableStructureKeysCache = reachable; - } - if (reachable.isEmpty()) { - return holders; - } + Set reachable = reachableStructureKeys(level); + IrisImportedStructureControl control = importedControl(); Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); - List> kept = new ArrayList<>(); + List> kept = new ArrayList<>(holders.size()); for (Holder holder : holders) { Object id = registry.getKey(holder.value()); - if (id != null && reachable.contains(id.toString())) { - kept.add(holder); + if (id == null) { + continue; } + String key = id.toString(); + IrisNativeStructureDecision decision = control.resolve( + key, NativeStructurePostProcessor.isUndergroundStep(holder.value().step())); + if (NATIVE_MONUMENT_KEY.equals(key) || !decision.generate() + || IrisStructureLocator.suppressesVanilla(engine, key) || !reachable.contains(key)) { + continue; + } + kept.add(holder); } if (kept.size() == holders.size()) { return holders; @@ -163,6 +184,30 @@ public class IrisChunkGenerator extends CustomChunkGenerator { return HolderSet.direct(kept); } + private Set reachableStructureKeys(ServerLevel level) { + IrisDimension dimension = engine.getDimension(); + ReachableStructureCache cached = reachableStructureCache; + if (cached != null && cached.dimension() == dimension) { + return cached.keys(); + } + synchronized (this) { + cached = reachableStructureCache; + if (cached != null && cached.dimension() == dimension) { + return cached.keys(); + } + Set reachable; + try { + reachable = Set.copyOf(VanillaStructureBiomes.reachableStructureKeys(level, customBiomeSource)); + } catch (Throwable error) { + IrisLogging.reportError("Iris could not resolve native structure biome reachability; " + + "native locate is disabled until the next hotload.", error); + reachable = Set.of(); + } + reachableStructureCache = new ReachableStructureCache(dimension, reachable); + return reachable; + } + } + @Override protected MapCodec codec() { return MapCodec.unit(null); @@ -177,12 +222,12 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public int getMinY() { - return delegate.getMinY(); + return runtimeMinY; } @Override public int getSeaLevel() { - return delegate.getSeaLevel(); + return runtimeMinY + engine.getDimension().getFluidHeight(); } @Override @@ -190,7 +235,42 @@ public class IrisChunkGenerator extends CustomChunkGenerator { if (!importedControl().active()) { return; } + Map previousStarts = new HashMap<>(access.getAllStarts()); super.createStructures(registryAccess, structureState, structureManager, access, templateManager, levelKey); + adjustGeneratedStructures(registryAccess, access, previousStarts); + } + + private void adjustGeneratedStructures(RegistryAccess registryAccess, ChunkAccess access, Map previousStarts) { + Registry registry = registryAccess.lookupOrThrow(Registries.STRUCTURE); + IrisImportedStructureControl control = importedControl(); + for (Map.Entry entry : access.getAllStarts().entrySet()) { + Structure structure = entry.getKey(); + StructureStart start = entry.getValue(); + if (!start.isValid() || previousStarts.get(structure) == start) { + continue; + } + Identifier id = registry.getKey(structure); + String structureId = id == null ? null : id.toString(); + IrisNativeStructureDecision decision = control.resolve( + structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); + if (!decision.generate() || IrisStructureLocator.suppressesVanilla(engine, structureId)) { + access.setStartForStructure(structure, StructureStart.INVALID_START); + continue; + } + try { + NativeStructurePostProcessor.applyVerticalShift( + start, + decision.yShift(), + access.getMinY(), + access.getMinY() + access.getHeight()); + } catch (RuntimeException error) { + access.setStartForStructure(structure, StructureStart.INVALID_START); + IrisLogging.reportError("Iris rejected native structure " + structureId + " in chunk " + + access.getPos().x() + "," + access.getPos().z() + + " because its vertical bounds are invalid.", error); + continue; + } + } } private IrisImportedStructureControl importedControl() { @@ -281,68 +361,119 @@ public class IrisChunkGenerator extends CustomChunkGenerator { SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY()); BlockPos origin = sectionPos.origin(); Registry registry = world.registryAccess().lookupOrThrow(Registries.STRUCTURE); - Map> byStep = registry.stream().collect(Collectors.groupingBy(s -> s.step().ordinal())); + List> byStep = structuresByStep(registry); WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed())); long decoSeed = random.setDecorationSeed(world.getSeed(), origin.getX(), origin.getZ()); BoundingBox area = writableArea(chunk); int steps = GenerationStep.Decoration.values().length; IrisImportedStructureControl control = importedControl(); + List placementGroups = new ArrayList<>(); + List vegetationTargets = new ArrayList<>(); for (int step = 0; step < steps; step++) { int index = 0; - for (Structure structure : byStep.getOrDefault(step, List.of())) { + for (Structure structure : byStep.get(step)) { Object id = registry.getKey(structure); String structureId = id == null ? null : id.toString(); - if (control.shouldGenerate(structureId) && !IrisStructureLocator.suppressesVanilla(engine, structureId)) { - random.setFeatureSeed(decoSeed, index, step); - int[] offset = control.resolveOffset(structureId, isUndergroundStep(structure.step())); - boolean shifted = offset[0] != 0 || offset[1] != 0 || offset[2] != 0; - WorldGenLevel target = shifted ? shiftedLevel(world, offset[0], offset[1], offset[2]) : world; - BoundingBox placeArea = shifted - ? new BoundingBox(area.minX() - offset[0], area.minY(), area.minZ() - offset[2], area.maxX() - offset[0], area.maxY(), area.maxZ() - offset[2]) - : area; + IrisNativeStructureDecision decision = control.resolve( + structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); + if (decision.generate() && !IrisStructureLocator.suppressesVanilla(engine, structureId)) { try { - structureManager.startsForStructure(sectionPos, structure) - .forEach(start -> start.placeInChunk(target, structureManager, this, random, placeArea, chunkPos)); + List starts = structureManager.startsForStructure(sectionPos, structure); + if (!starts.isEmpty()) { + List resolvedStarts = List.copyOf(starts); + placementGroups.add(new NativePlacementGroup( + structureId, decision, index, step, resolvedStarts)); + for (StructureStart start : resolvedStarts) { + vegetationTargets.add(new NativeStructurePostProcessor.VegetationTarget( + start, decision.clearVegetation())); + } + } } catch (Throwable e) { - IrisLogging.reportError(e); + IrisLogging.reportError("Iris failed to resolve native structure " + structureId + + " in chunk " + chunkPos.x() + "," + chunkPos.z() + ".", e); } } index++; } } + try { + NativeStructurePostProcessor.clearIntersectingVegetation( + world, chunk, area, vegetationTargets); + } catch (Throwable e) { + IrisLogging.reportError("Iris failed to clear vegetation from native structures in chunk " + + chunkPos.x() + "," + chunkPos.z() + ".", e); + } + for (NativePlacementGroup group : placementGroups) { + random.setFeatureSeed(decoSeed, group.featureIndex(), group.step()); + try { + for (StructureStart start : group.starts()) { + placeVanillaStructure(world, structureManager, random, area, chunkPos, + group.structureId(), start, group.decision()); + } + } catch (Throwable e) { + IrisLogging.reportError("Iris failed to place native structure " + group.structureId() + + " in chunk " + chunkPos.x() + "," + chunkPos.z() + ".", e); + } + } } - private static boolean isUndergroundStep(GenerationStep.Decoration step) { - return step == GenerationStep.Decoration.UNDERGROUND_STRUCTURES - || step == GenerationStep.Decoration.STRONGHOLDS; + private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, WorldgenRandom random, + BoundingBox area, ChunkPos chunkPos, String structureId, StructureStart start, + IrisNativeStructureDecision decision) { + NativeStructurePostProcessor.place(world, structureManager, this, random, area, chunkPos, + structureId, start, decision, this::resolveStiltBlock); } - private WorldGenLevel shiftedLevel(WorldGenLevel world, int dx, int dy, int dz) { - return (WorldGenLevel) Proxy.newProxyInstance( - WorldGenLevel.class.getClassLoader(), - new Class[]{WorldGenLevel.class}, - (proxy, method, args) -> { - if (args != null) { - for (int i = 0; i < args.length; i++) { - if (args[i] instanceof BlockPos bp) { - args[i] = new BlockPos(bp.getX() + dx, bp.getY() + dy, bp.getZ() + dz); - } - } - } - try { - return method.invoke(world, args); - } catch (InvocationTargetException e) { - throw e.getCause(); - } - }); + private List> structuresByStep(Registry registry) { + StructureStepCache cached = structureStepCache; + if (cached != null && cached.registry() == registry) { + return cached.structures(); + } + synchronized (this) { + cached = structureStepCache; + if (cached != null && cached.registry() == registry) { + return cached.structures(); + } + int steps = GenerationStep.Decoration.values().length; + List> grouped = new ArrayList<>(steps); + for (int step = 0; step < steps; step++) { + grouped.add(new ArrayList<>()); + } + for (Structure structure : registry) { + grouped.get(structure.step().ordinal()).add(structure); + } + for (int step = 0; step < steps; step++) { + grouped.set(step, List.copyOf(grouped.get(step))); + } + List> resolved = List.copyOf(grouped); + structureStepCache = new StructureStepCache(registry, resolved); + return resolved; + } + } + + private BlockState resolveStiltBlock(IrisVanillaStructureStiltSettings settings, RNG rng, int x, int y, int z) { + if (settings.getPalette() == null) { + return Blocks.COBBLESTONE.defaultBlockState(); + } + PlatformBlockState platformState = settings.getPalette().get(rng, x, y, z, engine.getData()); + if (platformState == null || !(platformState.nativeHandle() instanceof BlockData blockData)) { + return Blocks.COBBLESTONE.defaultBlockState(); + } + if (blockData instanceof IrisCustomData customData) { + blockData = customData.getBase(); + } + if (blockData instanceof CraftBlockData craftBlockData) { + return craftBlockData.getState(); + } + return Blocks.COBBLESTONE.defaultBlockState(); } private BoundingBox writableArea(ChunkAccess chunk) { ChunkPos cp = chunk.getPos(); int i = cp.getMinBlockX(); int j = cp.getMinBlockZ(); - int minY = getMinY() + 1; - int maxY = getMinY() + engine.getHeight() - 1; + int minY = chunk.getMinY(); + int maxY = minY + chunk.getHeight() - 1; return new BoundingBox(i, minY, j, i + 15, maxY, j + 15); } @@ -403,7 +534,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { @Override public int getGenDepth() { - return delegate.getGenDepth(); + return runtimeHeight; } @Override @@ -447,7 +578,7 @@ public class IrisChunkGenerator extends CustomChunkGenerator { Method setHeight = null; for (Method method : Heightmap.class.getDeclaredMethods()) { - var types = method.getParameterTypes(); + Class[] types = method.getParameterTypes(); if (types.length != 3 || !Arrays.equals(types, new Class[]{int.class, int.class, int.class}) || !method.getReturnType().equals(void.class)) continue; @@ -475,4 +606,14 @@ public class IrisChunkGenerator extends CustomChunkGenerator { private record SpawnTableKey(Biome biome, MobCategory category) { } + + private record ReachableStructureCache(IrisDimension dimension, Set keys) { + } + + private record StructureStepCache(Registry registry, List> structures) { + } + + private record NativePlacementGroup(String structureId, IrisNativeStructureDecision decision, + int featureIndex, int step, List starts) { + } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java index f4195c3fb..ec82f3c53 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/NMSBinding.java @@ -13,6 +13,7 @@ import art.arcane.iris.core.nms.datapack.DataVersion; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.util.project.agent.Agent; import art.arcane.volmlib.util.collection.KList; @@ -71,6 +72,7 @@ import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.server.commands.data.BlockDataAccessor; +import net.minecraft.server.level.ChunkMap; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ThreadedLevelLightEngine; import net.minecraft.tags.TagKey; @@ -93,6 +95,7 @@ import net.minecraft.world.level.chunk.ProtoChunk; import net.minecraft.world.level.chunk.status.ChunkStatus; import net.minecraft.world.level.chunk.status.WorldGenContext; import net.minecraft.world.level.dimension.LevelStem; +import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.levelgen.FlatLevelSource; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.WorldgenRandom; @@ -1019,30 +1022,39 @@ public class NMSBinding implements INMSBinding { } public void inject(long seed, Engine engine, World world) throws NoSuchFieldException, IllegalAccessException { - var chunkMap = ((CraftWorld)world).getHandle().getChunkSource().chunkMap; - var worldGenContextField = getField(chunkMap.getClass(), WorldGenContext.class); + ServerLevel level = ((CraftWorld) world).getHandle(); + validateDimensionContract(engine, world, level); + + ChunkMap chunkMap = level.getChunkSource().chunkMap; + Field worldGenContextField = getField(chunkMap.getClass(), WorldGenContext.class); worldGenContextField.setAccessible(true); - var worldGenContext = (WorldGenContext) worldGenContextField.get(chunkMap); - var dimensionType = chunkMap.level.dimensionTypeRegistration().unwrapKey().orElse(null); - String expectedDimensionType = "iris:" + engine.getDimension().getDimensionTypeKey(); - if (dimensionType != null) { - String actualDimensionType = dimensionType.identifier().toString(); - if (!dimensionType.identifier().getNamespace().equals("iris")) { - IrisLogging.error("Loaded world %s with invalid dimension type! expected=%s actual=%s", world.getName(), expectedDimensionType, actualDimensionType); - } else { - IrisLogging.debug("Loaded world " + world.getName() + " with Iris dimension type " + actualDimensionType); - } - } else { - IrisLogging.error("Loaded world %s with unknown dimension type! expected=%s", world.getName(), expectedDimensionType); - } + WorldGenContext worldGenContext = (WorldGenContext) worldGenContextField.get(chunkMap); IrisChunkGenerator irisGenerator = new IrisChunkGenerator(worldGenContext.generator(), seed, engine, world); - var newContext = new WorldGenContext( + WorldGenContext newContext = new WorldGenContext( worldGenContext.level(), irisGenerator, worldGenContext.structureManager(), worldGenContext.lightEngine(), worldGenContext.mainThreadExecutor(), worldGenContext.unsavedListener()); worldGenContextField.set(chunkMap, newContext); - retargetStructureCheck(((CraftWorld) world).getHandle(), irisGenerator); + retargetStructureCheck(level, irisGenerator); + } + + private void validateDimensionContract(Engine engine, World world, ServerLevel level) { + DimensionType actualType = level.dimensionType(); + String actualTypeKey = level.dimensionTypeRegistration().unwrapKey() + .map(key -> key.identifier().toString()) + .orElse(""); + IrisDimensionRuntimeContract expected = IrisDimensionRuntimeContract.expected(engine.getDimension(), "iris"); + IrisDimensionRuntimeContract actual = new IrisDimensionRuntimeContract( + actualTypeKey, + actualType.minY(), + actualType.height(), + actualType.logicalHeight()); + String runtimeName = "Bukkit world '" + world.getName() + "'"; + expected.requireExact(runtimeName, actual); + expected.requireHeight(runtimeName, level.getMinY(), level.getHeight()); + expected.requireHeight(runtimeName, world.getMinHeight(), world.getMaxHeight() - world.getMinHeight()); + IrisLogging.debug("Loaded world " + world.getName() + " with exact Iris dimension type " + actualTypeKey); } private static void retargetStructureCheck(ServerLevel level, IrisChunkGenerator generator) throws NoSuchFieldException, IllegalAccessException { @@ -1208,33 +1220,38 @@ public class NMSBinding implements INMSBinding { @Override public boolean injectBukkit() { - if (injected.getAndSet(true)) - return true; - try { - IrisLogging.info("Injecting Bukkit"); - new AgentBuilder.Default() - .disableClassFormatChanges() - .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) - .type(ElementMatchers.is(ServerLevel.class)) - .transform((builder, typeDescription, classLoader, module, protectionDomain) -> - builder.visit(Advice.to(ServerLevelAdvice.class).on(ElementMatchers.isConstructor() - .and(ElementMatchers.takesArgument(0, MinecraftServer.class)) - .and(ElementMatchers.takesArgument(5, LevelStem.class))))) - .installOn(Agent.getInstrumentation()); - ByteBuddy buddy = new ByteBuddy(); - for (Class clazz : List.of(ChunkAccess.class, ProtoChunk.class)) { - buddy.redefine(clazz) - .visit(Advice.to(ChunkAccessAdvice.class).on(ElementMatchers.isMethod().and(ElementMatchers.takesArguments(ShortList.class, int.class)))) - .make() - .load(clazz.getClassLoader(), Agent.installed()); + synchronized (injected) { + if (injected.get()) { + return true; } + try { + IrisLogging.info("Injecting Bukkit"); + new AgentBuilder.Default() + .disableClassFormatChanges() + .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) + .type(ElementMatchers.is(ServerLevel.class)) + .transform((builder, typeDescription, classLoader, module, protectionDomain) -> + builder.visit(Advice.to(ServerLevelAdvice.class).on(ElementMatchers.isConstructor() + .and(ElementMatchers.takesArgument(0, MinecraftServer.class)) + .and(ElementMatchers.takesArgument(5, LevelStem.class)) + .and(ElementMatchers.takesArgument(12, ChunkGenerator.class))))) + .installOn(Agent.getInstrumentation()); + ByteBuddy buddy = new ByteBuddy(); + for (Class clazz : List.of(ChunkAccess.class, ProtoChunk.class)) { + buddy.redefine(clazz) + .visit(Advice.to(ChunkAccessAdvice.class).on(ElementMatchers.isMethod().and(ElementMatchers.takesArguments(ShortList.class, int.class)))) + .make() + .load(clazz.getClassLoader(), Agent.installed()); + } - return true; - } catch (Throwable e) { - IrisLogging.error(C.RED + "Failed to inject Bukkit"); - e.printStackTrace(); + injected.set(true); + return true; + } catch (Throwable e) { + IrisLogging.error(C.RED + "Failed to inject Bukkit"); + e.printStackTrace(); + return false; + } } - return false; } @Override @@ -1304,7 +1321,8 @@ public class NMSBinding implements INMSBinding { static void enter( @Advice.Argument(0) MinecraftServer server, @Advice.Argument(4) ResourceKey dimensionKey, - @Advice.Argument(value = 5, readOnly = false) LevelStem levelStem + @Advice.Argument(value = 5, readOnly = false) LevelStem levelStem, + @Advice.Argument(12) ChunkGenerator constructorGenerator ) { if (dimensionKey == null) return; @@ -1315,18 +1333,19 @@ public class NMSBinding implements INMSBinding { return; } - Object generator = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, Bukkit.getPluginManager().getPlugin("Iris") - .getClass() - .getClassLoader()) - .getDeclaredMethod("consumeStemGenerator", String.class) - .invoke(null, levelId); + ClassLoader pluginClassLoader = Bukkit.getPluginManager().getPlugin("Iris").getClass().getClassLoader(); + Class generatorType = Class.forName("art.arcane.iris.engine.platform.PlatformChunkGenerator", true, pluginClassLoader); + Object generator = generatorType.isInstance(constructorGenerator) ? constructorGenerator : null; + if (generator == null) { + generator = Class.forName("art.arcane.iris.core.lifecycle.WorldLifecycleStaging", true, pluginClassLoader) + .getDeclaredMethod("consumeStemGenerator", String.class) + .invoke(null, levelId); + } if (!(generator instanceof ChunkGenerator gen) || !gen.getClass().getPackageName().startsWith("art.arcane.iris")) { return; } - Object bindings = Class.forName("art.arcane.iris.core.nms.INMS", true, Bukkit.getPluginManager().getPlugin("Iris") - .getClass() - .getClassLoader()) + Object bindings = Class.forName("art.arcane.iris.core.nms.INMS", true, pluginClassLoader) .getDeclaredMethod("get") .invoke(null); if (bindings == null) { diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java index 23f2dabc1..2cc2a7452 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java @@ -25,7 +25,10 @@ final class VanillaStructureBiomes { if (source == null) { return keys; } - for (Holder holder : source.possibleBiomes()) { + Set> possibleBiomes = source instanceof CustomBiomeSource customBiomeSource + ? customBiomeSource.possibleStructureBiomes() + : source.possibleBiomes(); + for (Holder holder : possibleBiomes) { Optional> key = holder.unwrapKey(); if (key.isPresent()) { keys.add(key.get().identifier().toString()); diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java new file mode 100644 index 000000000..357026fe3 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/IrisChunkGeneratorMonumentLocateContractTest.java @@ -0,0 +1,71 @@ +package art.arcane.iris.core.nms.v26_2_R1; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; + +public class IrisChunkGeneratorMonumentLocateContractTest { + @Test + public void irisPlacementRunsBeforeExactNativeMonumentIsRemovedFromDelegateLookup() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nmsChunkGeneratorSource"))); + int findStart = source.indexOf("findNearestMapStructure(ServerLevel level"); + assertTrue(findStart >= 0); + int filterStart = source.indexOf("private HolderSet filterReachableStructures", findStart); + assertTrue(filterStart > findStart); + String findMethod = source.substring(findStart, filterStart); + int irisLocate = findMethod.indexOf("IrisStructureLocator.locate("); + int searchLimit = findMethod.indexOf("LocateStatus.SEARCH_LIMIT_REACHED", irisLocate); + int limitSkip = findMethod.indexOf("continue;", searchLimit); + int nativeFilter = findMethod.indexOf("filterReachableStructures(level, holders)"); + int delegateLocate = findMethod.indexOf("delegate.findNearestMapStructure(level, reachable"); + int reachabilityStart = source.indexOf("private Set reachableStructureKeys", filterStart); + assertTrue(reachabilityStart > filterStart); + String filterMethod = source.substring(filterStart, reachabilityStart); + int monumentReject = filterMethod.indexOf("if (NATIVE_MONUMENT_KEY.equals(key)"); + int rejectContinue = filterMethod.indexOf("continue;", monumentReject); + + assertTrue(irisLocate >= 0); + assertTrue(searchLimit > irisLocate); + assertTrue(limitSkip > searchLimit); + assertTrue(nativeFilter > limitSkip); + assertTrue(delegateLocate > nativeFilter); + assertTrue(monumentReject >= 0); + assertTrue(rejectContinue > monumentReject); + assertTrue(findMethod.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); + } + + @Test + public void stiltSupportUsesPlacedSolidOccupancyWithoutSnapshotDifferenceRequirement() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nativeStructurePostProcessorSource"))); + int placement = source.indexOf("start.placeInChunk(world, structureManager, generator"); + int stiltPlacement = source.indexOf("placeStilts(world, area, structureId, start", placement); + int occupancyCheck = source.indexOf("if (state.isSolid())", stiltPlacement); + + assertTrue(placement >= 0); + assertTrue(stiltPlacement > placement); + assertTrue(occupancyCheck > stiltPlacement); + assertFalse(source.contains("state.equals(")); + assertFalse(source.contains("snapshot.states")); + } + + @Test + public void verticalShiftMovesPiecesJigsawJunctionsAndCachedBoundsTogether() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.nativeStructurePostProcessorSource"))); + int method = source.indexOf("public static int applyVerticalShift"); + int clamp = source.indexOf("StructureVerticalBounds.clampOffset", method); + int pieceMove = source.indexOf("piece.move(0, offsetY, 0)", clamp); + int junctionMove = source.indexOf("junction.getSourceGroundY() + offsetY", pieceMove); + int boundsMove = source.indexOf("bounds.move(0, offsetY, 0)", junctionMove); + + assertTrue(method >= 0); + assertTrue(clamp > method); + assertTrue(pieceMove > clamp); + assertTrue(junctionMove > pieceMove); + assertTrue(boundsMove > junctionMove); + } +} diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java new file mode 100644 index 000000000..ce6a24ef0 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructurePostProcessorVegetationTest.java @@ -0,0 +1,38 @@ +package art.arcane.iris.nativegen; + +import net.minecraft.world.level.levelgen.GenerationStep; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeStructurePostProcessorVegetationTest { + @Test + public void surfaceStructuresClearTreeColumnsAutomatically() { + assertTrue(NativeStructurePostProcessor.shouldClearVegetationColumn(100, 100, false)); + assertTrue(NativeStructurePostProcessor.shouldClearVegetationColumn(116, 100, false)); + } + + @Test + public void buriedStructuresPreserveUnrelatedSurfaceForest() { + assertFalse(NativeStructurePostProcessor.shouldClearVegetationColumn(99, 100, false)); + assertFalse(NativeStructurePostProcessor.shouldClearVegetationColumn(20, 100, false)); + } + + @Test + public void explicitVegetationOptionForcesUnusualPlacementCleanup() { + assertTrue(NativeStructurePostProcessor.shouldClearVegetationColumn(20, 100, true)); + } + + @Test + public void allUndergroundGenerationStepsShareOneClassification() { + assertTrue(NativeStructurePostProcessor.isUndergroundStep( + GenerationStep.Decoration.UNDERGROUND_STRUCTURES)); + assertTrue(NativeStructurePostProcessor.isUndergroundStep( + GenerationStep.Decoration.UNDERGROUND_DECORATION)); + assertTrue(NativeStructurePostProcessor.isUndergroundStep( + GenerationStep.Decoration.STRONGHOLDS)); + assertFalse(NativeStructurePostProcessor.isUndergroundStep( + GenerationStep.Decoration.SURFACE_STRUCTURES)); + } +} diff --git a/adapters/bukkit/plugin/build.gradle b/adapters/bukkit/plugin/build.gradle index beedc1592..67b002ce2 100644 --- a/adapters/bukkit/plugin/build.gradle +++ b/adapters/bukkit/plugin/build.gradle @@ -3,11 +3,13 @@ def mainClass = 'art.arcane.iris.Iris' def bootstrapperClass = 'art.arcane.iris.IrisBootstrap' String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1') + .orElse('com.github.VolmitSoftware:VolmLib:4c38988b344792a79f925f57f8a675bc85fc1bed') .get() dependencies { compileOnly(project(':core')) + compileOnly(libs.lombok) + annotationProcessor(libs.lombok) compileOnly(volmLibCoordinate) { transitive = false } @@ -21,7 +23,6 @@ dependencies { transitive = false } compileOnly(libs.placeholderApi) - compileOnly(libs.multiverseCore) } tasks.named('processResources').configure { @@ -41,3 +42,8 @@ tasks.named('processResources').configure { tasks.named('jar', Jar).configure { archiveBaseName.set('iris-bukkit-plugin') } + +tasks.named('test').configure { + systemProperty('iris.commandFindSource', file('src/main/java/art/arcane/iris/core/commands/CommandFind.java').absolutePath) + systemProperty('iris.commandStructureSource', file('src/main/java/art/arcane/iris/core/commands/CommandStructure.java').absolutePath) +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java index f831d7203..62d25579e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/Iris.java @@ -18,8 +18,12 @@ package art.arcane.iris; +import art.arcane.iris.engine.IrisEngineEffects; import art.arcane.iris.engine.IrisWorldManager; +import art.arcane.iris.engine.framework.EngineComponentCleanup; +import art.arcane.iris.engine.framework.EngineEffectsProvider; +import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.engine.framework.EngineWorldManagerProvider; import art.arcane.iris.core.splash.IrisSplashComposer; import art.arcane.iris.core.IrisSettings; @@ -29,6 +33,7 @@ import art.arcane.iris.core.ServerConfigurator; import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.lifecycle.PaperLibBootstrap; import art.arcane.iris.core.lifecycle.WorldLifecycleService; +import art.arcane.iris.core.runtime.BukkitEnginePlatformHooks; import art.arcane.iris.core.runtime.TransientWorldCleanupSupport; import art.arcane.iris.core.runtime.WorldRuntimeControlService; import art.arcane.iris.core.lifecycle.WorldLifecycleStaging; @@ -57,6 +62,7 @@ import art.arcane.iris.engine.platform.BukkitChunkGenerator; import art.arcane.iris.core.safeguard.IrisSafeguard; import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.platform.bukkit.BukkitEnvironment; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; @@ -116,6 +122,7 @@ import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; import java.lang.annotation.Annotation; +import java.lang.reflect.Modifier; import java.net.URI; import java.util.ArrayList; import java.util.Collection; @@ -201,27 +208,35 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } } - public static KList initialize(String s, Class slicedClass) { + private static KList initialize(String s, Class requiredType) { JarScanner js = new JarScanner(instance.getJarFile(), s); - KList v = new KList<>(); + KList v = new KList<>(); J.attempt(js::scan); for (Class i : js.getClasses()) { - if (slicedClass == null || i.isAnnotationPresent(slicedClass)) { - try { - v.add(i.getDeclaredConstructor().newInstance()); - } catch (Throwable ex) { - Iris.warn("Skipped class initialization for %s: %s%s", - i.getName(), - ex.getClass().getSimpleName(), - ex.getMessage() == null ? "" : " - " + ex.getMessage()); - Iris.reportError(ex); - } + if (!isConcreteImplementation(i, requiredType)) { + continue; + } + try { + v.add(requiredType.cast(i.getDeclaredConstructor().newInstance())); + } catch (Throwable ex) { + Iris.warn("Skipped class initialization for %s: %s%s", + i.getName(), + ex.getClass().getSimpleName(), + ex.getMessage() == null ? "" : " - " + ex.getMessage()); + Iris.reportError(ex); } } return v; } + static boolean isConcreteImplementation(Class candidate, Class requiredType) { + int modifiers = candidate.getModifiers(); + return requiredType.isAssignableFrom(candidate) + && !candidate.isInterface() + && !Modifier.isAbstract(modifiers); + } + public static KList> getClasses(String s, Class slicedClass) { JarScanner js = new JarScanner(instance.getJarFile(), s); KList> v = new KList<>(); @@ -243,10 +258,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { return v; } - public static KList initialize(String s) { - return initialize(s, null); - } - public static void sq(Runnable r) { synchronized (syncJobs) { syncJobs.queue(r); @@ -599,9 +610,10 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { services = new KMap<>(); setupAudience(); Bindings.setupSentry(); - initialize("art.arcane.iris.core.service").forEach((i) -> { - services.put((Class) i.getClass(), (IrisService) i); - IrisServices.register(i.getClass(), i); + initialize("art.arcane.iris.core.service", IrisService.class).forEach((i) -> { + Class serviceType = i.getClass().asSubclass(IrisService.class); + services.put(serviceType, i); + IrisServices.register(serviceType, i); }); IrisServices.register(BlockEditAccess.class, services.get(EditSVC.class)); IrisServices.register(PreservationRegistry.class, services.get(PreservationSVC.class)); @@ -619,6 +631,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { tickets = new ChunkTickets(); linkMultiverseCore = new MultiverseCoreLink(); IrisServices.register(MultiverseCoreLink.class, linkMultiverseCore); + IrisServices.register(EngineComponentCleanup.class, (EngineComponentCleanup) BukkitPlatform::unregisterListener); + IrisServices.register(EngineEffectsProvider.class, (EngineEffectsProvider) IrisEngineEffects::new); + IrisServices.register(EnginePlatformHooks.class, new BukkitEnginePlatformHooks()); IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> { IrisWorldManager manager = new IrisWorldManager(engine); manager.startManager(); @@ -711,7 +726,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { Iris.info(C.LIGHT_PURPLE + "Preparing Spawn for " + s + "' using Iris:" + generator + "..."); WorldCreator c = WorldCreator.ofKey(worldKey) .generator(gen) - .environment(dim.getEnvironment()); + .environment(BukkitEnvironment.from(dim.getEnvironment())); Long stagedSeed = IrisWorlds.readBukkitWorldSeed(s); if (stagedSeed != null) { c.seed(stagedSeed); @@ -1067,7 +1082,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { } private void setupPapi() { - if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) { + if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { new IrisPapiExpansion().register(); } } @@ -1215,10 +1230,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware { NamespacedKey worldKey = IrisWorldStorage.keyFromLegacyName(worldName); IrisWorld w = IrisWorld.builder() - .key(worldKey) + .platformIdentity(worldKey.toString()) .name(worldName) .seed(1337) - .environment(dim.getEnvironment()) .worldFolder(IrisWorldStorage.dimensionRoot(worldKey)) .minHeight(dim.getMinHeight()) .maxHeight(dim.getMaxHeight()) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java index 101c8892a..4e370f922 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/IrisBootstrap.java @@ -2,46 +2,19 @@ package art.arcane.iris; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner; import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult; -import io.papermc.paper.datapack.Datapack; -import io.papermc.paper.datapack.DatapackRegistrar; -import io.papermc.paper.datapack.DiscoveredDatapack; import io.papermc.paper.plugin.bootstrap.BootstrapContext; import io.papermc.paper.plugin.bootstrap.PluginBootstrap; -import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; import java.io.IOException; import java.nio.file.Path; @SuppressWarnings("UnstableApiUsage") public final class IrisBootstrap implements PluginBootstrap { - static final String PACK_ID = "generated"; - @Override public void bootstrap(BootstrapContext context) { ProvisionResult provisioned = provision(context); Path datapackRoot = provisioned.datapackRoot(); context.getLogger().info("Iris startup datapack is {} at {}", provisioned.status(), datapackRoot); - context.getLifecycleManager().registerEventHandler(LifecycleEvents.DATAPACK_DISCOVERY, event -> { - try { - discoverPack(event.registrar(), datapackRoot); - } catch (IOException e) { - throw new IllegalStateException("Unable to discover the Iris startup datapack at " + datapackRoot, e); - } - }); - } - - static DiscoveredDatapack discoverPack(DatapackRegistrar registrar, Path datapackRoot) throws IOException { - DiscoveredDatapack datapack = registrar.discoverPack( - datapackRoot, - PACK_ID, - configurer -> configurer - .autoEnableOnServerStart(true) - .position(true, Datapack.Position.TOP) - ); - if (datapack == null) { - throw new IllegalStateException("Paper did not accept the Iris startup datapack at " + datapackRoot); - } - return datapack; } private static ProvisionResult provision(BootstrapContext context) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicy.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicy.java new file mode 100644 index 000000000..9cd4b132e --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicy.java @@ -0,0 +1,35 @@ +/* + * Iris is a World Generator for Minecraft Bukkit 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 . + */ + +package art.arcane.iris.core.commands; + +final class BukkitNativeStructureLocatePolicy { + private static final String MONUMENT_KEY = "minecraft:monument"; + private static final String UNAVAILABLE_MESSAGE = "Native monument locating is unavailable on Paper, Purpur, and Folia because a cold search can stall the server thread. The monument lookup was skipped; monument generation is unaffected."; + + private BukkitNativeStructureLocatePolicy() { + } + + static boolean isUnavailable(String structureKey) { + return structureKey != null && MONUMENT_KEY.equalsIgnoreCase(structureKey.trim()); + } + + static String unavailableMessage() { + return UNAVAILABLE_MESSAGE; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java index 10f6afb0a..80a43e382 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java @@ -42,6 +42,7 @@ import io.papermc.paper.registry.RegistryKey; import org.bukkit.Location; import org.bukkit.NamespacedKey; import org.bukkit.Registry; +import org.bukkit.World; import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; import org.bukkit.util.StructureSearchResult; @@ -98,7 +99,7 @@ public class CommandFind implements DirectorExecutor { EngineBukkitOps.gotoPOI(e, type, player(), teleport); } - @Director(description = "Find a structure (a vanilla key like minecraft:village_plains or minecraft:stronghold, or an imported iris structure key)") + @Director(description = "Find a structure (a vanilla key like minecraft:village_plains or minecraft:stronghold, or an imported iris structure key)", sync = true) public void structure( @Param(description = "The structure to look for (e.g. minecraft:village_plains, minecraft:stronghold, minecraft_ancient_city)", customHandler = StructureHandler.class) String structure @@ -117,7 +118,12 @@ public class CommandFind implements DirectorExecutor { } if (IrisStructureLocator.isPlaced(e, structure)) { - EngineBukkitOps.gotoStructure(e, structure, player(), true); + locateIrisStructure(e, structure, commandSender); + return; + } + + if (BukkitNativeStructureLocatePolicy.isUnavailable(structure)) { + commandSender.sendMessage(C.RED + BukkitNativeStructureLocatePolicy.unavailableMessage()); return; } @@ -127,6 +133,8 @@ public class CommandFind implements DirectorExecutor { return; } + World targetWorld = target.getWorld(); + Location origin = target.getLocation(); commandSender.sendMessage(C.GRAY + "Locating " + structure + "..."); J.s(() -> { try { @@ -140,32 +148,66 @@ public class CommandFind implements DirectorExecutor { } } if (match == null) { - commandSender.sendMessage(C.RED + "Unknown structure: " + structure); + sendStructureMessage(target, commandSender, C.RED + "Unknown structure: " + structure); return; } if (!StructureReachability.isReachable(e, structure)) { KList miss = StructureReachability.missingBiomeKeys(e, structure); - commandSender.sendMessage(C.YELLOW + structure + " cannot generate in this world (its required biomes are not produced by this pack" - + (miss.isEmpty() ? "" : ": needs " + String.join("/", miss)) + ")."); + sendStructureMessage(target, commandSender, + C.YELLOW + structure + " cannot generate in this world (its required biomes are not produced by this pack" + + (miss.isEmpty() ? "" : ": needs " + String.join("/", miss)) + ")."); return; } - StructureSearchResult result = target.getWorld().locateNearestStructure(target.getLocation(), match, 100, true); + StructureSearchResult result = targetWorld.locateNearestStructure(origin, match, 100, false); if (result == null || result.getLocation() == null) { - commandSender.sendMessage(C.YELLOW + "No " + structure + " found within range of you."); + sendStructureMessage(target, commandSender, C.YELLOW + "No " + structure + " found within range of you."); return; } - Location at = result.getLocation(); - int y = target.getWorld().getHighestBlockYAt(at.getBlockX(), at.getBlockZ()) + 2; - Location dest = new Location(target.getWorld(), at.getBlockX() + 0.5, y, at.getBlockZ() + 0.5); - BukkitPlatform.teleportAsync(target, dest); - commandSender.sendMessage(C.GREEN + "Teleported to " + structure + " @ " + at.getBlockX() + ", " + at.getBlockZ()); + prepareStructureTeleport(target, targetWorld, commandSender, structure, result.getLocation(), false); } catch (Throwable t) { - commandSender.sendMessage(C.RED + "Could not locate " + structure + ": " + t.getClass().getSimpleName()); + sendStructureMessage(target, commandSender, C.RED + "Could not locate " + structure + ": " + t.getClass().getSimpleName()); Iris.reportError("Could not locate structure '" + structure + "'.", t); } }); } + private void locateIrisStructure(Engine engine, String structure, VolmitSender commandSender) { + Player target = player(); + if (target == null) { + commandSender.sendMessage(C.GOLD + "Run this in-game to teleport to a structure."); + return; + } + World targetWorld = target.getWorld(); + Location origin = target.getLocation(); + int blockX = origin.getBlockX(); + int blockZ = origin.getBlockZ(); + commandSender.sendMessage(C.GRAY + "Locating " + structure + "..."); + J.a(() -> { + try { + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locate(engine, structure, blockX, blockZ, 1024); + if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + sendStructureMessage(target, commandSender, + C.YELLOW + "Unable to locate " + structure + + ": the density search safety limit was reached before the full 1024-chunk radius was searched."); + return; + } + if (!result.found()) { + sendStructureMessage(target, commandSender, + C.YELLOW + "No " + structure + " found within 1024 chunks of you."); + return; + } + Location destination = new Location( + targetWorld, result.originX(), result.baseY(), result.originZ()); + prepareStructureTeleport(target, targetWorld, commandSender, structure, destination, true); + } catch (Throwable t) { + sendStructureMessage(target, commandSender, + C.RED + "Could not locate " + structure + ": " + t.getClass().getSimpleName()); + Iris.reportError("Could not locate Iris-placed structure '" + structure + "'.", t); + } + }); + } + @Director(description = "Find an object") public void object( @Param(description = "The object to look for", customHandler = ObjectHandler.class) @@ -199,4 +241,44 @@ public class CommandFind implements DirectorExecutor { sender().sendMessage(C.RED + object + " is not configured in any region/biome object placements."); } + + private void prepareStructureTeleport(Player target, World world, VolmitSender commandSender, String structure, + Location at, boolean useLocatedY) { + int chunkX = at.getBlockX() >> 4; + int chunkZ = at.getBlockZ() >> 4; + BukkitPlatform.chunkAtAsync(world, chunkX, chunkZ, true).whenComplete((chunk, error) -> { + if (error != null) { + sendStructureMessage(target, commandSender, C.RED + "Could not load the destination for " + structure + "."); + Iris.reportError("Could not load structure destination '" + structure + "'.", error); + return; + } + boolean scheduled = J.runRegion(world, chunkX, chunkZ, + () -> teleportToStructure(target, world, commandSender, structure, at, useLocatedY)); + if (!scheduled) { + sendStructureMessage(target, commandSender, C.RED + "Could not schedule the destination lookup for " + structure + "."); + } + }); + } + + private void teleportToStructure(Player target, World world, VolmitSender commandSender, String structure, + Location at, boolean useLocatedY) { + try { + int y = useLocatedY + ? Math.max(world.getMinHeight() + 1, Math.min(world.getMaxHeight() - 1, at.getBlockY() + 2)) + : world.getHighestBlockYAt(at.getBlockX(), at.getBlockZ()) + 2; + Location destination = new Location(world, at.getBlockX() + 0.5, y, at.getBlockZ() + 0.5); + J.runEntity(target, () -> { + BukkitPlatform.teleportAsync(target, destination); + commandSender.sendMessage(C.GREEN + "Teleported to " + structure + " @ " + + at.getBlockX() + ", " + y + ", " + at.getBlockZ()); + }); + } catch (Throwable t) { + sendStructureMessage(target, commandSender, C.RED + "Could not prepare the destination for " + structure + "."); + Iris.reportError("Could not prepare structure destination '" + structure + "'.", t); + } + } + + private void sendStructureMessage(Player target, VolmitSender commandSender, String message) { + J.runEntity(target, () -> commandSender.sendMessage(message)); + } } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java index 794e433f1..aa8fc1a8f 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStructure.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.commands; +import art.arcane.iris.Iris; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.structure.BulkStructureImporter; import art.arcane.iris.core.structure.StructureCaptureImporter; @@ -41,7 +42,9 @@ import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.volmlib.util.math.RNG; +import art.arcane.iris.util.common.scheduling.J; import io.papermc.paper.registry.RegistryAccess; import io.papermc.paper.registry.RegistryKey; import org.bukkit.Bukkit; @@ -51,12 +54,12 @@ import org.bukkit.Registry; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; +import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; -import org.bukkit.util.StructureSearchResult; import java.io.File; -import java.util.Collections; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import java.util.Set; @@ -111,7 +114,7 @@ public class CommandStructure implements DirectorExecutor { sender().sendMessage(C.GRAY + "Captured " + report.imported() + " structures. Place them from a 'structures' list and regenerate chunks. Delete a structures/*.json to re-capture it."); } - @Director(description = "Locate every vanilla/datapack/iris structure to verify which are locatable in this world. Heavy synchronous search per structure - keep the radius modest. 'Not found' can mean rarer than the radius, a different dimension (nether/end structures never appear in the overworld), or a structure that generates but cannot be located; generation itself happens during chunk decoration, independent of locate.", aliases = {"locateall"}, origin = DirectorOrigin.BOTH, sync = true) + @Director(description = "Verify native structure eligibility and locate Iris-placed structures without running blocking native searches.", aliases = {"locateall"}, origin = DirectorOrigin.BOTH, sync = true) public void verify( @Param(description = "The dimension to verify", aliases = "dim") IrisDimension dimension, @@ -124,85 +127,150 @@ public class CommandStructure implements DirectorExecutor { return; } boolean senderIsPlayer = sender() != null && sender().isPlayer(); - Location center = (senderIsPlayer && player().getWorld() == world) ? player().getLocation() : world.getSpawnLocation(); + Location center = senderIsPlayer && player().getWorld() == world + ? player().getLocation() + : world.getSpawnLocation(); int searchRadius = Math.max(1, Math.min(radius, 1000)); - - sender().sendMessage(C.GREEN + "Verifying structures in " + C.WHITE + world.getName() + C.GREEN + " from " + center.getBlockX() + "," + center.getBlockZ() + " within " + searchRadius + " chunks..."); - - Engine engine = null; PlatformChunkGenerator access = IrisToolbelt.access(world); - if (access != null) { - engine = access.getEngine(); + Engine engine = access == null ? null : access.getEngine(); + if (engine == null) { + sender().sendMessage(C.RED + "The selected Iris world has no active generator engine."); + return; } - Set reachable = engine == null ? Collections.emptySet() : StructureReachability.reachableKeys(engine); - - int found = 0; - int missing = 0; - int unreachable = 0; - int irisPlaced = 0; - KList notFound = new KList<>(); - KList cannotGenerate = new KList<>(); + KList structureKeys = new KList<>(); Registry structureRegistry = RegistryAccess.registryAccess().getRegistry(RegistryKey.STRUCTURE); for (Structure structure : structureRegistry) { NamespacedKey key = structureRegistry.getKey(structure); - String keyName = key == null ? structure.toString() : key.toString(); - boolean isIrisPlaced = engine != null && IrisStructureLocator.suppressesVanilla(engine, keyName); - boolean isReachable = engine != null && reachable.contains(keyName.toLowerCase()); - if (engine != null && !isIrisPlaced && !isReachable) { - unreachable++; - KList miss = StructureReachability.missingBiomeKeys(engine, keyName); - cannotGenerate.add(keyName + (miss.isEmpty() ? "" : " (needs " + String.join("/", miss) + ")")); + if (key != null) { + structureKeys.add(key.toString()); + } + } + VolmitSender commandSender = sender(); + Player target = senderIsPlayer ? player() : null; + commandSender.sendMessage(C.GREEN + "Verifying structures in " + C.WHITE + world.getName() + + C.GREEN + " from " + center.getBlockX() + "," + center.getBlockZ() + + " within " + searchRadius + " chunks..."); + int centerX = center.getBlockX(); + int centerZ = center.getBlockZ(); + J.a(() -> runVerification(engine, structureKeys, centerX, centerZ, searchRadius, commandSender, target)); + } + + private void runVerification(Engine engine, KList structureKeys, int centerX, int centerZ, + int searchRadius, VolmitSender commandSender, Player target) { + KList messages = new KList<>(); + Set reachable; + try { + reachable = StructureReachability.reachableKeys(engine); + } catch (Throwable error) { + messages.add(C.RED + "Structure verification could not resolve native biome reachability."); + sendVerificationMessages(commandSender, target, messages); + Iris.reportError("Could not resolve native structure biome reachability for verification.", error); + return; + } + int located = 0; + int nativeEligible = 0; + int disabled = 0; + int unreachable = 0; + int unavailable = 0; + int searchLimited = 0; + int errors = 0; + for (String keyName : structureKeys) { + boolean irisPlaced = IrisStructureLocator.isPlaced(engine, keyName); + if (irisPlaced) { + try { + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locate(engine, keyName, centerX, centerZ, searchRadius); + if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + searchLimited++; + messages.add(C.YELLOW + "[iris-search-limit] " + C.WHITE + keyName + C.YELLOW + + ": unable to complete the density search before its safety limit was reached"); + continue; + } + if (!result.found()) { + messages.add(C.YELLOW + "[iris-not-found] " + C.WHITE + keyName); + continue; + } + located++; + messages.add(C.AQUA + "[iris] " + C.WHITE + keyName + C.GREEN + " @ " + + result.originX() + "," + result.baseY() + "," + result.originZ()); + } catch (Throwable error) { + errors++; + messages.add(C.RED + "[error] " + C.WHITE + keyName + C.RED + ": " + + error.getClass().getSimpleName()); + Iris.reportError("Could not verify Iris-placed structure '" + keyName + "'.", error); + } continue; } - if (isIrisPlaced) { - irisPlaced++; + if (!engine.getDimension().getImportedStructures().shouldGenerate(keyName)) { + disabled++; + messages.add(C.GRAY + "[disabled] " + C.WHITE + keyName); + continue; } - try { - StructureSearchResult result = world.locateNearestStructure(center, structure, searchRadius, true); - if (result != null && result.getLocation() != null) { - found++; - Location l = result.getLocation(); - sender().sendMessage((isIrisPlaced ? C.AQUA + "[iris] " : C.GREEN + "[ok] ") + C.WHITE + keyName + C.GREEN + " @ " + l.getBlockX() + "," + l.getBlockZ()); - } else { - missing++; - notFound.add(keyName); - } - } catch (Throwable e) { - missing++; - notFound.add(keyName + " (error: " + e.getClass().getSimpleName() + ")"); + if (!reachable.contains(keyName.toLowerCase(Locale.ROOT))) { + unreachable++; + KList missing = StructureReachability.missingBiomeKeys(engine, keyName); + messages.add(C.YELLOW + "[unreachable] " + C.WHITE + keyName + + (missing.isEmpty() ? "" : C.YELLOW + " needs " + String.join("/", missing))); + continue; } + if (BukkitNativeStructureLocatePolicy.isUnavailable(keyName)) { + unavailable++; + messages.add(C.RED + "[unavailable] " + C.WHITE + keyName + C.RED + ": " + + BukkitNativeStructureLocatePolicy.unavailableMessage()); + continue; + } + nativeEligible++; + messages.add(C.GREEN + "[native-eligible] " + C.WHITE + keyName); } + messages.add(C.GREEN + "Structure verify: " + C.WHITE + located + C.GREEN + " Iris placements located, " + + C.WHITE + nativeEligible + C.GREEN + " native structures eligible, " + + C.WHITE + disabled + C.GREEN + " disabled by policy, " + + C.WHITE + unreachable + C.GREEN + " biome-unreachable, " + + C.WHITE + unavailable + C.GREEN + " unavailable on this platform, " + + C.WHITE + searchLimited + C.GREEN + " density searches safety-limited, " + + C.WHITE + errors + C.GREEN + " errors. Native eligibility is checked without running blocking live locates."); + sendVerificationMessages(commandSender, target, messages); + } - sender().sendMessage(C.GREEN + "Structure verify: " + C.WHITE + found + C.GREEN + " located (" + irisPlaced + " iris-placed), " - + C.WHITE + unreachable + C.GREEN + " cannot generate here, " - + C.WHITE + missing + C.GREEN + " reachable-but-not-found within " + searchRadius + " chunks."); - if (!cannotGenerate.isEmpty()) { - sender().sendMessage(C.RED + "Cannot generate (required biomes absent from this pack): " + C.GRAY + String.join(", ", cannotGenerate)); - } - if (!notFound.isEmpty()) { - sender().sendMessage(C.YELLOW + "Reachable but not found (rarer than radius): " + C.GRAY + String.join(", ", notFound)); + private void sendVerificationMessages(VolmitSender commandSender, Player target, + KList messages) { + Runnable send = () -> { + for (String message : messages) { + commandSender.sendMessage(message); + } + }; + if (target != null) { + J.runEntity(target, send); + } else { + J.s(send); } } private World resolveIrisWorld(IrisDimension dimension) { if (sender() != null && sender().isPlayer() && IrisToolbelt.isIrisWorld(player().getWorld())) { - return player().getWorld(); + PlatformChunkGenerator playerGenerator = IrisToolbelt.access(player().getWorld()); + if (matchesDimension(playerGenerator, dimension.getLoadKey())) { + return player().getWorld(); + } } - World fallback = null; for (World w : Bukkit.getWorlds()) { if (!IrisToolbelt.isIrisWorld(w)) { continue; } - if (fallback == null) { - fallback = w; - } PlatformChunkGenerator gen = IrisToolbelt.access(w); - if (gen != null && gen.getEngine() != null && gen.getEngine().getDimension() != null - && dimension.getLoadKey().equals(gen.getEngine().getDimension().getLoadKey())) { + if (matchesDimension(gen, dimension.getLoadKey())) { return w; } } - return fallback; + return null; + } + + static boolean matchesDimension(PlatformChunkGenerator generator, String dimensionKey) { + return dimensionKey != null + && generator != null + && generator.getEngine() != null + && generator.getEngine().getDimension() != null + && dimensionKey.equals(generator.getEngine().getDimension().getLoadKey()); } @Director(description = "Resolve an iris structure's jigsaw graph and report piece count & bounds", origin = DirectorOrigin.BOTH) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 4e9cc48e1..a11192dc7 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.commands; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.Iris; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.IrisSettings; @@ -43,6 +44,7 @@ import art.arcane.iris.engine.object.IrisNoiseGenerator; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.object.NoiseStyle; import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.engine.platform.PlatformChunkGenerator; @@ -621,14 +623,14 @@ public class CommandStudio implements DirectorExecutor { } sender().sendMessage(C.GREEN + "Sending you to the studio world!"); - var player = player(); - BukkitPlatform.teleportAsync(player(), Iris.service(StudioSVC.class) + Player player = player(); + IrisWorld studioWorld = Iris.service(StudioSVC.class) .getActiveProject() .getActiveProvider() .getTarget() - .getWorld() - .spawnLocation() - ).thenRun(() -> player.setGameMode(GameMode.SPECTATOR)); + .getWorld(); + BukkitPlatform.teleportAsync(player, BukkitWorldBinding.spawnLocation(studioWorld)) + .thenRun(() -> player.setGameMode(GameMode.SPECTATOR)); } @Director(description = "Update your dimension projects VSCode workspace") diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java index f53fd46b9..126f4bce4 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.gui; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.render.RenderType; @@ -47,7 +48,7 @@ public final class BukkitVisionOverlay implements GuiOverlay { public List players() { IrisWorld world = engine.getWorld(); List markers = new ArrayList<>(); - for (Player player : world.getPlayers()) { + for (Player player : BukkitWorldBinding.players(world)) { markers.add(GuiMarker.player(player.getName(), player.getLocation().getX(), player.getLocation().getZ())); } return markers; @@ -58,7 +59,7 @@ public final class BukkitVisionOverlay implements GuiOverlay { J.s(() -> { IrisWorld world = engine.getWorld(); List markers = new ArrayList<>(); - for (LivingEntity entity : world.getEntitiesByClass(LivingEntity.class)) { + for (LivingEntity entity : BukkitWorldBinding.entities(world, LivingEntity.class)) { if (entity instanceof Player) { continue; } @@ -78,11 +79,11 @@ public final class BukkitVisionOverlay implements GuiOverlay { @Override public void teleport(double worldX, double worldZ) { IrisWorld world = engine.getWorld(); - if (!world.hasRealWorld()) { + if (!world.hasPlatformWorld()) { return; } J.s(() -> { - List players = world.getPlayers(); + List players = BukkitWorldBinding.players(world); if (players.isEmpty()) { return; } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java new file mode 100644 index 000000000..335da8336 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/runtime/BukkitEnginePlatformHooks.java @@ -0,0 +1,122 @@ +/* + * Iris is a World Generator for Minecraft Bukkit 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 . + */ + +package art.arcane.iris.core.runtime; + +import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.datapack.DatapackIngestService; +import art.arcane.iris.core.events.IrisEngineHotloadEvent; +import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.project.IrisProject; +import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.core.tools.WorldMaintenance; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.util.common.scheduling.J; +import org.bukkit.World; + +public final class BukkitEnginePlatformHooks implements EnginePlatformHooks { + @Override + public void refreshWorkspace(Engine engine) { + new IrisProject(engine.getData().getDataFolder()).updateWorkspace(); + } + + @Override + public void refreshDatapackWorkspace(Engine engine) { + DatapackIngestService.refreshWorkspace(engine.getData()); + } + + @Override + public void reloadDatapacks(Engine engine) { + synchronized (ServerConfigurator.class) { + ServerConfigurator.installDataPacks(false); + } + } + + @Override + public void fireHotloadEvent(Engine engine) { + IrisPlatforms.get().callEvent(new IrisEngineHotloadEvent(engine)); + } + + @Override + public void validateDimensionHotload(Engine engine, IrisDimension replacement) { + IrisDimensionRuntimeContract.requireHotloadCompatible( + "Bukkit Studio world '" + engine.getWorld().name() + "'", + engine.getDimension(), + replacement, + "iris"); + } + + @Override + public boolean isPregeneratorActive(Engine engine) { + IrisWorld world = engine.getWorld(); + if (world == null) { + return false; + } + PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); + return pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(world.identity()); + } + + @Override + public void shutdownPregenerator(Engine engine) { + PregeneratorJob.shutdownInstance(); + } + + @Override + public boolean shouldDisableChunkContextCache(Engine engine) { + IrisWorld world = engine.getWorld(); + if (!J.isFolia() || world == null || !world.hasPlatformWorld()) { + return false; + } + boolean maintenanceActive = WorldMaintenance.isWorldMaintenanceActive(world.identity()); + return EngineMode.shouldDisableContextCacheForMaintenance(maintenanceActive, isPregeneratorActive(engine)); + } + + @Override + public boolean shouldSkipMantleCleanup(Engine engine) { + IrisWorld world = engine.getWorld(); + return world != null + && WorldMaintenance.isWorldMaintenanceActive(world.identity()) + && !isPregeneratorActive(engine); + } + + @Override + public boolean shouldSkipMantleMarkerRead(Engine engine, int chunkX, int chunkZ) { + IrisWorld irisWorld = engine.getWorld(); + if (!J.isFolia() || irisWorld == null || !irisWorld.hasPlatformWorld()) { + return false; + } + World world = BukkitWorldBinding.world(irisWorld); + return world != null && J.isOwnedByCurrentRegion(world, chunkX, chunkZ); + } + + @Override + public boolean shouldBypassMantleStages(Engine engine) { + if (!J.isFolia() || !engine.getWorld().hasPlatformWorld()) { + return false; + } + World world = BukkitWorldBinding.world(engine.getWorld()); + return world != null && IrisToolbelt.isWorldMaintenanceBypassingMantleStages(world); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java index bb07abc84..a9ed4b7e5 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisEngineSVC.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.service; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import com.google.common.util.concurrent.AtomicDouble; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -9,6 +10,7 @@ import art.arcane.iris.core.runtime.GoldenHashScanner; import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.platform.PlatformChunkGenerator; +import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.format.C; import art.arcane.volmlib.util.format.Form; @@ -312,7 +314,7 @@ public class IrisEngineSVC implements IrisService { || engine.getMantle().getMantle().isClosed() || !shouldReduce(engine)) return; - World engineWorld = engine.getWorld().realWorld(); + World engineWorld = BukkitWorldBinding.world(engine.getWorld()); if (shouldSkipForMaintenance(engineWorld)) { return; } @@ -346,7 +348,7 @@ public class IrisEngineSVC implements IrisService { || engine.getMantle().getMantle().isClosed() || !shouldReduce(engine)) return; - World engineWorld = engine.getWorld().realWorld(); + World engineWorld = BukkitWorldBinding.world(engine.getWorld()); if (shouldSkipForMaintenance(engineWorld)) { return; } @@ -446,7 +448,7 @@ public class IrisEngineSVC implements IrisService { } PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - boolean pregeneratorTargetsWorld = pregeneratorJob != null && pregeneratorJob.targetsWorld(world); + boolean pregeneratorTargetsWorld = pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(WorldIdentity.serialize(world)); return shouldSkipMantleReductionForMaintenance(maintenanceActive, pregeneratorTargetsWorld); } } diff --git a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml index 9ef7c54ec..59bbaca95 100644 --- a/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/paper-plugin.yml @@ -8,3 +8,65 @@ load: STARTUP authors: [ cyberpwn, NextdoorPsycho, Vatuu ] website: volmit.com description: More than a Dimension! +dependencies: + server: + PlaceholderAPI: + load: BEFORE + required: false + join-classpath: true + CraftEngine: + load: BEFORE + required: false + join-classpath: true + Nexo: + load: BEFORE + required: false + join-classpath: true + ItemsAdder: + load: BEFORE + required: false + join-classpath: true + SCore: + load: BEFORE + required: false + join-classpath: true + ExecutableItems: + load: BEFORE + required: false + join-classpath: false + MythicLib: + load: BEFORE + required: false + join-classpath: true + MMOItems: + load: BEFORE + required: false + join-classpath: true + eco: + load: BEFORE + required: false + join-classpath: true + EcoItems: + load: BEFORE + required: false + join-classpath: true + MythicMobs: + load: BEFORE + required: false + join-classpath: true + MythicCrucible: + load: BEFORE + required: false + join-classpath: true + KGenerators: + load: BEFORE + required: false + join-classpath: true + Multiverse-Core: + load: AFTER + required: false + join-classpath: true + WorldEdit: + load: BEFORE + required: false + join-classpath: true diff --git a/adapters/bukkit/plugin/src/main/resources/plugin.yml b/adapters/bukkit/plugin/src/main/resources/plugin.yml index ec4201169..bebe26b6e 100644 --- a/adapters/bukkit/plugin/src/main/resources/plugin.yml +++ b/adapters/bukkit/plugin/src/main/resources/plugin.yml @@ -3,6 +3,22 @@ version: ${version} main: ${main} folia-supported: true load: STARTUP +softdepend: + - PlaceholderAPI + - CraftEngine + - Nexo + - ItemsAdder + - SCore + - ExecutableItems + - MythicLib + - MMOItems + - eco + - EcoItems + - MythicMobs + - MythicCrucible + - KGenerators + - WorldEdit +loadbefore: [ Multiverse-Core ] authors: [ cyberpwn, NextdoorPsycho, Vatuu ] website: volmit.com description: More than a Dimension! diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapTest.java deleted file mode 100644 index a2a23972a..000000000 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisBootstrapTest.java +++ /dev/null @@ -1,199 +0,0 @@ -package art.arcane.iris; - -import io.papermc.paper.datapack.Datapack; -import io.papermc.paper.datapack.DatapackRegistrar; -import io.papermc.paper.datapack.DatapackSource; -import io.papermc.paper.datapack.DiscoveredDatapack; -import io.papermc.paper.plugin.configuration.PluginMeta; -import net.kyori.adventure.text.Component; -import org.bukkit.FeatureFlag; -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.File; -import java.io.IOException; -import java.net.URI; -import java.nio.file.Path; -import java.util.Collections; -import java.util.Map; -import java.util.Set; -import java.util.function.Consumer; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -public class IrisBootstrapTest { - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Test - public void discoverPackAutoEnablesAtFixedTop() throws Exception { - File datapackDirectory = temporaryFolder.newFolder("datapack"); - RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.ACCEPT); - - DiscoveredDatapack discovered = IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath()); - - assertSame(registrar.discoveredDatapack, discovered); - assertEquals(datapackDirectory.toPath(), registrar.discoveredPath); - assertEquals(IrisBootstrap.PACK_ID, registrar.discoveredId); - assertTrue(registrar.configurer.autoEnableOnServerStart); - assertTrue(registrar.configurer.fixedPosition); - assertEquals(Datapack.Position.TOP, registrar.configurer.position); - } - - @Test - public void rejectedDiscoveryReportsDatapackPath() throws Exception { - File datapackDirectory = temporaryFolder.newFolder("datapack"); - RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.REJECT); - - IllegalStateException failure = assertThrows( - IllegalStateException.class, - () -> IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath()) - ); - - assertTrue(failure.getMessage().contains(datapackDirectory.toPath().toString())); - } - - @Test - public void discoveryIoFailurePropagates() throws Exception { - File datapackDirectory = temporaryFolder.newFolder("datapack"); - RecordingRegistrar registrar = new RecordingRegistrar(DiscoveryBehavior.FAIL); - - IOException failure = assertThrows( - IOException.class, - () -> IrisBootstrap.discoverPack(registrar, datapackDirectory.toPath()) - ); - - assertEquals("discovery failed", failure.getMessage()); - } - - private enum DiscoveryBehavior { - ACCEPT, - REJECT, - FAIL - } - - private static final class RecordingConfigurer implements DatapackRegistrar.Configurer { - private boolean autoEnableOnServerStart; - private boolean fixedPosition; - private Datapack.Position position; - - @Override - public DatapackRegistrar.Configurer title(Component title) { - return this; - } - - @Override - public DatapackRegistrar.Configurer autoEnableOnServerStart(boolean autoEnableOnServerStart) { - this.autoEnableOnServerStart = autoEnableOnServerStart; - return this; - } - - @Override - public DatapackRegistrar.Configurer position(boolean fixed, Datapack.Position position) { - this.fixedPosition = fixed; - this.position = position; - return this; - } - } - - private static final class RecordingRegistrar implements DatapackRegistrar { - private final DiscoveryBehavior behavior; - private final RecordingConfigurer configurer; - private final DiscoveredDatapack discoveredDatapack; - private Path discoveredPath; - private String discoveredId; - - private RecordingRegistrar(DiscoveryBehavior behavior) { - this.behavior = behavior; - this.configurer = new RecordingConfigurer(); - this.discoveredDatapack = new StubDiscoveredDatapack(); - } - - @Override - public boolean hasPackDiscovered(String name) { - return false; - } - - @Override - public DiscoveredDatapack getDiscoveredPack(String name) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean removeDiscoveredPack(String name) { - throw new UnsupportedOperationException(); - } - - @Override - public Map getDiscoveredPacks() { - return Collections.emptyMap(); - } - - @Override - public DiscoveredDatapack discoverPack(URI uri, String id, Consumer configurer) { - throw new UnsupportedOperationException(); - } - - @Override - public DiscoveredDatapack discoverPack(Path path, String id, Consumer configurer) throws IOException { - this.discoveredPath = path; - this.discoveredId = id; - if (behavior == DiscoveryBehavior.FAIL) { - throw new IOException("discovery failed"); - } - configurer.accept(this.configurer); - return behavior == DiscoveryBehavior.ACCEPT ? discoveredDatapack : null; - } - - @Override - public DiscoveredDatapack discoverPack(PluginMeta pluginMeta, URI uri, String id, Consumer configurer) { - throw new UnsupportedOperationException(); - } - - @Override - public DiscoveredDatapack discoverPack(PluginMeta pluginMeta, Path path, String id, Consumer configurer) { - throw new UnsupportedOperationException(); - } - } - - private static final class StubDiscoveredDatapack implements DiscoveredDatapack { - @Override - public String getName() { - return IrisBootstrap.PACK_ID; - } - - @Override - public Component getTitle() { - return Component.text(IrisBootstrap.PACK_ID); - } - - @Override - public Component getDescription() { - return Component.empty(); - } - - @Override - public boolean isRequired() { - return true; - } - - @Override - public Datapack.Compatibility getCompatibility() { - return Datapack.Compatibility.COMPATIBLE; - } - - @Override - public Set getRequiredFeatures() { - return Collections.emptySet(); - } - - @Override - public DatapackSource getSource() { - throw new UnsupportedOperationException(); - } - } -} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java index 1d7953523..ad7e0e0a3 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/IrisDiagnosticsTest.java @@ -1,6 +1,8 @@ package art.arcane.iris; import art.arcane.iris.core.splash.IrisSplashPackScanner; +import art.arcane.iris.core.service.CommandSVC; +import art.arcane.iris.util.common.plugin.IrisService; import org.junit.Test; import java.io.ByteArrayOutputStream; @@ -13,9 +15,18 @@ import java.util.Comparator; import java.util.List; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; public class IrisDiagnosticsTest { + @Test + public void serviceInitializationSkipsPackageHelperClasses() throws Exception { + Class registrar = Class.forName("art.arcane.iris.core.service.PaperCommandRegistrar"); + + assertFalse(Iris.isConcreteImplementation(registrar, IrisService.class)); + assertTrue(Iris.isConcreteImplementation(CommandSVC.class, IrisService.class)); + } + @Test public void reportErrorWithContextPrintsFullStacktrace() { ByteArrayOutputStream output = new ByteArrayOutputStream(); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java index 2f587e497..e38a6a973 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/PaperPluginMetadataTest.java @@ -18,6 +18,30 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class PaperPluginMetadataTest { + private static final List OPTIONAL_PLUGIN_IDS = List.of( + "PlaceholderAPI", + "CraftEngine", + "Nexo", + "ItemsAdder", + "SCore", + "ExecutableItems", + "MythicLib", + "MMOItems", + "eco", + "EcoItems", + "MythicMobs", + "MythicCrucible", + "KGenerators", + "Multiverse-Core", + "WorldEdit" + ); + private static final List JOINED_PLUGIN_IDS = OPTIONAL_PLUGIN_IDS.stream() + .filter(pluginId -> !"ExecutableItems".equals(pluginId) && !"Multiverse-Core".equals(pluginId)) + .toList(); + private static final List BUKKIT_SOFT_DEPEND_IDS = OPTIONAL_PLUGIN_IDS.stream() + .filter(pluginId -> !"Multiverse-Core".equals(pluginId)) + .toList(); + @Test public void paperMetadataDeclaresBootstrapAndFoliaSupport() throws Exception { String metadata; @@ -30,6 +54,11 @@ public class PaperPluginMetadataTest { assertTrue(metadata.contains("folia-supported: true")); assertTrue(metadata.contains("load: STARTUP")); assertFalse(metadata.contains("commands:")); + for (String pluginId : JOINED_PLUGIN_IDS) { + assertTrue(metadata.contains(optionalDependencyBlock(pluginId, "BEFORE", true))); + } + assertTrue(metadata.contains(optionalDependencyBlock("ExecutableItems", "BEFORE", false))); + assertTrue(metadata.contains(optionalDependencyBlock("Multiverse-Core", "AFTER", true))); } @Test @@ -45,6 +74,8 @@ public class PaperPluginMetadataTest { Map> commands = metadata.getCommands(); assertTrue(commands.containsKey("iris")); assertEquals(List.of("ir", "irs"), commands.get("iris").get("aliases")); + assertEquals(BUKKIT_SOFT_DEPEND_IDS, metadata.getSoftDepend()); + assertEquals(List.of("Multiverse-Core"), metadata.getLoadBeforePlugins()); } @Test @@ -56,4 +87,11 @@ public class PaperPluginMetadataTest { assertTrue(Files.isRegularFile(bukkitMetadata)); assertFalse(Files.readString(bukkitMetadata, StandardCharsets.UTF_8).contains("${")); } + + private static String optionalDependencyBlock(String pluginId, String loadOrder, boolean joinClasspath) { + return " " + pluginId + ":\n" + + " load: " + loadOrder + "\n" + + " required: false\n" + + " join-classpath: " + joinClasspath + "\n"; + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicyTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicyTest.java new file mode 100644 index 000000000..b02d831f6 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/BukkitNativeStructureLocatePolicyTest.java @@ -0,0 +1,32 @@ +package art.arcane.iris.core.commands; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class BukkitNativeStructureLocatePolicyTest { + @Test + public void nativeMonumentLocateIsUnavailable() { + assertTrue(BukkitNativeStructureLocatePolicy.isUnavailable("minecraft:monument")); + assertTrue(BukkitNativeStructureLocatePolicy.isUnavailable(" MINECRAFT:MONUMENT ")); + } + + @Test + public void unrelatedAndNonCanonicalKeysRemainAvailable() { + assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable(null)); + assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable("")); + assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable("minecraft:stronghold")); + assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable("minecraft:ocean_monument")); + assertFalse(BukkitNativeStructureLocatePolicy.isUnavailable("minecraft:ocean_monuments")); + } + + @Test + public void rejectionExplainsSafetyAndGenerationBehavior() { + String message = BukkitNativeStructureLocatePolicy.unavailableMessage().toLowerCase(); + + assertTrue(message.contains("unavailable")); + assertTrue(message.contains("stall the server thread")); + assertTrue(message.contains("generation is unaffected")); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java new file mode 100644 index 000000000..81e61efc2 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java @@ -0,0 +1,34 @@ +package art.arcane.iris.core.commands; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisStructureLocateCommandContractTest { + @Test + public void findReportsDensityLimitAndUsesExactLocatedOrigin() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource"))); + + assertTrue(source.contains("LocateStatus.SEARCH_LIMIT_REACHED")); + assertTrue(source.contains("the density search safety limit was reached")); + assertTrue(source.contains("result.originX(), result.baseY(), result.originZ()")); + assertFalse(source.contains("at[0] + 8")); + assertFalse(source.contains("at[2] + 8")); + } + + @Test + public void structureVerifyReportsDensityLimitAndUsesExactLocatedOrigin() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandStructureSource"))); + + assertTrue(source.contains("[iris-search-limit]")); + assertTrue(source.contains("density searches safety-limited")); + assertTrue(source.contains("result.originX() + \",\" + result.baseY() + \",\" + result.originZ()")); + assertFalse(source.contains("at[0] + 8")); + assertFalse(source.contains("at[2] + 8")); + } +} diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 9d414f466..19e5672c7 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -22,7 +22,7 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion plugins { id 'java' - id 'net.fabricmc.fabric-loom' version '1.17.11' + id 'net.fabricmc.fabric-loom' version '1.17.14' alias(libs.plugins.shadow) } @@ -31,7 +31,7 @@ file('../../gradle.properties').withInputStream { InputStream stream -> rootProp String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:4c38988b344792a79f925f57f8a675bc85fc1bed')) Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } @@ -49,6 +49,7 @@ sourceSets { main { java { srcDir '../modded-common/src/main/java' + srcDir '../minecraft-common/src/main/java' srcDir '../client-common/src/main/java' } resources { @@ -99,6 +100,8 @@ configurations.named('bundle').configure { configurations.compileClasspath.extendsFrom(configurations.devBundle) configurations.runtimeClasspath.extendsFrom(configurations.devBundle) +configurations.testCompileClasspath.extendsFrom(configurations.devBundle) +configurations.testRuntimeClasspath.extendsFrom(configurations.devBundle) configurations.create('jij') { transitive = true @@ -127,24 +130,28 @@ dependencies { compileOnly(libs.spigot) { transitive = false } + add('bundle', volmLibCoordinate) { + transitive = false + } + add('devBundle', volmLibCoordinate) { + transitive = false + } + add('bundle', libs.zip) { + transitive = false + } + add('devBundle', libs.zip) { + transitive = false + } List shared = [ "art.arcane:core:${irisVersion}".toString(), "art.arcane:spi:${irisVersion}".toString(), - volmLibCoordinate, libs.paralithic, libs.lru, - libs.kotlin.stdlib, - libs.kotlin.coroutines, - libs.commons.lang, - libs.commons.math3, libs.caffeine, - libs.lz4, - libs.zip, - libs.sentry, - libs.oshi, - libs.byteBuddy.core, - libs.byteBuddy.agent + libs.dom4j, + libs.jaxen, + libs.sentry ] shared.each { Object notation -> add('bundle', notation) @@ -157,6 +164,10 @@ tasks.named('compileJava', JavaCompile).configure { options.release.set(25) } +tasks.named('test').configure { + systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath) +} + loom { accessWidenerPath = file('src/main/resources/irisworldgen.accesswidener') runs { diff --git a/adapters/fabric/logs/2026-07-13-1.log.gz b/adapters/fabric/logs/2026-07-13-1.log.gz new file mode 100644 index 000000000..4b7fcf581 Binary files /dev/null and b/adapters/fabric/logs/2026-07-13-1.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-2.log.gz b/adapters/fabric/logs/2026-07-13-2.log.gz new file mode 100644 index 000000000..90473b0bf Binary files /dev/null and b/adapters/fabric/logs/2026-07-13-2.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-3.log.gz b/adapters/fabric/logs/2026-07-13-3.log.gz new file mode 100644 index 000000000..5dce6915b Binary files /dev/null and b/adapters/fabric/logs/2026-07-13-3.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-4.log.gz b/adapters/fabric/logs/2026-07-13-4.log.gz new file mode 100644 index 000000000..e065b084a Binary files /dev/null and b/adapters/fabric/logs/2026-07-13-4.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-5.log.gz b/adapters/fabric/logs/2026-07-13-5.log.gz new file mode 100644 index 000000000..302c0a9ca Binary files /dev/null and b/adapters/fabric/logs/2026-07-13-5.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-6.log.gz b/adapters/fabric/logs/2026-07-13-6.log.gz new file mode 100644 index 000000000..af4a81f38 Binary files /dev/null and b/adapters/fabric/logs/2026-07-13-6.log.gz differ diff --git a/adapters/fabric/logs/2026-07-13-7.log.gz b/adapters/fabric/logs/2026-07-13-7.log.gz new file mode 100644 index 000000000..d10c5927e Binary files /dev/null and b/adapters/fabric/logs/2026-07-13-7.log.gz differ diff --git a/adapters/fabric/logs/latest.log b/adapters/fabric/logs/latest.log new file mode 100644 index 000000000..d3476ba9b --- /dev/null +++ b/adapters/fabric/logs/latest.log @@ -0,0 +1,142 @@ +[21:25:27] [Test worker/INFO]: Iris registered custom content provider 'iris_deferred_test' +[21:25:27] [Test worker/ERROR]: [worldcheck] server stop request failed +java.lang.IllegalStateException: stop request failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:218) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:215) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[21:25:27] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed +java.lang.IllegalStateException: shutdown wait failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:244) + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:186) + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:242) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[21:25:27] [Test worker/ERROR]: [worldcheck] check failed +java.lang.IllegalStateException: check failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:204) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:166) + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:202) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java index ed4a17d3e..29b8252ec 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/IrisFabricBootstrap.java @@ -19,15 +19,18 @@ package art.arcane.iris.fabric; import art.arcane.iris.modded.IrisModdedChunkGenerator; +import art.arcane.iris.modded.ModdedBlockBreakHandler; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.command.IrisModdedCommands; import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.brigadier.CommandDispatcher; import net.fabricmc.api.ModInitializer; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; import net.fabricmc.fabric.api.event.player.AttackBlockCallback; +import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; import net.fabricmc.fabric.api.event.player.UseBlockCallback; import net.minecraft.commands.CommandBuildContext; import net.minecraft.commands.CommandSourceStack; @@ -38,10 +41,13 @@ import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; import net.minecraft.world.InteractionHand; import net.minecraft.world.InteractionResult; import net.minecraft.world.entity.player.Player; import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.BlockHitResult; public final class IrisFabricBootstrap implements ModInitializer { @@ -51,8 +57,16 @@ public final class IrisFabricBootstrap implements ModInitializer { () -> Registry.register(BuiltInRegistries.CHUNK_GENERATOR, Identifier.fromNamespaceAndPath("irisworldgen", "iris"), IrisModdedChunkGenerator.CODEC)); FabricProtocolNetworking.install(); ServerLifecycleEvents.SERVER_STARTING.register((MinecraftServer server) -> ModdedEngineBootstrap.start(server)); + ServerLifecycleEvents.SERVER_STARTED.register((MinecraftServer server) -> ModdedEngineBootstrap.serverStarted(server)); ServerLifecycleEvents.SERVER_STOPPING.register((MinecraftServer server) -> ModdedEngineBootstrap.stop()); + ServerLevelEvents.LOAD.register((MinecraftServer server, ServerLevel level) -> ModdedEngineBootstrap.levelLoaded(level)); CommandRegistrationCallback.EVENT.register((CommandDispatcher dispatcher, CommandBuildContext buildContext, Commands.CommandSelection selection) -> IrisModdedCommands.register(dispatcher)); + PlayerBlockBreakEvents.BEFORE.register((Level level, Player player, BlockPos pos, BlockState state, BlockEntity blockEntity) -> { + if (level instanceof ServerLevel serverLevel) { + ModdedBlockBreakHandler.prepare(serverLevel, pos, state); + } + return true; + }); AttackBlockCallback.EVENT.register((Player player, Level level, InteractionHand hand, BlockPos pos, Direction direction) -> ModdedWandService.attackBlock(player, level, hand, pos) ? InteractionResult.SUCCESS : InteractionResult.PASS); UseBlockCallback.EVENT.register((Player player, Level level, InteractionHand hand, BlockHitResult hit) -> diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockMixin.java b/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockMixin.java new file mode 100644 index 000000000..df0c3abf0 --- /dev/null +++ b/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/BlockMixin.java @@ -0,0 +1,57 @@ +/* + * 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 . + */ + +package art.arcane.iris.fabric.mixin; + +import art.arcane.iris.modded.ModdedBlockBreakHandler; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemInstance; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +import java.util.ArrayList; +import java.util.List; + +@Mixin(Block.class) +public class BlockMixin { + @Inject(method = "getDrops(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/entity/BlockEntity;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/world/item/ItemInstance;)Ljava/util/List;", at = @At("RETURN"), cancellable = true) + private static void iris$applyBlockDrops(BlockState state, ServerLevel level, BlockPos position, BlockEntity blockEntity, + Entity breaker, ItemInstance tool, CallbackInfoReturnable> info) { + if (!(breaker instanceof Player)) { + return; + } + ModdedBlockBreakHandler.Result result = ModdedBlockBreakHandler.completePrepared(level, position); + if (result == null) { + return; + } + List drops = result.replaceVanillaDrops() + ? new ArrayList<>() + : new ArrayList<>(info.getReturnValue()); + drops.addAll(result.drops()); + info.setReturnValue(drops); + } +} diff --git a/adapters/fabric/src/main/resources/fabric.mod.json b/adapters/fabric/src/main/resources/fabric.mod.json index ab7636226..2752c6c5a 100644 --- a/adapters/fabric/src/main/resources/fabric.mod.json +++ b/adapters/fabric/src/main/resources/fabric.mod.json @@ -11,7 +11,7 @@ "license": "GPL-3.0", "environment": "*", "accessWidener": "irisworldgen.accesswidener", - "mixins": ["irisworldgen.mixins.json"], + "mixins": ["irisworldgen.mixins.json", "irisworldgen.entity.mixins.json"], "entrypoints": { "main": ["art.arcane.iris.fabric.IrisFabricBootstrap"], "client": ["art.arcane.iris.fabric.IrisFabricClient"] diff --git a/adapters/fabric/src/main/resources/irisworldgen.mixins.json b/adapters/fabric/src/main/resources/irisworldgen.mixins.json index 86bc9e136..c2f379c1c 100644 --- a/adapters/fabric/src/main/resources/irisworldgen.mixins.json +++ b/adapters/fabric/src/main/resources/irisworldgen.mixins.json @@ -4,7 +4,7 @@ "package": "art.arcane.iris.fabric.mixin", "compatibilityLevel": "JAVA_25", "mixins": [ - "LivingEntityMixin", + "BlockMixin", "ServerPacksSourceMixin" ], "injectors": { diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index e088d28d9..14d2550df 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -22,7 +22,7 @@ import org.gradle.jvm.toolchain.JavaLanguageVersion plugins { id 'java' - id 'net.minecraftforge.gradle' version '7.0.27' + id 'net.minecraftforge.gradle' version '7.0.31' alias(libs.plugins.shadow) } @@ -30,8 +30,8 @@ Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) -String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.0.0')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1')) +String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.0.4')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:4c38988b344792a79f925f57f8a675bc85fc1bed')) Closure loaderDisplayVersion = { String loaderVersion -> String coordinatePrefix = "${minecraftVersion}-" if (loaderVersion.startsWith(coordinatePrefix)) { @@ -57,12 +57,18 @@ sourceSets { main { java { srcDir '../modded-common/src/main/java' + srcDir '../minecraft-common/src/main/java' srcDir '../client-common/src/main/java' } resources { srcDir '../modded-common/src/main/resources' } } + test { + java { + srcDir '../modded-common/src/test/java' + } + } } repositories { @@ -88,11 +94,6 @@ configurations { canBeConsumed = false canBeResolved = true } - commonsLangRaw { - canBeConsumed = false - canBeResolved = true - transitive = false - } } configurations.named('bundle').configure { @@ -116,8 +117,10 @@ configurations.named('runBundle').configure { configurations.compileClasspath.extendsFrom(configurations.devBundle) configurations.runtimeClasspath.extendsFrom(configurations.runBundle) +configurations.testCompileClasspath.extendsFrom(configurations.compileClasspath, configurations.devBundle) +configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath, configurations.runBundle) -['compileClasspath', 'runtimeClasspath'].each { String name -> +['compileClasspath', 'runtimeClasspath', 'testCompileClasspath', 'testRuntimeClasspath'].each { String name -> configurations.named(name).configure { resolutionStrategy.capabilitiesResolution.withCapability('org.lz4:lz4-java') { selectHighestVersion() @@ -127,48 +130,45 @@ configurations.runtimeClasspath.extendsFrom(configurations.runBundle) dependencies { implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}") + testImplementation('junit:junit:4.13.2') compileOnly('org.slf4j:slf4j-api:2.0.17') compileOnly(libs.spigot) { transitive = false } + add('bundle', volmLibCoordinate) { + transitive = false + } + add('devBundle', volmLibCoordinate) { + transitive = false + } + add('runBundle', volmLibCoordinate) { + transitive = false + } + add('bundle', libs.zip) { + transitive = false + } + add('devBundle', libs.zip) { + transitive = false + } + add('runBundle', libs.zip) { + transitive = false + } List shared = [ "art.arcane:core:${irisVersion}".toString(), "art.arcane:spi:${irisVersion}".toString(), - volmLibCoordinate, libs.paralithic, libs.lru, - libs.kotlin.stdlib, - libs.kotlin.coroutines, - libs.commons.math3, libs.caffeine, - libs.lz4, - libs.zip, - libs.sentry, - libs.oshi, - libs.byteBuddy.core, - libs.byteBuddy.agent + libs.dom4j, + libs.jaxen, + libs.sentry ] shared.each { Object notation -> add('bundle', notation) add('devBundle', notation) add('runBundle', notation) } - add('bundle', libs.commons.lang) - add('devBundle', libs.commons.lang) - add('commonsLangRaw', libs.commons.lang) -} - -TaskProvider sanitizedCommonsLang = tasks.register('sanitizedCommonsLang', Jar) { - archiveBaseName.set('commons-lang-sanitized') - destinationDirectory.set(layout.buildDirectory.dir('sanitized')) - from({ zipTree(configurations.named('commonsLangRaw').get().singleFile) }) - exclude('org/apache/commons/lang/enum/**') - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} - -dependencies { - add('runBundle', files(sanitizedCommonsLang)) } minecraft { @@ -193,7 +193,7 @@ minecraft { systemProperty('iris.worldcheck', worldCheck) } jvmArgs('-Xmx8G') - args('--nogui') + args('--mixin.config', 'irisworldgen.entity.mixins.json', '--nogui') } } } @@ -203,6 +203,10 @@ tasks.named('compileJava', JavaCompile).configure { options.release.set(25) } +tasks.named('test').configure { + systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath) +} + processResources { inputs.property('version', project.version) inputs.property('minecraftVersion', minecraftVersion) @@ -215,6 +219,9 @@ tasks.named('shadowJar', ShadowJar).configure { doFirst { delete(layout.buildDirectory.file("libs/Iris-${project.version}+mc${minecraftVersion}-forge.jar")) } + manifest { + attributes('MixinConfigs': 'irisworldgen.entity.mixins.json') + } archiveFileName.set(irisArtifactName('Forge', "${minecraftVersion}+${loaderDisplayVersion(forgeVersion)}")) configurations = [project.configurations.named('bundle').get()] from(sourceSets.main.output) diff --git a/adapters/forge/logs/2026-07-13-1.log.gz b/adapters/forge/logs/2026-07-13-1.log.gz new file mode 100644 index 000000000..fe00e5f13 Binary files /dev/null and b/adapters/forge/logs/2026-07-13-1.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-2.log.gz b/adapters/forge/logs/2026-07-13-2.log.gz new file mode 100644 index 000000000..78a0d89ea Binary files /dev/null and b/adapters/forge/logs/2026-07-13-2.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-3.log.gz b/adapters/forge/logs/2026-07-13-3.log.gz new file mode 100644 index 000000000..ab3a4c968 Binary files /dev/null and b/adapters/forge/logs/2026-07-13-3.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-4.log.gz b/adapters/forge/logs/2026-07-13-4.log.gz new file mode 100644 index 000000000..06ab7744d Binary files /dev/null and b/adapters/forge/logs/2026-07-13-4.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-5.log.gz b/adapters/forge/logs/2026-07-13-5.log.gz new file mode 100644 index 000000000..75f340a49 Binary files /dev/null and b/adapters/forge/logs/2026-07-13-5.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-6.log.gz b/adapters/forge/logs/2026-07-13-6.log.gz new file mode 100644 index 000000000..1adc77fdd Binary files /dev/null and b/adapters/forge/logs/2026-07-13-6.log.gz differ diff --git a/adapters/forge/logs/2026-07-13-7.log.gz b/adapters/forge/logs/2026-07-13-7.log.gz new file mode 100644 index 000000000..456446564 Binary files /dev/null and b/adapters/forge/logs/2026-07-13-7.log.gz differ diff --git a/adapters/forge/logs/debug-1.log.gz b/adapters/forge/logs/debug-1.log.gz new file mode 100644 index 000000000..c495e0d26 Binary files /dev/null and b/adapters/forge/logs/debug-1.log.gz differ diff --git a/adapters/forge/logs/debug-2.log.gz b/adapters/forge/logs/debug-2.log.gz new file mode 100644 index 000000000..6398dca63 Binary files /dev/null and b/adapters/forge/logs/debug-2.log.gz differ diff --git a/adapters/forge/logs/debug-3.log.gz b/adapters/forge/logs/debug-3.log.gz new file mode 100644 index 000000000..eb39f1229 Binary files /dev/null and b/adapters/forge/logs/debug-3.log.gz differ diff --git a/adapters/forge/logs/debug-4.log.gz b/adapters/forge/logs/debug-4.log.gz new file mode 100644 index 000000000..ecefbdaf3 Binary files /dev/null and b/adapters/forge/logs/debug-4.log.gz differ diff --git a/adapters/forge/logs/debug-5.log.gz b/adapters/forge/logs/debug-5.log.gz new file mode 100644 index 000000000..2be6f7877 Binary files /dev/null and b/adapters/forge/logs/debug-5.log.gz differ diff --git a/adapters/forge/logs/debug.log b/adapters/forge/logs/debug.log new file mode 100644 index 000000000..c3f67fff3 --- /dev/null +++ b/adapters/forge/logs/debug.log @@ -0,0 +1,145 @@ +[13Jul2026 21:25:26.811] [Test worker/DEBUG] [io.netty.util.internal.logging.InternalLoggerFactory/]: Using SLF4J as the default logging framework +[13Jul2026 21:25:26.813] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.level: simple +[13Jul2026 21:25:26.813] [Test worker/DEBUG] [io.netty.util.ResourceLeakDetector/]: -Dio.netty.leakDetection.targetRecords: 4 +[13Jul2026 21:25:28.681] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[13Jul2026 21:25:28.758] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +java.lang.IllegalStateException: stop request failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:218) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:215) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[13Jul2026 21:25:28.765] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +java.lang.IllegalStateException: shutdown wait failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:244) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:186) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:242) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[13Jul2026 21:25:28.769] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +java.lang.IllegalStateException: check failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:204) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:166) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:202) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] diff --git a/adapters/forge/logs/latest.log b/adapters/forge/logs/latest.log new file mode 100644 index 000000000..bb52a3294 --- /dev/null +++ b/adapters/forge/logs/latest.log @@ -0,0 +1,142 @@ +[13Jul2026 21:25:28.681] [Test worker/INFO] [Iris/]: Iris registered custom content provider 'iris_deferred_test' +[13Jul2026 21:25:28.758] [Test worker/ERROR] [Iris/]: [worldcheck] server stop request failed +java.lang.IllegalStateException: stop request failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:218) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:171) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:215) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[13Jul2026 21:25:28.765] [Test worker/ERROR] [Iris/]: [worldcheck] waiting for server shutdown failed +java.lang.IllegalStateException: shutdown wait failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:244) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:186) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:242) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] +[13Jul2026 21:25:28.769] [Test worker/ERROR] [Iris/]: [worldcheck] check failed +java.lang.IllegalStateException: check failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:204) ~[test/:?] + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:166) ~[main/:?] + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:202) ~[test/:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) ~[junit-4.13.2.jar:4.13.2] + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) ~[junit-4.13.2.jar:4.13.2] + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) ~[junit-4.13.2.jar:4.13.2] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) ~[?:?] + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) ~[?:?] + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) ~[?:?] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[?:?] + at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[?:?] + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) ~[?:?] + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) ~[?:?] + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) ~[?:?] + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) ~[?:?] + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) ~[?:?] + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) ~[?:?] + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) ~[?:?] + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) ~[?:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) [gradle-worker.jar:?] + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) [gradle-worker.jar:?] diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBlockLootModifier.java b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBlockLootModifier.java new file mode 100644 index 000000000..22c095df9 --- /dev/null +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBlockLootModifier.java @@ -0,0 +1,74 @@ +/* + * 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 . + */ + +package art.arcane.iris.forge; + +import art.arcane.iris.modded.ModdedBlockBreakHandler; +import com.mojang.serialization.MapCodec; +import com.mojang.serialization.codecs.RecordCodecBuilder; +import it.unimi.dsi.fastutil.objects.ObjectArrayList; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.loot.LootContext; +import net.minecraft.world.level.storage.loot.LootTable; +import net.minecraft.world.level.storage.loot.parameters.LootContextParams; +import net.minecraft.world.level.storage.loot.predicates.LootItemCondition; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.common.loot.IGlobalLootModifier; +import net.minecraftforge.common.loot.LootModifier; +import org.jetbrains.annotations.NotNull; + +public final class IrisForgeBlockLootModifier extends LootModifier { + public static final MapCodec CODEC = RecordCodecBuilder.mapCodec(instance -> + codecStart(instance).apply(instance, IrisForgeBlockLootModifier::new)); + + public IrisForgeBlockLootModifier(LootItemCondition[] conditions) { + super(conditions); + } + + @Override + public MapCodec codec() { + return CODEC; + } + + @NotNull + @Override + protected ObjectArrayList doApply(LootTable table, ObjectArrayList generatedLoot, LootContext context) { + Entity breaker = context.getOptionalParameter(LootContextParams.THIS_ENTITY); + BlockState state = context.getOptionalParameter(LootContextParams.BLOCK_STATE); + Vec3 origin = context.getOptionalParameter(LootContextParams.ORIGIN); + if (!(breaker instanceof Player) || state == null || origin == null) { + return generatedLoot; + } + ServerLevel level = context.getLevel(); + BlockPos position = BlockPos.containing(origin); + ModdedBlockBreakHandler.Result result = ModdedBlockBreakHandler.completePrepared(level, position); + if (result == null) { + return generatedLoot; + } + if (result.replaceVanillaDrops()) { + generatedLoot.clear(); + } + generatedLoot.addAll(result.drops()); + return generatedLoot; + } +} diff --git a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java index b8b742130..478994553 100644 --- a/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java +++ b/adapters/forge/src/main/java/art/arcane/iris/forge/IrisForgeBootstrap.java @@ -19,7 +19,7 @@ package art.arcane.iris.forge; import art.arcane.iris.modded.IrisModdedChunkGenerator; -import art.arcane.iris.modded.ModdedDeathLoot; +import art.arcane.iris.modded.ModdedBlockBreakHandler; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedProtocolHandler; @@ -27,23 +27,29 @@ import art.arcane.iris.modded.command.IrisModdedCommands; import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.serialization.MapCodec; import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.packs.PackType; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraftforge.event.AddPackFindersEvent; import net.minecraftforge.event.RegisterCommandsEvent; import net.minecraftforge.event.TickEvent; -import net.minecraftforge.event.entity.living.LivingDropsEvent; import net.minecraftforge.event.entity.player.PlayerEvent; import net.minecraftforge.event.entity.player.PlayerInteractEvent; +import net.minecraftforge.event.level.BlockEvent; +import net.minecraftforge.event.level.LevelEvent; +import net.minecraftforge.event.server.ServerAboutToStartEvent; +import net.minecraftforge.event.server.ServerStartedEvent; import net.minecraftforge.event.server.ServerStartingEvent; import net.minecraftforge.event.server.ServerStoppingEvent; import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.common.loot.IGlobalLootModifier; import net.minecraftforge.fml.common.Mod; import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; import net.minecraftforge.fml.loading.FMLEnvironment; import net.minecraftforge.fml.loading.FMLLoader; import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; import java.util.function.Predicate; @@ -54,6 +60,9 @@ public final class IrisForgeBootstrap { DeferredRegister> chunkGenerators = DeferredRegister.create(Registries.CHUNK_GENERATOR, "irisworldgen"); chunkGenerators.register("iris", () -> IrisModdedChunkGenerator.CODEC); chunkGenerators.register(context.getModBusGroup()); + DeferredRegister> lootModifiers = DeferredRegister.create(ForgeRegistries.GLOBAL_LOOT_MODIFIER_SERIALIZERS, "irisworldgen"); + lootModifiers.register("block_drops", () -> IrisForgeBlockLootModifier.CODEC); + lootModifiers.register(context.getModBusGroup()); }); ForgeProtocolNetworking.register(); @@ -62,8 +71,15 @@ public final class IrisForgeBootstrap { IrisForgeClient.init(); } + ServerAboutToStartEvent.BUS.addListener((ServerAboutToStartEvent event) -> ModdedEngineBootstrap.serverAboutToStart(event.getServer())); ServerStartingEvent.BUS.addListener((ServerStartingEvent event) -> ModdedEngineBootstrap.start(event.getServer())); + ServerStartedEvent.BUS.addListener((ServerStartedEvent event) -> ModdedEngineBootstrap.serverStarted(event.getServer())); ServerStoppingEvent.BUS.addListener((ServerStoppingEvent event) -> ModdedEngineBootstrap.stop()); + LevelEvent.Load.BUS.addListener((LevelEvent.Load event) -> { + if (event.getLevel() instanceof ServerLevel level) { + ModdedEngineBootstrap.levelLoaded(level); + } + }); PlayerEvent.PlayerLoggedInEvent.BUS.addListener((PlayerEvent.PlayerLoggedInEvent event) -> { if (event.getEntity() instanceof ServerPlayer player) { ModdedProtocolHandler.onPlayerJoin(player); @@ -80,7 +96,11 @@ public final class IrisForgeBootstrap { } }); RegisterCommandsEvent.BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); - LivingDropsEvent.BUS.addListener((LivingDropsEvent event) -> ModdedDeathLoot.handle(event.getEntity())); + BlockEvent.BreakEvent.BUS.addListener((BlockEvent.BreakEvent event) -> { + if (event.getLevel() instanceof ServerLevel level && !event.getResult().isDenied()) { + ModdedBlockBreakHandler.prepare(level, event.getPos(), event.getState()); + } + }); PlayerInteractEvent.LeftClickBlock.BUS.addListener((Predicate) (PlayerInteractEvent.LeftClickBlock event) -> ModdedWandService.attackBlock(event.getEntity(), event.getLevel(), event.getHand(), event.getPos())); PlayerInteractEvent.RightClickBlock.BUS.addListener((Predicate) (PlayerInteractEvent.RightClickBlock event) -> diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java new file mode 100644 index 000000000..6cc2e6688 --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java @@ -0,0 +1,307 @@ +package art.arcane.iris.nativegen; + +import art.arcane.iris.engine.framework.StructureVerticalBounds; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisVanillaStructureStiltSettings; +import art.arcane.volmlib.util.math.RNG; +import net.minecraft.core.BlockPos; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; +import net.minecraft.world.level.WorldGenLevel; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.pools.JigsawJunction; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.List; + +public final class NativeStructurePostProcessor { + private static final int FOUNDATION_VERTICAL_TOLERANCE = 1; + + private NativeStructurePostProcessor() { + } + + public static void place(WorldGenLevel world, StructureManager structureManager, ChunkGenerator generator, + WorldgenRandom random, BoundingBox area, ChunkPos chunkPos, String structureId, + StructureStart start, IrisNativeStructureDecision decision, + StiltBlockResolver stiltBlockResolver) { + IrisVanillaStructureStiltSettings stilt = decision.stilt(); + start.placeInChunk(world, structureManager, generator, random, area, chunkPos); + if (stilt != null) { + placeStilts(world, area, structureId, start, stilt, stiltBlockResolver); + } + } + + public static int applyVerticalShift(StructureStart start, int requestedOffset, int worldMinY, + int worldMaxYExclusive) { + BoundingBox bounds = start.getBoundingBox(); + int offsetY = StructureVerticalBounds.clampOffset( + bounds.minY(), bounds.maxY(), requestedOffset, worldMinY, worldMaxYExclusive); + if (offsetY == 0) { + return 0; + } + for (StructurePiece piece : start.getPieces()) { + piece.move(0, offsetY, 0); + if (piece instanceof PoolElementStructurePiece poolPiece) { + List junctions = poolPiece.getJunctions(); + for (int i = 0; i < junctions.size(); i++) { + JigsawJunction junction = junctions.get(i); + junctions.set(i, new JigsawJunction( + junction.getSourceX(), + junction.getSourceGroundY() + offsetY, + junction.getSourceZ(), + junction.getDeltaY(), + junction.getDestProjection())); + } + } + } + bounds.move(0, offsetY, 0); + return offsetY; + } + + public static boolean isUndergroundStep(GenerationStep.Decoration step) { + return step == GenerationStep.Decoration.UNDERGROUND_STRUCTURES + || step == GenerationStep.Decoration.UNDERGROUND_DECORATION + || step == GenerationStep.Decoration.STRONGHOLDS; + } + + public static void clearIntersectingVegetation(WorldGenLevel world, ChunkAccess chunk, BoundingBox area, + List targets) { + if (targets == null || targets.isEmpty()) { + return; + } + VegetationSnapshot snapshot = captureVegetation(chunk, area); + if (snapshot.treeBlockCount() == 0) { + return; + } + boolean[] clearColumns = new boolean[area.getXSpan() * area.getZSpan()]; + for (VegetationTarget target : targets) { + if (target != null && target.start() != null && target.start().isValid()) { + markVegetationColumns(area, snapshot, target, clearColumns); + } + } + clearVegetationColumns(world, area, snapshot, clearColumns); + } + + private static VegetationSnapshot captureVegetation(ChunkAccess chunk, BoundingBox area) { + int width = area.getXSpan(); + int depth = area.getZSpan(); + BitSet[] columns = new BitSet[width * depth]; + int[] lowestY = new int[columns.length]; + Arrays.fill(lowestY, Integer.MAX_VALUE); + int treeBlockCount = 0; + LevelChunkSection[] sections = chunk.getSections(); + int chunkMinX = chunk.getPos().getMinBlockX(); + int chunkMinZ = chunk.getPos().getMinBlockZ(); + for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { + LevelChunkSection section = sections[sectionIndex]; + int sectionMinY = chunk.getSectionYFromSectionIndex(sectionIndex) << 4; + int minY = Math.max(area.minY(), sectionMinY); + int maxY = Math.min(area.maxY(), sectionMinY + 15); + if (minY > maxY || section.hasOnlyAir() || !section.maybeHas(NativeStructurePostProcessor::isTreeBlock)) { + continue; + } + for (int y = minY; y <= maxY; y++) { + int localY = y - sectionMinY; + for (int z = area.minZ(); z <= area.maxZ(); z++) { + int localZ = z - chunkMinZ; + for (int x = area.minX(); x <= area.maxX(); x++) { + int localX = x - chunkMinX; + if (!isTreeBlock(section.getBlockState(localX, localY, localZ))) { + continue; + } + int column = (z - area.minZ()) * width + x - area.minX(); + BitSet treeBlocks = columns[column]; + if (treeBlocks == null) { + treeBlocks = new BitSet(area.getYSpan()); + columns[column] = treeBlocks; + } + treeBlocks.set(y - area.minY()); + lowestY[column] = Math.min(lowestY[column], y); + treeBlockCount++; + } + } + } + } + return new VegetationSnapshot(columns, lowestY, treeBlockCount); + } + + private static void markVegetationColumns(BoundingBox area, VegetationSnapshot snapshot, + VegetationTarget target, boolean[] clearColumns) { + int width = area.getXSpan(); + int[] pieceTops = new int[clearColumns.length]; + Arrays.fill(pieceTops, Integer.MIN_VALUE); + for (StructurePiece piece : target.start().getPieces()) { + BoundingBox bounds = piece.getBoundingBox(); + int minX = Math.max(area.minX(), bounds.minX()); + int maxX = Math.min(area.maxX(), bounds.maxX()); + int minZ = Math.max(area.minZ(), bounds.minZ()); + int maxZ = Math.min(area.maxZ(), bounds.maxZ()); + if (minX > maxX || minZ > maxZ) { + continue; + } + for (int z = minZ; z <= maxZ; z++) { + for (int x = minX; x <= maxX; x++) { + int column = (z - area.minZ()) * width + x - area.minX(); + pieceTops[column] = Math.max(pieceTops[column], bounds.maxY()); + } + } + } + for (int column = 0; column < pieceTops.length; column++) { + if (snapshot.columns()[column] == null || pieceTops[column] == Integer.MIN_VALUE) { + continue; + } + if (shouldClearVegetationColumn(pieceTops[column], snapshot.lowestY()[column], target.force())) { + clearColumns[column] = true; + } + } + } + + static boolean shouldClearVegetationColumn(int pieceTopY, int lowestTreeY, boolean force) { + return force || pieceTopY >= lowestTreeY; + } + + private static void clearVegetationColumns(WorldGenLevel world, BoundingBox area, + VegetationSnapshot snapshot, boolean[] clearColumns) { + int width = area.getXSpan(); + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + BlockState air = Blocks.AIR.defaultBlockState(); + for (int z = area.minZ(); z <= area.maxZ(); z++) { + for (int x = area.minX(); x <= area.maxX(); x++) { + int column = (z - area.minZ()) * width + x - area.minX(); + BitSet treeBlocks = snapshot.columns()[column]; + if (!clearColumns[column] || treeBlocks == null) { + continue; + } + for (int bit = treeBlocks.nextSetBit(0); bit >= 0; bit = treeBlocks.nextSetBit(bit + 1)) { + int y = area.minY() + bit; + position.set(x, y, z); + BlockState state = world.getBlockState(position); + if (isTreeBlock(state)) { + world.setBlock(position, air, 2); + } + } + } + } + } + + private static boolean isTreeBlock(BlockState state) { + return state.is(BlockTags.LOGS) || state.is(BlockTags.LEAVES); + } + + private static List foundationEnvelope(BoundingBox area, StructureStart start) { + BoundingBox structure = start.getBoundingBox(); + int minX = Math.max(area.minX(), structure.minX()); + int minZ = Math.max(area.minZ(), structure.minZ()); + int maxX = Math.min(area.maxX(), structure.maxX()); + int maxZ = Math.min(area.maxZ(), structure.maxZ()); + if (minX > maxX || minZ > maxZ) { + return List.of(); + } + List pieces = start.getPieces(); + List columns = new ArrayList<>((maxX - minX + 1) * (maxZ - minZ + 1)); + BitSet envelope = new BitSet(area.getYSpan()); + for (int z = minZ; z <= maxZ; z++) { + for (int x = minX; x <= maxX; x++) { + envelope.clear(); + markFoundationEnvelope(envelope, pieces, area, x, z); + int cellCount = envelope.cardinality(); + if (cellCount == 0) { + continue; + } + int[] ys = new int[cellCount]; + int cell = 0; + for (int bit = envelope.nextSetBit(0); bit >= 0; bit = envelope.nextSetBit(bit + 1)) { + ys[cell++] = area.minY() + bit; + } + columns.add(new FoundationColumn(x, z, ys)); + } + } + return List.copyOf(columns); + } + + private static void markFoundationEnvelope(BitSet envelope, List pieces, BoundingBox area, + int x, int z) { + for (StructurePiece piece : pieces) { + BoundingBox bounds = piece.getBoundingBox(); + if (x < bounds.minX() || x > bounds.maxX() || z < bounds.minZ() || z > bounds.maxZ()) { + continue; + } + int groundY = bounds.minY(); + if (piece instanceof PoolElementStructurePiece poolPiece) { + groundY += poolPiece.getGroundLevelDelta(); + if (groundY < bounds.minY()) { + continue; + } + } + int minY = Math.max(area.minY(), bounds.minY()); + int maxY = Math.min(area.maxY(), Math.min(bounds.maxY(), groundY + FOUNDATION_VERTICAL_TOLERANCE)); + if (minY <= maxY) { + envelope.set(minY - area.minY(), maxY - area.minY() + 1); + } + } + } + + private static void placeStilts(WorldGenLevel world, BoundingBox area, String structureId, + StructureStart start, IrisVanillaStructureStiltSettings settings, + StiltBlockResolver stiltBlockResolver) { + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + int structureHash = structureId == null ? 0 : structureId.hashCode(); + RNG rng = new RNG(world.getSeed() ^ structureHash); + for (FoundationColumn column : foundationEnvelope(area, start)) { + int foundationY = findFoundationY(world, column, position); + if (foundationY == Integer.MIN_VALUE) { + continue; + } + for (int depth = 0, y = foundationY - 1; + depth < settings.getMaxDepth() && y >= area.minY(); depth++, y--) { + position.set(column.x(), y, column.z()); + BlockState existingState = world.getBlockState(position); + boolean vegetation = existingState.is(BlockTags.LOGS) || existingState.is(BlockTags.LEAVES); + if (existingState.isSolid() && !vegetation) { + break; + } + BlockState stilt = stiltBlockResolver.resolve(settings, rng, column.x(), y, column.z()); + world.setBlock(position, stilt == null ? Blocks.COBBLESTONE.defaultBlockState() : stilt, 2); + } + } + } + + private static int findFoundationY(WorldGenLevel world, FoundationColumn column, + BlockPos.MutableBlockPos position) { + for (int cell = 0; cell < column.ys().length; cell++) { + int y = column.ys()[cell]; + BlockState state = world.getBlockState(position.set(column.x(), y, column.z())); + if (state.isSolid()) { + return y; + } + } + return Integer.MIN_VALUE; + } + + @FunctionalInterface + public interface StiltBlockResolver { + BlockState resolve(IrisVanillaStructureStiltSettings settings, RNG rng, int x, int y, int z); + } + + private record FoundationColumn(int x, int z, int[] ys) { + } + + public record VegetationTarget(StructureStart start, boolean force) { + } + + private record VegetationSnapshot(BitSet[] columns, int[] lowestY, int treeBlockCount) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java new file mode 100644 index 000000000..3c41ecfa7 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedBiomeSource.java @@ -0,0 +1,534 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.volmlib.util.math.RNG; +import com.mojang.serialization.MapCodec; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.QuartPos; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.MinecraftServer; +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.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureSet; + +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +final class IrisModdedBiomeSource extends BiomeSource { + private static final int BIOME_CACHE_MAX = 262144; + + private final BiomeSource serializedSource; + private final ConcurrentHashMap> visibleBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> surfaceStructureBiomeCache = new ConcurrentHashMap<>(); + private volatile IrisModdedChunkGenerator generator; + private volatile Set possibleStructureBiomeKeys; + + IrisModdedBiomeSource(BiomeSource serializedSource) { + this.serializedSource = serializedSource; + } + + void bind(IrisModdedChunkGenerator generator) { + this.generator = generator; + } + + void clearCaches() { + visibleBiomeCache.clear(); + structureBiomeCache.clear(); + surfaceStructureBiomeCache.clear(); + possibleStructureBiomeKeys = null; + } + + BiomeSource forStructureState(HolderLookup structureSets) { + LinkedHashSet> possible = new LinkedHashSet<>(); + Registry registry = biomeRegistry(); + if (registry == null) { + throw new IllegalStateException("Iris cannot create structure state without the biome registry"); + } + Set generatedBiomeKeys = exactStructureBiomeKeys(); + Set missingBiomeKeys = new LinkedHashSet<>(generatedBiomeKeys); + missingBiomeKeys.removeAll(registeredBiomeKeys(registry)); + if (!missingBiomeKeys.isEmpty()) { + throw new IllegalStateException("Iris structure biomes are not registered: " + missingBiomeKeys); + } + structureSets.listElements().forEach((Holder.Reference structureSet) -> { + for (StructureSet.StructureSelectionEntry entry : structureSet.value().structures()) { + for (Holder biome : entry.structure().value().biomes()) { + String key = holderKey(biome); + if (isGeneratedBiomeKey(key, generatedBiomeKeys)) { + possible.add(biome); + } + } + } + }); + return new StructureStateBiomeSource(this, Set.copyOf(possible)); + } + + @Override + protected MapCodec codec() { + throw new UnsupportedOperationException("IrisModdedBiomeSource is serialized through IrisModdedChunkGenerator"); + } + + @Override + protected Stream> collectPossibleBiomes() { + Registry registry = biomeRegistry(); + if (registry == null) { + return serializedSource.possibleBiomes().stream(); + } + Set generatedBiomeKeys = possibleStructureBiomeKeys(); + LinkedHashSet> possible = new LinkedHashSet<>(); + Holder fallback = fallbackHolder(registry); + registry.listElements().forEach((Holder.Reference reference) -> { + String key = holderKey(reference); + if (isGeneratedBiomeKey(key, generatedBiomeKeys)) { + possible.add(reference); + } + }); + if (possible.isEmpty() && fallback != null) { + possible.add(fallback); + } + return possible.stream(); + } + + @Override + public Holder getNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) { + Engine engine = engineOrNull(); + if (!isReady(engine)) { + return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); + } + return getNoiseBiome(engine, quartX, quartY, quartZ, sampler); + } + + private Holder getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ, + Climate.Sampler sampler) { + if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { + return getSurfaceStructureBiome(engine, quartX, quartZ, sampler); + } + long key = packNoiseKey(quartX, quartY, quartZ); + Holder cached = structureBiomeCache.get(key); + if (cached != null) { + return cached; + } + Holder resolved = resolveStructureBiome(engine, quartX, quartY, quartZ, sampler); + Holder existing = structureBiomeCache.putIfAbsent(key, resolved); + if (structureBiomeCache.size() > BIOME_CACHE_MAX) { + structureBiomeCache.clear(); + } + return existing == null ? resolved : existing; + } + + Holder getVisibleNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) { + Engine engine = engineOrNull(); + if (!isReady(engine)) { + return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); + } + long key = packNoiseKey(quartX, quartY, quartZ); + Holder cached = visibleBiomeCache.get(key); + if (cached != null) { + return cached; + } + Holder resolved = resolveVisibleBiome(engine, quartX, quartY, quartZ, sampler); + Holder existing = visibleBiomeCache.putIfAbsent(key, resolved); + if (visibleBiomeCache.size() > BIOME_CACHE_MAX) { + visibleBiomeCache.clear(); + } + return existing == null ? resolved : existing; + } + + boolean isStructureReachable(Holder structure) { + Set possible = possibleStructureBiomeKeys(); + for (Holder biome : structure.value().biomes()) { + String key = holderKey(biome); + if (isGeneratedBiomeKey(key, possible)) { + return true; + } + } + return false; + } + + @Override + public Set> getBiomesWithin(int x, int y, int z, int radius, Climate.Sampler sampler) { + int minQuartY = QuartPos.fromBlock(y - radius); + Engine engine = engineOrNull(); + if (!isReady(engine)) { + return super.getBiomesWithin(x, y, z, radius, sampler); + } + boolean monumentQuery = isMonumentSurfaceBiomeQuery( + y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); + if (!monumentQuery && !isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight())) { + return super.getBiomesWithin(x, y, z, radius, sampler); + } + int minQuartX = QuartPos.fromBlock(x - radius); + int maxQuartX = QuartPos.fromBlock(x + radius); + int minQuartZ = QuartPos.fromBlock(z - radius); + int maxQuartZ = QuartPos.fromBlock(z + radius); + int columns = (maxQuartX - minQuartX + 1) * (maxQuartZ - minQuartZ + 1); + Set> biomes = new HashSet<>(columns); + for (int quartZ = minQuartZ; quartZ <= maxQuartZ; quartZ++) { + for (int quartX = minQuartX; quartX <= maxQuartX; quartX++) { + biomes.add(getSurfaceStructureBiome(engine, quartX, quartZ, sampler)); + } + } + return biomes; + } + + private Holder getSurfaceStructureBiome(Engine engine, int quartX, int quartZ, + Climate.Sampler sampler) { + long key = packColumnKey(quartX, quartZ); + Holder cached = surfaceStructureBiomeCache.get(key); + if (cached != null) { + return cached; + } + Holder resolved = resolveSurfaceStructureBiome(engine, quartX, quartZ, sampler); + Holder existing = surfaceStructureBiomeCache.putIfAbsent(key, resolved); + if (surfaceStructureBiomeCache.size() > BIOME_CACHE_MAX) { + surfaceStructureBiomeCache.clear(); + } + return existing == null ? resolved : existing; + } + + private Holder resolveSurfaceStructureBiome(Engine engine, int quartX, int quartZ, + Climate.Sampler sampler) { + Registry registry = biomeRegistry(); + if (!isReady(engine) || registry == null) { + return serializedSource.getNoiseBiome(quartX, 0, quartZ, sampler); + } + IrisBiome irisBiome = engine.getComplex().getTrueBiomeStream().get(quartX << 2, quartZ << 2); + Holder resolved = irisBiome == null ? null : resolveHolder(registry, irisBiome.getVanillaDerivativeKey()); + return resolved == null ? serializedSource.getNoiseBiome(quartX, 0, quartZ, sampler) : resolved; + } + + private Holder resolveStructureBiome(Engine engine, int quartX, int quartY, int quartZ, + Climate.Sampler sampler) { + Registry registry = biomeRegistry(); + BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); + if (resolution == null || registry == null) { + return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); + } + Holder resolved = resolveHolder(registry, resolution.irisBiome().getVanillaDerivativeKey()); + if (resolved == null && resolution.irisBiome().isCustom()) { + IrisBiomeCustom customBiome = resolution.irisBiome().getCustomBiome( + resolution.rng(), engine, resolution.blockX(), resolution.blockY(), resolution.blockZ()); + if (customBiome != null) { + resolved = resolveHolder(registry, engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT) + + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); + } + } + return resolved == null ? fallbackBiome(registry, quartX, quartY, quartZ, sampler) : resolved; + } + + private Holder resolveVisibleBiome(Engine engine, int quartX, int quartY, int quartZ, + Climate.Sampler sampler) { + Registry registry = biomeRegistry(); + BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); + if (resolution == null || registry == null) { + return serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler); + } + String biomeKey; + if (resolution.irisBiome().isCustom()) { + IrisBiomeCustom customBiome = resolution.irisBiome().getCustomBiome( + resolution.rng(), engine, resolution.blockX(), resolution.blockY(), resolution.blockZ()); + if (customBiome == null) { + return fallbackBiome(registry, quartX, quartY, quartZ, sampler); + } + biomeKey = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT) + + ":" + customBiome.getId().toLowerCase(Locale.ROOT); + } else if (resolution.underground()) { + biomeKey = resolution.irisBiome().getGroundBiomeKey( + resolution.rng(), engine, resolution.blockX(), resolution.blockY(), resolution.blockZ()); + } else { + biomeKey = resolution.irisBiome().getSkyBiomeKey( + resolution.rng(), engine, resolution.blockX(), resolution.blockY(), resolution.blockZ()); + } + Holder resolved = resolveHolder(registry, biomeKey); + return resolved == null + ? fallbackBiome(registry, quartX, quartY, quartZ, sampler) + : resolved; + } + + private BiomeResolution resolveBiomeResolution(Engine engine, int quartX, int quartY, int quartZ) { + if (!isReady(engine)) { + return null; + } + int blockX = quartX << 2; + int blockY = quartY << 2; + int blockZ = quartZ << 2; + int internalY = blockY - engine.getMinHeight(); + int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40); + boolean underground = false; + IrisBiome irisBiome; + if (internalY <= caveSwitchY) { + int surfaceY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue(); + underground = isUnderground(internalY, surfaceY); + irisBiome = underground + ? engine.getCaveBiome(blockX, internalY, blockZ) + : engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + } else { + irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + } + if (irisBiome == null && underground) { + irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ); + } + if (irisBiome == null) { + return null; + } + IrisModdedChunkGenerator current = generator; + long worldSeed = current == null ? engine.getWorld().getRawWorldSeed() : current.visibleBiomeSeed(); + long seed = biomeResolutionSeed(worldSeed, blockX, blockY, blockZ); + return new BiomeResolution(irisBiome, underground, blockX, blockY, blockZ, new RNG(seed)); + } + + private Holder resolveRequiredStructureBiome(int quartX, int quartY, int quartZ) { + IrisModdedChunkGenerator current = generator; + if (current == null) { + throw new IllegalStateException("Iris structure biome source is not bound to its generator"); + } + Engine engine = current.awaitStructureEngine(); + Registry registry = biomeRegistry(); + if (registry == null) { + throw new IllegalStateException("Iris structure biome lookup has no biome registry"); + } + IrisBiome irisBiome; + if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { + irisBiome = engine.getComplex().getTrueBiomeStream().get(quartX << 2, quartZ << 2); + } else { + BiomeResolution resolution = resolveBiomeResolution(engine, quartX, quartY, quartZ); + irisBiome = resolution == null ? null : resolution.irisBiome(); + } + if (irisBiome == null) { + throw new IllegalStateException("Iris returned no structure biome at quart " + + quartX + "," + quartY + "," + quartZ); + } + String derivativeKey = irisBiome.getVanillaDerivativeKey(); + Holder resolved = resolveHolder(registry, derivativeKey); + if (resolved == null) { + throw new IllegalStateException("Iris structure biome derivative '" + derivativeKey + + "' is not registered at quart " + quartX + "," + quartY + "," + quartZ); + } + return resolved; + } + + static long biomeResolutionSeed(long worldSeed, int blockX, int blockY, int blockZ) { + return worldSeed + ^ ((long) blockX * 341873128712L) + ^ ((long) blockY * 132897987541L) + ^ ((long) blockZ * 42317861L); + } + + static boolean isGeneratedBiomeKey(String key, Set generatedBiomeKeys) { + return key != null && generatedBiomeKeys.contains(key.toLowerCase(Locale.ROOT)); + } + + static boolean isGuaranteedSurfaceBiome(int quartY, int minHeight) { + int internalY = (quartY << 2) - minHeight; + int caveSwitchY = Math.max(-8 - minHeight, 40); + return internalY > caveSwitchY; + } + + static boolean isUnderground(int internalY, int surfaceY) { + return internalY <= surfaceY - 8; + } + + static boolean isMonumentSurfaceBiomeQuery(int blockY, int radius, int minHeight, int fluidHeight) { + return radius == 29 && blockY == minHeight + fluidHeight; + } + + private static boolean isReady(Engine engine) { + return engine != null && !engine.isClosed() && engine.getComplex() != null; + } + + private Engine engineOrNull() { + IrisModdedChunkGenerator current = generator; + return current == null ? null : current.structureEngineOrNull(); + } + + private Set possibleStructureBiomeKeys() { + Set cached = possibleStructureBiomeKeys; + if (cached != null) { + return cached; + } + LinkedHashSet possible = new LinkedHashSet<>(exactStructureBiomeKeys()); + Registry registry = biomeRegistry(); + if (registry != null) { + Set registered = registeredBiomeKeys(registry); + Holder fallback = fallbackHolder(registry); + String fallbackKey = fallback == null ? null : holderKey(fallback); + if (fallbackKey != null && requiresPossibleBiomeFallback(possible, registered)) { + possible.add(fallbackKey); + } + } + Set resolved = Set.copyOf(possible); + possibleStructureBiomeKeys = resolved; + return resolved; + } + + private Set exactStructureBiomeKeys() { + LinkedHashSet possible = new LinkedHashSet<>(); + Engine engine = engineOrNull(); + if (isReady(engine)) { + String namespace = engine.getDimension().getLoadKey().toLowerCase(Locale.ROOT); + for (IrisBiome irisBiome : engine.getAllBiomes()) { + String derivative = normalizeKey(irisBiome.getVanillaDerivativeKey()); + if (derivative != null) { + possible.add(derivative); + } + if (!irisBiome.isCustom()) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + possible.add(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); + } + } + } + IrisModdedChunkGenerator current = generator; + if (current != null) { + possible.addAll(current.configuredStructureBiomeKeys()); + } + return Set.copyOf(possible); + } + + static boolean requiresPossibleBiomeFallback(Set configured, Set registered) { + return configured.isEmpty() || !registered.containsAll(configured); + } + + private static Set registeredBiomeKeys(Registry registry) { + Set registered = new HashSet<>(); + registry.listElements().forEach((Holder.Reference reference) -> { + String key = holderKey(reference); + if (key != null) { + registered.add(key); + } + }); + return registered; + } + + private Registry biomeRegistry() { + MinecraftServer server = ModdedEngineBootstrap.currentServer(); + return server == null ? null : server.registryAccess().lookupOrThrow(Registries.BIOME); + } + + private static Holder resolveHolder(Registry registry, String key) { + if (key == null || key.isBlank()) { + return null; + } + Identifier identifier = Identifier.tryParse(key); + if (identifier == null) { + return null; + } + return registry.get(identifier).>map((Holder.Reference reference) -> reference).orElse(null); + } + + private static String holderKey(Holder holder) { + return holder.unwrapKey() + .map((ResourceKey key) -> key.identifier().toString().toLowerCase(Locale.ROOT)) + .orElse(null); + } + + private static String normalizeKey(String key) { + Identifier identifier = key == null ? null : Identifier.tryParse(key); + return identifier == null ? null : identifier.toString().toLowerCase(Locale.ROOT); + } + + private static Holder fallbackHolder(Registry registry) { + if (registry == null) { + return null; + } + Holder plains = resolveHolder(registry, "minecraft:plains"); + if (plains != null) { + return plains; + } + return registry.listElements().findFirst().>map( + (Holder.Reference reference) -> reference).orElse(null); + } + + private Holder fallbackBiome(Registry registry, int quartX, int quartY, int quartZ, + Climate.Sampler sampler) { + Holder plains = resolveHolder(registry, "minecraft:plains"); + return plains == null ? serializedSource.getNoiseBiome(quartX, quartY, quartZ, sampler) : plains; + } + + private static long packNoiseKey(int x, int y, int z) { + return (((long) x & 67108863L) << 38) + | (((long) z & 67108863L) << 12) + | ((long) y & 4095L); + } + + private static long packColumnKey(int x, int z) { + return ((long) x << 32) ^ ((long) z & 4294967295L); + } + + private record BiomeResolution(IrisBiome irisBiome, boolean underground, int blockX, int blockY, + int blockZ, RNG rng) { + } + + private static final class StructureStateBiomeSource extends BiomeSource { + private final IrisModdedBiomeSource delegate; + private final Set> possibleBiomes; + private final ConcurrentHashMap> resolvedBiomes = new ConcurrentHashMap<>(); + + private StructureStateBiomeSource(IrisModdedBiomeSource delegate, Set> possibleBiomes) { + this.delegate = delegate; + this.possibleBiomes = possibleBiomes; + } + + @Override + protected MapCodec codec() { + throw new UnsupportedOperationException("Structure state biome sources are not serializable"); + } + + @Override + protected Stream> collectPossibleBiomes() { + return possibleBiomes.stream(); + } + + @Override + public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { + long key = packNoiseKey(x, y, z); + Holder cached = resolvedBiomes.get(key); + if (cached != null) { + return cached; + } + Holder resolved = delegate.resolveRequiredStructureBiome(x, y, z); + Holder existing = resolvedBiomes.putIfAbsent(key, resolved); + if (resolvedBiomes.size() > BIOME_CACHE_MAX) { + resolvedBiomes.clear(); + } + return existing == null ? resolved : existing; + } + + @Override + public Set> getBiomesWithin(int x, int y, int z, int radius, Climate.Sampler sampler) { + return super.getBiomesWithin(x, y, z, radius, sampler); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java index a4d35f9df..96054f6ad 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/IrisModdedChunkGenerator.java @@ -18,22 +18,33 @@ package art.arcane.iris.modded; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisImportedStructureControl; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisVanillaStructureStiltSettings; +import art.arcane.iris.nativegen.NativeStructurePostProcessor; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.volmlib.util.math.RNG; +import com.mojang.datafixers.util.Pair; import com.mojang.serialization.Codec; import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; -import net.minecraft.core.QuartPos; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.HolderSet; import net.minecraft.core.Registry; import net.minecraft.core.RegistryAccess; +import net.minecraft.core.SectionPos; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.resources.ResourceKey; @@ -52,40 +63,59 @@ import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.biome.BiomeResolver; import net.minecraft.world.level.biome.BiomeSource; -import net.minecraft.world.level.biome.Climate; import net.minecraft.world.level.biome.MobSpawnSettings; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.EntityBlock; +import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.chunk.ChunkGeneratorStructureState; import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.RandomSupport; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.XoroshiroRandomSource; import net.minecraft.world.level.levelgen.blending.Blender; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.StructureStart; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; +import java.util.ArrayList; import java.util.Arrays; import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.IntBinaryOperator; public final class IrisModdedChunkGenerator extends ChunkGenerator { + private static final int WORLD_CHECK_SHIFT_RECORD_LIMIT = 4096; + private static final boolean WORLD_CHECK_ENABLED = Boolean.getBoolean("iris.worldcheck"); private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); public static final MapCodec CODEC = RecordCodecBuilder.mapCodec((RecordCodecBuilder.Instance instance) -> instance.group( - BiomeSource.CODEC.fieldOf("biome_source").forGetter((IrisModdedChunkGenerator generator) -> generator.biomeSource), + BiomeSource.CODEC.fieldOf("biome_source").forGetter((IrisModdedChunkGenerator generator) -> generator.serializedBiomeSource), Codec.STRING.fieldOf("dimension").forGetter((IrisModdedChunkGenerator generator) -> generator.dimensionKey) ).apply(instance, IrisModdedChunkGenerator::new)); @@ -141,10 +171,12 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private final String dimensionKey; private final String defaultPack; private final String defaultDimensionKey; - private final ConcurrentHashMap> biomeHolders = new ConcurrentHashMap<>(); + private final BiomeSource serializedBiomeSource; + private final IrisModdedBiomeSource structureBiomeSource; + private final EngineBinding engineBinding = new EngineBinding<>(60L, TimeUnit.SECONDS); private final ConcurrentHashMap> vanillaSpawnBiomes = new ConcurrentHashMap<>(); private final ConcurrentHashMap> mergedSpawnTables = new ConcurrentHashMap<>(); - private final Set missingBiomeWarnings = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap worldCheckStructureShifts = new ConcurrentHashMap<>(); private final AtomicBoolean announced = new AtomicBoolean(false); private volatile boolean vanillaSpawnBiomesInitialized; private volatile Engine engine; @@ -152,10 +184,20 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private volatile String activeDimensionKey; private volatile long seedOverride = Long.MIN_VALUE; private volatile long lastChunkGenAt = 0L; + private volatile Set configuredStructureBiomeKeys; + private volatile ConfiguredPack configuredPack; + private volatile StructureStepCache structureStepCache; public IrisModdedChunkGenerator(BiomeSource biomeSource, String dimensionKey) { - super(biomeSource); + this(biomeSource, dimensionKey, new IrisModdedBiomeSource(biomeSource)); + } + + private IrisModdedChunkGenerator(BiomeSource serializedBiomeSource, String dimensionKey, IrisModdedBiomeSource structureBiomeSource) { + super(structureBiomeSource); this.dimensionKey = dimensionKey; + this.serializedBiomeSource = serializedBiomeSource; + this.structureBiomeSource = structureBiomeSource; + this.structureBiomeSource.bind(this); int colon = dimensionKey.indexOf(':'); this.defaultPack = colon >= 0 ? dimensionKey.substring(0, colon) : dimensionKey; this.defaultDimensionKey = colon >= 0 ? dimensionKey.substring(colon + 1) : dimensionKey; @@ -172,9 +214,12 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { this.activeDimensionKey = packDimensionKey; this.seedOverride = seed; this.engine = null; + this.configuredStructureBiomeKeys = null; + this.configuredPack = null; + this.engineBinding.reset(); this.announced.set(false); - this.biomeHolders.clear(); - this.missingBiomeWarnings.clear(); + this.structureBiomeSource.clearCaches(); + this.worldCheckStructureShifts.clear(); resetVanillaSpawnBiomes(); } @@ -184,9 +229,12 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { ModdedWorldEngines.evict(level); } this.engine = null; + this.configuredStructureBiomeKeys = null; + this.configuredPack = null; + this.engineBinding.reset(); this.announced.set(false); - this.biomeHolders.clear(); - this.missingBiomeWarnings.clear(); + this.structureBiomeSource.clearCaches(); + this.worldCheckStructureShifts.clear(); resetVanillaSpawnBiomes(); } @@ -207,6 +255,107 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { return CODEC; } + @Override + public ChunkGeneratorStructureState createState(HolderLookup structureSets, RandomState randomState, long seed) { + return ChunkGeneratorStructureState.createForNormal( + randomState, seed, structureBiomeSource.forStructureState(structureSets), structureSets); + } + + @Override + public Pair> findNearestMapStructure(ServerLevel level, HolderSet holders, + BlockPos pos, int radius, + boolean findUnexplored) { + Engine current = engineOrNull(); + if (current == null) { + return super.findNearestMapStructure(level, holders, pos, radius, findUnexplored); + } + try { + Pair> irisPlaced = findNearestIrisStructure( + level, holders, pos, Math.max(1, radius), current); + if (irisPlaced != null) { + return irisPlaced; + } + } catch (Throwable e) { + LOGGER.error("Iris-placed structure locate failed near {},{}", pos.getX(), pos.getZ(), e); + } + IrisImportedStructureControl control = current.getDimension().getImportedStructures(); + if (control == null || !control.active()) { + return null; + } + HolderSet reachable = filterReachableNativeStructures(level, holders, current, control); + if (reachable.size() == 0) { + return null; + } + try { + return super.findNearestMapStructure(level, reachable, pos, radius, findUnexplored); + } catch (Throwable e) { + LOGGER.error("Native structure locate failed near {},{}", pos.getX(), pos.getZ(), e); + return null; + } + } + + public boolean isNativeStructureReachable(Holder structure) { + return structure != null && structureBiomeSource.isStructureReachable(structure); + } + + private Pair> findNearestIrisStructure(ServerLevel level, + HolderSet holders, + BlockPos pos, int radius, + Engine current) { + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + BlockPos best = null; + Holder bestHolder = null; + long bestDistance = Long.MAX_VALUE; + for (Holder holder : holders) { + Identifier id = registry.getKey(holder.value()); + if (id == null) { + continue; + } + IrisStructureLocator.LocateResult result = IrisStructureLocator.locate( + current, id.toString(), pos.getX(), pos.getZ(), radius); + if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + continue; + } + if (!result.found()) { + continue; + } + long dx = (long) result.originX() - pos.getX(); + long dz = (long) result.originZ() - pos.getZ(); + long distance = dx * dx + dz * dz; + if (distance < bestDistance) { + bestDistance = distance; + best = new BlockPos(result.originX(), result.baseY(), result.originZ()); + bestHolder = holder; + } + } + return best == null ? null : Pair.of(best, bestHolder); + } + + private HolderSet filterReachableNativeStructures(ServerLevel level, HolderSet holders, + Engine current, + IrisImportedStructureControl control) { + try { + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + List> kept = new ArrayList<>(holders.size()); + for (Holder holder : holders) { + Identifier id = registry.getKey(holder.value()); + String key = id == null ? null : id.toString(); + IrisNativeStructureDecision decision = control.resolve( + key, NativeStructurePostProcessor.isUndergroundStep(holder.value().step())); + if (key == null || !decision.generate() + || IrisStructureLocator.suppressesVanilla(current, key) + || !structureBiomeSource.isStructureReachable(holder)) { + continue; + } + kept.add(holder); + } + return kept.size() == holders.size() ? holders : HolderSet.direct(kept); + } catch (Throwable error) { + LOGGER.error("Iris could not resolve native structure biome reachability; native locate failed closed", error); + return HolderSet.direct(List.of()); + } + } + private ServerLevel boundLevel() { MinecraftServer server = ModdedEngineBootstrap.currentServer(); if (server == null) { @@ -225,17 +374,53 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { if (cached != null) { return cached; } + ServerLevel level = boundLevel(); + if (level == null) { + throw new IllegalStateException("Iris generator '" + dimensionKey + "' has no bound ServerLevel yet"); + } + return bindEngine(level); + } + + void bindLevel(ServerLevel level) { + if (level.getChunkSource().getGenerator() != this) { + throw new IllegalArgumentException("ServerLevel does not use Iris generator '" + dimensionKey + "'"); + } + bindEngine(level); + } + + private Engine bindEngine(ServerLevel level) { + Engine cached = engine; + if (cached != null && !cached.isClosed() && cached.getComplex() != null) { + engineBinding.complete(cached); + return cached; + } synchronized (this) { - if (engine != null) { - return engine; + Engine existing = engine; + if (existing != null && !existing.isClosed() && existing.getComplex() != null) { + engineBinding.complete(existing); + return existing; } - ServerLevel level = boundLevel(); - if (level == null) { - throw new IllegalStateException("Iris generator '" + dimensionKey + "' has no bound ServerLevel yet"); + try { + Engine created = ModdedWorldEngines.get(level, activePack, activeDimensionKey, seedOverride); + if (created.isClosed() || created.getComplex() == null) { + throw new IllegalStateException("Iris generator '" + dimensionKey + + "' created an engine without a ready biome complex"); + } + engine = created; + configuredStructureBiomeKeys = null; + structureBiomeSource.clearCaches(); + engineBinding.complete(created); + return created; + } catch (Throwable error) { + engineBinding.fail(error); + if (error instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (error instanceof Error fatalError) { + throw fatalError; + } + throw new IllegalStateException("Iris generator '" + dimensionKey + "' failed to bind", error); } - Engine created = ModdedWorldEngines.get(level, activePack, activeDimensionKey, seedOverride); - engine = created; - return created; } } @@ -251,6 +436,141 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } + Engine structureEngineOrNull() { + return engineOrNull(); + } + + Engine awaitStructureEngine() { + Engine current = engine; + if (current != null && !current.isClosed() && current.getComplex() != null) { + return current; + } + Engine bound = engineBinding.await(dimensionKey); + if (bound.isClosed() || bound.getComplex() == null) { + throw new IllegalStateException("Iris generator '" + dimensionKey + + "' completed bootstrap without a ready biome complex"); + } + return bound; + } + + private Engine requireDataQueryEngine(String operation) { + Engine current = engine; + if (current != null && !current.isClosed() && current.getComplex() != null) { + return current; + } + ServerLevel level = boundLevel(); + if (level != null) { + return bindEngine(level); + } + MinecraftServer server = ModdedEngineBootstrap.currentServer(); + if (server == null) { + throw new IllegalStateException("Iris generator '" + dimensionKey + "' cannot answer " + + operation + " without an active server"); + } + if (server.isSameThread()) { + throw new IllegalStateException("Iris generator '" + dimensionKey + "' cannot answer " + + operation + " on the server thread before its ServerLevel is bound"); + } + return awaitStructureEngine(); + } + + long visibleBiomeSeed() { + long configuredSeed = seedOverride; + if (configuredSeed != Long.MIN_VALUE) { + return configuredSeed; + } + ServerLevel level = boundLevel(); + if (level != null) { + return level.getSeed(); + } + Engine current = engine; + return current == null ? 0L : current.getWorld().getRawWorldSeed(); + } + + Set configuredStructureBiomeKeys() { + Set cached = configuredStructureBiomeKeys; + if (cached != null) { + return cached; + } + synchronized (this) { + if (configuredStructureBiomeKeys != null) { + return configuredStructureBiomeKeys; + } + Engine current = engine; + Iterable biomes; + String namespace; + if (current != null && !current.isClosed()) { + biomes = current.getAllBiomes(); + namespace = current.getDimension().getLoadKey(); + } else { + ConfiguredPack configured = configuredPack(); + Set resolved = collectConfiguredBiomeKeys(configured.dimension(), configured.data()); + configuredStructureBiomeKeys = resolved; + return resolved; + } + Set resolved = collectConfiguredBiomeKeys(biomes, namespace); + configuredStructureBiomeKeys = resolved; + return resolved; + } + } + + private ConfiguredPack configuredPack() { + ConfiguredPack cached = configuredPack; + if (cached != null) { + return cached; + } + synchronized (this) { + if (configuredPack != null) { + return configuredPack; + } + File packDirectory = ModdedWorldEngines.resolvePack(activePack, activeDimensionKey); + IrisData data = IrisData.get(packDirectory); + IrisDimension dimension = data.getDimensionLoader().load(activeDimensionKey); + if (dimension == null) { + throw new IllegalStateException("Iris dimension '" + activeDimensionKey + + "' missing from pack " + packDirectory.getAbsolutePath()); + } + ConfiguredPack resolved = new ConfiguredPack(data, dimension, dimensionMetadata(dimension)); + configuredPack = resolved; + return resolved; + } + } + + static DimensionMetadata dimensionMetadata(IrisDimension dimension) { + int minY = dimension.getMinHeight(); + int maxY = dimension.getMaxHeight(); + if (maxY <= minY) { + throw new IllegalStateException("Iris dimension '" + dimension.getLoadKey() + + "' has invalid height range " + minY + ".." + maxY); + } + return new DimensionMetadata(minY, maxY, minY + dimension.getFluidHeight()); + } + + static Set collectConfiguredBiomeKeys(IrisDimension dimension, IrisData data) { + return collectConfiguredBiomeKeys(dimension.getReachableBiomes(() -> data), dimension.getLoadKey()); + } + + static Set collectConfiguredBiomeKeys(Iterable biomes, String dimensionLoadKey) { + LinkedHashSet keys = new LinkedHashSet<>(); + String namespace = dimensionLoadKey.toLowerCase(Locale.ROOT); + for (IrisBiome irisBiome : biomes) { + if (irisBiome == null) { + continue; + } + Identifier derivative = Identifier.tryParse(irisBiome.getVanillaDerivativeKey()); + if (derivative != null) { + keys.add(derivative.toString().toLowerCase(Locale.ROOT)); + } + if (!irisBiome.isCustom()) { + continue; + } + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { + keys.add(namespace + ":" + customBiome.getId().toLowerCase(Locale.ROOT)); + } + } + return Set.copyOf(keys); + } + public String dimensionKey() { return dimensionKey; } @@ -268,8 +588,9 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } public void onHotload() { - biomeHolders.clear(); - missingBiomeWarnings.clear(); + configuredStructureBiomeKeys = null; + structureBiomeSource.clearCaches(); + worldCheckStructureShifts.clear(); resetVanillaSpawnBiomes(); } @@ -312,7 +633,7 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } String namespace = current.getDimension().getLoadKey().toLowerCase(Locale.ROOT); - for (IrisBiome irisBiome : current.getDimension().getAllBiomes(current)) { + for (IrisBiome irisBiome : current.getDimension().getReachableBiomes(current)) { if (irisBiome == null || !irisBiome.isCustom()) { continue; } @@ -348,6 +669,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { vanillaSpawnBiomesInitialized = false; } + @Override + public CompletableFuture createBiomes(RandomState randomState, Blender blender, + StructureManager structureManager, ChunkAccess chunk) { + chunk.fillBiomesFromNoise(structureBiomeSource::getVisibleNoiseBiome, randomState.sampler()); + return CompletableFuture.completedFuture(chunk); + } + @Override public CompletableFuture fillFromNoise(Blender blender, RandomState randomState, StructureManager structureManager, ChunkAccess chunk) { Engine generationEngine = engine(); @@ -363,20 +691,18 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { int dimMaxY = generationEngine.getMaxHeight(); int height = dimMaxY - dimMinY; PlatformBlockState air = IrisPlatforms.get().registries().air(); - Registry biomeRegistry = structureManager.registryAccess().lookupOrThrow(Registries.BIOME); if (PARALLEL_CHUNK_SYSTEM) { return CompletableFuture.completedFuture( - generateTerrain(chunk, generationEngine, pos, dimMinY, height, air, biomeRegistry, randomState)); + generateTerrain(chunk, generationEngine, pos, dimMinY, height, air)); } return CompletableFuture.supplyAsync( - () -> generateTerrain(chunk, generationEngine, pos, dimMinY, height, air, biomeRegistry, randomState), + () -> generateTerrain(chunk, generationEngine, pos, dimMinY, height, air), genPool); } private ChunkAccess generateTerrain(ChunkAccess chunk, Engine generationEngine, ChunkPos pos, - int dimMinY, int height, PlatformBlockState air, - Registry biomeRegistry, RandomState randomState) { + int dimMinY, int height, PlatformBlockState air) { ModdedBlockBuffer blocks = new ModdedBlockBuffer(height, air); Hunk biomes = Hunk.newArrayHunk(16, height, 16); try { @@ -394,50 +720,32 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } writeBlocks(chunk, blocks, dimMinY, height); - Heightmap.primeHeightmaps(chunk, EnumSet.of(Heightmap.Types.WORLD_SURFACE_WG, Heightmap.Types.OCEAN_FLOOR_WG)); - chunk.fillBiomesFromNoise(new HunkBiomeResolver(this, biomes, biomeRegistry, pos, dimMinY, height), randomState.sampler()); + writeTerrainHeightmaps(chunk, generationEngine, pos, height); + Heightmap.primeHeightmaps(chunk, EnumSet.of( + Heightmap.Types.MOTION_BLOCKING, + Heightmap.Types.MOTION_BLOCKING_NO_LEAVES)); ModdedWorldManager.enqueueGenerated(generationEngine, pos.x(), pos.z()); return chunk; } - private Holder fallbackBiome(Registry registry) { - Holder existing = biomeHolders.get("minecraft:plains"); - if (existing != null) { - return existing; - } - Holder resolved = registry.get(Identifier.fromNamespaceAndPath("minecraft", "plains")) - .>map((Holder.Reference reference) -> reference) - .orElseThrow(() -> new IllegalStateException("minecraft:plains missing from biome registry")); - Holder raced = biomeHolders.putIfAbsent("minecraft:plains", resolved); - return raced != null ? raced : resolved; + private void writeTerrainHeightmaps(ChunkAccess chunk, Engine generationEngine, ChunkPos pos, int height) { + int baseX = pos.getMinBlockX(); + int baseZ = pos.getMinBlockZ(); + writeTerrainHeightmap(chunk, Heightmap.Types.WORLD_SURFACE_WG, height, + (x, z) -> generationEngine.getHeight(baseX + x, baseZ + z, false) + 1); + writeTerrainHeightmap(chunk, Heightmap.Types.OCEAN_FLOOR_WG, height, + (x, z) -> generationEngine.getHeight(baseX + x, baseZ + z, true) + 1); } - private Holder holderFor(Registry registry, PlatformBiome biome) { - if (biome == null) { - return fallbackBiome(registry); - } - String key = biome.key(); - Holder existing = biomeHolders.get(key); - if (existing != null) { - return existing; - } - Identifier identifier = Identifier.tryParse(key); - Optional> reference = identifier == null ? Optional.empty() : registry.get(identifier); - Holder resolved = reference.>map((Holder.Reference value) -> value).orElseGet(() -> { - if (missingBiomeWarnings.size() < 256 && missingBiomeWarnings.add(key)) { - LOGGER.warn("Iris biome '{}' (pack {}) is not in the biome registry; writing minecraft:plains. Restart so the forced datapack registers the pack biomes.", key, activePack); - } - return fallbackBiome(registry); - }); - Holder raced = biomeHolders.putIfAbsent(key, resolved); - return raced != null ? raced : resolved; + private void writeTerrainHeightmap(ChunkAccess chunk, Heightmap.Types type, int height, + IntBinaryOperator heightResolver) { + Heightmap heightmap = chunk.getOrCreateHeightmapUnprimed(type); + heightmap.setRawData(chunk, type, ModdedHeightmaps.terrainRawData(height, heightResolver)); } - public BiomeResolver regenBiomeResolver(Registry registry, Hunk biomes, ChunkPos pos) { - Engine current = engine(); - int dimMinY = current.getMinHeight(); - int height = current.getMaxHeight() - dimMinY; - return new HunkBiomeResolver(this, biomes, registry, pos, dimMinY, height); + public BiomeResolver regenBiomeResolver() { + engine(); + return structureBiomeSource::getVisibleNoiseBiome; } private void writeBlocks(ChunkAccess chunk, ModdedBlockBuffer blocks, int dimMinY, int height) { @@ -445,6 +753,8 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { int chunkMaxY = chunkMinY + chunk.getHeight(); int from = Math.max(dimMinY, chunkMinY); int to = Math.min(dimMinY + height, chunkMaxY); + int baseX = chunk.getPos().getMinBlockX(); + int baseZ = chunk.getPos().getMinBlockZ(); for (int y = from; y < to; ) { int sectionIndex = chunk.getSectionIndex(y); @@ -462,7 +772,11 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { continue; } PlatformBlockState state = blocks.getRaw(x, bufferY, z); - section.setBlockState(x, localY, z, (BlockState) state.nativeHandle(), false); + BlockState blockState = (BlockState) state.nativeHandle(); + section.setBlockState(x, localY, z, blockState, false); + if (blockState.hasBlockEntity()) { + createDefaultBlockEntity(chunk, new BlockPos(baseX + x, blockY, baseZ + z), blockState); + } } } } @@ -473,30 +787,13 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { } } - private static final class HunkBiomeResolver implements BiomeResolver { - private final IrisModdedChunkGenerator generator; - private final Hunk biomes; - private final Registry registry; - private final ChunkPos pos; - private final int dimMinY; - private final int height; - - private HunkBiomeResolver(IrisModdedChunkGenerator generator, Hunk biomes, Registry registry, ChunkPos pos, int dimMinY, int height) { - this.generator = generator; - this.biomes = biomes; - this.registry = registry; - this.pos = pos; - this.dimMinY = dimMinY; - this.height = height; + static void createDefaultBlockEntity(ChunkAccess chunk, BlockPos position, BlockState state) { + if (!(state.getBlock() instanceof EntityBlock entityBlock)) { + return; } - - @Override - public Holder getNoiseBiome(int quartX, int quartY, int quartZ, Climate.Sampler sampler) { - int localX = QuartPos.toBlock(quartX) - pos.getMinBlockX(); - int localZ = QuartPos.toBlock(quartZ) - pos.getMinBlockZ(); - int bufferY = Math.max(0, Math.min(height - 1, QuartPos.toBlock(quartY) - dimMinY)); - PlatformBiome biome = biomes.get(localX, bufferY, localZ); - return generator.holderFor(registry, biome); + BlockEntity blockEntity = entityBlock.newBlockEntity(position, state); + if (blockEntity != null) { + chunk.setBlockEntity(blockEntity); } } @@ -510,14 +807,200 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public void applyBiomeDecoration(WorldGenLevel level, ChunkAccess chunk, StructureManager structureManager) { + if (importedControl().active()) { + placeVanillaStructures(level, chunk, structureManager); + } } @Override public void createStructures(RegistryAccess registryAccess, ChunkGeneratorStructureState structureState, StructureManager structureManager, ChunkAccess chunk, StructureTemplateManager templateManager, ResourceKey levelKey) { + if (!importedControl().active()) { + return; + } + Map previousStarts = new HashMap<>(chunk.getAllStarts()); + super.createStructures(registryAccess, structureState, structureManager, chunk, templateManager, levelKey); + adjustGeneratedStructures(registryAccess, chunk, previousStarts); } @Override public void createReferences(WorldGenLevel level, StructureManager structureManager, ChunkAccess chunk) { + super.createReferences(level, structureManager, chunk); + } + + private void adjustGeneratedStructures(RegistryAccess registryAccess, ChunkAccess chunk, + Map previousStarts) { + Registry registry = registryAccess.lookupOrThrow(Registries.STRUCTURE); + IrisImportedStructureControl control = importedControl(); + for (Map.Entry entry : chunk.getAllStarts().entrySet()) { + Structure structure = entry.getKey(); + StructureStart start = entry.getValue(); + if (!start.isValid() || previousStarts.get(structure) == start) { + continue; + } + Identifier id = registry.getKey(structure); + String structureId = id == null ? null : id.toString(); + IrisNativeStructureDecision decision = control.resolve( + structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); + if (!decision.generate() || IrisStructureLocator.suppressesVanilla(engine(), structureId)) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + continue; + } + int offsetY; + try { + offsetY = NativeStructurePostProcessor.applyVerticalShift( + start, + decision.yShift(), + chunk.getMinY(), + chunk.getMinY() + chunk.getHeight()); + } catch (RuntimeException error) { + chunk.setStartForStructure(structure, StructureStart.INVALID_START); + LOGGER.error("Iris rejected native structure {} in chunk {},{} because its vertical bounds are invalid", + structureId, chunk.getPos().x(), chunk.getPos().z(), error); + continue; + } + recordWorldCheckStructureShift(structureId, start.getChunkPos(), offsetY); + } + } + + private void placeVanillaStructures(WorldGenLevel world, ChunkAccess chunk, StructureManager structureManager) { + if (!structureManager.shouldGenerateStructures()) { + return; + } + ChunkPos chunkPos = chunk.getPos(); + SectionPos sectionPos = SectionPos.of(chunkPos, world.getMinSectionY()); + BlockPos origin = sectionPos.origin(); + Registry registry = world.registryAccess().lookupOrThrow(Registries.STRUCTURE); + List> byStep = structuresByStep(registry); + WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(RandomSupport.generateUniqueSeed())); + long decorationSeed = random.setDecorationSeed(world.getSeed(), origin.getX(), origin.getZ()); + BoundingBox area = writableArea(chunk); + int steps = GenerationStep.Decoration.values().length; + IrisImportedStructureControl control = importedControl(); + List placementGroups = new ArrayList<>(); + List vegetationTargets = new ArrayList<>(); + for (int step = 0; step < steps; step++) { + int index = 0; + for (Structure structure : byStep.get(step)) { + Identifier id = registry.getKey(structure); + String structureId = id == null ? null : id.toString(); + IrisNativeStructureDecision decision = control.resolve( + structureId, NativeStructurePostProcessor.isUndergroundStep(structure.step())); + if (decision.generate() && !IrisStructureLocator.suppressesVanilla(engine(), structureId)) { + try { + List starts = structureManager.startsForStructure(sectionPos, structure); + if (!starts.isEmpty()) { + List resolvedStarts = List.copyOf(starts); + placementGroups.add(new NativePlacementGroup( + structureId, decision, index, step, resolvedStarts)); + for (StructureStart start : resolvedStarts) { + vegetationTargets.add(new NativeStructurePostProcessor.VegetationTarget( + start, decision.clearVegetation())); + } + } + } catch (Throwable e) { + LOGGER.error("Iris failed to resolve native structure {} in chunk {},{}", + structureId, chunkPos.x(), chunkPos.z(), e); + } + } + index++; + } + } + try { + NativeStructurePostProcessor.clearIntersectingVegetation( + world, chunk, area, vegetationTargets); + } catch (Throwable e) { + LOGGER.error("Iris failed to clear vegetation from native structures in chunk {},{}", + chunkPos.x(), chunkPos.z(), e); + } + for (NativePlacementGroup group : placementGroups) { + random.setFeatureSeed(decorationSeed, group.featureIndex(), group.step()); + try { + for (StructureStart start : group.starts()) { + placeVanillaStructure(world, structureManager, random, area, chunkPos, + group.structureId(), start, group.decision()); + } + } catch (Throwable e) { + LOGGER.error("Iris failed to place native structure {} in chunk {},{}", + group.structureId(), chunkPos.x(), chunkPos.z(), e); + } + } + } + + private void placeVanillaStructure(WorldGenLevel world, StructureManager structureManager, + WorldgenRandom random, BoundingBox area, ChunkPos chunkPos, + String structureId, StructureStart start, + IrisNativeStructureDecision decision) { + NativeStructurePostProcessor.place(world, structureManager, this, random, area, chunkPos, + structureId, start, decision, this::resolveStiltBlock); + } + + private List> structuresByStep(Registry registry) { + StructureStepCache cached = structureStepCache; + if (cached != null && cached.registry() == registry) { + return cached.structures(); + } + synchronized (this) { + cached = structureStepCache; + if (cached != null && cached.registry() == registry) { + return cached.structures(); + } + int steps = GenerationStep.Decoration.values().length; + List> grouped = new ArrayList<>(steps); + for (int step = 0; step < steps; step++) { + grouped.add(new ArrayList<>()); + } + for (Structure structure : registry) { + grouped.get(structure.step().ordinal()).add(structure); + } + for (int step = 0; step < steps; step++) { + grouped.set(step, List.copyOf(grouped.get(step))); + } + List> resolved = List.copyOf(grouped); + structureStepCache = new StructureStepCache(registry, resolved); + return resolved; + } + } + + private void recordWorldCheckStructureShift(String structureId, ChunkPos startChunk, int offsetY) { + if (!WORLD_CHECK_ENABLED || structureId == null) { + return; + } + if (worldCheckStructureShifts.size() >= WORLD_CHECK_SHIFT_RECORD_LIMIT) { + worldCheckStructureShifts.clear(); + } + worldCheckStructureShifts.put(new NativeStructureStartKey(structureId, startChunk.pack()), offsetY); + } + + Integer worldCheckStructureShift(String structureId, ChunkPos startChunk) { + if (structureId == null || startChunk == null) { + return null; + } + return worldCheckStructureShifts.get(new NativeStructureStartKey(structureId, startChunk.pack())); + } + + private BlockState resolveStiltBlock(IrisVanillaStructureStiltSettings settings, RNG rng, + int x, int y, int z) { + if (settings.getPalette() == null) { + return Blocks.COBBLESTONE.defaultBlockState(); + } + PlatformBlockState platformState = settings.getPalette().get(rng, x, y, z, engine().getData()); + if (platformState == null || !(platformState.nativeHandle() instanceof BlockState blockState)) { + return Blocks.COBBLESTONE.defaultBlockState(); + } + return blockState; + } + + private IrisImportedStructureControl importedControl() { + return engine().getDimension().getImportedStructures(); + } + + private BoundingBox writableArea(ChunkAccess chunk) { + ChunkPos chunkPos = chunk.getPos(); + int minX = chunkPos.getMinBlockX(); + int minZ = chunkPos.getMinBlockZ(); + int minY = chunk.getMinY(); + int maxY = minY + chunk.getHeight() - 1; + return new BoundingBox(minX, minY, minZ, minX + 15, maxY, minZ + 15); } @Override @@ -526,48 +1009,58 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { @Override public int getGenDepth() { - Engine current = engineOrNull(); - return current == null ? 384 : current.getMaxHeight() - current.getMinHeight(); + Engine current = engine; + return current == null || current.isClosed() + ? configuredPack().metadata().depth() + : current.getMaxHeight() - current.getMinHeight(); } @Override public int getSeaLevel() { - Engine current = engineOrNull(); - return current == null ? 63 : current.getMinHeight() + current.getDimension().getFluidHeight(); + Engine current = engine; + return current == null || current.isClosed() + ? configuredPack().metadata().seaLevel() + : current.getMinHeight() + current.getDimension().getFluidHeight(); } @Override public int getMinY() { - Engine current = engineOrNull(); - return current == null ? -64 : current.getMinHeight(); + Engine current = engine; + return current == null || current.isClosed() + ? configuredPack().metadata().minY() + : current.getMinHeight(); + } + + @Override + public int getSpawnHeight(LevelHeightAccessor heightAccessor) { + return clampSpawnHeight(heightAccessor.getMinY(), heightAccessor.getHeight()); + } + + static int clampSpawnHeight(int minY, int height) { + int minimum = minY + 1; + int maximum = minY + height - 2; + return Math.max(minimum, Math.min(maximum, 96)); } @Override public int getBaseHeight(int x, int z, Heightmap.Types type, LevelHeightAccessor heightAccessor, RandomState randomState) { - Engine current = engineOrNull(); - if (current == null) { - return heightAccessor.getMinY() + Math.min(heightAccessor.getHeight(), 64); - } - boolean ignoreFluid = type == Heightmap.Types.OCEAN_FLOOR || type == Heightmap.Types.OCEAN_FLOOR_WG; - return current.getMinHeight() + current.getHeight(x, z, ignoreFluid) + 1; + Engine current = requireDataQueryEngine("base height"); + boolean ignoreFluid = !type.isOpaque().test(Blocks.WATER.defaultBlockState()); + return heightAccessor.getMinY() + current.getHeight(x, z, ignoreFluid) + 1; } @Override public NoiseColumn getBaseColumn(int x, int z, LevelHeightAccessor heightAccessor, RandomState randomState) { int minY = heightAccessor.getMinY(); BlockState[] states = new BlockState[heightAccessor.getHeight()]; - Engine current = engineOrNull(); + Engine current = requireDataQueryEngine("base column"); BlockState airState = Blocks.AIR.defaultBlockState(); - if (current == null) { - Arrays.fill(states, airState); - return new NoiseColumn(minY, states); - } - int surface = current.getMinHeight() + current.getHeight(x, z, true); - int fluid = current.getMinHeight() + current.getDimension().getFluidHeight(); + int surface = current.getHeight(x, z, true); + int fluid = current.getHeight(x, z, false); BlockState stone = Blocks.STONE.defaultBlockState(); BlockState water = Blocks.WATER.defaultBlockState(); - int solidEnd = Math.max(0, Math.min(states.length, surface - minY + 1)); - int fluidEnd = Math.max(solidEnd, Math.max(0, Math.min(states.length, fluid - minY + 1))); + int solidEnd = Math.max(0, Math.min(states.length, surface + 1)); + int fluidEnd = Math.max(solidEnd, Math.max(0, Math.min(states.length, fluid + 1))); Arrays.fill(states, 0, solidEnd, stone); Arrays.fill(states, solidEnd, fluidEnd, water); Arrays.fill(states, fluidEnd, states.length, airState); @@ -581,4 +1074,62 @@ public final class IrisModdedChunkGenerator extends ChunkGenerator { private record SpawnTableKey(Biome biome, MobCategory category) { } + + private record StructureStepCache(Registry registry, List> structures) { + } + + private record NativeStructureStartKey(String structureId, long chunkPosition) { + } + + private record NativePlacementGroup(String structureId, IrisNativeStructureDecision decision, + int featureIndex, int step, List starts) { + } + + record DimensionMetadata(int minY, int maxY, int seaLevel) { + int depth() { + return maxY - minY; + } + } + + private record ConfiguredPack(IrisData data, IrisDimension dimension, DimensionMetadata metadata) { + } + + static final class EngineBinding { + private final long timeout; + private final TimeUnit timeoutUnit; + private volatile CompletableFuture future = new CompletableFuture<>(); + + EngineBinding(long timeout, TimeUnit timeoutUnit) { + this.timeout = timeout; + this.timeoutUnit = timeoutUnit; + } + + T await(String dimensionKey) { + try { + return future.get(timeout, timeoutUnit); + } catch (InterruptedException error) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for Iris generator '" + + dimensionKey + "' to bind", error); + } catch (ExecutionException error) { + throw new IllegalStateException("Iris generator '" + dimensionKey + "' failed to bind", + error.getCause()); + } catch (TimeoutException error) { + throw new IllegalStateException("Timed out waiting for Iris generator '" + + dimensionKey + "' to bind", error); + } + } + + void complete(T value) { + future.complete(value); + } + + void fail(Throwable error) { + future.completeExceptionally(error); + } + + void reset() { + future = new CompletableFuture<>(); + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java new file mode 100644 index 000000000..0550b60ea --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockBreakHandler.java @@ -0,0 +1,257 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisBlockDrops; +import art.arcane.iris.engine.object.IrisLoot; +import art.arcane.iris.engine.object.IrisMarker; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.matter.MatterMarker; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.EntityTypes; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.ChunkGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ConcurrentHashMap; + +public final class ModdedBlockBreakHandler { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + private static final ConcurrentHashMap PENDING = new ConcurrentHashMap<>(); + + private ModdedBlockBreakHandler() { + } + + public static void prepare(ServerLevel level, BlockPos position, BlockState brokenState) { + if (engineFor(level) == null) { + return; + } + BreakKey key = new BreakKey(level, position.asLong()); + PendingBreak pending = new PendingBreak(brokenState); + PENDING.put(key, pending); + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler != null) { + scheduler.laterGlobal(() -> finishPending(key, pending, position), 1); + } + } + + public static void clear() { + PENDING.clear(); + } + + public static Result complete(ServerLevel level, BlockPos position, BlockState fallbackState) { + BreakKey key = new BreakKey(level, position.asLong()); + PendingBreak pending = PENDING.remove(key); + BlockState brokenState = pending == null ? fallbackState : pending.brokenState(); + return evaluateSafely(level, position, brokenState); + } + + public static Result completePrepared(ServerLevel level, BlockPos position) { + BreakKey key = new BreakKey(level, position.asLong()); + PendingBreak pending = PENDING.get(key); + if (pending == null || level.getBlockState(position).equals(pending.brokenState())) { + return null; + } + return PENDING.remove(key, pending) ? evaluateSafely(level, position, pending.brokenState()) : null; + } + + public static void spawn(ServerLevel level, BlockPos position, KList drops) { + for (ItemStack stack : drops) { + ItemEntity item = createDrop(level, position, stack); + if (item != null) { + level.addFreshEntity(item); + } + } + } + + public static ItemEntity createDrop(ServerLevel level, BlockPos position, ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return null; + } + RandomSource random = level.getRandom(); + double x = position.getX() + 0.5D + Mth.nextDouble(random, -0.25D, 0.25D); + double y = position.getY() + 0.5D + Mth.nextDouble(random, -0.25D, 0.25D) - EntityTypes.ITEM.getHeight() / 2D; + double z = position.getZ() + 0.5D + Mth.nextDouble(random, -0.25D, 0.25D); + ItemEntity item = new ItemEntity(level, x, y, z, stack); + item.setDefaultPickUpDelay(); + return item; + } + + private static void finishPending(BreakKey key, PendingBreak pending, BlockPos position) { + if (!PENDING.remove(key, pending)) { + return; + } + ServerLevel level = key.level(); + if (level.getBlockState(position).equals(pending.brokenState())) { + return; + } + Result result = evaluateSafely(level, position, pending.brokenState()); + spawn(level, position, result.drops()); + } + + private static Result evaluateSafely(ServerLevel level, BlockPos position, BlockState brokenState) { + try { + return evaluate(level, position, brokenState); + } catch (Throwable error) { + LOGGER.error("Iris block-break processing failed at {},{},{} in {}", position.getX(), position.getY(), position.getZ(), + level.dimension().identifier(), error); + return Result.empty(); + } + } + + private static Result evaluate(ServerLevel level, BlockPos position, BlockState brokenState) { + Engine engine = engineFor(level); + if (engine == null || engine.isClosed()) { + return Result.empty(); + } + + removeMarker(engine, position); + KList providers = providers(engine, position, brokenState); + if (providers.isEmpty()) { + return Result.empty(); + } + + KList drops = new KList<>(); + boolean replaceVanillaDrops = false; + for (IrisBlockDrops provider : providers) { + replaceVanillaDrops |= provider.isReplaceVanillaDrops(); + for (IrisLoot loot : provider.getDrops()) { + if (RNG.r.i(1, loot.getRarity()) != loot.getRarity()) { + continue; + } + ItemStack stack = ModdedItemTranslator.stack(loot, RNG.r, level); + if (stack != null && !stack.isEmpty()) { + drops.add(stack); + } + } + } + return new Result(drops, replaceVanillaDrops); + } + + private static void removeMarker(Engine engine, BlockPos position) { + int mantleY = position.getY() - engine.getMinHeight(); + MatterMarker marker = engine.getMantle().getMantle().get(position.getX(), mantleY, position.getZ(), MatterMarker.class); + if (marker == null) { + return; + } + String tag = marker.getTag(); + if ("cave_floor".equals(tag) || "cave_ceiling".equals(tag)) { + return; + } + IrisMarker configured = engine.getData().getMarkerLoader().load(tag); + if (configured == null || configured.isRemoveOnChange()) { + engine.getMantle().getMantle().remove(position.getX(), mantleY, position.getZ(), MatterMarker.class); + } + } + + private static KList providers(Engine engine, BlockPos position, BlockState brokenState) { + KList providers = new KList<>(); + IrisData data = engine.getData(); + int relativeY = position.getY() - engine.getMinHeight(); + IrisBiome biome = engine.getBiome(position.getX(), relativeY, position.getZ()); + if (biome != null) { + addMatching(providers, biome.getBlockDrops(), brokenState, data); + } + if (skipsParents(providers)) { + return providers; + } + IrisRegion region = engine.getRegion(position.getX(), position.getZ()); + if (region != null) { + addMatching(providers, region.getBlockDrops(), brokenState, data); + } + addMatching(providers, engine.getDimension().getBlockDrops(), brokenState, data); + return providers; + } + + private static void addMatching(KList matches, KList candidates, BlockState brokenState, IrisData data) { + for (IrisBlockDrops candidate : candidates) { + if (matches(candidate, brokenState, data)) { + matches.add(candidate); + } + } + } + + private static boolean skipsParents(KList providers) { + for (IrisBlockDrops provider : providers) { + if (provider.isSkipParents()) { + return true; + } + } + return false; + } + + private static boolean matches(IrisBlockDrops provider, BlockState brokenState, IrisData data) { + for (IrisBlockData configuredBlock : provider.getBlocks()) { + PlatformBlockState resolved = configuredBlock.getBlockData(data); + if (resolved == null || !(resolved.nativeHandle() instanceof BlockState configuredState)) { + continue; + } + if (matchesState(configuredState, brokenState, provider.isExactBlocks())) { + return true; + } + } + return false; + } + + static boolean matchesState(BlockState configuredState, BlockState brokenState, boolean exact) { + return exact ? configuredState.equals(brokenState) : configuredState.getBlock() == brokenState.getBlock(); + } + + private static Engine engineFor(ServerLevel level) { + ChunkGenerator generator = level.getChunkSource().getGenerator(); + if (!(generator instanceof IrisModdedChunkGenerator irisGenerator)) { + return null; + } + Engine engine = irisGenerator.engineIfBound(); + if (engine != null) { + return engine; + } + try { + return irisGenerator.commandEngine(); + } catch (Throwable error) { + LOGGER.error("Iris could not resolve the engine for a block break in {}", level.dimension().identifier(), error); + return null; + } + } + + public record Result(KList drops, boolean replaceVanillaDrops) { + private static Result empty() { + return new Result(new KList<>(), false); + } + } + + private record BreakKey(ServerLevel level, long position) { + } + + private record PendingBreak(BlockState brokenState) { + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java index ce5cd7471..27d851544 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockResolution.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.modded.api.ModdedCustomContentRegistry; +import art.arcane.iris.modded.api.ModdedBlockData; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.data.UnresolvedKeyLog; import com.mojang.brigadier.StringReader; @@ -89,7 +90,7 @@ public final class ModdedBlockResolution { private ModdedBlockResolution() { } - record Parsed(BlockState state, Map, Comparable> properties) { + record Parsed(BlockState state, Map, Comparable> properties, String deferredPlacementKey) { } private static Set blockSet(String... ids) { @@ -171,12 +172,12 @@ public final class ModdedBlockResolution { public static ModdedBlockState get(String bdxf) { Parsed parsed = resolveGet(bdxf); - return ModdedBlockState.of(parsed.state(), parsed.properties()); + return stateFrom(parsed); } public static ModdedBlockState getNoCompat(String bdxf) { Parsed parsed = resolveNoCompat(bdxf); - return ModdedBlockState.of(parsed.state(), parsed.properties()); + return stateFrom(parsed); } public static ModdedBlockState getOrNull(String bdxf) { @@ -185,7 +186,13 @@ public final class ModdedBlockResolution { public static ModdedBlockState getOrNull(String bdxf, boolean warn) { Parsed parsed = resolveOrNull(bdxf, warn); - return parsed == null ? null : ModdedBlockState.of(parsed.state(), parsed.properties()); + return parsed == null ? null : stateFrom(parsed); + } + + private static ModdedBlockState stateFrom(Parsed parsed) { + return parsed.deferredPlacementKey() == null + ? ModdedBlockState.of(parsed.state(), parsed.properties()) + : ModdedBlockState.deferred(parsed.state(), parsed.properties(), parsed.deferredPlacementKey()); } static Parsed resolveGet(String bdxf) { @@ -194,7 +201,7 @@ public final class ModdedBlockResolution { return parsed; } IrisLogging.error("Can't find block data for " + bdxf); - return new Parsed(AIR, null); + return new Parsed(AIR, null, null); } static Parsed resolveNoCompat(String bdxf) { @@ -202,7 +209,7 @@ public final class ModdedBlockResolution { if (parsed != null) { return parsed; } - return new Parsed(AIR, null); + return new Parsed(AIR, null, null); } static Parsed resolveOrNull(String bdxf, boolean warn) { @@ -214,20 +221,20 @@ public final class ModdedBlockResolution { } if (bd.equals("minecraft:grass_path")) { - return new Parsed(Blocks.DIRT_PATH.defaultBlockState(), null); + return new Parsed(Blocks.DIRT_PATH.defaultBlockState(), null, null); } Parsed bdx = parseBlockData(bd, warn); if (bdx == null) { - BlockState provided = ModdedCustomContentRegistry.resolveBlock(bd); + ModdedBlockData provided = ModdedCustomContentRegistry.resolveBlock(bd); if (provided != null) { - return new Parsed(provided, null); + return new Parsed(provided.state(), null, provided.deferredPlacement() ? bd : null); } if (warn) { warnUnresolved(bd, "Unknown Block Data '" + bd + "'"); } - return new Parsed(AIR, null); + return new Parsed(AIR, null, null); } return bdx; @@ -243,7 +250,7 @@ public final class ModdedBlockResolution { public static ModdedBlockState strictParse(String key) { Parsed parsed = parseStrict(key); - return ModdedBlockState.of(parsed.state(), parsed.properties()); + return stateFrom(parsed); } private static Parsed parseStrict(String key) { @@ -257,7 +264,7 @@ public final class ModdedBlockResolution { if (reader.canRead()) { throw new IllegalArgumentException("Could not parse remainder: " + reader.getRemaining()); } - return new Parsed(result.blockState(), result.properties()); + return new Parsed(result.blockState(), result.properties(), null); } private static Parsed createBlockData(String s, boolean warn) { @@ -283,7 +290,7 @@ public final class ModdedBlockResolution { if (identifier == null || !BuiltInRegistries.BLOCK.containsKey(identifier)) { return null; } - return new Parsed(BuiltInRegistries.BLOCK.getValue(identifier).defaultBlockState(), null); + return new Parsed(BuiltInRegistries.BLOCK.getValue(identifier).defaultBlockState(), null, null); } private static Parsed parseBlockData(String ix, boolean warn) { @@ -307,7 +314,7 @@ public final class ModdedBlockResolution { if (bx.state().getBlock() instanceof LeavesBlock) { BlockState mutated = bx.state().setValue(LeavesBlock.PERSISTENT, shouldPreventLeafDecay()); - bx = new Parsed(mutated, bx.properties()); + bx = new Parsed(mutated, bx.properties(), bx.deferredPlacementKey()); } return bx; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java index 45425c505..0cf22b69d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedBlockState.java @@ -20,11 +20,13 @@ package art.arcane.iris.modded; import art.arcane.iris.spi.PlatformBlockState; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.tags.BlockTags; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.block.state.properties.Property; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @@ -35,24 +37,31 @@ public final class ModdedBlockState implements PlatformBlockState { private final Map, Comparable> parsedProperties; private final String key; private final String namespace; + private final String deferredPlacementKey; private volatile Boolean air; private volatile Boolean solid; private volatile Boolean occluding; private volatile Boolean fluid; private volatile Boolean water; private volatile Boolean foliage; + private volatile Boolean treeBlock; private volatile Boolean decorant; - private ModdedBlockState(BlockState state, Map, Comparable> parsedProperties, String key) { + private ModdedBlockState(BlockState state, Map, Comparable> parsedProperties, String key, String deferredPlacementKey) { this.state = state; this.parsedProperties = parsedProperties; this.key = key; this.namespace = parseNamespace(key); + this.deferredPlacementKey = deferredPlacementKey; } public static ModdedBlockState of(BlockState state, Map, Comparable> parsedProperties) { String key = serialize(state); - return CACHE.computeIfAbsent(key, (String k) -> new ModdedBlockState(state, parsedProperties, k)); + return CACHE.computeIfAbsent(key, (String k) -> new ModdedBlockState(state, parsedProperties, k, null)); + } + + public static ModdedBlockState deferred(BlockState state, Map, Comparable> parsedProperties, String placementKey) { + return new ModdedBlockState(state, parsedProperties, serialize(state), Objects.requireNonNull(placementKey)); } public static String serialize(BlockState state) { @@ -83,12 +92,12 @@ public final class ModdedBlockState implements PlatformBlockState { if (!(other instanceof ModdedBlockState blockState)) { return false; } - return state.equals(blockState.state); + return state.equals(blockState.state) && Objects.equals(deferredPlacementKey, blockState.deferredPlacementKey); } @Override public int hashCode() { - return key.hashCode(); + return Objects.hash(state, deferredPlacementKey); } private static String parseNamespace(String key) { @@ -171,7 +180,17 @@ public final class ModdedBlockState implements PlatformBlockState { @Override public boolean isCustom() { - return false; + return deferredPlacementKey != null; + } + + @Override + public String deferredPlacementKey() { + return deferredPlacementKey; + } + + @Override + public PlatformBlockState placementBaseState() { + return deferredPlacementKey == null ? this : of(state, parsedProperties); } @Override @@ -219,6 +238,16 @@ public final class ModdedBlockState implements PlatformBlockState { return cached; } + @Override + public boolean isTreeBlock() { + Boolean cached = treeBlock; + if (cached == null) { + cached = state.is(BlockTags.LOGS) || state.is(BlockTags.LEAVES); + treeBlock = cached; + } + return cached; + } + @Override public boolean isFoliagePlantable() { return ModdedBlockResolution.isFoliagePlantable(state); @@ -298,7 +327,17 @@ public final class ModdedBlockState implements PlatformBlockState { @Override public PlatformBlockState withProperty(String name, String value) { String merged = mergeProperty(key, name, value); - return ModdedBlockResolution.strictParse(merged); + ModdedBlockState resolved = ModdedBlockResolution.strictParse(merged); + return withHandle(resolved.handle(), resolved.parsedProperties()); + } + + ModdedBlockState withHandle(BlockState updated, Map, Comparable> properties) { + if (updated == state && properties == parsedProperties) { + return this; + } + return deferredPlacementKey == null + ? of(updated, properties) + : deferred(updated, properties, deferredPlacementKey); } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java index bf822677e..b15762a1d 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDeathLoot.java @@ -24,9 +24,12 @@ import art.arcane.iris.engine.object.IrisLootTable; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.LivingEntity; -import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.vehicle.ContainerEntity; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.chunk.ChunkGenerator; @@ -37,81 +40,125 @@ public final class ModdedDeathLoot { private static final String TAG_PREFIX = "iris_loot|"; private static final int LOOT_RNG_SALT = 345911; private static final Set WARNED_TABLES = ConcurrentHashMap.newKeySet(); + private static final Set WARNED_ENTITY_TYPES = ConcurrentHashMap.newKeySet(); private ModdedDeathLoot() { } - public static void tag(LivingEntity entity, KList tableKeys, int spawnX, int spawnY, int spawnZ, RNG rng) { - if (entity == null || tableKeys == null || tableKeys.isEmpty()) { + public static void bind(Engine engine, Entity entity, KList tableKeys, int spawnX, int spawnY, int spawnZ, RNG rng) { + if (engine == null || entity == null || tableKeys == null || tableKeys.isEmpty()) { return; } - entity.addTag(TAG_PREFIX + rng.getSeed() + "|" + spawnX + "|" + spawnY + "|" + spawnZ + "|" + String.join(",", tableKeys)); + LootBinding binding = new LootBinding(rng.getSeed(), spawnX, spawnY, spawnZ, new KList<>(tableKeys)); + if (entity instanceof Mob) { + entity.addTag(encode(binding)); + return; + } + if (entity instanceof ContainerEntity container && entity.level() instanceof ServerLevel level) { + fillContainer(engine, level, container, binding); + return; + } + + String type = String.valueOf(BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType())); + if (WARNED_ENTITY_TYPES.add(type)) { + IrisLogging.warn("Iris entity loot: entity type '" + type + "' does not expose a vanilla lootable path"); + } } - public static void handle(LivingEntity entity) { + public static boolean replaceBaseLoot(LivingEntity entity) { if (entity == null || !(entity.level() instanceof ServerLevel level)) { - return; + return false; } String encoded = findTag(entity); if (encoded == null) { - return; + return false; } - String[] parts = encoded.substring(TAG_PREFIX.length()).split("\\|", 5); - if (parts.length < 5) { - IrisLogging.debug("Iris death loot: malformed loot tag '" + encoded + "'"); - return; - } - long seed; - int spawnX; - int spawnY; - int spawnZ; - try { - seed = Long.parseLong(parts[0]); - spawnX = Integer.parseInt(parts[1]); - spawnY = Integer.parseInt(parts[2]); - spawnZ = Integer.parseInt(parts[3]); - } catch (NumberFormatException error) { - IrisLogging.debug("Iris death loot: malformed loot tag '" + encoded + "'"); - return; + LootBinding binding = decode(encoded); + if (binding == null) { + return true; } Engine engine = engineFor(level); if (engine == null) { - return; + return true; } - RNG lootRng = new RNG(seed).nextParallelRNG(LOOT_RNG_SALT); - KList drops = new KList<>(); - for (String key : parts[4].split(",")) { - String trimmed = key.trim(); - if (trimmed.isEmpty()) { - continue; + KList stacks = resolve(engine, level, binding); + emit(level, entity, stacks); + return true; + } + + static boolean hasBindingTag(Set tags) { + for (String tag : tags) { + if (tag.startsWith(TAG_PREFIX)) { + return true; } - IrisLootTable table = engine.getData().getLootLoader().load(trimmed); + } + return false; + } + + private static void fillContainer(Engine engine, ServerLevel level, ContainerEntity container, LootBinding binding) { + container.setContainerLootTable(null); + container.clearItemStacks(); + KList items = resolve(engine, level, binding); + ModdedLootApplier.fillContainer(container, items, new RNG(binding.seed())); + } + + private static KList resolve(Engine engine, ServerLevel level, LootBinding binding) { + KList drops = new KList<>(); + for (String key : binding.tableKeys()) { + IrisLootTable table = engine.getData().getLootLoader().load(key); if (table == null) { - if (WARNED_TABLES.add(trimmed)) { - IrisLogging.warn("Iris death loot: unknown loot table '" + trimmed + "'"); + if (WARNED_TABLES.add(key)) { + IrisLogging.warn("Iris death loot: unknown loot table '" + key + "'"); } continue; } - drops.addAll(ModdedItemTranslator.loot(table, lootRng, InventorySlotType.STORAGE, level, spawnX, spawnY, spawnZ)); + RNG lootRng = new RNG(binding.seed()).nextParallelRNG(LOOT_RNG_SALT); + drops.addAll(ModdedItemTranslator.loot(table, lootRng, InventorySlotType.STORAGE, level, + binding.spawnX(), binding.spawnY(), binding.spawnZ())); } - emit(level, entity, drops); + return drops; } private static void emit(ServerLevel level, LivingEntity entity, KList drops) { - double x = entity.getX(); - double y = entity.getY() + 0.5D; - double z = entity.getZ(); for (ItemStack stack : drops) { if (stack == null || stack.isEmpty()) { continue; } - ItemEntity itemEntity = new ItemEntity(level, x, y, z, stack); - itemEntity.setDefaultPickUpDelay(); - level.addFreshEntity(itemEntity); + entity.spawnAtLocation(level, stack); } } - private static String findTag(LivingEntity entity) { + private static String encode(LootBinding binding) { + return TAG_PREFIX + binding.seed() + "|" + binding.spawnX() + "|" + binding.spawnY() + "|" + + binding.spawnZ() + "|" + String.join(",", binding.tableKeys()); + } + + private static LootBinding decode(String encoded) { + String[] parts = encoded.substring(TAG_PREFIX.length()).split("\\|", 5); + if (parts.length < 5) { + IrisLogging.debug("Iris death loot: malformed loot tag '" + encoded + "'"); + return null; + } + try { + long seed = Long.parseLong(parts[0]); + int spawnX = Integer.parseInt(parts[1]); + int spawnY = Integer.parseInt(parts[2]); + int spawnZ = Integer.parseInt(parts[3]); + KList tableKeys = new KList<>(); + for (String key : parts[4].split(",")) { + String trimmed = key.trim(); + if (!trimmed.isEmpty()) { + tableKeys.add(trimmed); + } + } + return new LootBinding(seed, spawnX, spawnY, spawnZ, tableKeys); + } catch (NumberFormatException error) { + IrisLogging.debug("Iris death loot: malformed loot tag '" + encoded + "'"); + return null; + } + } + + private static String findTag(Entity entity) { for (String tag : entity.entityTags()) { if (tag.startsWith(TAG_PREFIX)) { return tag; @@ -127,4 +174,7 @@ public final class ModdedDeathLoot { } return null; } + + private record LootBinding(long seed, int spawnX, int spawnY, int spawnZ, KList tableKeys) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java index 6f8ddd9f5..5a46711ab 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java @@ -91,7 +91,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi } } - return ModdedBlockState.of(cloned, fabric.parsedProperties()); + return fabric.withHandle(cloned, fabric.parsedProperties()); } @Override diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java index 1b3b1cfe0..f22d8d3f8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionManager.java @@ -39,7 +39,6 @@ import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.BiomeManager; import net.minecraft.world.level.biome.Biomes; import net.minecraft.world.level.biome.FixedBiomeSource; -import net.minecraft.world.level.dimension.BuiltinDimensionTypes; import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.dimension.LevelStem; import net.minecraft.world.level.levelgen.Heightmap; @@ -53,7 +52,6 @@ import org.slf4j.LoggerFactory; import java.io.File; import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -64,7 +62,6 @@ public final class ModdedDimensionManager { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Object LOCK = new Object(); private static final ConcurrentHashMap HANDLES = new ConcurrentHashMap<>(); - private static final Set TYPE_FALLBACK_WARNINGS = ConcurrentHashMap.newKeySet(); private static final TicketType TELEPORT_WARM_TICKET = new TicketType(TicketType.NO_TIMEOUT, TicketType.FLAG_LOADING); private static volatile ModdedServerAccess access; @@ -234,21 +231,15 @@ public final class ModdedDimensionManager { private static Holder resolveDimensionType(RegistryAccess registryAccess, String pack, String packDimensionKey) { Registry registry = registryAccess.lookupOrThrow(Registries.DIMENSION_TYPE); IrisDimension dimension = loadPackDimension(pack, packDimensionKey); - if (dimension != null) { - String typeRef = ModdedForcedDatapack.dimensionTypeRef(dimension); - ResourceKey typeKey = ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.parse(typeRef)); - Optional> packType = registry.get(typeKey); - if (packType.isPresent()) { - return packType.get(); - } - if (TYPE_FALLBACK_WARNINGS.add(typeRef)) { - LOGGER.warn("Iris dimension type {} (pack {} dim {}) is not registered yet; injecting with fallback heights. Restart the server so the forced datapack installs it.", typeRef, pack, packDimensionKey); - } + if (dimension == null) { + throw new IllegalStateException("Iris cannot resolve dimension type for missing pack dimension '" + + packDimensionKey + "' in pack '" + pack + "'"); } - ResourceKey studioPool = ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.parse("irisworldgen:studio_pool")); - return registry.get(studioPool) - .map(reference -> (Holder) reference) - .orElseGet(() -> registry.getOrThrow(BuiltinDimensionTypes.OVERWORLD)); + String typeRef = ModdedForcedDatapack.dimensionTypeRef(dimension); + ResourceKey typeKey = ResourceKey.create(Registries.DIMENSION_TYPE, Identifier.parse(typeRef)); + Holder.Reference packType = ModdedForcedDatapack.requireRegisteredDimensionType( + typeRef, registry.get(typeKey), pack, packDimensionKey); + return packType; } private static IrisDimension loadPackDimension(String pack, String packDimensionKey) { @@ -295,6 +286,7 @@ public final class ModdedDimensionManager { false); serverAccess.putLevel(server, key, level); + generator.bindLevel(level); server.getPlayerList().addWorldborderListener(level); return new Handle(dimensionId, pack, packDimensionKey, seed, level, generator); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java index 582fab6cb..b1f49740e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineBootstrap.java @@ -21,6 +21,8 @@ package art.arcane.iris.modded; import art.arcane.iris.core.gui.GuiHost; import art.arcane.iris.engine.decorator.DecoratorPlatformHooks; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineEffectsProvider; +import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.engine.framework.EngineWorldManagerProvider; import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.engine.object.BlockDataMergeSupport; @@ -42,7 +44,12 @@ import art.arcane.iris.modded.service.ModdedSettingsHotloadService; import art.arcane.iris.modded.service.ModdedStudioHotloadService; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; +import net.minecraft.core.BlockPos; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.storage.LevelData; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,6 +65,8 @@ public final class ModdedEngineBootstrap { private static volatile ModdedLoader loader; private static volatile ModdedPlatform platform; private static volatile MinecraftServer currentServer; + private static volatile MinecraftServer spawnCaptureServer; + private static volatile boolean initialSpawnWasDefault; private ModdedEngineBootstrap() { } @@ -81,8 +90,10 @@ public final class ModdedEngineBootstrap { } public static void start(MinecraftServer server) { + captureInitialSpawn(server); currentServer = server; bind(); + bindWorldGenerators(server); ModdedStartup.reset(); ModdedScheduler scheduler = schedulerOrNull(); if (scheduler != null) { @@ -94,11 +105,38 @@ public final class ModdedEngineBootstrap { ModdedSentry.start(loader()); } + private static void bindWorldGenerators(MinecraftServer server) { + for (ServerLevel level : server.getAllLevels()) { + if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) { + generator.bindLevel(level); + } + } + } + + public static void serverAboutToStart(MinecraftServer server) { + captureInitialSpawn(server); + } + + public static void serverStarted(MinecraftServer server) { + bindWorldGenerators(server); + reconcileSpawn(server); + ModdedWorldCheck.serverStarted(server); + } + + public static void levelLoaded(ServerLevel level) { + if (level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator generator) { + generator.bindLevel(level); + } + } + public static void stop() { + MinecraftServer stoppingServer = currentServer; + ModdedWorldCheck.serverStopped(stoppingServer); ModdedProtocolHandler.stop(); ModdedPregenJob.shutdown(); ModdedObjectUndo.clearAll(); ModdedWandService.clearAll(); + ModdedBlockBreakHandler.clear(); ModdedStudioCommands.clear(); ModdedWorldEngines.shutdown(); ModdedPrimaryWorldRouter.clear(); @@ -112,6 +150,49 @@ public final class ModdedEngineBootstrap { ModdedSentry.flush(); ModdedStartup.reset(); currentServer = null; + spawnCaptureServer = null; + initialSpawnWasDefault = false; + } + + private static void captureInitialSpawn(MinecraftServer server) { + if (spawnCaptureServer == server) { + return; + } + LevelData.RespawnData respawnData = server.getRespawnData(); + initialSpawnWasDefault = respawnData == null || LevelData.RespawnData.DEFAULT.equals(respawnData); + spawnCaptureServer = server; + } + + private static void reconcileSpawn(MinecraftServer server) { + LevelData.RespawnData current = server.getRespawnData(); + if (current == null) { + return; + } + ServerLevel level = server.getLevel(current.dimension()); + if (level == null || !(level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator)) { + return; + } + String dimensionId = level.dimension().identifier().toString(); + boolean studio = dimensionId.startsWith("irisworldgen:studio_"); + if (!shouldReconcileSpawn(initialSpawnWasDefault, studio, current.pos().getX(), current.pos().getZ())) { + return; + } + LevelChunk originChunk = level.getChunk(0, 0); + int surfaceY = originChunk.getHeight(Heightmap.Types.MOTION_BLOCKING, 0, 0) + 1; + BlockPos position = reconciledSpawnPosition(surfaceY, level.getMinY(), level.getHeight()); + server.setRespawnData(LevelData.RespawnData.of( + level.dimension(), position, current.yaw(), current.pitch())); + LOGGER.info("Iris spawn reconciled for {} at {},{},{}", dimensionId, + position.getX(), position.getY(), position.getZ()); + } + + static boolean shouldReconcileSpawn(boolean initialDefault, boolean studio, int currentX, int currentZ) { + return initialDefault || studio || currentX == 0 && currentZ == 0; + } + + static BlockPos reconciledSpawnPosition(int surfaceY, int minY, int height) { + int y = Math.max(minY + 1, Math.min(minY + height - 2, surfaceY)); + return new BlockPos(0, y, 0); } public static void bootCommon(ModdedLoader moddedLoader, String loaderDescription, Runnable chunkGeneratorRegistration) { @@ -191,6 +272,7 @@ public final class ModdedEngineBootstrap { IrisObjectRotation.bindFallbackRotator(new ModdedStateRotator()); BlockDataMergeSupport.bindFallbackMerger(new ModdedStateMerger()); TileData.bindFallbackReader(new ModdedTileReader(boundLoader::currentServer)); + TileData.bindFallbackFactory(ModdedTileData::fromProperties); ModdedGuiHost.install(); ModdedDecoratorHooks decoratorHooks = new ModdedDecoratorHooks(); DecoratorPlatformHooks.bind(decoratorHooks, decoratorHooks); @@ -198,10 +280,12 @@ public final class ModdedEngineBootstrap { SERVICE_MANAGER.register(ModdedLogFilterService.class, new ModdedLogFilterService()); SERVICE_MANAGER.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); SERVICE_MANAGER.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); - SERVICE_MANAGER.register(ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); + ModdedStudioHotloadService studioHotloadService = SERVICE_MANAGER.register(ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); SERVICE_MANAGER.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); SERVICE_MANAGER.register(ModdedEntitySpawnService.class, new ModdedEntitySpawnService()); IrisServices.register(PreservationRegistry.class, preservation); + IrisServices.register(EngineEffectsProvider.class, (EngineEffectsProvider) ModdedEngineEffects::new); + IrisServices.register(EnginePlatformHooks.class, studioHotloadService); IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> new ModdedWorldManager(engine)); ModdedCustomContentRegistry.discover(); platform = created; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java new file mode 100644 index 000000000..600de8b39 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEngineEffects.java @@ -0,0 +1,499 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineAssignedComponent; +import art.arcane.iris.engine.framework.EngineEffects; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisCommand; +import art.arcane.iris.engine.object.IrisCommandRegistry; +import art.arcane.iris.engine.object.IrisEffect; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.math.RNG; +import net.minecraft.core.Holder; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.core.particles.SimpleParticleType; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.protocol.game.ClientboundSoundPacket; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.effect.MobEffect; +import net.minecraft.world.effect.MobEffectInstance; +import net.minecraft.world.effect.MobEffects; +import net.minecraft.world.phys.Vec3; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class ModdedEngineEffects extends EngineAssignedComponent implements EngineEffects { + private static final long EFFECT_BUDGET_NANOS = 1_500_000L; + private static final long SAMPLE_INTERVAL_MILLIS = 55L; + private static final double SAMPLE_DISTANCE_SQUARED = 81.0D; + private static final double PLAYER_REFRESH_CHANCE = 0.02D; + private static final Set UNKNOWN_POTIONS = ConcurrentHashMap.newKeySet(); + private static final Set UNKNOWN_SOUNDS = ConcurrentHashMap.newKeySet(); + private static final Set UNKNOWN_PARTICLES = ConcurrentHashMap.newKeySet(); + private static final Set UNSUPPORTED_PARTICLES = ConcurrentHashMap.newKeySet(); + + private final Map players; + private final AtomicBoolean updateRequested; + private final AtomicBoolean tickRequested; + private final AtomicBoolean passQueued; + + public ModdedEngineEffects(Engine engine) { + super(engine, "FX"); + players = new HashMap<>(); + updateRequested = new AtomicBoolean(false); + tickRequested = new AtomicBoolean(false); + passQueued = new AtomicBoolean(false); + } + + @Override + public void updatePlayerMap() { + updateRequested.set(true); + queueMainPass(); + } + + @Override + public void tickRandomPlayer() { + tickRequested.set(true); + queueMainPass(); + } + + private void queueMainPass() { + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null || !passQueued.compareAndSet(false, true)) { + return; + } + scheduler.global(this::runQueuedPass); + } + + private void runQueuedPass() { + try { + boolean shouldUpdate = updateRequested.getAndSet(false); + boolean shouldTick = tickRequested.getAndSet(false); + if (!shouldUpdate && !shouldTick) { + return; + } + + ServerLevel level = resolveLevel(); + if (level == null) { + players.clear(); + return; + } + + if (shouldUpdate) { + syncPlayers(level); + } + if (shouldTick) { + tickPlayers(level); + } + } catch (Throwable error) { + IrisLogging.reportError(error); + } finally { + passQueued.set(false); + if (updateRequested.get() || tickRequested.get()) { + queueMainPass(); + } + } + } + + private ServerLevel resolveLevel() { + Engine engine = getEngine(); + if (engine.isClosed() || !engine.getWorld().hasPlatformWorld()) { + return null; + } + Object nativeWorld = engine.getWorld().platformWorld().nativeHandle(); + return nativeWorld instanceof ServerLevel ? (ServerLevel) nativeWorld : null; + } + + private void syncPlayers(ServerLevel level) { + List activePlayers = level.players(); + Set activeIds = new HashSet<>(Math.max(16, activePlayers.size() * 2)); + for (ServerPlayer player : activePlayers) { + UUID playerId = player.getUUID(); + activeIds.add(playerId); + PlayerState state = players.get(playerId); + if (state == null) { + players.put(playerId, new PlayerState(player)); + } else { + state.player(player); + } + } + + Iterator> iterator = players.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (!activeIds.contains(entry.getKey())) { + iterator.remove(); + } + } + } + + private void tickPlayers(ServerLevel level) { + if (players.isEmpty()) { + syncPlayers(level); + } else if (RNG.r.d() < PLAYER_REFRESH_CHANCE) { + syncPlayers(level); + return; + } + if (players.isEmpty()) { + return; + } + + List snapshot = new ArrayList<>(players.values()); + long started = System.nanoTime(); + int remaining = snapshot.size(); + while (remaining-- > 0 && System.nanoTime() - started < EFFECT_BUDGET_NANOS) { + PlayerState state = snapshot.get(RNG.r.nextInt(snapshot.size())); + try { + tickPlayer(level, state); + } catch (Throwable error) { + IrisLogging.reportError(error); + } + } + } + + private void tickPlayer(ServerLevel level, PlayerState state) { + ServerPlayer player = state.player(); + if (!player.isAlive() || player.isRemoved() || player.level() != level) { + return; + } + + samplePlayer(state); + if (!IrisSettings.get().getWorld().isEffectSystem()) { + return; + } + + IrisRegion region = state.region(); + if (region != null) { + applyEffects(level, player, region.getEffects()); + } + IrisBiome biome = state.biome(); + if (biome != null) { + applyEffects(level, player, biome.getEffects()); + } + } + + private void samplePlayer(PlayerState state) { + ServerPlayer player = state.player(); + double deltaX = player.getX() - state.lastX(); + double deltaY = player.getY() - state.lastY(); + double deltaZ = player.getZ() - state.lastZ(); + double distanceSquared = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ; + long now = System.currentTimeMillis(); + if (!needsSample(state.sampled(), now - state.lastSample(), distanceSquared)) { + return; + } + + state.sampled(true); + state.lastPosition(player.getX(), player.getY(), player.getZ()); + state.lastSample(now); + int blockX = floor(player.getX()); + int blockY = floor(player.getY()) - getEngine().getWorld().minHeight(); + int blockZ = floor(player.getZ()); + state.biome(getEngine().getBiome(blockX, blockY, blockZ)); + state.region(getEngine().getRegion(blockX, blockZ)); + } + + private void applyEffects(ServerLevel level, ServerPlayer player, Iterable effects) { + for (IrisEffect effect : effects) { + try { + applyEffect(level, player, effect); + } catch (Throwable error) { + IrisLogging.reportError(error); + } + } + } + + private void applyEffect(ServerLevel level, ServerPlayer player, IrisEffect effect) { + if (!effect.shouldApplyNow()) { + return; + } + + applySound(player, effect); + applyParticles(level, player, effect); + applyCommands(level, player, effect.getCommandRegistry()); + applyPotion(player, effect); + } + + private void applySound(ServerPlayer player, IrisEffect effect) { + String key = normalizeRegistryKey(effect.getSoundKey()); + if (key == null) { + return; + } + Identifier identifier = Identifier.tryParse(key); + Optional> sound = identifier == null + ? Optional.empty() + : BuiltInRegistries.SOUND_EVENT.get(identifier); + if (sound.isEmpty()) { + warnUnknown(UNKNOWN_SOUNDS, "sound", key); + return; + } + + int distance = effect.getSoundDistance(); + double x = player.getX() + RNG.r.i(-distance, distance); + double y = player.getY() + RNG.r.i(-distance, distance); + double z = player.getZ() + RNG.r.i(-distance, distance); + float volume = (float) effect.getVolume(); + float pitch = (float) RNG.r.d(effect.getMinPitch(), effect.getMaxPitch()); + player.connection.send(new ClientboundSoundPacket( + sound.get(), SoundSource.MASTER, x, y, z, volume, pitch, ThreadLocalRandom.current().nextLong())); + } + + private void applyParticles(ServerLevel level, ServerPlayer player, IrisEffect effect) { + String key = normalizeRegistryKey(effect.getParticleEffectKey()); + if (key == null) { + return; + } + Identifier identifier = Identifier.tryParse(key); + ParticleType particleType = identifier == null + ? null + : BuiltInRegistries.PARTICLE_TYPE.getValue(identifier); + if (particleType == null) { + warnUnknown(UNKNOWN_PARTICLES, "particle", key); + return; + } + if (!(particleType instanceof SimpleParticleType simpleParticle)) { + if (UNSUPPORTED_PARTICLES.add(key)) { + IrisLogging.warn("Particle type \"" + key + "\" requires particle data and cannot be used by an Iris effect without data."); + } + return; + } + + Vec3 direction = player.getLookAngle(); + double forward = RNG.r.i(effect.getParticleDistance()) + effect.getParticleAway(); + double sideways = RNG.r.d(-effect.getParticleDistanceWidth(), effect.getParticleDistanceWidth()); + double surfaceX = player.getX() + direction.x * forward + direction.z * sideways; + double surfaceZ = player.getZ() + direction.z * forward - direction.x * sideways; + int surfaceY = getEngine().getHeight(floor(surfaceX), floor(surfaceZ)); + double x = surfaceX + RNG.r.d(); + double y = surfaceY + 1.0D + level.getMinY() + RNG.r.i(effect.getParticleOffset()); + double z = surfaceZ + RNG.r.d(); + double altX = randomized(effect.getParticleAltX(), effect.isRandomAltX()); + double altY = randomized(effect.getParticleAltY(), effect.isRandomAltY()); + double altZ = randomized(effect.getParticleAltZ(), effect.isRandomAltZ()); + level.sendParticles(player, simpleParticle, false, false, + x, y, z, effect.getParticleCount(), altX, altY, altZ, effect.getExtra()); + } + + private void applyCommands(ServerLevel level, ServerPlayer player, IrisCommandRegistry registry) { + if (registry == null || registry.getRawCommands() == null || registry.getRawCommands().isEmpty()) { + return; + } + + ModdedPlatformWorld world = new ModdedPlatformWorld(level); + double x = commandCoordinate(player.getX(), registry.getCommandOffsetX(), registry.isCommandRandomAltX()); + double y = commandCoordinate(player.getY(), registry.getCommandOffsetY(), registry.isCommandRandomAltY()); + double z = commandCoordinate(player.getZ(), registry.getCommandOffsetZ(), registry.isCommandRandomAltZ()); + for (IrisCommand command : registry.getRawCommands()) { + command.run(world, floor(x), floor(y), floor(z)); + if (registry.isCommandAllRandomLocations()) { + x = commandCoordinate(player.getX(), registry.getCommandOffsetX(), registry.isCommandRandomAltX()); + y = commandCoordinate(player.getY(), registry.getCommandOffsetY(), registry.isCommandRandomAltY()); + z = commandCoordinate(player.getZ(), registry.getCommandOffsetZ(), registry.isCommandRandomAltZ()); + } + } + } + + private void applyPotion(ServerPlayer player, IrisEffect effect) { + int strength = effect.getPotionStrength(); + if (strength < 0) { + return; + } + + Holder type = resolvePotion(effect.getPotionEffect()); + MobEffectInstance current = player.getEffect(type); + if (current != null && !shouldReplacePotionEffect(current.getAmplifier(), strength)) { + return; + } + if (current != null) { + player.removeEffect(type); + } + + int minimum = Math.min(effect.getPotionTicksMin(), effect.getPotionTicksMax()); + int maximum = Math.max(effect.getPotionTicksMin(), effect.getPotionTicksMax()); + int duration = RNG.r.i(minimum, maximum); + player.addEffect(new MobEffectInstance(type, duration, strength, true, false, false)); + } + + private Holder resolvePotion(String rawKey) { + String key = normalizePotionEffectKey(rawKey); + Identifier identifier = Identifier.tryParse(key); + if (identifier != null) { + Optional> effect = BuiltInRegistries.MOB_EFFECT.get(identifier); + if (effect.isPresent()) { + return effect.get(); + } + } + if (UNKNOWN_POTIONS.add(key)) { + IrisLogging.warn("Unknown Potion Effect Type: \"" + rawKey + "\". Using LUCK instead."); + } + return MobEffects.LUCK; + } + + static String normalizeRegistryKey(String rawKey) { + if (rawKey == null || rawKey.isBlank()) { + return null; + } + String normalized = rawKey.trim().toLowerCase(Locale.ROOT).replace(' ', '_'); + return normalized.contains(":") ? normalized : "minecraft:" + normalized; + } + + static String normalizePotionEffectKey(String rawKey) { + String normalized = normalizeRegistryKey(rawKey == null || rawKey.isBlank() ? "luck" : rawKey); + int separator = normalized.indexOf(':'); + String namespace = normalized.substring(0, separator); + String path = normalized.substring(separator + 1); + String aliased = switch (path) { + case "slow" -> "slowness"; + case "fast_digging" -> "haste"; + case "slow_digging" -> "mining_fatigue"; + case "increase_damage" -> "strength"; + case "heal" -> "instant_health"; + case "harm" -> "instant_damage"; + case "jump" -> "jump_boost"; + case "confusion" -> "nausea"; + case "damage_resistance" -> "resistance"; + default -> path; + }; + return namespace + ":" + aliased; + } + + static boolean needsSample(boolean sampled, long elapsedMillis, double distanceSquared) { + return !sampled || elapsedMillis > SAMPLE_INTERVAL_MILLIS && distanceSquared > SAMPLE_DISTANCE_SQUARED; + } + + static boolean shouldReplacePotionEffect(int existingAmplifier, int configuredAmplifier) { + return existingAmplifier <= configuredAmplifier; + } + + private static int floor(double value) { + return (int) Math.floor(value); + } + + private static double randomized(double value, boolean random) { + return random ? RNG.r.d(-value, value) : value; + } + + private static double commandCoordinate(double base, double offset, boolean random) { + return base + randomized(offset, random); + } + + private static void warnUnknown(Set warned, String type, String key) { + if (warned.add(key)) { + IrisLogging.warn("Unknown " + type + " type: \"" + key + "\"."); + } + } + + private static final class PlayerState { + private ServerPlayer player; + private IrisBiome biome; + private IrisRegion region; + private double lastX; + private double lastY; + private double lastZ; + private long lastSample; + private boolean sampled; + + private PlayerState(ServerPlayer player) { + this.player = player; + lastX = player.getX(); + lastY = player.getY(); + lastZ = player.getZ(); + lastSample = -1L; + sampled = false; + } + + private ServerPlayer player() { + return player; + } + + private void player(ServerPlayer player) { + this.player = player; + } + + private IrisBiome biome() { + return biome; + } + + private void biome(IrisBiome biome) { + this.biome = biome; + } + + private IrisRegion region() { + return region; + } + + private void region(IrisRegion region) { + this.region = region; + } + + private double lastX() { + return lastX; + } + + private double lastY() { + return lastY; + } + + private double lastZ() { + return lastZ; + } + + private void lastPosition(double x, double y, double z) { + lastX = x; + lastY = y; + lastZ = z; + } + + private long lastSample() { + return lastSample; + } + + private void lastSample(long lastSample) { + this.lastSample = lastSample; + } + + private boolean sampled() { + return sampled; + } + + private void sampled(boolean sampled) { + this.sampled = sampled; + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityAwareness.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityAwareness.java new file mode 100644 index 000000000..c72991497 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityAwareness.java @@ -0,0 +1,39 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.spi.IrisLogging; +import net.minecraft.world.entity.Mob; + +import java.util.Set; + +public final class ModdedEntityAwareness { + private static final String UNAWARE_TAG = "iris_unaware"; + + private ModdedEntityAwareness() { + } + + public static void configure(Mob mob, boolean aware) { + if (aware) { + mob.removeTag(UNAWARE_TAG); + return; + } + if (!mob.entityTags().contains(UNAWARE_TAG) && !mob.addTag(UNAWARE_TAG)) { + IrisLogging.warn("Iris could not mark generated mob '" + mob.getStringUUID() + "' as unaware"); + } + } + + public static boolean isAware(Mob mob) { + return isAware(mob.entityTags()); + } + + static boolean isAware(Set tags) { + return !tags.contains(UNAWARE_TAG); + } + + static void configureTags(Set tags, boolean aware) { + if (aware) { + tags.remove(UNAWARE_TAG); + } else { + tags.add(UNAWARE_TAG); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityCommandRunner.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityCommandRunner.java new file mode 100644 index 000000000..f1a6709eb --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityCommandRunner.java @@ -0,0 +1,82 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.object.IrisCommand; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.collection.KList; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; + +final class ModdedEntityCommandRunner { + private ModdedEntityCommandRunner() { + } + + static void run(KList commands, ServerLevel level, int blockX, int blockY, int blockZ) { + if (commands.isEmpty()) { + return; + } + MinecraftServer server = level.getServer(); + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (server == null || scheduler == null) { + IrisLogging.error("Iris could not schedule entity commands because the modded server scheduler is unavailable."); + return; + } + + ModdedPlatformWorld world = new ModdedPlatformWorld(level); + for (IrisCommand command : commands) { + if (command == null || !command.isValid(world)) { + continue; + } + schedule(command, server, scheduler, blockX, blockY, blockZ); + } + } + + private static void schedule(IrisCommand command, MinecraftServer server, ModdedScheduler scheduler, int blockX, int blockY, int blockZ) { + int delay = clampDelay(command.getDelay(), 0); + int repeatDelay = clampDelay(command.getRepeatDelay(), 1); + for (String raw : command.getCommands()) { + String prepared = prepareCommand(raw, blockX, blockY, blockZ); + if (prepared == null) { + continue; + } + if (command.isRepeat()) { + scheduler.laterGlobal(() -> new RepeatingCommand(server, scheduler, prepared, repeatDelay).run(), delay); + } else { + scheduler.laterGlobal(() -> ModdedServerCommands.dispatch(server, prepared), delay); + } + } + } + + static String prepareCommand(String raw, int blockX, int blockY, int blockZ) { + if (raw == null || raw.isBlank()) { + return null; + } + return (raw.startsWith("/") ? raw.substring(1) : raw) + .replace("{x}", String.valueOf(blockX)) + .replace("{y}", String.valueOf(blockY)) + .replace("{z}", String.valueOf(blockZ)); + } + + static int clampDelay(long delay, int minimum) { + return (int) Math.max(minimum, Math.min(Integer.MAX_VALUE, delay)); + } + + private static final class RepeatingCommand implements Runnable { + private final MinecraftServer server; + private final ModdedScheduler scheduler; + private final String command; + private final int interval; + + private RepeatingCommand(MinecraftServer server, ModdedScheduler scheduler, String command, int interval) { + this.server = server; + this.scheduler = scheduler; + this.command = command; + this.interval = interval; + } + + @Override + public void run() { + ModdedServerCommands.dispatch(server, command); + scheduler.laterGlobal(this, interval); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityPersistence.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityPersistence.java new file mode 100644 index 000000000..362bed5e7 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntityPersistence.java @@ -0,0 +1,43 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.spi.IrisLogging; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.Mob; + +import java.util.Set; + +public final class ModdedEntityPersistence { + private static final String NON_PERSISTENT_TAG = "iris_non_persistent"; + + private ModdedEntityPersistence() { + } + + public static void configure(Entity entity, boolean persistent) { + if (persistent) { + entity.removeTag(NON_PERSISTENT_TAG); + if (entity instanceof Mob mob) { + mob.setPersistenceRequired(); + } + return; + } + if (!entity.entityTags().contains(NON_PERSISTENT_TAG) && !entity.addTag(NON_PERSISTENT_TAG)) { + IrisLogging.warn("Iris could not mark generated entity '" + entity.getStringUUID() + "' as non-persistent"); + } + } + + public static boolean shouldSave(Entity entity, boolean vanillaResult) { + return shouldSave(entity.entityTags(), vanillaResult); + } + + static boolean shouldSave(Set tags, boolean vanillaResult) { + return vanillaResult && !tags.contains(NON_PERSISTENT_TAG); + } + + static void configureTags(Set tags, boolean persistent) { + if (persistent) { + tags.remove(NON_PERSISTENT_TAG); + } else { + tags.add(NON_PERSISTENT_TAG); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java index 759b2f82c..4d68be056 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedEntitySpawner.java @@ -21,8 +21,8 @@ package art.arcane.iris.modded; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisAttributeModifier; -import art.arcane.iris.engine.object.IrisCommand; import art.arcane.iris.engine.object.IrisEntity; +import art.arcane.iris.engine.object.IrisEffect; import art.arcane.iris.engine.object.IrisLoot; import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.iris.spi.IrisLogging; @@ -31,13 +31,20 @@ import art.arcane.volmlib.util.math.RNG; import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.Registry; +import net.minecraft.core.particles.ItemParticleOption; +import net.minecraft.core.particles.ParticleOptions; +import net.minecraft.core.particles.ParticleType; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.core.particles.SimpleParticleType; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; -import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; -import net.minecraft.world.entity.AgeableMob; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntitySpawnReason; import net.minecraft.world.entity.EntityType; @@ -48,21 +55,28 @@ import net.minecraft.world.entity.ai.attributes.Attribute; import net.minecraft.world.entity.ai.attributes.AttributeInstance; import net.minecraft.world.entity.ai.attributes.AttributeModifier; import net.minecraft.world.entity.animal.panda.Panda; -import net.minecraft.world.entity.monster.zombie.Zombie; import net.minecraft.world.entity.npc.villager.Villager; import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; import java.util.Locale; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; public final class ModdedEntitySpawner { private static final int PASSENGER_RNG_BASE = 234858; private static final int LEASH_RNG_SEED = 234548; + private static final int PLAYER_EFFECT_RADIUS = 32; + private static final int RISE_MAX_MOVES = 101; private static final String COLOR_CODES = "0123456789AaBbCcDdEeFfKkLlMmNnOoRrXx"; private static final Set WARNED_TYPES = ConcurrentHashMap.newKeySet(); private static final Set WARNED_ATTRIBUTES = ConcurrentHashMap.newKeySet(); - private static boolean warnedSpawnEffect = false; + private static final Set WARNED_EFFECT_SOUNDS = ConcurrentHashMap.newKeySet(); + private static final Set WARNED_EFFECT_PARTICLES = ConcurrentHashMap.newKeySet(); + private static final Set WARNED_PERSISTENCE_TYPES = ConcurrentHashMap.newKeySet(); private ModdedEntitySpawner() { } @@ -71,12 +85,18 @@ public final class ModdedEntitySpawner { if (engine == null || irisEntity == null || level == null) { return null; } + if (!chunksSafe(level, blockX >> 4, blockZ >> 4)) { + return null; + } double x = blockX + 0.5; double y = blockY + 0.5; double z = blockZ + 0.5; + boolean riseEffectActive = irisEntity.isSpawnEffectRiseOutOfGround() && hasPlayersNearby(level, x, y, z); + int spawnBlockY = riseEffectActive ? blockY - 5 : blockY; + double spawnY = spawnBlockY + 0.5; - Entity created = create(irisEntity, level, x, y, z); + Entity created = create(irisEntity, level, x, spawnY, z); if (created == null) { return null; } @@ -85,7 +105,7 @@ public final class ModdedEntitySpawner { return created; } - applyConfig(engine, irisEntity, created, level, blockX, blockY, blockZ, rng); + applyConfig(engine, irisEntity, created, level, blockX, spawnBlockY, blockZ, rng, riseEffectActive); return created; } @@ -99,12 +119,16 @@ public final class ModdedEntitySpawner { return null; } - return type.spawn(level, BlockPos.containing(x, y, z), reasonFor(irisEntity.getReason())); + Entity entity = type.spawn(level, BlockPos.containing(x, y, z), reasonFor(irisEntity.getReason())); + if (entity != null) { + entity.snapTo(x, y, z, 0F, 0F); + } + return entity; } - private static void applyConfig(Engine engine, IrisEntity irisEntity, Entity entity, ServerLevel level, int blockX, int blockY, int blockZ, RNG rng) { + private static void applyConfig(Engine engine, IrisEntity irisEntity, Entity entity, ServerLevel level, int blockX, int blockY, int blockZ, RNG rng, boolean riseEffectActive) { String customName = irisEntity.getCustomName(); - if (customName != null && !customName.isBlank()) { + if (customName != null) { entity.setCustomName(Component.literal(colorize(customName))); } entity.setCustomNameVisible(irisEntity.isCustomNameVisible()); @@ -114,9 +138,7 @@ public final class ModdedEntitySpawner { entity.setSilent(irisEntity.isSilent()); boolean persistent = irisEntity.isKeepEntity() || forcePersist(); - if (persistent && entity instanceof Mob persistentMob) { - persistentMob.setPersistenceRequired(); - } + applyPersistence(entity, persistent); applyPassengers(engine, irisEntity, entity, level, blockX, blockY, blockZ, rng); @@ -124,12 +146,13 @@ public final class ModdedEntitySpawner { applyAttributes(irisEntity, living, level, rng); } - if (entity instanceof LivingEntity lootHolder && !irisEntity.getLoot().getTables().isEmpty()) { - ModdedDeathLoot.tag(lootHolder, irisEntity.getLoot().getTables(), blockX, blockY, blockZ, rng); + if (!irisEntity.getLoot().getTables().isEmpty()) { + ModdedDeathLoot.bind(engine, entity, irisEntity.getLoot().getTables(), blockX, blockY, blockZ, rng); } if (entity instanceof Mob mob) { - mob.setNoAi(!irisEntity.isAi()); + mob.setNoAi(shouldDisableAi(irisEntity.isAi())); + ModdedEntityAwareness.configure(mob, irisEntity.isAware()); mob.setCanPickUpLoot(irisEntity.isPickupItems()); if (!irisEntity.isRemovable()) { mob.setPersistenceRequired(); @@ -156,14 +179,14 @@ public final class ModdedEntitySpawner { } if (entity instanceof Villager villager) { - villager.setPersistenceRequired(); + ModdedEntityPersistence.configure(villager, true); } - if (irisEntity.getSpawnEffect() != null || irisEntity.isSpawnEffectRiseOutOfGround()) { - noteSpawnEffectGap(); + applySpawnEffect(irisEntity.getSpawnEffect(), entity, level); + ModdedEntityCommandRunner.run(irisEntity.getRawCommands(), level, blockX, blockY, blockZ); + if (riseEffectActive && entity instanceof LivingEntity living) { + startRiseEffect(engine, level, entity, living); } - - applyRawCommands(irisEntity, level, blockX, blockY, blockZ); } private static void applyPassengers(Engine engine, IrisEntity irisEntity, Entity entity, ServerLevel level, int blockX, int blockY, int blockZ, RNG rng) { @@ -228,46 +251,153 @@ public final class ModdedEntitySpawner { } private static void applyBaby(Entity entity) { - if (entity instanceof AgeableMob ageable) { - ageable.setBaby(true); - return; - } - if (entity instanceof Zombie zombie) { - zombie.setBaby(true); + if (entity instanceof Mob mob) { + mob.setBaby(true); } } - private static void applyRawCommands(IrisEntity irisEntity, ServerLevel level, int blockX, int blockY, int blockZ) { - KList rawCommands = irisEntity.getRawCommands(); - if (rawCommands.isEmpty()) { - return; + static boolean isAreaClearForSpawn(ServerLevel level, IrisEntity irisEntity, int blockX, int blockY, int blockZ) { + if (irisEntity.isSpecialType()) { + return true; } - - MinecraftServer server = level.getServer(); - if (server == null) { - return; + EntityType type = resolveType(irisEntity.getType()); + if (type == null) { + return true; } + return isAreaClearForSpawn(blockX, blockY, blockZ, type.getWidth(), type.getHeight(), + position -> level.getBlockState(position).is(Blocks.AIR)); + } - for (IrisCommand command : rawCommands) { - for (String raw : command.getCommands()) { - if (raw == null || raw.isBlank()) { - continue; + static boolean isAreaClearForSpawn(int blockX, int blockY, int blockZ, float width, float height, Predicate isAir) { + int radius = (int) (width / 2F); + int endY = blockY + (int) height; + for (int x = blockX - radius; x <= blockX + radius; x++) { + for (int y = blockY; y <= endY; y++) { + for (int z = blockZ - radius; z <= blockZ + radius; z++) { + if (!isAir.test(new BlockPos(x, y, z))) { + return false; + } } - String prepared = (raw.startsWith("/") ? raw.substring(1) : raw) - .replace("{x}", String.valueOf(blockX)) - .replace("{y}", String.valueOf(blockY)) - .replace("{z}", String.valueOf(blockZ)); - ModdedServerCommands.dispatch(server, prepared); } } + return true; } - private static EntityType resolveType(String key) { + static boolean chunksSafe(ServerLevel level, int chunkX, int chunkZ) { + return allNeighborChunksLoaded(chunkX, chunkZ, + (x, z) -> level.getChunkSource().getChunkNow(x, z) != null); + } + + static boolean allNeighborChunksLoaded(int chunkX, int chunkZ, ChunkLoadedLookup lookup) { + for (int x = chunkX - 1; x <= chunkX + 1; x++) { + for (int z = chunkZ - 1; z <= chunkZ + 1; z++) { + if (!lookup.loaded(x, z)) { + return false; + } + } + } + return true; + } + + static boolean shouldDisableAi(boolean ai) { + return !ai; + } + + private static boolean hasPlayersNearby(ServerLevel level, double x, double y, double z) { + AABB bounds = new AABB( + x - PLAYER_EFFECT_RADIUS, + y - PLAYER_EFFECT_RADIUS, + z - PLAYER_EFFECT_RADIUS, + x + PLAYER_EFFECT_RADIUS, + y + PLAYER_EFFECT_RADIUS, + z + PLAYER_EFFECT_RADIUS); + for (ServerPlayer player : level.players()) { + if (player.getBoundingBox().intersects(bounds)) { + return true; + } + } + return false; + } + + private static void applySpawnEffect(IrisEffect effect, Entity entity, ServerLevel level) { + if (effect == null || !effect.shouldApplyNow()) { + return; + } + + SoundEvent sound = resolveSound(effect.getSoundKey()); + if (sound != null) { + double soundX = entity.getX() + RNG.r.i(-effect.getSoundDistance(), effect.getSoundDistance()); + double soundY = entity.getY() + RNG.r.i(-effect.getSoundDistance(), effect.getSoundDistance()); + double soundZ = entity.getZ() + RNG.r.i(-effect.getSoundDistance(), effect.getSoundDistance()); + level.playSound(null, soundX, soundY, soundZ, sound, SoundSource.MASTER, + (float) effect.getVolume(), (float) RNG.r.d(effect.getMinPitch(), effect.getMaxPitch())); + } + + ParticleOptions particle = resolveParticle(effect.getParticleEffectKey()); + if (particle == null) { + return; + } + + double addition = RNG.r.d(); + double subtraction = RNG.r.d(); + double particleX = entity.getX() + addition - subtraction + RNG.r.d(); + double particleY = entity.getY() + 0.25 + addition - subtraction + level.getMinY() + RNG.r.i(effect.getParticleOffset()); + double particleZ = entity.getZ() + addition - subtraction + RNG.r.d(); + double altX = effect.isRandomAltX() ? RNG.r.d(-effect.getParticleAltX(), effect.getParticleAltX()) : effect.getParticleAltX(); + double altY = effect.isRandomAltY() ? RNG.r.d(-effect.getParticleAltY(), effect.getParticleAltY()) : effect.getParticleAltY(); + double altZ = effect.isRandomAltZ() ? RNG.r.d(-effect.getParticleAltZ(), effect.getParticleAltZ()) : effect.getParticleAltZ(); + level.sendParticles(particle, particleX, particleY, particleZ, effect.getParticleCount(), + altX, altY, altZ, effect.getExtra()); + } + + private static SoundEvent resolveSound(String key) { + Identifier id = identifierFor(key); + if (id == null || !BuiltInRegistries.SOUND_EVENT.containsKey(id)) { + if (key != null && WARNED_EFFECT_SOUNDS.add(key)) { + IrisLogging.warn("Iris entity effect: unknown sound '" + key + "'"); + } + return null; + } + return BuiltInRegistries.SOUND_EVENT.getValue(id); + } + + private static ParticleOptions resolveParticle(String key) { + Identifier id = identifierFor(key); + if (id == null || !BuiltInRegistries.PARTICLE_TYPE.containsKey(id)) { + if (key != null && WARNED_EFFECT_PARTICLES.add(key)) { + IrisLogging.warn("Iris entity effect: unknown particle '" + key + "'"); + } + return null; + } + ParticleType type = BuiltInRegistries.PARTICLE_TYPE.getValue(id); + if (type instanceof SimpleParticleType simple) { + return simple; + } + if (WARNED_EFFECT_PARTICLES.add(key)) { + IrisLogging.warn("Iris entity effect: particle '" + key + "' requires data that the effect does not define"); + } + return null; + } + + private static Identifier identifierFor(String key) { if (key == null || key.isBlank()) { return null; } String normalized = key.trim().toLowerCase(Locale.ROOT); - Identifier id = Identifier.tryParse(normalized.indexOf(':') >= 0 ? normalized : "minecraft:" + normalized); + return Identifier.tryParse(normalized.indexOf(':') >= 0 ? normalized : "minecraft:" + normalized); + } + + private static void startRiseEffect(Engine engine, ServerLevel level, Entity entity, LivingEntity living) { + RiseTask task = new RiseTask(engine, level, entity, living); + task.start(); + } + + static EntityType resolveType(String key) { + if (key == null || key.isBlank()) { + return null; + } + String normalized = key.trim().toLowerCase(Locale.ROOT); + Identifier id = identifierFor(normalized); if (id == null || !BuiltInRegistries.ENTITY_TYPE.containsKey(id)) { if (WARNED_TYPES.add(normalized)) { IrisLogging.warn("Iris entity: unknown entity type '" + key + "'; skipping spawn"); @@ -277,23 +407,35 @@ public final class ModdedEntitySpawner { return BuiltInRegistries.ENTITY_TYPE.getValue(id); } - private static EntitySpawnReason reasonFor(String reason) { + static EntitySpawnReason reasonFor(String reason) { if (reason == null || reason.isBlank()) { return EntitySpawnReason.NATURAL; } String value = reason.trim().toUpperCase(Locale.ROOT); - String mapped = switch (value) { - case "CHUNK_GEN" -> "CHUNK_GENERATION"; - case "SPAWNER_EGG" -> "SPAWN_EGG"; - case "DISPENSE_EGG" -> "DISPENSER"; - case "BUILD_SNOWMAN", "BUILD_IRONGOLEM", "BUILD_WITHER", "CUSTOM", "DEFAULT" -> "MOB_SUMMONED"; - default -> value; + return switch (value) { + case "NATURAL", "DEFAULT" -> EntitySpawnReason.NATURAL; + case "JOCKEY", "MOUNT" -> EntitySpawnReason.JOCKEY; + case "CHUNK_GEN" -> EntitySpawnReason.CHUNK_GENERATION; + case "SPAWNER" -> EntitySpawnReason.SPAWNER; + case "TRIAL_SPAWNER" -> EntitySpawnReason.TRIAL_SPAWNER; + case "EGG", "BUILD_SNOWMAN", "BUILD_IRONGOLEM", "BUILD_COPPERGOLEM", "BUILD_WITHER", + "SLIME_SPLIT", "SILVERFISH_BLOCK", "TRAP", "ENDER_PEARL", "EXPLOSION", + "ENCHANTMENT", "OMINOUS_ITEM_SPAWNER", "POTION_EFFECT", "REANIMATE" -> EntitySpawnReason.TRIGGERED; + case "SPAWNER_EGG" -> EntitySpawnReason.SPAWN_ITEM_USE; + case "LIGHTNING", "VILLAGE_INVASION", "RAID", "CUSTOM" -> EntitySpawnReason.EVENT; + case "VILLAGE_DEFENSE", "SPELL" -> EntitySpawnReason.MOB_SUMMONED; + case "BREEDING", "OCELOT_BABY", "DUPLICATION", "REHYDRATION" -> EntitySpawnReason.BREEDING; + case "REINFORCEMENTS" -> EntitySpawnReason.REINFORCEMENT; + case "NETHER_PORTAL" -> EntitySpawnReason.STRUCTURE; + case "DISPENSE_EGG" -> EntitySpawnReason.DISPENSER; + case "INFECTION", "CURED", "DROWNED", "SHEARED", "PIGLIN_ZOMBIFIED", "FROZEN", + "METAMORPHOSIS" -> EntitySpawnReason.CONVERSION; + case "SHOULDER_ENTITY", "BEEHIVE" -> EntitySpawnReason.LOAD; + case "PATROL" -> EntitySpawnReason.PATROL; + case "COMMAND" -> EntitySpawnReason.COMMAND; + case "BUCKET" -> EntitySpawnReason.BUCKET; + default -> EntitySpawnReason.NATURAL; }; - try { - return EntitySpawnReason.valueOf(mapped); - } catch (IllegalArgumentException ignored) { - return EntitySpawnReason.NATURAL; - } } private static Panda.Gene gene(String value) { @@ -311,10 +453,13 @@ public final class ModdedEntitySpawner { return IrisSettings.get().getWorld().isForcePersistEntities(); } - private static void noteSpawnEffectGap() { - if (!warnedSpawnEffect) { - warnedSpawnEffect = true; - IrisLogging.debug("Iris entity spawnEffect / spawnEffectRiseOutOfGround are Bukkit-only visual paths and are skipped on modded."); + private static void applyPersistence(Entity entity, boolean persistent) { + ModdedEntityPersistence.configure(entity, persistent); + if (persistent && (!entity.getType().canSerialize() || !entity.shouldBeSaved())) { + String type = String.valueOf(BuiltInRegistries.ENTITY_TYPE.getKey(entity.getType())); + if (WARNED_PERSISTENCE_TYPES.add(type)) { + IrisLogging.warn("Iris entity: vanilla cannot persist non-serializable entity type '" + type + "'"); + } } } @@ -334,4 +479,106 @@ public final class ModdedEntitySpawner { } return new String(chars); } + + @FunctionalInterface + interface ChunkLoadedLookup { + boolean loaded(int chunkX, int chunkZ); + } + + private static final class RiseTask implements Runnable { + private final Engine engine; + private final ServerLevel level; + private final Entity entity; + private final LivingEntity living; + private final double x; + private final double z; + private final boolean originalInvulnerable; + private final boolean originalNoPhysics; + private final int originalInvulnerableTime; + private final boolean originalNoAi; + private double y; + private int moves; + private boolean restored; + + private RiseTask(Engine engine, ServerLevel level, Entity entity, LivingEntity living) { + this.engine = engine; + this.level = level; + this.entity = entity; + this.living = living; + this.x = entity.getX(); + this.y = entity.getY(); + this.z = entity.getZ(); + this.originalInvulnerable = entity.isInvulnerable(); + this.originalNoPhysics = entity.noPhysics; + this.originalInvulnerableTime = entity.invulnerableTime; + this.originalNoAi = entity instanceof Mob mob && mob.isNoAi(); + } + + private void start() { + entity.setInvulnerable(true); + entity.noPhysics = true; + entity.invulnerableTime = 100_000; + if (entity instanceof Mob mob) { + mob.setNoAi(true); + } + run(); + } + + @Override + public void run() { + if (moves >= RISE_MAX_MOVES || !entity.isAlive() || engine.isClosed() || entity.level() != level) { + restore(); + return; + } + BlockPos current = entity.blockPosition(); + if (level.getChunkSource().getChunkNow(current.getX() >> 4, current.getZ() >> 4) == null) { + restore(); + return; + } + BlockPos eye = BlockPos.containing(entity.getX(), living.getEyeY(), entity.getZ()); + if (!ModdedBlockResolution.isSolid(level.getBlockState(current)) + && !ModdedBlockResolution.isSolid(level.getBlockState(eye))) { + restore(); + return; + } + + moves++; + y += 0.1; + entity.setPos(x, y, z); + emitEffects(); + + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + IrisLogging.error("Iris could not continue an entity rise effect because the modded scheduler is unavailable."); + restore(); + return; + } + scheduler.laterGlobal(this, 1); + } + + private void emitEffects() { + BlockPos source = BlockPos.containing(entity.getX(), living.getEyeY() - 2, entity.getZ()); + BlockState state = level.getBlockState(source); + ItemParticleOption item = new ItemParticleOption(ParticleTypes.ITEM, state.getBlock().asItem()); + level.sendParticles(item, entity.getX(), living.getEyeY(), entity.getZ(), 6, + 0.2, 0.4, 0.2, 0.06); + if (RNG.r.nextDouble() < 0.2) { + level.playSound(null, entity.getX(), entity.getY(), entity.getZ(), SoundEvents.CHORUS_FLOWER_GROW, + SoundSource.MASTER, 0.8F, 0.1F); + } + } + + private void restore() { + if (restored) { + return; + } + restored = true; + entity.invulnerableTime = originalInvulnerableTime; + entity.noPhysics = originalNoPhysics; + entity.setInvulnerable(originalInvulnerable); + if (entity instanceof Mob mob) { + mob.setNoAi(originalNoAi); + } + } + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java index f6e0506a1..e9945cfa3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedForcedDatapack.java @@ -38,13 +38,14 @@ import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Stream; @@ -53,10 +54,6 @@ public final class ModdedForcedDatapack { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final String PACK_ID = "iris_worldgen"; private static final String PACK_FOLDER = "iris"; - private static final String STUDIO_POOL_TYPE_KEY = "studio_pool"; - private static final String STUDIO_POOL_TYPE_RESOURCE = "/data/irisworldgen/dimension_type/overworld.json"; - private static final int STUDIO_POOL_MIN_Y = -256; - private static final int STUDIO_POOL_MAX_Y = 512; private static final Object LOCK = new Object(); private ModdedForcedDatapack() { @@ -80,7 +77,7 @@ public final class ModdedForcedDatapack { } private static Pack buildPack() { - Path directory = generate(); + Path directory = regenerate(); if (directory == null) { return null; } @@ -98,7 +95,7 @@ public final class ModdedForcedDatapack { return pack; } - private static Path generate() { + public static Path regenerate() { synchronized (LOCK) { try { return write(); @@ -122,7 +119,7 @@ public final class ModdedForcedDatapack { File packFolder = packDirectory.toFile(); KList folders = new KList<>(); folders.add(packFolder); - KSet seenBiomes = new KSet<>(); + Map> seenBiomes = new LinkedHashMap<>(); IDataFixer fixer = DataVersion.getLatest().get(); int packCount = 0; @@ -137,18 +134,20 @@ public final class ModdedForcedDatapack { } writePackMeta(packDirectory); - writeStudioPoolType(packDirectory); + if ("forge".equalsIgnoreCase(ModdedEngineBootstrap.loader().platformName())) { + writeForgeBlockLootModifier(packDirectory); + } if (!presetIds.isEmpty()) { writeWorldPresetTag(packDirectory, presetIds); } - LOGGER.info("Iris forced startup datapack regenerated: {} pack(s), {} world preset(s), {} custom biome(s) at {}", packCount, presetIds.size(), seenBiomes.size(), packDirectory); + LOGGER.info("Iris forced startup datapack regenerated: {} pack(s), {} world preset(s), {} custom biome(s) at {}", packCount, presetIds.size(), countBiomes(seenBiomes), packDirectory); if (packCount == 0) { LOGGER.warn("Iris installed NO worldgen packs into the forced datapack - custom biomes and their colors will NOT generate. Install a pack (e.g. /iris download overworld) and restart the server before creating an Iris world."); } return packDirectory; } - private static boolean installPack(File packFolder, IDataFixer fixer, KList folders, KSet seenBiomes, KList presetIds) { + private static boolean installPack(File packFolder, IDataFixer fixer, KList folders, Map> seenBiomes, KList presetIds) { String packName = packFolder.getName(); File[] dimensionFiles = new File(packFolder, "dimensions").listFiles((File file) -> file.isFile() && file.getName().endsWith(".json")); if (dimensionFiles == null || dimensionFiles.length == 0) { @@ -163,7 +162,7 @@ public final class ModdedForcedDatapack { if (dimension == null) { continue; } - dimension.installBiomes(fixer, () -> data, folders, seenBiomes); + dimension.installBiomes(fixer, () -> data, folders, biomesForNamespace(seenBiomes, dimension.getLoadKey())); writeDimensionType(folders, fixer, dimension); String presetKey = dimensionKey.equals(packName) ? packName : packName + "_" + dimensionKey; writeWorldPreset(folders, dimension, packName, dimensionKey, presetKey); @@ -176,10 +175,19 @@ public final class ModdedForcedDatapack { return installed; } + static KSet biomesForNamespace(Map> biomes, String namespace) { + return biomes.computeIfAbsent(namespace, ignored -> new KSet<>()); + } + public static String dimensionTypeRef(IrisDimension dimension) { - return fitsStudioPool(dimension) - ? "irisworldgen:" + STUDIO_POOL_TYPE_KEY - : "irisworldgen:" + dimension.getDimensionTypeKey(); + return "irisworldgen:" + dimension.getDimensionTypeKey(); + } + + static T requireRegisteredDimensionType(String typeRef, Optional registeredType, + String pack, String packDimensionKey) { + return registeredType.orElseThrow(() -> new IllegalStateException( + "Iris dimension type '" + typeRef + "' for pack '" + pack + "' dimension '" + + packDimensionKey + "' is not loaded. Restart the server so the forced Iris datapack registers it before creating the world.")); } private static void writeWorldPreset(KList folders, IrisDimension dimension, String packName, String dimensionKey, String presetKey) throws IOException { @@ -250,10 +258,7 @@ public final class ModdedForcedDatapack { Files.writeString(output, json, StandardCharsets.UTF_8); } - private static void writeDimensionType(KList folders, IDataFixer fixer, IrisDimension dimension) throws IOException { - if (fitsStudioPool(dimension)) { - return; - } + static void writeDimensionType(KList folders, IDataFixer fixer, IrisDimension dimension) throws IOException { IrisDimensionType type = dimension.getDimensionType(); String json = type.toJson(fixer); String typeKey = dimension.getDimensionTypeKey(); @@ -264,23 +269,20 @@ public final class ModdedForcedDatapack { } } - private static boolean fitsStudioPool(IrisDimension dimension) { - return dimension.getMinHeight() >= STUDIO_POOL_MIN_Y && dimension.getMaxHeight() <= STUDIO_POOL_MAX_Y; - } + static void writeForgeBlockLootModifier(Path packDirectory) throws IOException { + Path list = packDirectory.resolve("data").resolve("forge").resolve("loot_modifiers").resolve("global_loot_modifiers.json"); + Files.createDirectories(list.getParent()); + Files.writeString(list, "{\n" + + " \"replace\": false,\n" + + " \"entries\": [\"irisworldgen:block_drops\"]\n" + + "}\n", StandardCharsets.UTF_8); - private static void writeStudioPoolType(Path packDirectory) throws IOException { - Path output = packDirectory.resolve("data").resolve("irisworldgen").resolve("dimension_type").resolve(STUDIO_POOL_TYPE_KEY + ".json"); - Files.createDirectories(output.getParent()); - Files.writeString(output, readStudioPoolType(), StandardCharsets.UTF_8); - } - - private static String readStudioPoolType() throws IOException { - try (InputStream stream = ModdedForcedDatapack.class.getResourceAsStream(STUDIO_POOL_TYPE_RESOURCE)) { - if (stream == null) { - throw new IOException("Bundled studio pool dimension type resource is missing: " + STUDIO_POOL_TYPE_RESOURCE); - } - return new String(stream.readAllBytes(), StandardCharsets.UTF_8); - } + Path modifier = packDirectory.resolve("data").resolve("irisworldgen").resolve("loot_modifiers").resolve("block_drops.json"); + Files.createDirectories(modifier.getParent()); + Files.writeString(modifier, "{\n" + + " \"type\": \"irisworldgen:block_drops\",\n" + + " \"conditions\": []\n" + + "}\n", StandardCharsets.UTF_8); } private static void writePackMeta(Path packDirectory) throws IOException { @@ -296,6 +298,14 @@ public final class ModdedForcedDatapack { Files.writeString(packDirectory.resolve("pack.mcmeta"), json, StandardCharsets.UTF_8); } + private static int countBiomes(Map> biomes) { + int count = 0; + for (KSet values : biomes.values()) { + count += values.size(); + } + return count; + } + private static void clean(Path packDirectory) throws IOException { if (!Files.exists(packDirectory)) { return; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedHeightmaps.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedHeightmaps.java new file mode 100644 index 000000000..a9e4d1fbd --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedHeightmaps.java @@ -0,0 +1,22 @@ +package art.arcane.iris.modded; + +import net.minecraft.util.Mth; +import net.minecraft.util.SimpleBitStorage; + +import java.util.function.IntBinaryOperator; + +final class ModdedHeightmaps { + private ModdedHeightmaps() { + } + + static long[] terrainRawData(int height, IntBinaryOperator heightResolver) { + SimpleBitStorage storage = new SimpleBitStorage(Mth.ceillog2(height + 1), 256); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + int value = Mth.clamp(heightResolver.applyAsInt(x, z), 0, height); + storage.set(x + z * 16, value); + } + } + return storage.getRaw(); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java index f8c8169e4..607a5a461 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedLootApplier.java @@ -23,6 +23,8 @@ import art.arcane.iris.engine.framework.PlacedObject; import art.arcane.iris.engine.object.IObjectLoot; import art.arcane.iris.engine.object.InventorySlotType; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisLootMode; +import art.arcane.iris.engine.object.IrisLootReference; import art.arcane.iris.engine.object.IrisLootTable; import art.arcane.iris.engine.object.IrisObjectLoot; import art.arcane.iris.engine.object.IrisObjectPlacement; @@ -43,41 +45,24 @@ import net.minecraft.world.Container; import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.ChestBlock; import net.minecraft.world.level.block.entity.BlockEntity; -import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity; import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.loot.LootParams; +import net.minecraft.world.level.storage.loot.LootTable; +import net.minecraft.world.level.storage.loot.parameters.LootContextParamSets; +import net.minecraft.world.level.storage.loot.parameters.LootContextParams; +import net.minecraft.world.phys.Vec3; import java.util.ArrayList; import java.util.List; +import java.util.function.Predicate; public final class ModdedLootApplier { private ModdedLootApplier() { } - private record Candidate(IrisLootTable table, Identifier vanillaId, int weight) { - } - - private record Resolution(KList tables, Identifier vanillaTable) { - boolean isEmpty() { - return tables.isEmpty() && vanillaTable == null; - } - } - public static void apply(Engine engine, ServerLevel level, BlockPos pos, BlockState state, MantleChunk mc, RNG rng, int localX, int localZ) { - Resolution resolution = resolveTables(engine, rng, pos, state, mc); - if (resolution.isEmpty()) { - return; - } - - if (resolution.vanillaTable() != null) { - BlockEntity blockEntity = level.getBlockEntity(pos); - if (blockEntity instanceof RandomizableContainerBlockEntity randomizable) { - randomizable.setLootTable(ResourceKey.create(Registries.LOOT_TABLE, resolution.vanillaTable()), rng.nextLong()); - } else { - IrisLogging.debug("Iris loot: no randomizable container at " + pos + " for vanilla table " + resolution.vanillaTable()); - } - } - - if (resolution.tables().isEmpty()) { + List sources = resolveSources(engine, level, rng, pos, state, mc); + if (sources.isEmpty()) { return; } @@ -88,40 +73,43 @@ public final class ModdedLootApplier { } KList items = new KList<>(); - for (IrisLootTable table : resolution.tables()) { - if (table == null) { + LootParams nativeParams = null; + for (LootSource source : sources) { + if (source instanceof IrisLootSource irisSource) { + IrisLootTable table = irisSource.table(); + if (table == null) { + continue; + } + items.addAll(ModdedItemTranslator.loot(table, rng, InventorySlotType.STORAGE, level, localX, pos.getY(), localZ)); continue; } - items.addAll(ModdedItemTranslator.loot(table, rng, InventorySlotType.STORAGE, level, localX, pos.getY(), localZ)); + if (source instanceof NativeLootSource nativeSource) { + if (nativeParams == null) { + nativeParams = new LootParams.Builder(level) + .withParameter(LootContextParams.ORIGIN, Vec3.atCenterOf(pos)) + .create(LootContextParamSets.CHEST); + } + items.addAll(nativeSource.table().getRandomItems(nativeParams, rng.nextLong())); + } } - for (ItemStack item : items) { - addItem(container, item); - } - - scramble(container, rng); - container.setChanged(); + fillContainer(container, items, rng); } - private static Resolution resolveTables(Engine engine, RNG rng, BlockPos pos, BlockState state, MantleChunk mc) { + private static List resolveSources(Engine engine, ServerLevel level, RNG rng, BlockPos pos, BlockState state, MantleChunk mc) { int rx = pos.getX(); int rz = pos.getZ(); int ry = pos.getY() - engine.getWorld().minHeight(); double he = engine.getComplex().getHeightStream().get(rx, rz); - KList tables = new KList<>(); - Identifier vanillaTable = null; + List sources = new ArrayList<>(); PlacedObject po = engine.getObjectPlacement(rx, ry, rz, mc); if (po != null && po.getPlacement() != null && ModdedBlockResolution.isStorageChest(state)) { - Candidate picked = pickPlacementTable(engine, po.getPlacement(), state, rng); + Candidate picked = pickPlacementTable(engine, level, po.getPlacement(), state, rng); if (picked != null) { - if (picked.table() != null) { - tables.add(picked.table()); - } else { - vanillaTable = picked.vanillaId(); - } + sources.add(picked.source()); if (po.getPlacement().isOverrideGlobalLoot()) { - return new Resolution(tables, vanillaTable); + return sources; } } } @@ -131,28 +119,16 @@ public final class ModdedLootApplier { IrisBiome biomeUnder = ry < he ? engine.getCaveBiome(rx, ry, rz) : biomeSurface; double multiplier = 1D * engine.getDimension().getLoot().getMultiplier() * region.getLoot().getMultiplier() * biomeSurface.getLoot().getMultiplier() * biomeUnder.getLoot().getMultiplier(); - boolean fallback = tables.isEmpty() && vanillaTable == null; - engine.injectTables(tables, engine.getDimension().getLoot(), fallback); - engine.injectTables(tables, region.getLoot(), fallback); - engine.injectTables(tables, biomeSurface.getLoot(), fallback); - engine.injectTables(tables, biomeUnder.getLoot(), fallback); - - if (tables.isNotEmpty()) { - int target = (int) Math.round(tables.size() * multiplier); - - while (tables.size() < target && tables.isNotEmpty()) { - tables.add(tables.get(rng.i(tables.size() - 1))); - } - - while (tables.size() > target && tables.isNotEmpty()) { - tables.remove(rng.i(tables.size() - 1)); - } - } - - return new Resolution(tables, vanillaTable); + boolean fallback = sources.isEmpty(); + injectReferenceSources(sources, engine.getDimension().getLoot(), engine, fallback); + injectReferenceSources(sources, region.getLoot(), engine, fallback); + injectReferenceSources(sources, biomeSurface.getLoot(), engine, fallback); + injectReferenceSources(sources, biomeUnder.getLoot(), engine, fallback); + scaleSources(sources, multiplier, rng); + return sources; } - private static Candidate pickPlacementTable(Engine engine, IrisObjectPlacement placement, BlockState state, RNG rng) { + private static Candidate pickPlacementTable(Engine engine, ServerLevel level, IrisObjectPlacement placement, BlockState state, RNG rng) { List exact = new ArrayList<>(); List basic = new ArrayList<>(); List global = new ArrayList<>(); @@ -166,25 +142,93 @@ public final class ModdedLootApplier { IrisLogging.warn("Couldn't find loot table " + loot.getName()); continue; } - bucket(engine, loot, new Candidate(table, null, loot.getWeight()), state, exact, basic, global); + bucket(engine, loot, new Candidate(new IrisLootSource(table), loot.getWeight()), state, exact, basic, global); } for (IrisObjectVanillaLoot loot : placement.getVanillaLoot()) { if (loot == null) { continue; } - Identifier id = loot.getName() == null ? null : Identifier.tryParse(loot.getName()); - if (id == null) { - IrisLogging.warn("Couldn't parse vanilla loot table " + loot.getName()); + ResourceKey key = resolveNativeKey(loot.getName(), candidate -> hasNativeTable(level, candidate)); + if (key == null) { + IrisLogging.warn("Couldn't find vanilla loot table " + loot.getName()); continue; } - bucket(engine, loot, new Candidate(null, id, loot.getWeight()), state, exact, basic, global); + LootTable table = level.getServer().reloadableRegistries().lookup() + .lookupOrThrow(Registries.LOOT_TABLE) + .getOrThrow(key) + .value(); + bucket(engine, loot, new Candidate(new NativeLootSource(table), loot.getWeight()), state, exact, basic, global); } List pool = !exact.isEmpty() ? exact : !basic.isEmpty() ? basic : global; return pickWeighted(pool, rng); } + static void injectSources(List sources, List additions, IrisLootMode mode, boolean fallback) { + if (mode == IrisLootMode.FALLBACK && !fallback) { + return; + } + if (mode == IrisLootMode.CLEAR || mode == IrisLootMode.REPLACE) { + sources.clear(); + } + sources.addAll(additions); + } + + static void scaleSources(List sources, double multiplier, RNG rng) { + if (sources.isEmpty()) { + return; + } + int target = (int) Math.round(sources.size() * multiplier); + while (sources.size() < target && !sources.isEmpty()) { + sources.add(sources.get(rng.i(sources.size() - 1))); + } + while (sources.size() > target && !sources.isEmpty()) { + sources.remove(rng.i(sources.size() - 1)); + } + } + + static ResourceKey resolveNativeKey(String name, Predicate> registryContains) { + Identifier id = name == null ? null : Identifier.tryParse(name); + if (id == null) { + return null; + } + ResourceKey key = ResourceKey.create(Registries.LOOT_TABLE, id); + return registryContains.test(key) ? key : null; + } + + static void fillContainer(Container container, List items, RNG rng) { + for (ItemStack item : items) { + addItem(container, item); + } + scramble(container, rng); + container.setChanged(); + } + + private static void injectReferenceSources(List sources, IrisLootReference reference, Engine engine, boolean fallback) { + IrisLootMode mode = reference.getMode(); + if (mode == IrisLootMode.FALLBACK && !fallback) { + return; + } + injectSources(sources, irisSources(reference, engine), mode, fallback); + } + + private static List irisSources(IrisLootReference reference, Engine engine) { + KList tables = reference.getLootTables(engine.getComplex()); + List sources = new ArrayList<>(tables.size()); + for (IrisLootTable table : tables) { + sources.add(new IrisLootSource(table)); + } + return sources; + } + + private static boolean hasNativeTable(ServerLevel level, ResourceKey key) { + return level.getServer().reloadableRegistries().lookup() + .lookup(Registries.LOOT_TABLE) + .flatMap(registry -> registry.get(key)) + .isPresent(); + } + private static void bucket(Engine engine, IObjectLoot loot, Candidate candidate, BlockState state, List exact, List basic, List global) { if (loot.getFilter().isEmpty()) { global.add(candidate); @@ -288,4 +332,16 @@ public final class ModdedLootApplier { container.setItem(i, items[i]); } } + + private interface LootSource { + } + + private record IrisLootSource(IrisLootTable table) implements LootSource { + } + + private record NativeLootSource(LootTable table) implements LootSource { + } + + private record Candidate(LootSource source, int weight) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatformWorld.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatformWorld.java new file mode 100644 index 000000000..4c7ef31c3 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedPlatformWorld.java @@ -0,0 +1,108 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformWorld; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.block.state.BlockState; + +public final class ModdedPlatformWorld implements PlatformWorld { + private final ServerLevel level; + + public ModdedPlatformWorld(ServerLevel level) { + this.level = level; + } + + @Override + public String name() { + return level.dimension().identifier().toString(); + } + + @Override + public long seed() { + return level.getSeed(); + } + + @Override + public int minHeight() { + return level.getMinY(); + } + + @Override + public int maxHeight() { + return exclusiveMaxHeight(level.getMinY(), level.getHeight()); + } + + @Override + public PlatformBlockState getBlock(int x, int y, int z) { + return ModdedBlockState.of(level.getBlockState(new BlockPos(x, y, z)), null); + } + + @Override + public void setBlock(int x, int y, int z, PlatformBlockState block, int flags) { + level.setBlock(new BlockPos(x, y, z), (BlockState) block.nativeHandle(), flags); + } + + @Override + public PlatformBiome getBiome(int x, int y, int z) { + Holder biome = level.getBiome(new BlockPos(x, y, z)); + String key = biome.unwrapKey() + .map(resourceKey -> resourceKey.identifier().toString()) + .orElse("minecraft:plains"); + return ModdedBiome.of(biome.value(), key); + } + + @Override + public boolean isChunkLoaded(int chunkX, int chunkZ) { + return level.getChunkSource().getChunkNow(chunkX, chunkZ) != null; + } + + @Override + public long getTime() { + return level.getDefaultClockTime(); + } + + @Override + public boolean isStorming() { + return level.isRaining(); + } + + @Override + public boolean isThundering() { + return level.isThundering(); + } + + @Override + public Object nativeHandle() { + return level; + } + + ServerLevel level() { + return level; + } + + static int exclusiveMaxHeight(int minHeight, int height) { + return minHeight + height; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java index 615461fc4..569c05362 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java @@ -30,23 +30,24 @@ public final class ModdedStateMerger implements BlockDataMergeSupport.StateMerge public PlatformBlockState merge(PlatformBlockState base, PlatformBlockState update) { ModdedBlockState fabricBase = (ModdedBlockState) base; ModdedBlockState fabricUpdate = (ModdedBlockState) update; + ModdedBlockState metadataSource = fabricUpdate.isCustom() ? fabricUpdate : fabricBase; try { - return ModdedBlockState.of(mergeStates(fabricBase.handle(), fabricUpdate.handle(), fabricUpdate.parsedProperties()), null); + return metadataSource.withHandle(mergeStates(fabricBase.handle(), fabricUpdate.handle(), fabricUpdate.parsedProperties()), null); } catch (IllegalArgumentException e) { ModdedBlockResolution.Parsed normalizedBase = ModdedBlockResolution.resolveGet(ModdedBlockState.serialize(fabricBase.handle())); ModdedBlockResolution.Parsed normalizedUpdate = ModdedBlockResolution.resolveGet(ModdedBlockState.serialize(fabricUpdate.handle())); if (normalizedBase != null && normalizedUpdate != null) { try { - return ModdedBlockState.of(mergeStates(normalizedBase.state(), normalizedUpdate.state(), normalizedUpdate.properties()), null); + return metadataSource.withHandle(mergeStates(normalizedBase.state(), normalizedUpdate.state(), normalizedUpdate.properties()), null); } catch (IllegalArgumentException ignored) { - return ModdedBlockState.of(normalizedUpdate.state(), normalizedUpdate.properties()); + return metadataSource.withHandle(normalizedUpdate.state(), normalizedUpdate.properties()); } } if (normalizedUpdate != null) { - return ModdedBlockState.of(normalizedUpdate.state(), normalizedUpdate.properties()); + return metadataSource.withHandle(normalizedUpdate.state(), normalizedUpdate.properties()); } return update; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java index 418741c7b..7fb8f9625 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java @@ -168,7 +168,7 @@ public final class ModdedStateRotator implements IrisObjectRotation.StateRotator return null; } - return d == fabric.handle() ? state : ModdedBlockState.of(d, fabric.parsedProperties()); + return d == fabric.handle() ? state : fabric.withHandle(d, fabric.parsedProperties()); } private static Property findFacing(BlockState state) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java index 978824977..185bb7c8b 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java @@ -18,18 +18,41 @@ package art.arcane.iris.modded; +import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.spi.PlatformWorld; +import net.minecraft.core.BlockPos; import net.minecraft.core.Holder; import net.minecraft.core.Registry; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.StructureManager; import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.chunk.ChunkGenerator; +import net.minecraft.world.level.levelgen.RandomState; +import net.minecraft.world.level.levelgen.WorldgenRandom; +import net.minecraft.world.level.levelgen.XoroshiroRandomSource; +import net.minecraft.world.level.levelgen.feature.AbstractHugeMushroomFeature; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.feature.FallenTreeFeature; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.TreeFeature; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.function.Supplier; public final class ModdedStructureHooks implements PlatformStructureHooks { @@ -52,64 +75,254 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { @Override public List structureBiomeKeys(String structureKey) { List keys = new ArrayList<>(); - MinecraftServer instance = server.get(); - if (instance == null) { - return keys; - } - Identifier identifier = Identifier.tryParse(structureKey); - if (identifier == null) { - return keys; - } - Registry registry = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE); - Structure structure = registry.getValue(identifier); - if (structure == null) { - return keys; - } - for (Holder holder : structure.biomes()) { - holder.unwrapKey().ifPresent((net.minecraft.resources.ResourceKey key) -> keys.add(key.identifier().toString())); + try { + MinecraftServer instance = server.get(); + if (instance == null) { + return keys; + } + Identifier identifier = Identifier.tryParse(structureKey); + if (identifier == null) { + return keys; + } + Registry registry = instance.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Structure structure = registry.getValue(identifier); + if (structure == null) { + return keys; + } + for (Holder holder : structure.biomes()) { + Optional> key = holder.unwrapKey(); + if (key.isPresent()) { + keys.add(key.get().identifier().toString()); + } + } + } catch (Throwable error) { + IrisLogging.reportError(error); } return keys; } @Override public List objectFeatureKeys() { - return registryKeys(Registries.CONFIGURED_FEATURE); + List keys = new ArrayList<>(); + try { + MinecraftServer instance = server.get(); + if (instance == null) { + return keys; + } + Registry> registry = instance.registryAccess().lookupOrThrow(Registries.CONFIGURED_FEATURE); + for (Identifier identifier : registry.keySet()) { + ConfiguredFeature configuredFeature = registry.getValue(identifier); + if (configuredFeature == null) { + continue; + } + String group = classifyFeature(configuredFeature.feature()); + if (group != null) { + keys.add(group + "|" + identifier); + } + } + } catch (Throwable error) { + IrisLogging.reportError(error); + } + return keys; } @Override public List reachableStructureKeys(PlatformWorld world) { - return List.of(); + List keys = new ArrayList<>(); + try { + ServerLevel level = level(world); + if (level == null) { + return keys; + } + BiomeSource source = level.getChunkSource().getGenerator().getBiomeSource(); + Set possibleBiomes = possibleBiomeKeys(source); + if (possibleBiomes.isEmpty()) { + return keys; + } + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + for (Map.Entry, Structure> entry : registry.entrySet()) { + if (hasPossibleBiome(entry.getValue(), possibleBiomes)) { + keys.add(entry.getKey().identifier().toString()); + } + } + } catch (Throwable error) { + IrisLogging.reportError(error); + } + return keys; } @Override public List possibleBiomeKeys(PlatformWorld world) { - return List.of(); + List keys = new ArrayList<>(); + try { + ServerLevel level = level(world); + if (level == null) { + return keys; + } + keys.addAll(possibleBiomeKeys(level.getChunkSource().getGenerator().getBiomeSource())); + } catch (Throwable error) { + IrisLogging.reportError(error); + } + return keys; } @Override public boolean placeFeature(PlatformWorld world, int x, int y, int z, String featureKey, long seed) { - return false; + try { + ServerLevel level = level(world); + Identifier identifier = Identifier.tryParse(featureKey); + if (level == null || identifier == null) { + return false; + } + ChunkGenerator generator = level.getChunkSource().getGenerator(); + Registry> registry = level.registryAccess().lookupOrThrow(Registries.CONFIGURED_FEATURE); + ConfiguredFeature configuredFeature = registry.getValue(identifier); + if (configuredFeature == null) { + return false; + } + WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(seed)); + return configuredFeature.place(level, generator, random, new BlockPos(x, y, z)); + } catch (Throwable error) { + IrisLogging.reportError(error); + return false; + } } @Override public int[] placeStructure(PlatformWorld world, int chunkX, int chunkZ, String structureKey, long seed, int maxSpan) { - return null; + try { + ServerLevel level = level(world); + Identifier identifier = Identifier.tryParse(structureKey); + if (level == null || identifier == null) { + return null; + } + ChunkGenerator generator = level.getChunkSource().getGenerator(); + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + Structure structure = registry.getValue(identifier); + if (structure == null) { + return null; + } + Holder holder = registry.wrapAsHolder(structure); + RandomState randomState = level.getChunkSource().randomState(); + StructureTemplateManager templateManager = level.getStructureManager(); + StructureManager structureManager = level.structureManager(); + BiomeSource biomeSource = generator.getBiomeSource(); + ChunkPos chunkPos = new ChunkPos(chunkX, chunkZ); + StructureStart start = structure.generate( + holder, + level.dimension(), + level.registryAccess(), + generator, + biomeSource, + randomState, + templateManager, + seed, + chunkPos, + 0, + level, + biome -> true); + if (start == null || !start.isValid()) { + return null; + } + BoundingBox box = start.getBoundingBox(); + if (!isWithinSpan(box, maxSpan)) { + return null; + } + placeChunks(level, structureManager, generator, start, box, seed); + return bounds(box); + } catch (Throwable error) { + IrisLogging.reportError(error); + return null; + } } @Override public boolean supportsStructurePlacement() { + return true; + } + + static String classifyFeature(Feature feature) { + if (feature instanceof TreeFeature) { + return "trees"; + } + if (feature instanceof FallenTreeFeature) { + return "fallen_trees"; + } + if (feature instanceof AbstractHugeMushroomFeature) { + return "mushrooms"; + } + return null; + } + + static boolean isWithinSpan(BoundingBox box, int maxSpan) { + return maxSpan <= 0 + || box.getXSpan() <= maxSpan && box.getYSpan() <= maxSpan && box.getZSpan() <= maxSpan; + } + + static int[] bounds(BoundingBox box) { + return new int[]{box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()}; + } + + private static Set possibleBiomeKeys(BiomeSource source) { + Set keys = new LinkedHashSet<>(); + if (source == null) { + return keys; + } + for (Holder holder : source.possibleBiomes()) { + Optional> key = holder.unwrapKey(); + if (key.isPresent()) { + keys.add(key.get().identifier().toString()); + } + } + return keys; + } + + private static boolean hasPossibleBiome(Structure structure, Set possibleBiomeKeys) { + for (Holder holder : structure.biomes()) { + Optional> key = holder.unwrapKey(); + if (key.isPresent() && possibleBiomeKeys.contains(key.get().identifier().toString())) { + return true; + } + } return false; } - private List registryKeys(net.minecraft.resources.ResourceKey> registryKey) { - List keys = new ArrayList<>(); - MinecraftServer instance = server.get(); - if (instance == null) { - return keys; + private static ServerLevel level(PlatformWorld world) { + return world instanceof ModdedPlatformWorld moddedWorld ? moddedWorld.level() : null; + } + + private static void placeChunks(ServerLevel level, StructureManager structureManager, ChunkGenerator generator, + StructureStart start, BoundingBox box, long seed) { + WorldgenRandom random = new WorldgenRandom(new XoroshiroRandomSource(seed)); + int minChunkX = box.minX() >> 4; + int maxChunkX = box.maxX() >> 4; + int minChunkZ = box.minZ() >> 4; + int maxChunkZ = box.maxZ() >> 4; + for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { + level.getChunk(chunkX, chunkZ); + ChunkPos chunkPos = new ChunkPos(chunkX, chunkZ); + BoundingBox chunkBox = new BoundingBox( + chunkPos.getMinBlockX(), box.minY(), chunkPos.getMinBlockZ(), + chunkPos.getMaxBlockX(), box.maxY(), chunkPos.getMaxBlockZ()); + start.placeInChunk(level, structureManager, generator, random, chunkBox, chunkPos); + } } - Registry registry = instance.registryAccess().lookupOrThrow(registryKey); - for (Identifier identifier : registry.keySet()) { - keys.add(identifier.toString()); + } + + private List registryKeys(ResourceKey> registryKey) { + List keys = new ArrayList<>(); + try { + MinecraftServer instance = server.get(); + if (instance == null) { + return keys; + } + Registry registry = instance.registryAccess().lookupOrThrow(registryKey); + for (Identifier identifier : registry.keySet()) { + keys.add(identifier.toString()); + } + } catch (Throwable error) { + IrisLogging.reportError(error); } return keys; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java index c8caa84fb..dea603a86 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileData.java @@ -19,26 +19,58 @@ package art.arcane.iris.modded; import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KMap; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.Strictness; +import net.minecraft.nbt.ByteTag; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.DoubleTag; +import net.minecraft.nbt.FloatTag; +import net.minecraft.nbt.IntTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.LongTag; +import net.minecraft.nbt.NbtUtils; +import net.minecraft.nbt.ShortTag; +import net.minecraft.nbt.StringTag; +import net.minecraft.nbt.Tag; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.BannerBlockEntity; +import net.minecraft.world.level.block.entity.RandomizableContainerBlockEntity; +import net.minecraft.world.level.block.entity.SignBlockEntity; +import net.minecraft.world.level.block.entity.SpawnerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.Property; +import net.minecraft.world.level.storage.TagValueInput; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.List; +import java.util.Map; public final class ModdedTileData extends TileData { public static final String NBT_PROPERTY = "nbt"; + static final String LEGACY_BANNER_COLOR_PROPERTY = "iris:legacy_banner_color"; private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create(); private final byte[] raw; private final KMap tileProperties; + private final String expectedBlockKey; + private final int legacyType; - ModdedTileData(byte[] raw, KMap tileProperties) { + ModdedTileData(byte[] raw, KMap tileProperties, String expectedBlockKey, int legacyType) { super(); this.raw = raw; this.tileProperties = tileProperties == null ? new KMap<>() : tileProperties; + this.expectedBlockKey = expectedBlockKey; + this.legacyType = legacyType; } public static ModdedTileData capture(String blockKey, String snbt) throws IOException { @@ -49,7 +81,26 @@ public final class ModdedTileData extends TileData { out.writeUTF(blockKey); out.writeUTF(GSON.toJson(properties)); } - return new ModdedTileData(bytes.toByteArray(), properties); + return new ModdedTileData(bytes.toByteArray(), properties, normalizeBlockKey(blockKey), -1); + } + + public static ModdedTileData fromProperties(PlatformBlockState state, KMap properties) { + String blockKey = state.placementBaseState().key(); + int bracket = blockKey.indexOf('['); + if (bracket >= 0) { + blockKey = blockKey.substring(0, bracket); + } + KMap copied = properties == null ? new KMap<>() : properties.copy(); + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bytes)) { + out.writeUTF(blockKey); + out.writeUTF(GSON.toJson(copied)); + } + return new ModdedTileData(bytes.toByteArray(), copied, normalizeBlockKey(blockKey), -1); + } catch (IOException e) { + throw new IllegalStateException("Failed to encode modded tile data for " + blockKey, e); + } } public String snbt() { @@ -57,6 +108,136 @@ public final class ModdedTileData extends TileData { return value == null ? null : value.toString(); } + public boolean apply(BlockEntity blockEntity, ServerLevel level) throws Exception { + CompoundTag payload = payload(); + if (payload == null) { + return false; + } + CompoundTag merged = blockEntity.saveWithoutMetadata(level.registryAccess()); + merged.merge(payload); + blockEntity.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, level.registryAccess(), merged)); + blockEntity.setChanged(); + level.sendBlockUpdated(blockEntity.getBlockPos(), blockEntity.getBlockState(), blockEntity.getBlockState(), Block.UPDATE_CLIENTS); + return true; + } + + public boolean isApplicable(BlockState state, BlockEntity blockEntity) { + if (expectedBlockKey != null) { + return expectedBlockKey.equals(normalizeBlockKey(BuiltInRegistries.BLOCK.getKey(state.getBlock()).toString())); + } + return switch (legacyType) { + case 0 -> blockEntity instanceof SignBlockEntity; + case 1 -> blockEntity instanceof SpawnerBlockEntity; + case 2 -> blockEntity instanceof BannerBlockEntity; + case 3 -> blockEntity instanceof RandomizableContainerBlockEntity; + default -> false; + }; + } + + CompoundTag payload() throws Exception { + String snbt = snbt(); + if (snbt != null && !snbt.isBlank()) { + return NbtUtils.snbtToStructure(snbt); + } + Tag converted = toTag(tileProperties); + return converted instanceof CompoundTag compound ? compound : null; + } + + public BlockState adjustBlockState(BlockState state) { + Object colorValue = tileProperties.get(LEGACY_BANNER_COLOR_PROPERTY); + if (colorValue == null) { + return state; + } + Identifier currentId = BuiltInRegistries.BLOCK.getKey(state.getBlock()); + String currentPath = currentId.getPath(); + if (!currentPath.endsWith("_banner")) { + return state; + } + String suffix = currentPath.endsWith("_wall_banner") ? "_wall_banner" : "_banner"; + Identifier targetId = Identifier.tryParse("minecraft:" + colorValue + suffix); + if (targetId == null || !BuiltInRegistries.BLOCK.containsKey(targetId)) { + return state; + } + BlockState adjusted = BuiltInRegistries.BLOCK.getValue(targetId).defaultBlockState(); + for (Property property : state.getProperties()) { + if (adjusted.hasProperty(property)) { + adjusted = copyProperty(adjusted, state, property); + } + } + return adjusted; + } + + private static Tag toTag(Object value) { + if (value == null) { + return null; + } + if (value instanceof Map map) { + CompoundTag compound = new CompoundTag(); + for (Map.Entry entry : map.entrySet()) { + if (LEGACY_BANNER_COLOR_PROPERTY.equals(String.valueOf(entry.getKey()))) { + continue; + } + Tag child = toTag(entry.getValue()); + if (child != null) { + compound.put(String.valueOf(entry.getKey()), child); + } + } + return compound; + } + if (value instanceof List values) { + ListTag list = new ListTag(); + for (Object entry : values) { + Tag child = toTag(entry); + if (child != null) { + list.add(child); + } + } + return list; + } + if (value instanceof Boolean bool) { + return ByteTag.valueOf(bool); + } + if (value instanceof Byte number) { + return ByteTag.valueOf(number); + } + if (value instanceof Short number) { + return ShortTag.valueOf(number); + } + if (value instanceof Integer number) { + return IntTag.valueOf(number); + } + if (value instanceof Long number) { + return LongTag.valueOf(number); + } + if (value instanceof Float number) { + return FloatTag.valueOf(number); + } + if (value instanceof Number number) { + return DoubleTag.valueOf(number.doubleValue()); + } + return StringTag.valueOf(String.valueOf(value)); + } + + private static > BlockState copyProperty(BlockState target, BlockState source, Property property) { + return target.setValue(property, source.getValue(property)); + } + + static String normalizeBlockKey(String blockKey) { + if (blockKey == null || blockKey.isBlank()) { + return null; + } + String normalized = blockKey.trim().toLowerCase(java.util.Locale.ROOT); + int bracket = normalized.indexOf('['); + if (bracket >= 0) { + normalized = normalized.substring(0, bracket); + } + if (!normalized.contains(":")) { + normalized = "minecraft:" + normalized.replaceAll("\\s+", "_"); + } + Identifier identifier = Identifier.tryParse(normalized); + return identifier == null ? null : identifier.toString(); + } + @Override public KMap getProperties() { return tileProperties; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileReader.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileReader.java index 3afc1afb2..2a1780f22 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileReader.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedTileReader.java @@ -28,18 +28,68 @@ import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; +import net.minecraft.world.item.DyeColor; import net.minecraft.world.level.block.entity.BannerPattern; import java.io.DataInputStream; import java.io.IOException; import java.io.InputStream; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.Locale; import java.util.function.Supplier; public final class ModdedTileReader implements TileData.TileReader { private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create(); private static final int DYE_COLOR_COUNT = 16; + private static final Identifier DEFAULT_SPAWNER_ENTITY = Identifier.parse("minecraft:pig"); + private static final Identifier DEFAULT_BANNER_PATTERN = Identifier.parse("minecraft:base"); + private static final List LEGACY_BUKKIT_ENTITY_TYPES = createLegacyBukkitEntityTypes(); + private static final List PAPER_26_2_BANNER_PATTERNS = List.of( + Identifier.parse("minecraft:small_stripes"), + Identifier.parse("minecraft:stripe_right"), + Identifier.parse("minecraft:diagonal_left"), + Identifier.parse("minecraft:stripe_middle"), + Identifier.parse("minecraft:square_bottom_right"), + Identifier.parse("minecraft:half_horizontal"), + Identifier.parse("minecraft:skull"), + Identifier.parse("minecraft:flow"), + Identifier.parse("minecraft:rhombus"), + Identifier.parse("minecraft:border"), + Identifier.parse("minecraft:gradient_up"), + Identifier.parse("minecraft:guster"), + Identifier.parse("minecraft:square_top_left"), + Identifier.parse("minecraft:triangle_bottom"), + Identifier.parse("minecraft:triangles_bottom"), + Identifier.parse("minecraft:half_horizontal_bottom"), + Identifier.parse("minecraft:gradient"), + Identifier.parse("minecraft:triangle_top"), + Identifier.parse("minecraft:piglin"), + Identifier.parse("minecraft:stripe_center"), + Identifier.parse("minecraft:circle"), + Identifier.parse("minecraft:stripe_left"), + Identifier.parse("minecraft:stripe_bottom"), + Identifier.parse("minecraft:square_top_right"), + Identifier.parse("minecraft:curly_border"), + Identifier.parse("minecraft:creeper"), + Identifier.parse("minecraft:square_bottom_left"), + Identifier.parse("minecraft:triangles_top"), + Identifier.parse("minecraft:half_vertical"), + Identifier.parse("minecraft:mojang"), + Identifier.parse("minecraft:diagonal_right"), + Identifier.parse("minecraft:cross"), + Identifier.parse("minecraft:straight_cross"), + Identifier.parse("minecraft:bricks"), + Identifier.parse("minecraft:diagonal_up_left"), + Identifier.parse("minecraft:base"), + Identifier.parse("minecraft:flower"), + Identifier.parse("minecraft:stripe_downleft"), + Identifier.parse("minecraft:diagonal_up_right"), + Identifier.parse("minecraft:stripe_downright"), + Identifier.parse("minecraft:stripe_top"), + Identifier.parse("minecraft:globe"), + Identifier.parse("minecraft:half_vertical_right")); private final Supplier server; @@ -135,7 +185,8 @@ public final class ModdedTileReader implements TileData.TileReader { if (properties == null) { throw new NullPointerException("properties is marked non-null but is null"); } - return new ModdedTileData(replay.consumed(), properties); + return new ModdedTileData(replay.consumed(), properties, + ModdedTileData.normalizeBlockKey(materialKey), -1); } catch (Throwable e) { replay.rewind(); return parseLegacy(din, replay); @@ -149,102 +200,162 @@ public final class ModdedTileReader implements TileData.TileReader { private TileData parseLegacy(DataInputStream din, ReplayInputStream replay) throws IOException { int id = din.readShort(); - switch (id) { + String expectedBlockKey = null; + KMap properties = switch (id) { case 0 -> readSign(din); case 1 -> readSpawner(din, replay); case 2 -> readBanner(din, replay); - case 3 -> readLootable(din); + case 3 -> { + expectedBlockKey = ModdedTileData.normalizeBlockKey(din.readUTF()); + yield readLootable(din); + } default -> throw new IOException("Unknown tile type: " + id); - } - return new ModdedTileData(replay.consumed(), new KMap<>()); + }; + return new ModdedTileData(replay.consumed(), properties, expectedBlockKey, id); } - private static void readSign(DataInputStream din) throws IOException { - din.readUTF(); - din.readUTF(); - din.readUTF(); - din.readUTF(); + private static KMap readSign(DataInputStream din) throws IOException { + List messages = List.of(din.readUTF(), din.readUTF(), din.readUTF(), din.readUTF()); byte dye = din.readByte(); if (dye < 0 || dye >= DYE_COLOR_COUNT) { throw new ArrayIndexOutOfBoundsException("Index " + dye + " out of bounds for length " + DYE_COLOR_COUNT); } + KMap text = new KMap<>(); + text.put("messages", messages); + text.put("color", DyeColor.byId(dye).getName()); + text.put("has_glowing_text", false); + KMap properties = new KMap<>(); + properties.put("front_text", text); + properties.put("back_text", text.copy()); + return properties; } - private static void readSpawner(DataInputStream din, ReplayInputStream replay) throws IOException { - boolean resolved = false; + private static KMap readSpawner(DataInputStream din, ReplayInputStream replay) throws IOException { + Identifier entityId = null; replay.mark(Integer.MAX_VALUE); try { String keyString = din.readUTF(); Identifier key = Identifier.tryParse(keyString); - resolved = key != null && BuiltInRegistries.ENTITY_TYPE.containsKey(key); - if (!resolved) { + if (key != null && BuiltInRegistries.ENTITY_TYPE.containsKey(key)) { + entityId = key; + } else { replay.reset(); } } catch (Throwable ignored) { replay.reset(); } - if (!resolved) { - din.readShort(); + if (entityId == null) { + entityId = legacySpawnerEntityId(din.readShort()); } + KMap entity = new KMap<>(); + entity.put("id", entityId.toString()); + KMap spawnData = new KMap<>(); + spawnData.put("entity", entity); + KMap properties = new KMap<>(); + properties.put("SpawnData", spawnData); + return properties; } - private void readBanner(DataInputStream din, ReplayInputStream replay) throws IOException { - din.readUnsignedByte(); + static Identifier legacySpawnerEntityId(int legacyOrdinal) { + if (legacyOrdinal < 0 || legacyOrdinal >= LEGACY_BUKKIT_ENTITY_TYPES.size()) { + return DEFAULT_SPAWNER_ENTITY; + } + return LEGACY_BUKKIT_ENTITY_TYPES.get(legacyOrdinal); + } + + private static List createLegacyBukkitEntityTypes() { + List entityTypes = new ArrayList<>(); + for (Identifier key : BuiltInRegistries.ENTITY_TYPE.keySet()) { + if (Identifier.DEFAULT_NAMESPACE.equals(key.getNamespace())) { + entityTypes.add(key); + } + } + entityTypes.sort(Identifier::compareTo); + return List.copyOf(entityTypes); + } + + private KMap readBanner(DataInputStream din, ReplayInputStream replay) throws IOException { + int baseColor = din.readUnsignedByte(); int listSize = din.readUnsignedByte(); replay.mark(Integer.MAX_VALUE); - boolean parsedKeyed = false; + List layers = new ArrayList<>(listSize); try { for (int i = 0; i < listSize; i++) { - din.readUnsignedByte(); + int color = din.readUnsignedByte(); Identifier patternKey = Identifier.tryParse(din.readUTF()); if (patternKey == null || !bannerPatternExists(patternKey)) { throw new IOException("Unknown banner pattern key"); } + layers.add(bannerLayer(patternKey, color)); } - parsedKeyed = true; } catch (Throwable ignored) { replay.reset(); + layers.clear(); } - if (parsedKeyed) { - return; + if (layers.isEmpty() && listSize > 0) { + for (int i = 0; i < listSize; i++) { + int color = din.readUnsignedByte(); + int pattern = din.readUnsignedByte(); + layers.add(bannerLayer(legacyBannerPatternKey(pattern), color)); + } } + KMap properties = new KMap<>(); + properties.put("patterns", layers); + properties.put(ModdedTileData.LEGACY_BANNER_COLOR_PROPERTY, DyeColor.byId(baseColor).getName()); + return properties; + } - for (int i = 0; i < listSize; i++) { - din.readUnsignedByte(); - din.readUnsignedByte(); + private static KMap bannerLayer(Identifier pattern, int color) { + KMap layer = new KMap<>(); + layer.put("pattern", pattern.toString()); + layer.put("color", DyeColor.byId(color).getName()); + return layer; + } + + static Identifier legacyBannerPatternKey(int legacyOrdinal) { + if (legacyOrdinal < 0 || legacyOrdinal >= PAPER_26_2_BANNER_PATTERNS.size()) { + return DEFAULT_BANNER_PATTERN; } + return PAPER_26_2_BANNER_PATTERNS.get(legacyOrdinal); } private boolean bannerPatternExists(Identifier key) { MinecraftServer instance = server.get(); if (instance == null) { - return false; + return true; } Registry registry = instance.registryAccess().lookupOrThrow(Registries.BANNER_PATTERN); return registry.containsKey(key); } - private static void readLootable(DataInputStream din) throws IOException { - din.readUTF(); - din.readUTF(); - din.readLong(); + private static KMap readLootable(DataInputStream din) throws IOException { + String lootTable = din.readUTF(); + long seed = din.readLong(); + KMap properties = new KMap<>(); + if (!lootTable.isBlank()) { + properties.put("LootTable", lootTable); + properties.put("LootTableSeed", seed); + } + return properties; } private static boolean matchMaterial(String name) { - String filtered = name; - if (filtered.startsWith("minecraft:")) { - filtered = filtered.substring("minecraft:".length()); + String filtered = name.trim().toLowerCase(Locale.ROOT); + int bracket = filtered.indexOf('['); + if (bracket >= 0) { + filtered = filtered.substring(0, bracket); } - filtered = filtered.toUpperCase(Locale.ROOT); - filtered = filtered.replaceAll("\\s+", "_").replaceAll("\\W", ""); - Identifier identifier = Identifier.tryParse("minecraft:" + filtered.toLowerCase(Locale.ROOT)); + if (!filtered.contains(":")) { + filtered = "minecraft:" + filtered.replaceAll("\\s+", "_"); + } + Identifier identifier = Identifier.tryParse(filtered); if (identifier == null) { return false; } - return BuiltInRegistries.ITEM.containsKey(identifier) || BuiltInRegistries.BLOCK.containsKey(identifier); + return BuiltInRegistries.BLOCK.containsKey(identifier); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java index 7487f2bba..7717d6c76 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldCheck.java @@ -18,14 +18,39 @@ package art.arcane.iris.modded; +import art.arcane.iris.core.nms.datapack.DataVersion; +import art.arcane.iris.engine.framework.StructureVerticalBounds; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.nativegen.NativeStructurePostProcessor; +import art.arcane.volmlib.util.json.JSONObject; +import com.mojang.datafixers.util.Pair; import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; +import net.minecraft.tags.BlockTags; +import net.minecraft.world.entity.ai.village.poi.PoiManager; +import net.minecraft.world.entity.ai.village.poi.PoiRecord; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkAccess; import net.minecraft.world.level.chunk.LevelChunkSection; +import net.minecraft.world.level.dimension.DimensionType; import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructurePiece; +import net.minecraft.world.level.levelgen.structure.StructureStart; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,19 +58,39 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.Comparator; import java.util.HexFormat; import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; public final class ModdedWorldCheck { private static final int EXIT_PASS = 0; private static final int EXIT_FAILURE = 1; + private static final int MAX_FOOTPRINT_CHUNKS = 96; + private static final int MAX_START_REFERENCE_CHUNKS = 16; private static final long SERVER_WAIT_TIMEOUT_MILLIS = 600000L; private static final long SERVER_WAIT_INTERVAL_MILLIS = 250L; + private static final List STRUCTURE_CHECKS = List.of( + new StructureCheck("stronghold", List.of("minecraft:stronghold"), 256), + new StructureCheck("trial_chambers", List.of("minecraft:trial_chambers"), 128), + new StructureCheck("mansion", List.of("minecraft:mansion"), 256), + new StructureCheck("village", List.of( + "minecraft:village_plains", + "minecraft:village_desert", + "minecraft:village_savanna", + "minecraft:village_snowy", + "minecraft:village_taiga"), 128), + new StructureCheck("monument", List.of("minecraft:monument"), 128) + ); private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final ProcessExit PROCESS_EXIT = Runtime.getRuntime()::exit; + private static volatile MinecraftServer startedServer; private ModdedWorldCheck() { } @@ -54,6 +99,16 @@ public final class ModdedWorldCheck { coordinatorThread(() -> waitAndRun(PROCESS_EXIT)).start(); } + public static void serverStarted(MinecraftServer server) { + startedServer = server; + } + + public static void serverStopped(MinecraftServer server) { + if (startedServer == server) { + startedServer = null; + } + } + static Thread coordinatorThread(Runnable coordinator) { Thread thread = new Thread(coordinator, "Iris World Check"); thread.setDaemon(false); @@ -63,30 +118,31 @@ public final class ModdedWorldCheck { private static void waitAndRun(ProcessExit processExit) { long start = System.currentTimeMillis(); MinecraftServer server = null; - boolean ready = false; int exitCode = EXIT_FAILURE; + AtomicBoolean stopRequested = new AtomicBoolean(false); try { while (System.currentTimeMillis() - start < SERVER_WAIT_TIMEOUT_MILLIS) { - MinecraftServer candidate = ModdedEngineBootstrap.currentServer(); + MinecraftServer candidate = startedServer; if (candidate != null) { server = candidate; - if (candidate.isReady()) { - ready = true; - break; - } + break; } Thread.sleep(SERVER_WAIT_INTERVAL_MILLIS); } - if (!ready) { - LOGGER.error("[worldcheck] server did not become ready within 10 minutes"); + if (server == null) { + LOGGER.error("[worldcheck] server did not finish starting within 10 minutes"); return; } MinecraftServer serverRef = server; - AtomicBoolean pass = new AtomicBoolean(false); - serverRef.submit(() -> pass.set(run(serverRef))).join(); - exitCode = pass.get() ? EXIT_PASS : EXIT_FAILURE; + exitCode = serverRef.submit(() -> runAndRequestStop( + () -> run(serverRef), + () -> { + stopRequested.set(true); + serverRef.halt(false); + } + )).join(); } catch (InterruptedException e) { LOGGER.error("[worldcheck] coordinator interrupted", e); Thread.currentThread().interrupt(); @@ -96,23 +152,41 @@ public final class ModdedWorldCheck { int resultCode = exitCode; LOGGER.info("[worldcheck] shutting down dev server (result={})", resultCode == EXIT_PASS ? "PASS" : "FAIL"); MinecraftServer serverRef = server; - stopAndExit(serverRef == null ? null : () -> serverRef.halt(true), resultCode, processExit); + if (serverRef != null && stopRequested.get()) { + awaitStopAndExit(() -> serverRef.halt(true), resultCode, processExit); + } else { + processExit.exit(EXIT_FAILURE); + } } } - static void stopAndExit(Runnable serverStop, int requestedExitCode, ProcessExit processExit) { + static int runAndRequestStop(BooleanSupplier check, Runnable requestStop) { + int exitCode = EXIT_FAILURE; + try { + exitCode = check.getAsBoolean() ? EXIT_PASS : EXIT_FAILURE; + } catch (Throwable e) { + LOGGER.error("[worldcheck] check failed", e); + } + try { + requestStop.run(); + } catch (Throwable e) { + LOGGER.error("[worldcheck] server stop request failed", e); + return EXIT_FAILURE; + } + return exitCode; + } + + static void awaitStopAndExit(Runnable awaitStop, int requestedExitCode, ProcessExit processExit) { int exitCode = requestedExitCode; boolean interrupted = Thread.interrupted(); if (interrupted) { exitCode = EXIT_FAILURE; } try { - if (serverStop != null) { - serverStop.run(); - } + awaitStop.run(); } catch (Throwable e) { exitCode = EXIT_FAILURE; - LOGGER.error("[worldcheck] server shutdown failed", e); + LOGGER.error("[worldcheck] waiting for server shutdown failed", e); } finally { if (interrupted) { Thread.currentThread().interrupt(); @@ -131,10 +205,13 @@ public final class ModdedWorldCheck { String levelId = level.dimension().identifier().toString(); String generatorClass = level.getChunkSource().getGenerator().getClass().getName(); LOGGER.info("[worldcheck] {} generator: {}", levelId, generatorClass); - boolean irisGenerator = level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator; + IrisModdedChunkGenerator generator = level.getChunkSource().getGenerator() instanceof IrisModdedChunkGenerator iris + ? iris : null; + boolean irisGenerator = generator != null; if (!irisGenerator) { LOGGER.error("[worldcheck] {} is NOT using IrisModdedChunkGenerator", levelId); } + boolean dimensionTypeOk = generator != null && checkDimensionType(level, generator); BlockPos spawn = level.getRespawnData().pos(); LOGGER.info("[worldcheck] spawn: {} {} {} (minY={} height={})", spawn.getX(), spawn.getY(), spawn.getZ(), level.getMinY(), level.getHeight()); @@ -190,11 +267,892 @@ public final class ModdedWorldCheck { LOGGER.error("[worldcheck] generated terrain has no block variety (flat-world signature)"); } - boolean pass = irisGenerator && sectionsOk && varietyOk; + boolean entityMixinsOk = checkEntityMixins(level); + boolean structureOk = false; + if (generator != null) { + structureOk = checkNativeStructures(level, generator, spawn); + } + + boolean pass = irisGenerator && dimensionTypeOk && sectionsOk && varietyOk && entityMixinsOk && structureOk; LOGGER.info("[worldcheck] {}", pass ? "PASS" : "FAIL"); + qaEvent("worldcheck_final", "all", pass, + "structures=" + STRUCTURE_CHECKS.size() + ",terrain=" + (sectionsOk && varietyOk) + + ",dimensionType=" + dimensionTypeOk + ",entityMixins=" + entityMixinsOk); return pass; } + private static boolean checkDimensionType(ServerLevel level, IrisModdedChunkGenerator generator) { + try { + IrisDimension dimension = generator.commandEngine().getDimension(); + DimensionContract expected = expectedDimensionContract(dimension); + DimensionContract actual = runtimeDimensionContract(level.dimensionType()); + boolean pass = matchesDimensionContract(level.getMinY(), level.getHeight(), expected, actual); + String detail = "expected=" + expected + ",actual=" + actual + + ",levelMinY=" + level.getMinY() + ",levelHeight=" + level.getHeight(); + qaEvent("dimension_type", dimension.getLoadKey(), pass, detail); + if (!pass) { + LOGGER.error("[worldcheck] dimension type mismatch for {}: {}", dimension.getLoadKey(), detail); + } else { + LOGGER.info("[worldcheck] dimension type contract: {}", detail); + } + return pass; + } catch (Throwable error) { + LOGGER.error("[worldcheck] could not validate the Iris dimension type contract", error); + qaEvent("dimension_type", generator.activeDimensionKey(), false, + "validationError=" + error.getClass().getSimpleName() + ":" + error.getMessage()); + return false; + } + } + + static DimensionContract expectedDimensionContract(IrisDimension dimension) { + JSONObject json = new JSONObject(dimension.getDimensionType().toJson(DataVersion.getLatest().get())); + return new DimensionContract( + json.getInt("min_y"), + json.getInt("height"), + json.getInt("logical_height"), + json.getDouble("coordinate_scale"), + (float) json.getDouble("ambient_light"), + json.getBoolean("has_skylight"), + json.getBoolean("has_ceiling"), + json.getBoolean("has_ender_dragon_fight"), + json.getInt("monster_spawn_block_light_limit")); + } + + static DimensionContract runtimeDimensionContract(DimensionType dimensionType) { + return new DimensionContract( + dimensionType.minY(), + dimensionType.height(), + dimensionType.logicalHeight(), + dimensionType.coordinateScale(), + dimensionType.ambientLight(), + dimensionType.hasSkyLight(), + dimensionType.hasCeiling(), + dimensionType.hasEnderDragonFight(), + dimensionType.monsterSpawnBlockLightLimit()); + } + + static boolean matchesDimensionContract(int levelMinY, int levelHeight, + DimensionContract expected, DimensionContract actual) { + return levelMinY == expected.minY() + && levelHeight == expected.height() + && actual.equals(expected); + } + + private static boolean checkEntityMixins(ServerLevel level) { + ItemEntity item = new ItemEntity(level, 0D, level.getMinY(), 0D, Items.COBBLESTONE.getDefaultInstance()); + boolean vanillaSave = item.shouldBeSaved(); + ModdedEntityPersistence.configure(item, false); + boolean suppressed = !item.shouldBeSaved(); + ModdedEntityPersistence.configure(item, true); + boolean restored = item.shouldBeSaved(); + boolean pass = vanillaSave && suppressed && restored; + qaEvent("entity_mixin", "persistence", pass, + "vanilla=" + vanillaSave + ",suppressed=" + suppressed + ",restored=" + restored); + if (!pass) { + LOGGER.error("[worldcheck] shared entity mixins are not active on this loader"); + } + return pass; + } + + private static boolean checkNativeStructures(ServerLevel level, IrisModdedChunkGenerator generator, BlockPos origin) { + boolean pass = true; + int passed = 0; + for (StructureCheck check : STRUCTURE_CHECKS) { + boolean structurePass = checkNativeStructure(level, generator, origin, check); + if (structurePass) { + passed++; + } else { + pass = false; + } + } + LOGGER.info("[worldcheck] native structure gate: {}/{} passed", passed, STRUCTURE_CHECKS.size()); + qaEvent("structure_aggregate", "all", pass, + "passed=" + passed + ",total=" + STRUCTURE_CHECKS.size()); + return pass; + } + + private static boolean checkNativeStructure(ServerLevel level, IrisModdedChunkGenerator generator, + BlockPos origin, StructureCheck check) { + Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); + List> registered = new ArrayList<>(check.registryKeys().size()); + LinkedHashSet registeredKeys = new LinkedHashSet<>(); + for (String key : check.registryKeys()) { + Identifier identifier = Identifier.tryParse(key); + if (identifier == null) { + continue; + } + Optional> resolved = registry.get(identifier); + if (resolved.isPresent()) { + registered.add(resolved.get()); + registeredKeys.add(identifier.toString()); + } + } + boolean registryOk = registered.size() == check.registryKeys().size(); + LOGGER.info("[worldcheck] {} registry: {}/{} resolved {}", check.label(), registered.size(), + check.registryKeys().size(), registeredKeys); + qaEvent("structure_registry", check.label(), registryOk, + "resolved=" + registered.size() + ",expected=" + check.registryKeys().size() + + ",keys=" + String.join("|", registeredKeys)); + if (!registryOk) { + LOGGER.error("[worldcheck] {} registry resolution failed; expected {}", check.label(), check.registryKeys()); + emitSkipped(check, "registry", "structure_reachability", "structure_locate", + "structure_start_reference", "structure_footprint", "structure_material", + "structure_block_entity"); + return false; + } + + List> reachable = new ArrayList<>(registered.size()); + LinkedHashSet reachableKeys = new LinkedHashSet<>(); + for (Holder holder : registered) { + if (!generator.isNativeStructureReachable(holder)) { + continue; + } + reachable.add(holder); + Identifier key = registry.getKey(holder.value()); + if (key != null) { + reachableKeys.add(key.toString()); + } + } + boolean reachableOk = !reachable.isEmpty(); + LOGGER.info("[worldcheck] {} biome-reachable through Iris: {}", check.label(), reachableKeys); + qaEvent("structure_reachability", check.label(), reachableOk, + "reachable=" + reachable.size() + ",registered=" + registered.size() + + ",keys=" + String.join("|", reachableKeys)); + if (!reachableOk) { + LOGGER.error("[worldcheck] {} cannot generate in any biome produced by this Iris pack", check.label()); + emitSkipped(check, "reachability", "structure_locate", "structure_start_reference", + "structure_footprint", "structure_material", "structure_block_entity"); + return false; + } + + long locateStart = System.nanoTime(); + Pair> found = generator.findNearestMapStructure( + level, HolderSet.direct(reachable), origin, check.locateRadius(), false); + long locateMillis = (System.nanoTime() - locateStart) / 1_000_000L; + Identifier foundKey = found == null ? null : registry.getKey(found.getSecond().value()); + boolean locateOk = found != null && foundKey != null && reachableKeys.contains(foundKey.toString()); + qaEvent("structure_locate", check.label(), locateOk, + "millis=" + locateMillis + ",radius=" + check.locateRadius() + + ",result=" + (foundKey == null ? "none" : foundKey)); + if (found == null) { + LOGGER.error("[worldcheck] {} locate found no result within {} chunks after {}ms", + check.label(), check.locateRadius(), locateMillis); + emitSkipped(check, "locate", "structure_start_reference", "structure_footprint", + "structure_material", "structure_block_entity"); + return false; + } + + BlockPos position = found.getFirst(); + LOGGER.info("[worldcheck] {} locate: {} {} {} in {}ms (radius={}, unexplored=false, result={})", + check.label(), position.getX(), position.getY(), position.getZ(), locateMillis, + check.locateRadius(), foundKey); + if (!locateOk) { + LOGGER.error("[worldcheck] {} locate returned unexpected structure {}", check.label(), foundKey); + emitSkipped(check, "locate", "structure_start_reference", "structure_footprint", + "structure_material", "structure_block_entity"); + return false; + } + + int chunkX = position.getX() >> 4; + int chunkZ = position.getZ() >> 4; + ChunkAccess targetChunk = level.getChunk(chunkX, chunkZ); + Structure structure = found.getSecond().value(); + StructureStart start = resolveStructureStart(level, targetChunk, structure); + boolean validStart = start != null && start.isValid(); + int references = targetChunk.getReferencesForStructure(structure).size(); + boolean startReferenceOk = hasNativeStructureEvidence(validStart, references); + LOGGER.info("[worldcheck] {} target chunk {},{}: valid start={}, references={}", + check.label(), chunkX, chunkZ, validStart, references); + qaEvent("structure_start_reference", check.label(), startReferenceOk, + "chunk=" + chunkX + "," + chunkZ + ",validStart=" + validStart + + ",references=" + references); + if (!startReferenceOk || !validStart) { + LOGGER.error("[worldcheck] {} located at chunk {},{} but no resolvable valid start was generated", + check.label(), chunkX, chunkZ); + emitSkipped(check, "start_reference", "structure_footprint", "structure_material", + "structure_block_entity"); + return false; + } + + IrisNativeStructureDecision decision = generator.commandEngine().getDimension().getImportedStructures() + .resolve(foundKey.toString(), NativeStructurePostProcessor.isUndergroundStep(structure.step())); + Integer appliedShift = generator.worldCheckStructureShift(foundKey.toString(), start.getChunkPos()); + BoundingBox shiftedBounds = start.getBoundingBox(); + boolean verticalShiftOk = verticalShiftMatches( + decision.yShift(), appliedShift, shiftedBounds.minY(), shiftedBounds.maxY(), + level.getMinY(), level.getMaxY()); + qaEvent("structure_vertical_shift", check.label(), verticalShiftOk, + "configured=" + decision.yShift() + ",applied=" + + (appliedShift == null ? "unrecorded" : appliedShift)); + if (!verticalShiftOk) { + LOGGER.error("[worldcheck] {} expected vertical shift {} but generation recorded {}", + check.label(), decision.yShift(), appliedShift); + } + + FootprintAudit footprint = auditFootprint(level, structure, start, check, foundKey); + boolean footprintOk = footprint.inspectedChunks() > 0 + && footprint.evidenceChunks() == footprint.inspectedChunks() + && footprint.coveredPieces() == footprint.totalPieces(); + LOGGER.info("[worldcheck] {} footprint: chunks={}/{} evidence={} pieces={}/{}", + check.label(), footprint.inspectedChunks(), footprint.availableChunks(), + footprint.evidenceChunks(), footprint.coveredPieces(), footprint.totalPieces()); + qaEvent("structure_footprint", check.label(), footprintOk, + "inspected=" + footprint.inspectedChunks() + ",available=" + footprint.availableChunks() + + ",evidence=" + footprint.evidenceChunks() + ",coveredPieces=" + + footprint.coveredPieces() + ",totalPieces=" + footprint.totalPieces()); + + boolean materialOk = hasCharacteristicMaterialEvidence(footprint.characteristicBlocks(), + footprint.characteristicChunks(), footprint.materialScannedChunks()); + LOGGER.info("[worldcheck] {} material: blocks={} chunks={}/{}", + check.label(), footprint.characteristicBlocks(), footprint.characteristicChunks(), + footprint.materialScannedChunks()); + qaEvent("structure_material", check.label(), materialOk, + "blocks=" + footprint.characteristicBlocks() + ",chunks=" + footprint.characteristicChunks() + + ",scanned=" + footprint.materialScannedChunks()); + + boolean vegetationOk = true; + if (check.label().equals("mansion")) { + boolean overlap = footprint.vegetationBlocks() > 0; + vegetationOk = mansionVegetationPass(footprint.vegetationBlocks()); + LOGGER.info("[worldcheck] mansion vegetation metric: remaining log/leaf blocks={} columns={} overlap={}", + footprint.vegetationBlocks(), footprint.vegetationColumns(), overlap); + qaEvent("mansion_vegetation_metric", check.label(), vegetationOk, + "remainingLogsOrLeaves=" + footprint.vegetationBlocks() + ",columns=" + + footprint.vegetationColumns() + ",overlap=" + overlap); + } + + boolean foundationOk = true; + boolean poiOk = true; + if (check.label().equals("village")) { + foundationOk = villageFoundationPass(footprint.foundationGapColumns()); + LOGGER.info("[worldcheck] village foundation metric: cobblestone below piece bases={} columns={} unsupported={}", + footprint.foundationBlocks(), footprint.foundationColumns(), footprint.foundationGapColumns()); + qaEvent("village_foundation_metric", check.label(), foundationOk, + "cobblestoneBelowBase=" + footprint.foundationBlocks() + ",columns=" + + footprint.foundationColumns() + ",unsupported=" + footprint.foundationGapColumns()); + PoiAudit poi = auditStructurePois(level, start); + poiOk = villagePoiPass(poi.inBounds(), poi.outOfBounds()); + qaEvent("village_poi_metric", check.label(), poiOk, + "inBounds=" + poi.inBounds() + ",outOfBounds=" + poi.outOfBounds()); + if (!poiOk) { + LOGGER.error("[worldcheck] village POI audit failed: inBounds={} outOfBounds={}", + poi.inBounds(), poi.outOfBounds()); + } + } + + boolean blockEntityOk = footprint.blockEntityStates() == footprint.blockEntitiesPresent(); + LOGGER.info("[worldcheck] {} block entities: state blocks={}, present={}, missing={}", + check.label(), footprint.blockEntityStates(), footprint.blockEntitiesPresent(), + footprint.blockEntityStates() - footprint.blockEntitiesPresent()); + qaEvent("structure_block_entity", check.label(), blockEntityOk, + "states=" + footprint.blockEntityStates() + ",present=" + footprint.blockEntitiesPresent() + + ",missing=" + (footprint.blockEntityStates() - footprint.blockEntitiesPresent())); + if (!footprintOk) { + LOGGER.error("[worldcheck] {} structure footprint is incomplete", check.label()); + } + if (!materialOk) { + LOGGER.error("[worldcheck] {} has no distributed characteristic structure material", check.label()); + } + if (!blockEntityOk) { + LOGGER.error("[worldcheck] {} generated block-entity states without matching block entities", check.label()); + } + if (!vegetationOk) { + LOGGER.error("[worldcheck] mansion vegetation still intersects the generated structure footprint"); + } + if (!foundationOk) { + LOGGER.error("[worldcheck] village has unsupported foundation columns after stilt placement"); + } + return verticalShiftOk && footprintOk && materialOk && blockEntityOk + && vegetationOk && foundationOk && poiOk; + } + + private static StructureStart resolveStructureStart(ServerLevel level, ChunkAccess targetChunk, + Structure structure) { + StructureStart direct = targetChunk.getStartForStructure(structure); + if (direct != null && direct.isValid()) { + return direct; + } + int checked = 0; + for (long packed : targetChunk.getReferencesForStructure(structure)) { + if (checked++ >= MAX_START_REFERENCE_CHUNKS) { + break; + } + ChunkAccess referencedChunk = level.getChunk(ChunkPos.getX(packed), ChunkPos.getZ(packed)); + StructureStart referenced = referencedChunk.getStartForStructure(structure); + if (referenced != null && referenced.isValid()) { + return referenced; + } + } + return direct; + } + + private static FootprintAudit auditFootprint(ServerLevel level, Structure structure, StructureStart start, + StructureCheck check, Identifier structureKey) { + List pieces = start.getPieces(); + BoundingBox bounds = start.getBoundingBox(); + int availableChunks = footprintChunkCount(bounds); + List selected = selectFootprintChunks(start, MAX_FOOTPRINT_CHUNKS); + int evidenceChunks = 0; + int blockEntityStates = 0; + int blockEntitiesPresent = 0; + int materialScannedChunks = 0; + int characteristicBlocks = 0; + int characteristicChunks = 0; + int vegetationBlocks = 0; + int vegetationColumns = 0; + int foundationBlocks = 0; + int foundationColumns = 0; + int foundationGapColumns = 0; + BitSet visited = new BitSet(level.getHeight() << 8); + int[] minimumPieceY = new int[256]; + int[] maximumPieceY = new int[256]; + for (ChunkPos chunkPos : selected) { + ChunkAccess chunk = level.getChunk(chunkPos.x(), chunkPos.z()); + StructureStart localStart = chunk.getStartForStructure(structure); + boolean validStart = localStart != null && localStart.isValid(); + int references = chunk.getReferencesForStructure(structure).size(); + if (hasNativeStructureEvidence(validStart, references)) { + evidenceChunks++; + } + BlockEntityAudit blockEntities = auditBlockEntities(level, chunk); + blockEntityStates += blockEntities.states(); + blockEntitiesPresent += blockEntities.present(); + StructureMaterialAudit material = auditStructureMaterial(level, chunk, pieces, check, + structureKey, visited, minimumPieceY, maximumPieceY); + if (material.scanned()) { + materialScannedChunks++; + } + characteristicBlocks += material.characteristicBlocks(); + if (material.characteristicBlocks() > 0) { + characteristicChunks++; + } + vegetationBlocks += material.vegetationBlocks(); + vegetationColumns += material.vegetationColumns(); + foundationBlocks += material.foundationBlocks(); + foundationColumns += material.foundationColumns(); + foundationGapColumns += material.foundationGapColumns(); + } + int coveredPieces = 0; + for (StructurePiece piece : pieces) { + boolean covered = false; + for (ChunkPos chunkPos : selected) { + if (intersectsChunk(piece.getBoundingBox(), chunkPos)) { + covered = true; + break; + } + } + if (covered) { + coveredPieces++; + } + } + return new FootprintAudit(selected.size(), availableChunks, evidenceChunks, + coveredPieces, pieces.size(), blockEntityStates, blockEntitiesPresent, + materialScannedChunks, characteristicBlocks, characteristicChunks, + vegetationBlocks, vegetationColumns, foundationBlocks, foundationColumns, + foundationGapColumns); + } + + private static StructureMaterialAudit auditStructureMaterial(ServerLevel level, ChunkAccess chunk, + List pieces, + StructureCheck check, + Identifier structureKey, + BitSet visited, + int[] minimumPieceY, + int[] maximumPieceY) { + visited.clear(); + Arrays.fill(minimumPieceY, Integer.MAX_VALUE); + Arrays.fill(maximumPieceY, Integer.MIN_VALUE); + int characteristicBlocks = 0; + int minimumWorldY = level.getMinY(); + int maximumWorldY = level.getMaxY() - 1; + int minimumChunkX = chunk.getPos().getMinBlockX(); + int maximumChunkX = chunk.getPos().getMaxBlockX(); + int minimumChunkZ = chunk.getPos().getMinBlockZ(); + int maximumChunkZ = chunk.getPos().getMaxBlockZ(); + boolean scanned = false; + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + for (StructurePiece piece : pieces) { + BoundingBox bounds = piece.getBoundingBox(); + int minimumX = Math.max(minimumChunkX, bounds.minX()); + int maximumX = Math.min(maximumChunkX, bounds.maxX()); + int minimumY = Math.max(minimumWorldY, bounds.minY()); + int maximumY = Math.min(maximumWorldY, bounds.maxY()); + int minimumZ = Math.max(minimumChunkZ, bounds.minZ()); + int maximumZ = Math.min(maximumChunkZ, bounds.maxZ()); + if (minimumX > maximumX || minimumY > maximumY || minimumZ > maximumZ) { + continue; + } + scanned = true; + int metricMinimumY = minimumY; + boolean metricBaseValid = true; + if (check.label().equals("village") && piece instanceof PoolElementStructurePiece poolPiece) { + int groundY = bounds.minY() + poolPiece.getGroundLevelDelta(); + metricBaseValid = groundY >= bounds.minY(); + metricMinimumY = Math.max(minimumWorldY, Math.min(maximumWorldY, groundY)); + } + for (int z = minimumZ; z <= maximumZ; z++) { + int localZ = z - minimumChunkZ; + for (int x = minimumX; x <= maximumX; x++) { + int column = (localZ << 4) | (x - minimumChunkX); + if (metricBaseValid) { + minimumPieceY[column] = Math.min(minimumPieceY[column], metricMinimumY); + } + maximumPieceY[column] = Math.max(maximumPieceY[column], maximumY); + } + } + for (int y = minimumY; y <= maximumY; y++) { + int verticalIndex = (y - minimumWorldY) << 8; + for (int z = minimumZ; z <= maximumZ; z++) { + int localZ = z - minimumChunkZ; + for (int x = minimumX; x <= maximumX; x++) { + int column = (localZ << 4) | (x - minimumChunkX); + int index = verticalIndex | column; + if (visited.get(index)) { + continue; + } + visited.set(index); + BlockState state = chunk.getBlockState(position.set(x, y, z)); + Identifier blockKey = BuiltInRegistries.BLOCK.getKey(state.getBlock()); + if (isCharacteristicMaterial(check.label(), structureKey, blockKey)) { + characteristicBlocks++; + } + } + } + } + } + + int vegetationBlocks = 0; + int vegetationColumns = 0; + if (check.label().equals("mansion")) { + for (int column = 0; column < maximumPieceY.length; column++) { + int highestPieceY = maximumPieceY[column]; + if (highestPieceY == Integer.MIN_VALUE || highestPieceY >= maximumWorldY) { + continue; + } + boolean vegetationColumn = false; + int x = minimumChunkX + (column & 15); + int z = minimumChunkZ + (column >> 4); + for (int y = highestPieceY + 1; y <= maximumWorldY; y++) { + BlockState state = chunk.getBlockState(position.set(x, y, z)); + boolean vegetation = state.is(BlockTags.LOGS) || state.is(BlockTags.LEAVES); + if (!mansionVegetationAbovePiece(vegetation, y, highestPieceY)) { + continue; + } + vegetationBlocks++; + vegetationColumn = true; + } + if (vegetationColumn) { + vegetationColumns++; + } + } + } + + int foundationBlocks = 0; + int foundationColumns = 0; + int foundationGapColumns = 0; + if (check.label().equals("village")) { + for (int column = 0; column < minimumPieceY.length; column++) { + int minimumY = minimumPieceY[column]; + if (minimumY == Integer.MAX_VALUE) { + continue; + } + boolean foundationColumn = false; + int x = minimumChunkX + (column & 15); + int z = minimumChunkZ + (column >> 4); + int foundationY = Integer.MIN_VALUE; + int foundationMaximumY = Math.min(maximumWorldY, minimumY + 1); + for (int y = minimumY; y <= foundationMaximumY; y++) { + if (chunk.getBlockState(position.set(x, y, z)).isSolid()) { + foundationY = y; + break; + } + } + boolean basePresent = foundationY != Integer.MIN_VALUE; + boolean supportSolid = false; + boolean vegetationSupport = false; + if (basePresent && foundationY > minimumWorldY) { + BlockState support = chunk.getBlockState(position.set(x, foundationY - 1, z)); + supportSolid = support.isSolid(); + vegetationSupport = support.is(BlockTags.LOGS) || support.is(BlockTags.LEAVES); + } + boolean worldFloorBase = basePresent && foundationY == minimumWorldY; + if (!villageFoundationSupported(basePresent, worldFloorBase, supportSolid, vegetationSupport)) { + foundationGapColumns++; + continue; + } + for (int y = foundationY - 1; y >= minimumWorldY; y--) { + BlockState state = chunk.getBlockState(position.set(x, y, z)); + if (!state.is(Blocks.COBBLESTONE)) { + break; + } + foundationBlocks++; + foundationColumn = true; + } + if (foundationColumn) { + foundationColumns++; + } + } + } + + return new StructureMaterialAudit(scanned, characteristicBlocks, vegetationBlocks, + vegetationColumns, foundationBlocks, foundationColumns, foundationGapColumns); + } + + private static List selectFootprintChunks(StructureStart start, int limit) { + LinkedHashSet selected = new LinkedHashSet<>(); + addBounded(selected, start.getChunkPos(), limit); + List pieceAnchors = new ArrayList<>(); + for (StructurePiece piece : start.getPieces()) { + BoundingBox bounds = piece.getBoundingBox(); + pieceAnchors.add(new ChunkPos((bounds.minX() + bounds.maxX()) >> 5, + (bounds.minZ() + bounds.maxZ()) >> 5)); + pieceAnchors.add(new ChunkPos(bounds.minX() >> 4, bounds.minZ() >> 4)); + pieceAnchors.add(new ChunkPos(bounds.maxX() >> 4, bounds.maxZ() >> 4)); + } + pieceAnchors.sort(Comparator.comparingInt((ChunkPos chunkPos) -> + chunkPos.distanceSquared(start.getChunkPos()))); + for (ChunkPos chunkPos : pieceAnchors) { + addBounded(selected, chunkPos, limit); + } + for (ChunkPos chunkPos : boundedFootprintChunks(start.getBoundingBox(), start.getChunkPos(), limit)) { + if (intersectsAnyPiece(start.getPieces(), chunkPos)) { + addBounded(selected, chunkPos, limit); + } + } + return List.copyOf(selected); + } + + static List boundedFootprintChunks(BoundingBox bounds, ChunkPos origin, int limit) { + if (limit <= 0) { + return List.of(); + } + int minChunkX = bounds.minX() >> 4; + int maxChunkX = bounds.maxX() >> 4; + int minChunkZ = bounds.minZ() >> 4; + int maxChunkZ = bounds.maxZ() >> 4; + long width = (long) maxChunkX - minChunkX + 1L; + long depth = (long) maxChunkZ - minChunkZ + 1L; + long total = width * depth; + LinkedHashSet chunks = new LinkedHashSet<>(); + addBounded(chunks, origin, limit); + if (total <= limit) { + for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { + for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { + addBounded(chunks, new ChunkPos(chunkX, chunkZ), limit); + } + } + return List.copyOf(chunks); + } + addBounded(chunks, new ChunkPos(minChunkX, minChunkZ), limit); + addBounded(chunks, new ChunkPos(maxChunkX, minChunkZ), limit); + addBounded(chunks, new ChunkPos(minChunkX, maxChunkZ), limit); + addBounded(chunks, new ChunkPos(maxChunkX, maxChunkZ), limit); + int samplesPerAxis = Math.max(2, (int) Math.floor(Math.sqrt(limit))); + for (int sampleZ = 0; sampleZ < samplesPerAxis; sampleZ++) { + int chunkZ = sampleCoordinate(minChunkZ, maxChunkZ, sampleZ, samplesPerAxis); + for (int sampleX = 0; sampleX < samplesPerAxis; sampleX++) { + int chunkX = sampleCoordinate(minChunkX, maxChunkX, sampleX, samplesPerAxis); + addBounded(chunks, new ChunkPos(chunkX, chunkZ), limit); + } + } + return List.copyOf(chunks); + } + + private static int footprintChunkCount(BoundingBox bounds) { + long width = (long) (bounds.maxX() >> 4) - (bounds.minX() >> 4) + 1L; + long depth = (long) (bounds.maxZ() >> 4) - (bounds.minZ() >> 4) + 1L; + long total = width * depth; + return total > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) total; + } + + private static int sampleCoordinate(int minimum, int maximum, int index, int samples) { + if (samples <= 1 || minimum == maximum) { + return minimum; + } + double progress = (double) index / (double) (samples - 1); + return minimum + (int) Math.round((maximum - minimum) * progress); + } + + private static void addBounded(Set chunks, ChunkPos chunkPos, int limit) { + if (chunks.size() < limit) { + chunks.add(chunkPos); + } + } + + private static boolean intersectsAnyPiece(List pieces, ChunkPos chunkPos) { + for (StructurePiece piece : pieces) { + if (intersectsChunk(piece.getBoundingBox(), chunkPos)) { + return true; + } + } + return false; + } + + private static boolean intersectsChunk(BoundingBox bounds, ChunkPos chunkPos) { + return bounds.maxX() >= chunkPos.getMinBlockX() + && bounds.minX() <= chunkPos.getMaxBlockX() + && bounds.maxZ() >= chunkPos.getMinBlockZ() + && bounds.minZ() <= chunkPos.getMaxBlockZ(); + } + + private static BlockEntityAudit auditBlockEntities(ServerLevel level, ChunkAccess chunk) { + int states = 0; + int present = 0; + BlockPos.MutableBlockPos position = new BlockPos.MutableBlockPos(); + LevelChunkSection[] sections = chunk.getSections(); + for (int sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) { + LevelChunkSection section = sections[sectionIndex]; + if (!section.maybeHas(BlockState::hasBlockEntity)) { + continue; + } + int sectionMinY = chunk.getSectionYFromSectionIndex(sectionIndex) << 4; + for (int localY = 0; localY < 16; localY++) { + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + BlockState state = section.getBlockState(localX, localY, localZ); + if (!state.hasBlockEntity()) { + continue; + } + states++; + position.set(chunk.getPos().getBlockX(localX), sectionMinY + localY, + chunk.getPos().getBlockZ(localZ)); + if (level.getBlockEntity(position) != null) { + present++; + } + } + } + } + } + return new BlockEntityAudit(states, present); + } + + private static PoiAudit auditStructurePois(ServerLevel level, StructureStart start) { + int inBounds = 0; + int outOfBounds = 0; + PoiManager poiManager = level.getPoiManager(); + for (ChunkPos chunkPos : selectFootprintChunks(start, MAX_FOOTPRINT_CHUNKS)) { + List records = poiManager.getInChunk( + holder -> true, chunkPos, PoiManager.Occupancy.ANY).toList(); + for (PoiRecord record : records) { + BlockPos position = record.getPos(); + if (position.getY() < level.getMinY() || position.getY() >= level.getMaxY()) { + outOfBounds++; + continue; + } + if (insideAnyPiece(start.getPieces(), position)) { + inBounds++; + } + } + } + return new PoiAudit(inBounds, outOfBounds); + } + + private static boolean insideAnyPiece(List pieces, BlockPos position) { + for (StructurePiece piece : pieces) { + if (piece.getBoundingBox().isInside(position)) { + return true; + } + } + return false; + } + + private static void emitSkipped(StructureCheck check, String reason, String... events) { + for (String event : events) { + qaEvent(event, check.label(), false, "skipped=" + reason); + } + } + + private static void qaEvent(String event, String structure, boolean pass, String detail) { + LOGGER.info(qaEventJson(event, structure, pass, detail)); + } + + static String qaEventJson(String event, String structure, boolean pass, String detail) { + return "QA_EVT {\"event\":\"" + jsonEscape(event) + + "\",\"structure\":\"" + jsonEscape(structure) + + "\",\"pass\":" + pass + + ",\"detail\":\"" + jsonEscape(detail) + "\"}"; + } + + static String jsonEscape(String value) { + StringBuilder escaped = new StringBuilder(value.length() + 16); + for (int i = 0; i < value.length(); i++) { + char character = value.charAt(i); + switch (character) { + case '"' -> escaped.append("\\\""); + case '\\' -> escaped.append("\\\\"); + case '\b' -> escaped.append("\\b"); + case '\f' -> escaped.append("\\f"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + default -> { + if (character < 32) { + escaped.append("\\u"); + String hex = Integer.toHexString(character); + escaped.append("0".repeat(4 - hex.length())).append(hex); + } else { + escaped.append(character); + } + } + } + } + return escaped.toString(); + } + + static boolean hasNativeStructureEvidence(boolean validStart, int references) { + return validStart || references > 0; + } + + static boolean hasCharacteristicMaterialEvidence(int blocks, int chunksWithMaterial, int scannedChunks) { + if (blocks <= 0 || chunksWithMaterial <= 0 || scannedChunks <= 0 + || chunksWithMaterial > scannedChunks) { + return false; + } + return scannedChunks == 1 || chunksWithMaterial > 1; + } + + static boolean verticalShiftMatches(int configuredShift, Integer appliedShift, int shiftedMinY, + int shiftedMaxY, int worldMinY, int worldMaxYExclusive) { + try { + if (appliedShift == null) { + return configuredShift == 0 && StructureVerticalBounds.clampOffset( + shiftedMinY, shiftedMaxY, 0, worldMinY, worldMaxYExclusive) == 0; + } + int originalMinY = Math.subtractExact(shiftedMinY, appliedShift); + int originalMaxY = Math.subtractExact(shiftedMaxY, appliedShift); + int expectedShift = StructureVerticalBounds.clampOffset( + originalMinY, originalMaxY, configuredShift, worldMinY, worldMaxYExclusive); + return appliedShift == expectedShift; + } catch (RuntimeException error) { + return false; + } + } + + static boolean mansionVegetationPass(int remainingVegetationBlocks) { + return remainingVegetationBlocks == 0; + } + + static boolean mansionVegetationAbovePiece(boolean vegetation, int blockY, int highestPieceY) { + return vegetation && blockY > highestPieceY; + } + + static boolean villageFoundationSupported(boolean basePresent, boolean worldFloorBase, + boolean supportSolid, boolean vegetationSupport) { + return basePresent && (worldFloorBase || (supportSolid && !vegetationSupport)); + } + + static boolean villageFoundationPass(int unsupportedColumns) { + return unsupportedColumns == 0; + } + + static boolean villagePoiPass(int inBounds, int outOfBounds) { + return inBounds > 0 && outOfBounds == 0; + } + + static boolean isCharacteristicMaterial(String structureLabel, Identifier structureKey, Identifier blockKey) { + if (structureKey == null || blockKey == null || !blockKey.getNamespace().equals("minecraft")) { + return false; + } + String block = blockKey.getPath(); + return switch (structureLabel) { + case "stronghold" -> isStrongholdMaterial(block); + case "trial_chambers" -> isTrialChamberMaterial(block); + case "mansion" -> isWoodConstructionMaterial(block, "dark_oak") + || isWoodConstructionMaterial(block, "birch") + || isCobblestoneConstructionMaterial(block); + case "village" -> isVillageMaterial(structureKey.getPath(), block); + case "monument" -> isMonumentMaterial(block); + default -> false; + }; + } + + private static boolean isStrongholdMaterial(String block) { + return block.equals("stone_bricks") + || block.equals("cracked_stone_bricks") + || block.equals("mossy_stone_bricks") + || block.equals("infested_stone_bricks") + || block.equals("infested_cracked_stone_bricks") + || block.equals("infested_mossy_stone_bricks") + || block.equals("stone_brick_stairs") + || block.equals("stone_brick_slab") + || block.equals("stone_brick_wall"); + } + + private static boolean isTrialChamberMaterial(String block) { + return block.contains("tuff_brick") + || block.equals("polished_tuff") + || block.equals("chiseled_tuff") + || block.endsWith("copper_grate") + || block.equals("trial_spawner") + || block.equals("vault"); + } + + private static boolean isVillageMaterial(String structure, String block) { + if (isCobblestoneConstructionMaterial(block)) { + return true; + } + return switch (structure) { + case "village_plains" -> isWoodConstructionMaterial(block, "oak"); + case "village_desert" -> block.equals("cut_sandstone") + || block.equals("smooth_sandstone") + || block.equals("cut_sandstone_slab") + || block.equals("smooth_sandstone_slab") + || block.equals("smooth_sandstone_stairs") + || block.equals("sandstone_stairs") + || block.equals("sandstone_slab") + || block.equals("sandstone_wall"); + case "village_savanna" -> isWoodConstructionMaterial(block, "acacia"); + case "village_snowy", "village_taiga" -> isWoodConstructionMaterial(block, "spruce"); + default -> false; + }; + } + + private static boolean isWoodConstructionMaterial(String block, String wood) { + if (block.startsWith(wood)) { + int suffixOffset = wood.length(); + if (matchesSuffix(block, suffixOffset, "_planks") + || matchesSuffix(block, suffixOffset, "_stairs") + || matchesSuffix(block, suffixOffset, "_slab") + || matchesSuffix(block, suffixOffset, "_fence") + || matchesSuffix(block, suffixOffset, "_fence_gate") + || matchesSuffix(block, suffixOffset, "_door") + || matchesSuffix(block, suffixOffset, "_trapdoor")) { + return true; + } + } + int strippedOffset = "stripped_".length(); + if (!block.startsWith("stripped_") + || !block.regionMatches(strippedOffset, wood, 0, wood.length())) { + return false; + } + int suffixOffset = strippedOffset + wood.length(); + return matchesSuffix(block, suffixOffset, "_log") + || matchesSuffix(block, suffixOffset, "_wood"); + } + + private static boolean isCobblestoneConstructionMaterial(String block) { + return block.equals("cobblestone") + || block.equals("cobblestone_stairs") + || block.equals("cobblestone_slab") + || block.equals("cobblestone_wall") + || block.equals("mossy_cobblestone") + || block.equals("mossy_cobblestone_stairs") + || block.equals("mossy_cobblestone_slab") + || block.equals("mossy_cobblestone_wall"); + } + + private static boolean matchesSuffix(String value, int offset, String suffix) { + return value.length() == offset + suffix.length() + && value.regionMatches(offset, suffix, 0, suffix.length()); + } + + private static boolean isMonumentMaterial(String block) { + return block.equals("prismarine") + || block.equals("prismarine_bricks") + || block.equals("dark_prismarine") + || block.equals("sea_lantern"); + } + private static ServerLevel targetLevel(MinecraftServer server) { String target = System.getProperty("iris.worldcheck.dimension"); if (target != null && !target.isBlank()) { @@ -227,4 +1185,33 @@ public final class ModdedWorldCheck { interface ProcessExit { void exit(int status); } + + record DimensionContract(int minY, int height, int logicalHeight, double coordinateScale, + float ambientLight, boolean hasSkyLight, boolean hasCeiling, + boolean hasEnderDragonFight, int monsterSpawnBlockLightLimit) { + } + + private record StructureCheck(String label, List registryKeys, int locateRadius) { + } + + private record FootprintAudit(int inspectedChunks, int availableChunks, int evidenceChunks, + int coveredPieces, int totalPieces, int blockEntityStates, + int blockEntitiesPresent, int materialScannedChunks, + int characteristicBlocks, int characteristicChunks, + int vegetationBlocks, int vegetationColumns, + int foundationBlocks, int foundationColumns, + int foundationGapColumns) { + } + + private record BlockEntityAudit(int states, int present) { + } + + private record StructureMaterialAudit(boolean scanned, int characteristicBlocks, + int vegetationBlocks, int vegetationColumns, + int foundationBlocks, int foundationColumns, + int foundationGapColumns) { + } + + private record PoiAudit(int inBounds, int outOfBounds) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java index 25a76e3aa..ade411ac3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldEngines.java @@ -23,6 +23,7 @@ import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.dimension.DimensionType; @@ -82,6 +83,7 @@ public final class ModdedWorldEngines { } long seed = seedOverride == Long.MIN_VALUE ? level.getSeed() : seedOverride; + validateDimensionContract(dimension, level); File worldFolder = DimensionType.getStorageFolder(level.dimension(), level.getServer().getWorldPath(LevelResource.ROOT)).toFile(); IrisWorld world = IrisWorld.builder() .platformIdentity(level.dimension().identifier().toString()) @@ -90,21 +92,31 @@ public final class ModdedWorldEngines { .worldFolder(worldFolder) .minHeight(dimension.getMinHeight()) .maxHeight(dimension.getMaxHeight()) + .platformWorld(new ModdedPlatformWorld(level)) .build(); Engine engine = new IrisEngine(new EngineTarget(world, dimension, data), false); - int levelMinY = level.getMinY(); - int levelMaxY = level.getMinY() + level.getHeight(); - if (dimension.getMinHeight() < levelMinY || dimension.getMaxHeight() > levelMaxY) { - LOGGER.warn("Iris pack height range for {} exceeds the level: pack generates {}..{} but the level is {}..{}. Terrain outside the level range will be clipped; restart so the forced datapack registers the pack dimension type.", - level.dimension().identifier(), dimension.getMinHeight(), dimension.getMaxHeight(), levelMinY, levelMaxY); - } - LOGGER.info("Iris engine up for {}: pack={} dim={} seed={} height={}..{}", level.dimension().identifier(), packDir.getAbsolutePath(), dimension.getLoadKey(), seed, dimension.getMinHeight(), dimension.getMaxHeight()); return engine; } + private static void validateDimensionContract(IrisDimension dimension, ServerLevel level) { + DimensionType actualType = level.dimensionType(); + String actualTypeKey = level.dimensionTypeRegistration().unwrapKey() + .map(key -> key.identifier().toString()) + .orElse(""); + IrisDimensionRuntimeContract expected = IrisDimensionRuntimeContract.expected(dimension, "irisworldgen"); + IrisDimensionRuntimeContract actual = new IrisDimensionRuntimeContract( + actualTypeKey, + actualType.minY(), + actualType.height(), + actualType.logicalHeight()); + String runtimeName = "Modded level '" + level.dimension().identifier() + "'"; + expected.requireExact(runtimeName, actual); + expected.requireHeight(runtimeName, level.getMinY(), level.getHeight()); + } + public static File packFolder(String pack) { return ModdedEngineBootstrap.loader().configDir() .resolve("irisworldgen") @@ -113,7 +125,7 @@ public final class ModdedWorldEngines { .toFile(); } - private static File resolvePack(String pack, String dimensionKey) { + static File resolvePack(String pack, String dimensionKey) { File packDir = packFolder(pack); if (packDir.isDirectory()) { return packDir; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java index 2f51960ea..f2b57a548 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedWorldManager.java @@ -30,7 +30,6 @@ import art.arcane.iris.engine.object.IrisEntitySpawn; import art.arcane.iris.engine.object.IrisMarker; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRange; -import art.arcane.iris.engine.object.IrisRate; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawnGroup; import art.arcane.iris.engine.object.IrisSpawner; @@ -43,18 +42,16 @@ import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.MatterMarker; +import art.arcane.volmlib.util.matter.slices.MarkerMatter; +import it.unimi.dsi.fastutil.longs.LongArrayList; import net.minecraft.core.BlockPos; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.Entity; -import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.phys.AABB; -import org.bukkit.Chunk; -import org.bukkit.event.block.BlockBreakEvent; -import org.bukkit.event.block.BlockPlaceEvent; -import org.bukkit.event.player.PlayerTeleportEvent; import java.util.HashSet; import java.util.Set; @@ -66,15 +63,13 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public final class ModdedWorldManager implements EngineWorldManager { + static final MantleFlag INITIAL_SPAWN_COMPLETION_FLAG = MantleFlag.INITIAL_SPAWNED_MARKER; private static final int MAX_INITIAL_QUEUE = 8192; private static final int MAX_INITIAL_DRAIN_PER_TICK = 8; private static final int MAX_INITIAL_RECOVERY_PER_PASS = 128; private static final int MANTLE_WARMUP_QUEUE_CAPACITY = 256; private static final long INITIAL_RECOVERY_INTERVAL_MS = 1_000L; private static final long COUNT_INTERVAL_MS = 3_000L; - private static final int ENTITY_SCAN_RADIUS = 64; - private static final int PLAYER_CHUNK_RADIUS = 4; - private static final int MIN_TICK_INTERVAL_MS = 1_000; private final Engine engine; private final InitialSpawnQueue initialSpawnQueue; @@ -123,6 +118,9 @@ public final class ModdedWorldManager implements EngineWorldManager { if (closed || engine.isClosed() || engine.getMantle().getMantle().isClosed()) { return; } + if (!isEntitySpawningEnabledForCurrentWorld()) { + return; + } if (isPregenActive()) { return; } @@ -132,7 +130,7 @@ public final class ModdedWorldManager implements EngineWorldManager { } private void recoverLoadedInitialSpawns(ServerLevel level) { - if (!markerSystemEnabled() && !ambientSystemEnabled()) { + if (!markerSystemEnabled()) { return; } long now = System.currentTimeMillis(); @@ -161,7 +159,7 @@ public final class ModdedWorldManager implements EngineWorldManager { if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null) { continue; } - if (mantle.isChunkLoaded(chunkX, chunkZ) && mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) { + if (mantle.isChunkLoaded(chunkX, chunkZ) && mantle.hasFlag(chunkX, chunkZ, INITIAL_SPAWN_COMPLETION_FLAG)) { continue; } if (initialSpawnQueue.offer(key) && ++recovered >= MAX_INITIAL_RECOVERY_PER_PASS) { @@ -174,7 +172,7 @@ public final class ModdedWorldManager implements EngineWorldManager { if (initialSpawnQueue.isEmpty()) { return; } - if (!markerSystemEnabled() && !ambientSystemEnabled()) { + if (!markerSystemEnabled()) { initialSpawnQueue.clear(); return; } @@ -211,23 +209,22 @@ public final class ModdedWorldManager implements EngineWorldManager { } private boolean initialSpawnChunk(ServerLevel level, int chunkX, int chunkZ) { + if (!ModdedEntitySpawner.chunksSafe(level, chunkX, chunkZ)) { + return false; + } Mantle mantle = engine.getMantle().getMantle(); if (!mantle.isChunkLoaded(chunkX, chunkZ)) { return false; } - if (mantle.hasFlag(chunkX, chunkZ, MantleFlag.INITIAL_SPAWNED)) { + if (mantle.hasFlag(chunkX, chunkZ, INITIAL_SPAWN_COMPLETION_FLAG)) { return true; } MantleChunk chunk = mantle.getChunk(chunkX, chunkZ).use(); try { - chunk.raiseFlagUnchecked(MantleFlag.INITIAL_SPAWNED, () -> { - if (markerSystemEnabled()) { - spawnMarkerSpawners(level, chunkX, chunkZ, chunk, true); - } - if (ambientSystemEnabled()) { - spawnAmbient(level, chunkX, chunkZ, true); - } + chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> { + spawnMarkerSpawners(level, chunkX, chunkZ, chunk, true); + scheduleInitialFollowUp(level, chunkX, chunkZ); }); } finally { chunk.release(); @@ -235,6 +232,40 @@ public final class ModdedWorldManager implements EngineWorldManager { return true; } + private void scheduleInitialFollowUp(ServerLevel level, int chunkX, int chunkZ) { + ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); + if (scheduler == null) { + IrisLogging.error("Iris could not schedule the initial entity-spawn follow-up because the modded scheduler is unavailable."); + return; + } + scheduler.laterGlobal(() -> runInitialFollowUp(level, chunkX, chunkZ), RNG.r.i(5, 200)); + } + + private void runInitialFollowUp(ServerLevel level, int chunkX, int chunkZ) { + Mantle mantle = engine.getMantle().getMantle(); + if (closed || engine.isClosed() || mantle.isClosed() || !isEntitySpawningEnabledForCurrentWorld()) { + return; + } + if (level.getChunkSource().getChunkNow(chunkX, chunkZ) == null || !mantle.isChunkLoaded(chunkX, chunkZ)) { + return; + } + if (engine.getComplex() == null) { + return; + } + + MantleChunk chunk = mantle.getChunk(chunkX, chunkZ).use(); + try { + if (markerSystemEnabled()) { + spawnMarkerSpawners(level, chunkX, chunkZ, chunk, false); + } + if (ambientSystemEnabled()) { + spawnAmbient(level, chunkX, chunkZ, true); + } + } finally { + chunk.release(); + } + } + private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) { if (closed || !mantleWarmups.add(key)) { return; @@ -263,25 +294,25 @@ public final class ModdedWorldManager implements EngineWorldManager { private void ambientTick(ServerLevel level) { long now = System.currentTimeMillis(); - long interval = Math.max((long) MIN_TICK_INTERVAL_MS, IrisSettings.get().getWorld().getAsyncTickIntervalMS()); + long interval = IrisSettings.get().getWorld().getAsyncTickIntervalMS(); if (now - lastAmbientAt < interval) { return; } lastAmbientAt = now; - if (level.players().isEmpty()) { - return; - } if (!markerSystemEnabled() && !ambientSystemEnabled()) { return; } + if (level.players().isEmpty()) { + return; + } - refreshEntityCount(level, now); + long[] candidates = loadedChunkPositionsSnapshot(level); + refreshEntityCount(level, now, candidates.length); if (cachedSaturation > IrisSettings.get().getWorld().getTargetSpawnEntitiesPerChunk()) { return; } - long[] candidates = loadedChunksNearPlayers(level); if (candidates.length == 0) { return; } @@ -298,6 +329,9 @@ public final class ModdedWorldManager implements EngineWorldManager { } private void ambientSpawnChunk(ServerLevel level, int chunkX, int chunkZ) { + if (!ModdedEntitySpawner.chunksSafe(level, chunkX, chunkZ)) { + return; + } Mantle mantle = engine.getMantle().getMantle(); if (!mantle.isChunkLoaded(chunkX, chunkZ)) { return; @@ -318,6 +352,7 @@ public final class ModdedWorldManager implements EngineWorldManager { private void spawnMarkerSpawners(ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk, boolean initial) { int minHeight = engine.getWorld().minHeight(); + KList obstructed = new KList<>(); chunk.iterate(MatterMarker.class, (Integer x, Integer yf, Integer z, MatterMarker marker) -> { String tag = marker.getTag(); if (tag.equals("cave_floor") || tag.equals("cave_ceiling")) { @@ -333,6 +368,7 @@ public final class ModdedWorldManager implements EngineWorldManager { int worldY = yf + minHeight; if (resolved.isEmptyAbove() && aboveObstructed(level, worldX, worldY, worldZ)) { + obstructed.add(new IrisPosition(worldX, yf, worldZ)); return; } @@ -346,6 +382,10 @@ public final class ModdedWorldManager implements EngineWorldManager { } spawnFromSpawner(level, new IrisPosition(worldX, worldY, worldZ), chosen, initial); }); + Mantle mantle = engine.getMantle().getMantle(); + for (IrisPosition position : obstructed) { + mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class); + } } private KList resolveMarkerSpawners(IrisMarker marker) { @@ -397,7 +437,7 @@ public final class ModdedWorldManager implements EngineWorldManager { int blockZ = chunkZ << 4; IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ); IrisRegion region = engine.getRegion(blockX, blockZ); - int chunkMobs = countChunkMobs(level, chunkX, chunkZ); + int chunkMobs = countChunkLivingEntities(level, chunkX, chunkZ); KList pool = new KList<>(); collectSpawns(pool, engine.getData().getSpawnerLoader().loadAll(engine.getDimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial); @@ -457,19 +497,33 @@ public final class ModdedWorldManager implements EngineWorldManager { return 0; } - RNG entityRng = new RNG(engine.getSeedManager().getEntity()); + RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity())); IrisSpawnGroup group = spawner.getGroup(); + KList caveFloors = group == IrisSpawnGroup.CAVE + ? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR) + : new KList<>(); int spawned = 0; for (int i = 0; i < count; i++) { - int worldX = (chunkX << 4) + RNG.r.i(15); - int worldZ = (chunkZ << 4) + RNG.r.i(15); - int surfaceY = level.getMinY() + engine.getHeight(worldX, worldZ, false); - int solidY = level.getMinY() + engine.getHeight(worldX, worldZ, true); - int worldY = switch (group) { - case NORMAL -> surfaceY + 1; - case CAVE -> solidY + 1; - case UNDERWATER, BEACH -> surfaceY > solidY + 1 ? RNG.r.i(solidY + 1, surfaceY) : surfaceY; - }; + int worldX; + int worldY; + int worldZ; + if (group == IrisSpawnGroup.CAVE) { + if (caveFloors.isEmpty()) { + continue; + } + IrisPosition caveFloor = caveFloors.getRandom(RNG.r); + worldX = caveFloor.getX(); + worldY = caveFloor.getY() + 1; + worldZ = caveFloor.getZ(); + } else { + worldX = (chunkX << 4) + RNG.r.i(15); + worldZ = (chunkZ << 4) + RNG.r.i(15); + int surfaceY = level.getMinY() + engine.getHeight(worldX, worldZ, false); + int solidY = level.getMinY() + engine.getHeight(worldX, worldZ, true); + worldY = group == IrisSpawnGroup.NORMAL + ? surfaceY + 1 + : RNG.r.i(solidY + 1, surfaceY); + } if (worldY <= level.getMinY() || worldY >= level.getMaxY()) { continue; } @@ -479,7 +533,7 @@ public final class ModdedWorldManager implements EngineWorldManager { if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) { continue; } - if (!clearForSpawn(level, worldX, worldY, worldZ)) { + if (!ModdedEntitySpawner.isAreaClearForSpawn(level, irisEntity, worldX, worldY, worldZ)) { continue; } if (ModdedEntitySpawner.spawn(engine, irisEntity, level, worldX, worldY, worldZ, entityRng) != null) { @@ -504,7 +558,7 @@ public final class ModdedWorldManager implements EngineWorldManager { exhaustMarker(spawner, position); - RNG entityRng = new RNG(engine.getSeedManager().getEntity()); + RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity())); int worldX = position.getX(); int worldY = position.getY() + 1; int worldZ = position.getZ(); @@ -529,12 +583,7 @@ public final class ModdedWorldManager implements EngineWorldManager { } private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ) { - IrisRate rate = spawner.getMaximumRate(); - if (!rate.isInfinite() && !engine.getEngineData().getCooldown(spawner).canSpawn(rate)) { - return false; - } - IrisRate chunkRate = spawner.getMaximumRatePerChunk(); - return chunkRate.isInfinite() || engine.getEngineData().getChunk(chunkX, chunkZ).getCooldown(spawner).canSpawn(chunkRate); + return spawner.canSpawn(engine, chunkX, chunkZ); } private boolean lightAllowed(IrisSpawner spawner, ServerLevel level, int worldX, int worldY, int worldZ) { @@ -575,68 +624,51 @@ public final class ModdedWorldManager implements EngineWorldManager { || state.is(Blocks.KELP) || state.is(Blocks.KELP_PLANT); } - private boolean clearForSpawn(ServerLevel level, int worldX, int worldY, int worldZ) { - BlockState at = level.getBlockState(new BlockPos(worldX, worldY, worldZ)); - BlockState above = level.getBlockState(new BlockPos(worldX, worldY + 1, worldZ)); - return !ModdedBlockResolution.isSolid(at) && !ModdedBlockResolution.isSolid(above); - } - private boolean aboveObstructed(ServerLevel level, int worldX, int worldY, int worldZ) { - return ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(worldX, worldY + 1, worldZ))) + return worldY + 2 >= level.getMaxY() + || ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(worldX, worldY + 1, worldZ))) || ModdedBlockResolution.isSolid(level.getBlockState(new BlockPos(worldX, worldY + 2, worldZ))); } - private int countChunkMobs(ServerLevel level, int chunkX, int chunkZ) { + private int countChunkLivingEntities(ServerLevel level, int chunkX, int chunkZ) { int baseX = chunkX << 4; int baseZ = chunkZ << 4; AABB box = new AABB(baseX, level.getMinY(), baseZ, baseX + 16, level.getMaxY(), baseZ + 16); - return level.getEntities((Entity) null, box, (Entity e) -> e instanceof Mob && e.isAlive()).size(); + return level.getEntities((Entity) null, box, + (Entity entity) -> isLivingEntityInChunk(entity, chunkX, chunkZ)).size(); } - private void refreshEntityCount(ServerLevel level, long now) { + private static boolean isLivingEntityInChunk(Entity entity, int chunkX, int chunkZ) { + if (!(entity instanceof LivingEntity)) { + return false; + } + BlockPos position = entity.blockPosition(); + return (position.getX() >> 4) == chunkX && (position.getZ() >> 4) == chunkZ; + } + + private void refreshEntityCount(ServerLevel level, long now, int loadedChunks) { + cachedConsideredChunks = loadedChunks; + cachedSaturation = cachedEntityCount / (loadedChunks + 1.0) * 1.28; if (now - lastCountAt < COUNT_INTERVAL_MS) { return; } lastCountAt = now; - int mobs = 0; - int chunks = 0; - for (ServerPlayer player : level.players()) { - int px = player.blockPosition().getX(); - int pz = player.blockPosition().getZ(); - AABB box = new AABB(px - ENTITY_SCAN_RADIUS, level.getMinY(), pz - ENTITY_SCAN_RADIUS, - px + ENTITY_SCAN_RADIUS, level.getMaxY(), pz + ENTITY_SCAN_RADIUS); - mobs += level.getEntities((Entity) null, box, (Entity e) -> e instanceof Mob && e.isAlive()).size(); - chunks += (PLAYER_CHUNK_RADIUS * 2 + 1) * (PLAYER_CHUNK_RADIUS * 2 + 1); - } - - cachedEntityCount = mobs; - cachedConsideredChunks = chunks; - cachedSaturation = mobs / (chunks + 1.0) * 1.28; - } - - private long[] loadedChunksNearPlayers(ServerLevel level) { - Set keys = new HashSet<>(); - for (ServerPlayer player : level.players()) { - int centerX = player.blockPosition().getX() >> 4; - int centerZ = player.blockPosition().getZ() >> 4; - for (int dx = -PLAYER_CHUNK_RADIUS; dx <= PLAYER_CHUNK_RADIUS; dx++) { - for (int dz = -PLAYER_CHUNK_RADIUS; dz <= PLAYER_CHUNK_RADIUS; dz++) { - int chunkX = centerX + dx; - int chunkZ = centerZ + dz; - if (level.getChunkSource().getChunkNow(chunkX, chunkZ) != null) { - keys.add(pack(chunkX, chunkZ)); - } - } + int livingEntities = 0; + for (Entity entity : level.getAllEntities()) { + if (entity instanceof LivingEntity && entity.isAlive()) { + livingEntities++; } } - long[] result = new long[keys.size()]; - int index = 0; - for (long key : keys) { - result[index++] = key; - } - return result; + cachedEntityCount = livingEntities; + cachedSaturation = livingEntities / (loadedChunks + 1.0) * 1.28; + } + + private long[] loadedChunkPositionsSnapshot(ServerLevel level) { + LongArrayList positions = new LongArrayList(level.getChunkSource().getLoadedChunksCount()); + level.getChunkSource().chunkMap.forEachReadyToSendChunk(chunk -> positions.add(chunk.getPos().pack())); + return positions.toLongArray(); } private boolean isPregenActive() { @@ -652,6 +684,14 @@ public final class ModdedWorldManager implements EngineWorldManager { return IrisSettings.get().getWorld().isAmbientEntitySpawningSystem(); } + private boolean isEntitySpawningEnabledForCurrentWorld() { + return entitySpawningEnabled(engine.isStudio(), IrisSettings.get().getStudio().isEnableEntitySpawning()); + } + + static boolean entitySpawningEnabled(boolean studio, boolean studioSetting) { + return !studio || studioSetting; + } + private IrisEntitySpawn rarityPick(KList entries) { int totalRarity = 0; for (IrisEntitySpawn entry : entries) { @@ -710,24 +750,4 @@ public final class ModdedWorldManager implements EngineWorldManager { public void onSave() { engine.getMantle().save(); } - - @Override - public void onBlockBreak(BlockBreakEvent e) { - } - - @Override - public void onBlockPlace(BlockPlaceEvent e) { - } - - @Override - public void onChunkLoad(Chunk e, boolean generated) { - } - - @Override - public void onChunkUnload(Chunk e) { - } - - @Override - public void teleportAsync(PlayerTeleportEvent e) { - } } diff --git a/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/LivingEntityMixin.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedBlockData.java similarity index 54% rename from adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/LivingEntityMixin.java rename to adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedBlockData.java index 75eed5747..1d1b67bb8 100644 --- a/adapters/fabric/src/main/java/art/arcane/iris/fabric/mixin/LivingEntityMixin.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedBlockData.java @@ -16,20 +16,22 @@ * along with this program. If not, see . */ -package art.arcane.iris.fabric.mixin; +package art.arcane.iris.modded.api; -import art.arcane.iris.modded.ModdedDeathLoot; -import net.minecraft.world.damagesource.DamageSource; -import net.minecraft.world.entity.LivingEntity; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Inject; -import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import net.minecraft.world.level.block.state.BlockState; -@Mixin(LivingEntity.class) -public class LivingEntityMixin { - @Inject(method = "die", at = @At("TAIL")) - private void iris$dropIrisDeathLoot(DamageSource damageSource, CallbackInfo info) { - ModdedDeathLoot.handle((LivingEntity) (Object) this); +import java.util.Objects; + +public record ModdedBlockData(BlockState state, boolean deferredPlacement) { + public ModdedBlockData { + Objects.requireNonNull(state); + } + + public static ModdedBlockData direct(BlockState state) { + return new ModdedBlockData(state, false); + } + + public static ModdedBlockData deferred(BlockState state) { + return new ModdedBlockData(state, true); } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedBlockPlacementContext.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedBlockPlacementContext.java new file mode 100644 index 000000000..55b4076cc --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedBlockPlacementContext.java @@ -0,0 +1,45 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded.api; + +import art.arcane.iris.engine.framework.Engine; +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.state.BlockState; + +import java.util.Map; +import java.util.Objects; + +public record ModdedBlockPlacementContext( + Engine engine, + ServerLevel level, + BlockPos position, + Identifier blockId, + Map state, + BlockState blockState) { + public ModdedBlockPlacementContext { + Objects.requireNonNull(engine); + Objects.requireNonNull(level); + Objects.requireNonNull(position); + Objects.requireNonNull(blockId); + state = Map.copyOf(state); + Objects.requireNonNull(blockState); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java index 57043683c..1eedfa27e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedCustomContentRegistry.java @@ -18,7 +18,9 @@ package art.arcane.iris.modded.api; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.modded.ModdedBlockResolution; +import net.minecraft.core.BlockPos; import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; @@ -96,7 +98,7 @@ public final class ModdedCustomContentRegistry { return !PROVIDERS.isEmpty() || !CUSTOM_BLOCKS.isEmpty(); } - public static BlockState resolveBlock(String key) { + public static ModdedBlockData resolveBlock(String key) { if (key == null || (PROVIDERS.isEmpty() && CUSTOM_BLOCKS.isEmpty())) { return null; } @@ -106,7 +108,7 @@ public final class ModdedCustomContentRegistry { } BlockState custom = CUSTOM_BLOCKS.get(base.toString()); if (custom != null) { - return custom; + return ModdedBlockData.direct(custom); } if (PROVIDERS.isEmpty()) { return null; @@ -117,7 +119,7 @@ public final class ModdedCustomContentRegistry { continue; } try { - BlockState resolved = provider.getBlockData(base, state); + ModdedBlockData resolved = provider.getBlockData(base, state); if (resolved != null) { return resolved; } @@ -128,6 +130,28 @@ public final class ModdedCustomContentRegistry { return null; } + public static void processBlockPlacement(Engine engine, ServerLevel level, BlockPos position, String key) { + Identifier base = parseIdentifier(key); + if (base == null) { + LOGGER.warn("Iris deferred custom block placement rejected invalid id {}", key); + return; + } + Map state = parseState(key); + for (ModdedDataProvider provider : PROVIDERS) { + if (!provider.isReady() || !provider.isValidProvider(base, ModdedDataType.BLOCK)) { + continue; + } + try { + provider.processBlockPlacement(new ModdedBlockPlacementContext( + engine, level, position.immutable(), base, state, level.getBlockState(position))); + } catch (Throwable error) { + LOGGER.error("Iris custom content provider '{}' failed post-placement for {} at {}", provider.modId(), key, position, error); + } + return; + } + LOGGER.warn("Iris deferred custom block placement has no provider for {}", key); + } + public static Entity spawnMob(ServerLevel level, double x, double y, double z, String key) { if (PROVIDERS.isEmpty() || level == null || key == null) { return null; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java index cdfc7e8de..b1907267e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/api/ModdedDataProvider.java @@ -21,7 +21,6 @@ package art.arcane.iris.modded.api; import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.entity.Entity; -import net.minecraft.world.level.block.state.BlockState; import java.util.Collection; import java.util.Map; @@ -37,10 +36,13 @@ public interface ModdedDataProvider { boolean isValidProvider(Identifier id, ModdedDataType type); - default BlockState getBlockData(Identifier blockId, Map state) { + default ModdedBlockData getBlockData(Identifier blockId, Map state) { return null; } + default void processBlockPlacement(ModdedBlockPlacementContext context) { + } + default Entity spawnMob(ServerLevel level, double x, double y, double z, Identifier entityId) { return null; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index b10c7dba9..d15d5974e 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -27,6 +27,7 @@ import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.Locator; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisImportedStructureControl; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.modded.IrisModdedChunkGenerator; @@ -40,6 +41,7 @@ import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.matter.MatterMarker; +import com.mojang.datafixers.util.Pair; import com.mojang.brigadier.CommandDispatcher; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.LongArgumentType; @@ -58,10 +60,14 @@ import net.minecraft.commands.Commands; import net.minecraft.commands.SharedSuggestionProvider; import net.minecraft.commands.arguments.DimensionArgument; import net.minecraft.commands.arguments.EntityArgument; -import net.minecraft.network.chat.Component; import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.HolderSet; +import net.minecraft.core.Registry; import net.minecraft.core.particles.DustParticleOptions; import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; @@ -71,6 +77,7 @@ import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.ChunkGenerator; import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.phys.BlockHitResult; import net.minecraft.world.phys.HitResult; import org.slf4j.Logger; @@ -79,10 +86,13 @@ import org.slf4j.LoggerFactory; import java.awt.Desktop; import java.io.File; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.TreeMap; +import java.util.TreeSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.function.Predicate; @@ -91,6 +101,7 @@ public final class IrisModdedCommands { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); private static final long LOCATE_TIMEOUT_MS = 120000L; + private static final int NATIVE_STRUCTURE_LOCATE_RADIUS = 100; private static final SuggestionProvider BIOME_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestBiomeKeys(context, builder); private static final SuggestionProvider REGION_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestRegionKeys(context, builder); @@ -869,36 +880,58 @@ public final class IrisModdedCommands { return 0; } String key = keyRaw.trim(); - Set placed = IrisStructureLocator.placedKeys(engine); - if (!IrisStructureLocator.isPlaced(engine, key)) { - fail(source, "Structure " + key + " is not placed by this pack. Placed keys (" + placed.size() + "): " - + String.join(", ", placed.stream().limit(8).toList()) + (placed.size() > 8 ? ", ..." : "")); + if (key.isEmpty()) { + fail(source, "Name an Iris or native structure to locate."); return 0; } ServerPlayer player = source.getPlayer(); if (player == null) { - fail(source, "This command can only be used by players. (structure key '" + key + "' resolved; " - + placed.size() + " placed structure keys loaded)"); + fail(source, "This command can only be used by players."); return 0; } + if (IrisStructureLocator.isPlaced(engine, key)) { + locateIrisStructure(source, level, engine, player, key); + return 1; + } + Optional resolved = resolveNativeStructure(source, level, engine, key); + if (resolved.isEmpty()) { + fail(source, "Unknown structure '" + key + "'. Use tab completion to choose an Iris placement or a registered native/datapack structure."); + return 0; + } + NativeStructureTarget target = resolved.get(); + if (target.availability() != NativeStructureAvailability.AVAILABLE) { + fail(source, nativeUnavailableMessage(target.key(), target.availability())); + return 0; + } + ok(source, "Searching for native structure " + target.key() + " within " + NATIVE_STRUCTURE_LOCATE_RADIUS + " chunks..."); + runNativeStructureLocate(source, level, player, target); + return 1; + } + + private static void locateIrisStructure(CommandSourceStack source, ServerLevel level, Engine engine, + ServerPlayer player, String key) { MinecraftServer server = source.getServer(); int blockX = player.blockPosition().getX(); int blockZ = player.blockPosition().getZ(); - ok(source, "Searching for structure " + key + "..."); + ok(source, "Searching for Iris-placed structure " + key + "..."); Thread thread = new Thread(() -> { try { - int[] at = IrisStructureLocator.locate(engine, key, blockX, blockZ, 1024); - if (at == null) { - server.execute(() -> fail(source, "Could not find structure " + key + " within 1024 chunks.")); + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locate(engine, key, blockX, blockZ, 1024); + if (result.status() == IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) { + server.execute(() -> fail(source, "Unable to locate Iris-placed structure " + key + + ": the density search safety limit was reached before the full 1024-chunk radius was searched.")); return; } - int targetX = at[0] + 8; - int targetY = at[1] + 2; - int targetZ = at[2] + 8; - server.execute(() -> { - player.teleportTo(level, targetX + 0.5D, targetY, targetZ + 0.5D, Set.of(), player.getYRot(), player.getXRot(), false); - ok(source, "Teleported to structure " + key + " at " + targetX + " " + targetY + " " + targetZ); - }); + if (!result.found()) { + server.execute(() -> fail(source, "Could not find Iris-placed structure " + key + " within 1024 chunks.")); + return; + } + int targetX = result.originX(); + int targetY = result.baseY() + 2; + int targetZ = result.originZ(); + server.execute(() -> teleportToStructure(source, level, player, targetX, targetY, targetZ, + "Iris-placed structure " + key)); } catch (Throwable e) { LOGGER.error("Iris structure locate failed for {}", key, e); server.execute(() -> fail(source, "Search failed: " + e.getClass().getSimpleName())); @@ -906,9 +939,196 @@ public final class IrisModdedCommands { }, "Iris Structure Locator"); thread.setDaemon(true); thread.start(); + } + + private static void runNativeStructureLocate(CommandSourceStack source, ServerLevel level, + ServerPlayer player, NativeStructureTarget target) { + MinecraftServer server = source.getServer(); + Runnable locateTask = () -> locateNativeStructure(source, level, player, target); + if (Thread.currentThread() == server.getRunningThread()) { + locateTask.run(); + return; + } + server.execute(locateTask); + } + + private static void locateNativeStructure(CommandSourceStack source, ServerLevel level, + ServerPlayer player, NativeStructureTarget target) { + try { + ChunkGenerator generator = level.getChunkSource().getGenerator(); + Pair> found = generator.findNearestMapStructure( + level, + HolderSet.direct(target.holder()), + player.blockPosition(), + NATIVE_STRUCTURE_LOCATE_RADIUS, + false); + if (found == null) { + fail(source, "Could not find native structure " + target.key() + " within " + + NATIVE_STRUCTURE_LOCATE_RADIUS + " chunks."); + return; + } + BlockPos position = found.getFirst(); + int targetX = position.getX(); + int targetZ = position.getZ(); + level.getChunk(targetX >> 4, targetZ >> 4); + int surfaceY = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, targetX, targetZ) + 1; + int targetY = Math.max(level.getMinY() + 1, Math.min(level.getMaxY() - 1, surfaceY)); + teleportToStructure(source, level, player, targetX, targetY, targetZ, + "native structure " + target.key()); + } catch (Throwable e) { + LOGGER.error("Native structure locate failed for {}", target.key(), e); + fail(source, "Search for native structure " + target.key() + " failed: " + e.getClass().getSimpleName()); + } + } + + private static void teleportToStructure(CommandSourceStack source, ServerLevel level, ServerPlayer player, + int targetX, int targetY, int targetZ, String label) { + if (player.hasDisconnected() || player.isRemoved()) { + fail(source, "The player disconnected before the structure search completed."); + return; + } + if (player.level() != level) { + fail(source, "You changed dimensions before the structure search completed; run the command again."); + return; + } + level.getChunk(targetX >> 4, targetZ >> 4); + int clampedY = Math.max(level.getMinY() + 1, Math.min(level.getMaxY() - 1, targetY)); + boolean teleported = player.teleportTo(level, targetX + 0.5D, clampedY, targetZ + 0.5D, + Set.of(), player.getYRot(), player.getXRot(), false); + if (!teleported) { + fail(source, "Found " + label + " at " + targetX + " " + clampedY + " " + targetZ + ", but teleportation failed."); + return; + } + ok(source, "Teleported to " + label + " at " + targetX + " " + clampedY + " " + targetZ); + } + + static int verifyStructures(CommandSourceStack source, String keyRaw) { + ServerLevel level = source.getLevel(); + Engine engine = engineFor(level); + if (engine == null) { + fail(source, "This dimension is not generated by Iris."); + return 0; + } + String key = keyRaw == null ? "" : keyRaw.trim(); + if (!key.isEmpty()) { + return verifyStructure(source, level, engine, key); + } + Registry registry = source.getServer().registryAccess().lookupOrThrow(Registries.STRUCTURE); + int available = 0; + int disabled = 0; + int suppressed = 0; + int unreachableBiomes = 0; + int unsupported = 0; + for (Identifier identifier : registry.keySet()) { + Optional> holder = registry.get(identifier); + if (holder.isEmpty()) { + continue; + } + NativeStructureAvailability availability = nativeAvailability(source, level, engine, + identifier.toString(), holder.get()); + switch (availability) { + case AVAILABLE -> available++; + case WORLD_DISABLED, FILTERED -> disabled++; + case IRIS_SUPPRESSED -> suppressed++; + case BIOME_UNREACHABLE -> unreachableBiomes++; + case NO_PLACEMENT -> unsupported++; + } + } + int irisPlaced = IrisStructureLocator.placedKeys(engine).size(); + ok(source, "Structure reachability: " + available + " native locatable, " + irisPlaced + + " Iris-placed, " + disabled + " native disabled, " + suppressed + + " native replaced by Iris placements, " + unreachableBiomes + + " native excluded by this pack's biomes, and " + unsupported + + " registered native structures unsupported in this dimension. Use /iris structure verify for one structure."); return 1; } + private static int verifyStructure(CommandSourceStack source, ServerLevel level, Engine engine, String key) { + if (IrisStructureLocator.isPlaced(engine, key)) { + ok(source, "Structure " + key + " is Iris-placed and locatable with /iris goto structure " + key + "."); + return 1; + } + Optional target = resolveNativeStructure(source, level, engine, key); + if (target.isEmpty()) { + fail(source, "Unknown structure '" + key + "'. It is neither Iris-placed nor registered by vanilla or a datapack."); + return 0; + } + NativeStructureTarget resolved = target.get(); + if (resolved.availability() != NativeStructureAvailability.AVAILABLE) { + fail(source, nativeUnavailableMessage(resolved.key(), resolved.availability())); + return 0; + } + ok(source, "Native structure " + resolved.key() + " is enabled, supported by this dimension's generator state, and locatable with /iris goto structure " + resolved.key() + "."); + return 1; + } + + private static Optional resolveNativeStructure(CommandSourceStack source, + ServerLevel level, + Engine engine, + String keyRaw) { + Identifier identifier = Identifier.tryParse(keyRaw); + if (identifier == null) { + return Optional.empty(); + } + Registry registry = source.getServer().registryAccess().lookupOrThrow(Registries.STRUCTURE); + Optional> holder = registry.get(identifier); + if (holder.isEmpty()) { + return Optional.empty(); + } + String key = identifier.toString(); + NativeStructureAvailability availability = nativeAvailability(source, level, engine, key, holder.get()); + return Optional.of(new NativeStructureTarget(key, holder.get(), availability)); + } + + private static NativeStructureAvailability nativeAvailability(CommandSourceStack source, ServerLevel level, + Engine engine, String key, + Holder.Reference holder) { + boolean worldEnabled = source.getServer().getWorldGenSettings().options().generateStructures(); + IrisImportedStructureControl control = engine.getDimension().getImportedStructures(); + boolean selected = control != null && control.active() && control.shouldGenerate(key); + boolean suppressed = IrisStructureLocator.suppressesVanilla(engine, key); + ChunkGenerator chunkGenerator = level.getChunkSource().getGenerator(); + boolean biomeReachable = chunkGenerator instanceof IrisModdedChunkGenerator irisGenerator + && irisGenerator.isNativeStructureReachable(holder); + boolean hasPlacement = false; + if (worldEnabled && selected && !suppressed && biomeReachable) { + hasPlacement = !level.getChunkSource().getGeneratorState().getPlacementsForStructure(holder).isEmpty(); + } + return classifyNativeAvailability(worldEnabled, selected, suppressed, biomeReachable, hasPlacement); + } + + static NativeStructureAvailability classifyNativeAvailability(boolean worldEnabled, boolean selected, + boolean suppressed, boolean biomeReachable, + boolean hasPlacement) { + if (!worldEnabled) { + return NativeStructureAvailability.WORLD_DISABLED; + } + if (!selected) { + return NativeStructureAvailability.FILTERED; + } + if (suppressed) { + return NativeStructureAvailability.IRIS_SUPPRESSED; + } + if (!biomeReachable) { + return NativeStructureAvailability.BIOME_UNREACHABLE; + } + if (!hasPlacement) { + return NativeStructureAvailability.NO_PLACEMENT; + } + return NativeStructureAvailability.AVAILABLE; + } + + private static String nativeUnavailableMessage(String key, NativeStructureAvailability availability) { + return switch (availability) { + case WORLD_DISABLED -> "Native structure generation is disabled for this world, so " + key + " cannot generate or be located."; + case FILTERED -> "Native structure " + key + " is disabled by this dimension's importedStructures settings."; + case IRIS_SUPPRESSED -> "Native structure " + key + " is replaced by an Iris placement in this pack; locate the Iris key instead."; + case BIOME_UNREACHABLE -> "Native structure " + key + " cannot generate because none of its required biomes are produced by this Iris pack."; + case NO_PLACEMENT -> "Native structure " + key + " is registered, but its structure set has no placement supported by this dimension's generator state."; + case AVAILABLE -> "Native structure " + key + " is available."; + }; + } + private static int gotoPoi(CommandSourceStack source, String typeRaw) { ServerLevel level = source.getLevel(); Engine engine = engineFor(level); @@ -991,7 +1211,7 @@ public final class IrisModdedCommands { boolean installed = ModdedPackInstaller.install(ModdedEngineBootstrap.loader().configDir(), pack, branch, (String message) -> server.execute(() -> ok(source, message))); if (installed) { - server.execute(() -> ok(source, "Pack '" + pack + "' installed. Its dimension types and custom biomes join the forced Iris datapack on the next server restart; worlds created before restarting run with fallback heights until then.")); + server.execute(() -> ok(source, "Pack '" + pack + "' installed. Its exact dimension types and custom biomes join the forced Iris datapack on the next server restart; restart before creating worlds from this pack.")); } else { server.execute(() -> fail(source, "Pack download failed for " + pack + " (" + downloadSource + "; see console).")); } @@ -1079,18 +1299,29 @@ public final class IrisModdedCommands { return builder.buildFuture(); } - private static CompletableFuture suggestStructureKeys(CommandContext context, SuggestionsBuilder builder) { + static CompletableFuture suggestStructureKeys(CommandContext context, SuggestionsBuilder builder) { ModdedCommandFeedback.tab(context.getSource()); try { Engine engine = engineFor(context.getSource().getLevel()); - if (engine != null) { - return SharedSuggestionProvider.suggest(IrisStructureLocator.placedKeys(engine), builder); + Collection irisKeys = engine == null ? List.of() : IrisStructureLocator.placedKeys(engine); + Registry registry = context.getSource().getServer().registryAccess().lookupOrThrow(Registries.STRUCTURE); + List nativeKeys = new ArrayList<>(registry.keySet().size()); + for (Identifier identifier : registry.keySet()) { + nativeKeys.add(identifier.toString()); } + return SharedSuggestionProvider.suggest(combineStructureKeys(irisKeys, nativeKeys), builder); } catch (Throwable ignored) { } return builder.buildFuture(); } + static List combineStructureKeys(Collection irisKeys, Collection nativeKeys) { + Set combined = new TreeSet<>(); + combined.addAll(irisKeys); + combined.addAll(nativeKeys); + return List.copyOf(combined); + } + private static CompletableFuture suggestPackNames(CommandContext context, SuggestionsBuilder builder) { ModdedCommandFeedback.tab(context.getSource()); List names = new ArrayList<>(); @@ -1132,4 +1363,17 @@ public final class IrisModdedCommands { static void fail(CommandSourceStack source, String message) { ModdedCommandFeedback.fail(source, message); } + + enum NativeStructureAvailability { + AVAILABLE, + WORLD_DISABLED, + FILTERED, + IRIS_SUPPRESSED, + BIOME_UNREACHABLE, + NO_PLACEMENT + } + + private record NativeStructureTarget(String key, Holder.Reference holder, + NativeStructureAvailability availability) { + } } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java index a47eeb418..9ae99c05f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java @@ -53,7 +53,7 @@ final class ModdedCommandHelp { Entry.command("version", "", "Print version information"), Entry.command("info", "[dimension]", "List loaded Iris dimensions and pack details"), Entry.command("what", "[block|hand|markers]", "Inspect the Iris biome, region, cave biome, surface and chunk at your position, the block you look at, your held item, or nearby markers"), - Entry.group("find", "Find and teleport to Iris biomes, regions, objects, structures and points of interest", "goto"), + Entry.group("find", "Find and teleport to Iris biomes, regions, objects, Iris structures, native structures and points of interest", "goto"), Entry.command("tp", " [player]", "Teleport yourself or a named player into a loaded Iris dimension"), Entry.command("evacuate", "[dimension]", "Teleport every player out of an Iris dimension to the primary world spawn"), Entry.command("seed", "", "Print world and engine seed information"), @@ -79,7 +79,7 @@ final class ModdedCommandHelp { Entry.command("biome", "", "Find an Iris biome"), Entry.command("region", "", "Find an Iris region"), Entry.command("object", "", "Find an object placement"), - Entry.command("structure", "", "Find an Iris-placed structure"), + Entry.command("structure", "", "Find an Iris-placed or native/datapack structure"), Entry.command("poi", "", "Find a supported point of interest") )); SECTIONS.put("goto", SECTIONS.get("find")); @@ -160,7 +160,7 @@ final class ModdedCommandHelp { Entry.command("place", "", "Assemble and place an Iris structure at your location", "p"), Entry.command("import", "", "Explain Bukkit structure import workflow", "import-all", "reimport", "imp", "all"), Entry.command("capture", "", "Explain Bukkit structure capture workflow", "cap"), - Entry.command("verify", "", "Explain modded structure locate behavior", "locateall") + Entry.command("verify", "[key]", "Report native and Iris structure reachability in the current dimension", "locateall") )); SECTIONS.put("struct", SECTIONS.get("structure")); SECTIONS.put("str", SECTIONS.get("structure")); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java index 2d170aba5..7cd818bff 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedDatapackCommands.java @@ -69,8 +69,8 @@ public final class ModdedDatapackCommands { root.then(Commands.literal("ls") .executes((CommandContext context) -> list(context.getSource()))); - root.then(message("ingest", "Modrinth datapack ingest requires the Bukkit plugin: its post-restart structure import into editable Iris resources uses Bukkit registries, and Iris modded dimensions do not run vanilla structure placement, so ingested structure datapacks would not generate. Drop datapacks into world/datapacks manually for non-structure content.")); - root.then(message("pull", "Modrinth datapack ingest requires the Bukkit plugin: its post-restart structure import into editable Iris resources uses Bukkit registries, and Iris modded dimensions do not run vanilla structure placement, so ingested structure datapacks would not generate. Drop datapacks into world/datapacks manually for non-structure content.")); + root.then(message("ingest", "Modrinth datapack ingest requires the Bukkit plugin because its editable-resource import and manifest workflow use Bukkit tooling. Iris modded dimensions do run native vanilla and datapack structure placement; install the datapack in world/datapacks and restart to generate its registered structures.")); + root.then(message("pull", "Modrinth datapack ingest requires the Bukkit plugin because its editable-resource import and manifest workflow use Bukkit tooling. Iris modded dimensions do run native vanilla and datapack structure placement; install the datapack in world/datapacks and restart to generate its registered structures.")); root.then(message("remove", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart.")); root.then(message("rm", "Datapack removal manages the Bukkit ingest manifest. On modded servers delete the datapack folder from world/datapacks and restart.")); @@ -135,8 +135,7 @@ public final class ModdedDatapackCommands { + (override.isFile() ? " (world datapack override installed)" : "")); if (!matches) { mismatches++; - IrisModdedCommands.fail(source, " WARNING: the active dimension type does not match the pack. Terrain outside " - + activeMin + ".." + activeMax + " will be clipped. Run /iris world enable for new worlds, or /iris datapack install for already-loaded Iris dimensions, then restart."); + IrisModdedCommands.fail(source, " WARNING: the active dimension type does not match the pack. Iris will refuse to start this world engine instead of clipping terrain. Run /iris world enable for new worlds, or /iris datapack install for already-loaded Iris dimensions, then restart."); } } if (irisLevels == 0) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java index f4ea4eb58..709686fbb 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedObjectPlacer.java @@ -27,11 +27,10 @@ import art.arcane.iris.modded.ModdedBlockState; import art.arcane.iris.modded.ModdedTileData; import art.arcane.iris.spi.PlatformBlockState; import net.minecraft.core.BlockPos; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtUtils; import net.minecraft.server.level.ServerLevel; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.EntityBlock; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.levelgen.Heightmap; @@ -148,26 +147,31 @@ final class ModdedObjectPlacer implements IObjectPlacer { skippedTiles++; return; } - String snbt = moddedTile.snbt(); - if (snbt == null || snbt.isBlank()) { - skippedTiles++; - return; - } BlockPos pos = new BlockPos(xx, yy, zz); BlockState state = level.getBlockState(pos); + BlockState adjusted = moddedTile.adjustBlockState(state); + if (adjusted != state) { + level.setBlock(pos, adjusted, Block.UPDATE_CLIENTS | Block.UPDATE_KNOWN_SHAPE); + state = adjusted; + } if (!state.hasBlockEntity()) { skippedTiles++; return; } try { - CompoundTag tag = NbtUtils.snbtToStructure(snbt); - BlockEntity restored = BlockEntity.loadStatic(pos, state, tag, level.registryAccess()); + BlockEntity restored = level.getBlockEntity(pos); + if (restored == null && state.getBlock() instanceof EntityBlock entityBlock) { + restored = entityBlock.newBlockEntity(pos, state); + } if (restored == null) { skippedTiles++; return; } level.setBlockEntity(restored); - restored.setChanged(); + if (!moddedTile.apply(restored, level)) { + skippedTiles++; + return; + } restoredTiles++; } catch (Throwable e) { LOGGER.error("Iris tile restore failed at {} {} {}", xx, yy, zz, e); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java index 7e1a9e776..f0e2596c3 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedRegen.java @@ -23,6 +23,8 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedBlockBuffer; +import art.arcane.iris.modded.ModdedEngineBootstrap; +import art.arcane.iris.modded.service.ModdedChunkUpdateService; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; @@ -33,16 +35,13 @@ import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.math.M; import net.minecraft.commands.CommandSourceStack; import net.minecraft.core.BlockPos; -import net.minecraft.core.Registry; import net.minecraft.core.SectionPos; -import net.minecraft.core.registries.Registries; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ThreadedLevelLightEngine; import net.minecraft.world.entity.Entity; import net.minecraft.world.level.ChunkPos; -import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.EntityBlock; import net.minecraft.world.level.block.entity.BlockEntity; @@ -240,9 +239,13 @@ public final class ModdedRegen { lightEngine.updateSectionStatus(SectionPos.of(pos, chunk.getSectionYFromSectionIndex(i)), section.hasOnlyAir()); } + ModdedChunkUpdateService updateService = ModdedEngineBootstrap.services().service(ModdedChunkUpdateService.class); + if (updateService == null) { + throw new IllegalStateException("Iris chunk update service is unavailable during regeneration"); + } + updateService.updateRegeneratedChunk(engine, level, chunkX, chunkZ); Heightmap.primeHeightmaps(chunk, ChunkStatus.FULL.heightmapsAfter()); - Registry biomeRegistry = level.registryAccess().lookupOrThrow(Registries.BIOME); - chunk.fillBiomesFromNoise(generator.regenBiomeResolver(biomeRegistry, biomes, pos), level.getChunkSource().randomState().sampler()); + chunk.fillBiomesFromNoise(generator.regenBiomeResolver(), level.getChunkSource().randomState().sampler()); chunk.markUnsaved(); for (BlockPos check : lightChecks) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java index 7ab0d5632..aee8d3f77 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedStructureCommands.java @@ -52,6 +52,7 @@ public final class ModdedStructureCommands { private static final Predicate GATE = Commands.hasPermission(Commands.LEVEL_GAMEMASTERS); private static final SuggestionProvider IRIS_STRUCTURE_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestIrisStructureKeys(context, builder); + private static final SuggestionProvider ALL_STRUCTURE_KEYS = (CommandContext context, SuggestionsBuilder builder) -> IrisModdedCommands.suggestStructureKeys(context, builder); private ModdedStructureCommands() { } @@ -84,12 +85,20 @@ public final class ModdedStructureCommands { root.then(message("all", "Structure import rebuilds vanilla & datapack structures as editable Iris resources through Bukkit/NMS template managers; run /iris structure import on a Bukkit server against this pack, then copy the pack folder over.")); root.then(message("capture", "Structure capture generates each structure in a throwaway Bukkit scratch world to read its blocks; it requires the Bukkit plugin (v26 NMS binding).")); root.then(message("cap", "Structure capture generates each structure in a throwaway Bukkit scratch world to read its blocks; it requires the Bukkit plugin (v26 NMS binding).")); - root.then(message("verify", "Vanilla structure locate is meaningless here: Iris modded dimensions do not run vanilla structure placement (Iris places structures itself). Use /iris goto structure to locate Iris-placed structures.")); - root.then(message("locateall", "Vanilla structure locate is meaningless here: Iris modded dimensions do not run vanilla structure placement (Iris places structures itself). Use /iris goto structure to locate Iris-placed structures.")); + root.then(verifyTree("verify")); + root.then(verifyTree("locateall")); return root; } + private static LiteralArgumentBuilder verifyTree(String name) { + return Commands.literal(name) + .executes((CommandContext context) -> IrisModdedCommands.verifyStructures(context.getSource(), null)) + .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(ALL_STRUCTURE_KEYS) + .executes((CommandContext context) -> IrisModdedCommands.verifyStructures( + context.getSource(), StringArgumentType.getString(context, "key")))); + } + private static LiteralArgumentBuilder message(String name, String text) { return Commands.literal(name) .executes((CommandContext context) -> { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/EntityPersistenceMixin.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/EntityPersistenceMixin.java new file mode 100644 index 000000000..adf8c27b8 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/EntityPersistenceMixin.java @@ -0,0 +1,17 @@ +package art.arcane.iris.modded.mixin; + +import art.arcane.iris.modded.ModdedEntityPersistence; +import net.minecraft.world.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(Entity.class) +public abstract class EntityPersistenceMixin { + @Inject(method = "shouldBeSaved", at = @At("RETURN"), cancellable = true) + private void iris$applyGeneratedPersistence(CallbackInfoReturnable info) { + Entity entity = (Entity) (Object) this; + info.setReturnValue(ModdedEntityPersistence.shouldSave(entity, info.getReturnValue())); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/LivingEntityLootMixin.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/LivingEntityLootMixin.java new file mode 100644 index 000000000..ba96edbe9 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/LivingEntityLootMixin.java @@ -0,0 +1,20 @@ +package art.arcane.iris.modded.mixin; + +import art.arcane.iris.modded.ModdedDeathLoot; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.LivingEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LivingEntity.class) +public abstract class LivingEntityLootMixin { + @Inject(method = "dropFromLootTable(Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/world/damagesource/DamageSource;Z)V", at = @At("HEAD"), cancellable = true) + private void iris$replaceBaseLoot(ServerLevel level, DamageSource damageSource, boolean playerKilled, CallbackInfo info) { + if (ModdedDeathLoot.replaceBaseLoot((LivingEntity) (Object) this)) { + info.cancel(); + } + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/MobAwarenessMixin.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/MobAwarenessMixin.java new file mode 100644 index 000000000..72d92e1d4 --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/mixin/MobAwarenessMixin.java @@ -0,0 +1,39 @@ +package art.arcane.iris.modded.mixin; + +import art.arcane.iris.modded.ModdedEntityAwareness; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.ai.goal.FloatGoal; +import net.minecraft.world.entity.ai.goal.WrappedGoal; +import org.objectweb.asm.Opcodes; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(Mob.class) +public abstract class MobAwarenessMixin { + @Inject( + method = "serverAiStep", + at = @At( + value = "FIELD", + target = "Lnet/minecraft/world/entity/Mob;noActionTime:I", + opcode = Opcodes.PUTFIELD, + shift = At.Shift.AFTER), + cancellable = true) + private void iris$tickUnawareMob(CallbackInfo info) { + Mob mob = (Mob) (Object) this; + if (ModdedEntityAwareness.isAware(mob)) { + return; + } + for (WrappedGoal wrappedGoal : mob.getGoalSelector().getAvailableGoals()) { + if (wrappedGoal.getGoal() instanceof FloatGoal floatGoal) { + if (floatGoal.canUse()) { + floatGoal.tick(); + } + mob.getJumpControl().tick(); + break; + } + } + info.cancel(); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java index 2b8590179..c4096b2a8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -20,6 +20,7 @@ package art.arcane.iris.modded.service; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.PregeneratorJob; +import art.arcane.iris.core.link.Identifier; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.TileData; @@ -27,6 +28,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedBlockResolution; import art.arcane.iris.modded.ModdedLootApplier; import art.arcane.iris.modded.ModdedTileData; +import art.arcane.iris.modded.api.ModdedCustomContentRegistry; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.project.matter.TileWrapper; import art.arcane.volmlib.util.mantle.flag.MantleFlag; @@ -39,14 +41,13 @@ import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterUpdate; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import net.minecraft.core.BlockPos; -import net.minecraft.nbt.CompoundTag; -import net.minecraft.nbt.NbtUtils; import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.EntityBlock; import net.minecraft.world.level.block.entity.BlockEntity; import net.minecraft.world.level.block.state.BlockState; @@ -148,11 +149,21 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } } + public void updateRegeneratedChunk(Engine engine, ServerLevel level, int chunkX, int chunkZ) { + updateChunk(engine, level, chunkX, chunkZ, false); + } + private void updateChunk(Engine engine, ServerLevel level, int chunkX, int chunkZ) { - for (int x = -1; x <= 1; x++) { - for (int z = -1; z <= 1; z++) { - if (level.getChunkSource().getChunkNow(chunkX + x, chunkZ + z) == null) { - return; + updateChunk(engine, level, chunkX, chunkZ, true); + } + + private void updateChunk(Engine engine, ServerLevel level, int chunkX, int chunkZ, boolean requireNeighbors) { + if (requireNeighbors) { + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + if (level.getChunkSource().getChunkNow(chunkX + x, chunkZ + z) == null) { + return; + } } } } @@ -170,6 +181,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { try { chunk.raiseFlagUnchecked(MantleFlag.ETCHED, () -> { chunk.raiseFlagUnchecked(MantleFlag.TILE, () -> runTilePass(engine, level, chunkX, chunkZ, chunk)); + chunk.raiseFlagUnchecked(MantleFlag.CUSTOM, () -> runCustomPass(engine, level, chunkX, chunkZ, chunk)); chunk.raiseFlagUnchecked(MantleFlag.UPDATE, () -> runUpdatePass(engine, level, chunkX, chunkZ, chunk)); }); } finally { @@ -183,34 +195,57 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { int baseZ = chunkZ << 4; chunk.iterate(TileWrapper.class, (Integer x, Integer yf, Integer z, TileWrapper v) -> { int y = yf + minHeight; - if (y < level.getMinY() || y > level.getMaxY()) { + if (y < level.getMinY() || y >= level.getMaxY()) { return; } applyTile(level, baseX + (x & 15), y, baseZ + (z & 15), v.getData()); }); } + private void runCustomPass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk) { + int minHeight = engine.getWorld().minHeight(); + int baseX = chunkX << 4; + int baseZ = chunkZ << 4; + chunk.iterate(Identifier.class, (Integer x, Integer yf, Integer z, Identifier identifier) -> { + int y = yf + minHeight; + if (y < level.getMinY() || y >= level.getMaxY()) { + return; + } + BlockPos position = new BlockPos(baseX + (x & 15), y, baseZ + (z & 15)); + ModdedCustomContentRegistry.processBlockPlacement(engine, level, position, identifier.toString()); + }); + } + private void applyTile(ServerLevel level, int x, int y, int z, TileData tile) { if (!(tile instanceof ModdedTileData moddedTile)) { return; } - String snbt = moddedTile.snbt(); - if (snbt == null || snbt.isBlank()) { - return; - } BlockPos pos = new BlockPos(x, y, z); BlockState state = level.getBlockState(pos); - if (!state.hasBlockEntity()) { + BlockState adjusted = moddedTile.adjustBlockState(state); + if (adjusted != state) { + level.setBlock(pos, adjusted, SILENT_FLAGS); + state = adjusted; + } + if (!state.hasBlockEntity() || !(state.getBlock() instanceof EntityBlock entityBlock)) { return; } try { - CompoundTag tag = NbtUtils.snbtToStructure(snbt); - BlockEntity restored = BlockEntity.loadStatic(pos, state, tag, level.registryAccess()); - if (restored == null) { + BlockEntity blockEntity = level.getBlockEntity(pos); + if (blockEntity == null) { + blockEntity = entityBlock.newBlockEntity(pos, state); + } + if (blockEntity == null) { + IrisLogging.warn("Iris could not create block entity at " + pos + " for " + state); return; } - level.setBlockEntity(restored); - restored.setChanged(); + if (!moddedTile.isApplicable(state, blockEntity)) { + return; + } + level.setBlockEntity(blockEntity); + if (!moddedTile.apply(blockEntity, level)) { + IrisLogging.warn("Iris tile payload was empty at " + pos + " for " + state); + } } catch (Throwable e) { IrisLogging.reportError(e); } @@ -255,7 +290,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { RNG rng = new RNG(Cache.key(chunkX, chunkZ)); chunk.iterate(MatterCavern.class, (Integer x, Integer yf, Integer z, MatterCavern v) -> { int y = yf + minHeight; - if (y < level.getMinY() || y > level.getMaxY()) { + if (y < level.getMinY() || y >= level.getMaxY()) { return; } int lx = x & 15; @@ -294,7 +329,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } private void update(Engine engine, ServerLevel level, int x, int y, int z, int baseX, int baseZ, MantleChunk chunk, RNG rng) { - if (y < level.getMinY() || y > level.getMaxY()) { + if (y < level.getMinY() || y >= level.getMaxY()) { return; } BlockPos pos = new BlockPos(baseX + (x & 15), y, baseZ + (z & 15)); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java index 86603fd2b..5738457d4 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedEngineMaintenanceService.java @@ -31,9 +31,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; import java.util.Locale; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; @@ -47,7 +48,7 @@ public final class ModdedEngineMaintenanceService implements ModdedTickableServi private static final long SAVE_PERIOD_MILLIS = 60_000L; private final AtomicInteger tectonicLimit = new AtomicInteger(30); - private final Set inFlight = ConcurrentHashMap.newKeySet(); + private final Set inFlight = Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>())); private volatile ExecutorService service; private long lastMaintenanceAt; private long lastSaveAt; diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java index d94344790..cdf32c5e7 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedStudioHotloadService.java @@ -20,9 +20,15 @@ package art.arcane.iris.modded.service; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; +import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedDimensionManager; +import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedWorkspaceGenerator; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.io.ReactiveFolder; @@ -41,7 +47,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicBoolean; -public final class ModdedStudioHotloadService implements ModdedTickableService { +public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks { private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); private static final String STUDIO_DIMENSION_PREFIX = "irisworldgen:studio_"; private static final long POLL_MILLIS = 250L; @@ -76,6 +82,34 @@ public final class ModdedStudioHotloadService implements ModdedTickableService { watches.clear(); } + @Override + public void refreshWorkspace(Engine engine) { + writeWorkspace(engine, "workspace refresh"); + } + + @Override + public void refreshDatapackWorkspace(Engine engine) { + IrisData data = engine.getData(); + KList dimensions = data.getDimensionLoader().loadAll(data.getDimensionLoader().getPossibleKeys()); + if (hasDatapackImports(dimensions)) { + writeWorkspace(engine, "datapack workspace refresh"); + } + } + + @Override + public void reloadDatapacks(Engine engine) { + ModdedForcedDatapack.regenerate(); + } + + @Override + public void validateDimensionHotload(Engine engine, IrisDimension replacement) { + IrisDimensionRuntimeContract.requireHotloadCompatible( + "Modded Studio level '" + engine.getWorld().identity() + "'", + engine.getDimension(), + replacement, + "irisworldgen"); + } + @Override public void onServerTick(MinecraftServer server) { ExecutorService active = executor; @@ -134,12 +168,34 @@ public final class ModdedStudioHotloadService implements ModdedTickableService { } } + @Override + public boolean isPregeneratorActive(Engine engine) { + IrisWorld world = engine.getWorld(); + if (world == null) { + return false; + } + PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); + return pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(world.identity()); + } + + @Override + public void shutdownPregenerator(Engine engine) { + PregeneratorJob.shutdownInstance(); + } + + @Override + public boolean shouldSkipMantleCleanup(Engine engine) { + IrisWorld world = engine.getWorld(); + return world != null + && WorldMaintenance.isWorldMaintenanceActive(world.identity()) + && !isPregeneratorActive(engine); + } + private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) { if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) { return true; } - PregeneratorJob job = PregeneratorJob.getInstance(); - return job != null && job.targetsWorldIdentity(engine.getWorld().identity()); + return isPregeneratorActive(engine); } private void poll(String dimensionId, Watch watch, IrisModdedChunkGenerator generator, Engine engine) { @@ -171,19 +227,32 @@ public final class ModdedStudioHotloadService implements ModdedTickableService { try { engine.hotloadSilently(); generator.onHotload(); - regenerateSchemas(dimensionId, engine); LOGGER.info("Iris studio hotload {} pack={} {}ms", dimensionId, engine.getDimension().getLoadKey(), System.currentTimeMillis() - start); } catch (Throwable e) { LOGGER.error("Iris studio hotload failed for {}", dimensionId, e); } } - private void regenerateSchemas(String dimensionId, Engine engine) { + static boolean hasDatapackImports(Iterable dimensions) { + if (dimensions == null) { + return false; + } + for (IrisDimension dimension : dimensions) { + if (dimension != null + && dimension.getDatapackImports() != null + && !dimension.getDatapackImports().isEmpty()) { + return true; + } + } + return false; + } + + private void writeWorkspace(Engine engine, String operation) { try { IrisData data = engine.getData(); ModdedWorkspaceGenerator.writeWorkspace(data, data.getDataFolder()); } catch (Throwable e) { - LOGGER.error("Iris studio schema regeneration failed for {}", dimensionId, e); + LOGGER.error("Iris {} failed for {}", operation, engine.getDimension().getLoadKey(), e); } } diff --git a/adapters/modded-common/src/main/resources/data/irisworldgen/dimension_type/overworld.json b/adapters/modded-common/src/main/resources/data/irisworldgen/dimension_type/overworld.json deleted file mode 100644 index ec5e0d60f..000000000 --- a/adapters/modded-common/src/main/resources/data/irisworldgen/dimension_type/overworld.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "ambient_light": 0.0, - "attributes": { - "minecraft:audio/ambient_sounds": { - "mood": { - "block_search_extent": 8, - "offset": 2.0, - "sound": "minecraft:ambient.cave", - "tick_delay": 6000 - } - }, - "minecraft:audio/background_music": { - "creative": { - "max_delay": 24000, - "min_delay": 12000, - "sound": "minecraft:music.creative" - }, - "default": { - "max_delay": 24000, - "min_delay": 12000, - "sound": "minecraft:music.game" - } - }, - "minecraft:gameplay/bed_rule": { - "can_set_spawn": "always", - "can_sleep": "when_dark", - "error_message": { - "translate": "block.minecraft.bed.no_sleep" - } - }, - "minecraft:gameplay/nether_portal_spawns_piglin": true, - "minecraft:gameplay/respawn_anchor_works": false, - "minecraft:visual/ambient_light_color": "#0a0a0a", - "minecraft:visual/cloud_color": "#ccffffff", - "minecraft:visual/cloud_height": 192.33, - "minecraft:visual/fog_color": "#c0d8ff", - "minecraft:visual/sky_color": "#78a7ff" - }, - "coordinate_scale": 1.0, - "default_clock": "minecraft:overworld", - "has_ceiling": false, - "has_ender_dragon_fight": false, - "has_skylight": true, - "height": 768, - "infiniburn": "#minecraft:infiniburn_overworld", - "logical_height": 512, - "min_y": -256, - "monster_spawn_block_light_limit": 0, - "monster_spawn_light_level": { - "type": "minecraft:uniform", - "max_inclusive": 7, - "min_inclusive": 0 - }, - "timelines": "#minecraft:in_overworld" -} diff --git a/adapters/modded-common/src/main/resources/irisworldgen.entity.mixins.json b/adapters/modded-common/src/main/resources/irisworldgen.entity.mixins.json new file mode 100644 index 000000000..366956d9e --- /dev/null +++ b/adapters/modded-common/src/main/resources/irisworldgen.entity.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "art.arcane.iris.modded.mixin", + "compatibilityLevel": "JAVA_21", + "mixins": [ + "EntityPersistenceMixin", + "LivingEntityLootMixin", + "MobAwarenessMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedBlockEntityParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedBlockEntityParityTest.java new file mode 100644 index 000000000..5292c0c50 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedBlockEntityParityTest.java @@ -0,0 +1,152 @@ +package art.arcane.iris.modded; + +import art.arcane.volmlib.util.math.RNG; +import com.mojang.serialization.Codec; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.core.IdMapper; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.component.DataComponentMap; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.Container; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.LevelHeightAccessor; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.BiomeSpecialEffects; +import net.minecraft.world.level.biome.MobSpawnSettings; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BannerBlockEntity; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.entity.SignBlockEntity; +import net.minecraft.world.level.block.entity.SpawnerBlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.chunk.PalettedContainerFactory; +import net.minecraft.world.level.chunk.PalettedContainer; +import net.minecraft.world.level.chunk.PalettedContainerRO; +import net.minecraft.world.level.chunk.ProtoChunk; +import net.minecraft.world.level.chunk.Strategy; +import net.minecraft.world.level.chunk.UpgradeData; +import net.minecraft.world.level.storage.TagValueInput; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class IrisModdedBlockEntityParityTest { + private static RegistryAccess registries; + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + if (!Items.DIAMOND.builtInRegistryHolder().areComponentsBound()) { + Items.DIAMOND.builtInRegistryHolder().bindComponents(DataComponentMap.EMPTY); + } + } + + @Test + public void generatedContainerGetsBlockEntityAndPersistsFilledLoot() { + ProtoChunk chunk = newChunk(); + BlockPos position = new BlockPos(3, 70, 5); + chunk.setBlockState(position, Blocks.CHEST.defaultBlockState(), 0); + + IrisModdedChunkGenerator.createDefaultBlockEntity(chunk, position, Blocks.CHEST.defaultBlockState()); + + BlockEntity blockEntity = chunk.getBlockEntity(position); + assertNotNull(blockEntity); + assertTrue(blockEntity instanceof Container); + Container container = (Container) blockEntity; + ModdedLootApplier.fillContainer(container, List.of(new ItemStack(Items.DIAMOND)), new RNG(17L)); + assertEquals(1, countItem(container, Items.DIAMOND)); + CompoundTag saved = blockEntity.saveWithoutMetadata(registries); + ChestBlockEntity restored = new ChestBlockEntity(position, Blocks.CHEST.defaultBlockState()); + restored.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, saved)); + assertEquals(1, countItem(restored, Items.DIAMOND)); + } + + @Test + public void generatedSignSpawnerAndBannerGetNativeBlockEntities() { + ProtoChunk chunk = newChunk(); + BlockState signState = Blocks.OAK_SIGN.defaultBlockState(); + BlockState spawnerState = Blocks.SPAWNER.defaultBlockState(); + BlockState bannerState = BuiltInRegistries.BLOCK.getValue( + Identifier.parse("minecraft:white_banner")).defaultBlockState(); + BlockPos signPosition = new BlockPos(1, 70, 1); + BlockPos spawnerPosition = new BlockPos(2, 70, 1); + BlockPos bannerPosition = new BlockPos(3, 70, 1); + + chunk.setBlockState(signPosition, signState, 0); + chunk.setBlockState(spawnerPosition, spawnerState, 0); + chunk.setBlockState(bannerPosition, bannerState, 0); + IrisModdedChunkGenerator.createDefaultBlockEntity(chunk, signPosition, signState); + IrisModdedChunkGenerator.createDefaultBlockEntity(chunk, spawnerPosition, spawnerState); + IrisModdedChunkGenerator.createDefaultBlockEntity(chunk, bannerPosition, bannerState); + + assertTrue(chunk.getBlockEntity(signPosition) instanceof SignBlockEntity); + assertTrue(chunk.getBlockEntity(spawnerPosition) instanceof SpawnerBlockEntity); + assertTrue(chunk.getBlockEntity(bannerPosition) instanceof BannerBlockEntity); + } + + private static ProtoChunk newChunk() { + return new ProtoChunk( + new ChunkPos(0, 0), + UpgradeData.EMPTY, + LevelHeightAccessor.create(-64, 384), + palettedContainerFactory(), + null); + } + + private static PalettedContainerFactory palettedContainerFactory() { + Strategy blockStrategy = Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY); + Codec> blockCodec = PalettedContainer.codecRW( + BlockState.CODEC, blockStrategy, Blocks.AIR.defaultBlockState()); + Biome biome = new Biome.BiomeBuilder() + .hasPrecipitation(false) + .temperature(0.8F) + .downfall(0.4F) + .specialEffects(new BiomeSpecialEffects.Builder().waterColor(0x3F76E4).build()) + .mobSpawnSettings(MobSpawnSettings.EMPTY) + .generationSettings(BiomeGenerationSettings.EMPTY) + .build(); + Holder biomeHolder = Holder.direct(biome); + IdMapper> biomeIds = new IdMapper<>(1); + biomeIds.add(biomeHolder); + Strategy> biomeStrategy = Strategy.createForBiomes(biomeIds); + Codec>> biomeCodec = PalettedContainer.codecRO( + Biome.CODEC, biomeStrategy, biomeHolder); + return new PalettedContainerFactory( + blockStrategy, + Blocks.AIR.defaultBlockState(), + blockCodec, + biomeStrategy, + biomeHolder, + biomeCodec); + } + + private static int countItem(Container container, Item item) { + int count = 0; + for (int slot = 0; slot < container.getContainerSize(); slot++) { + ItemStack stack = container.getItem(slot); + if (stack.getItem() == item) { + count += stack.getCount(); + } + } + return count; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedHeightmapParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedHeightmapParityTest.java new file mode 100644 index 000000000..cd6099ad1 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedHeightmapParityTest.java @@ -0,0 +1,33 @@ +package art.arcane.iris.modded; + +import net.minecraft.util.Mth; +import net.minecraft.util.SimpleBitStorage; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class IrisModdedHeightmapParityTest { + @Test + public void terrainHeightmapEncodesPaperRelativeSurfaceHeights() { + int height = 384; + long[] rawData = ModdedHeightmaps.terrainRawData(height, + (x, z) -> x + z * 16 + 1); + SimpleBitStorage storage = new SimpleBitStorage(Mth.ceillog2(height + 1), 256, rawData); + + assertEquals(1, storage.get(0)); + assertEquals(16, storage.get(15)); + assertEquals(17, storage.get(16)); + assertEquals(256, storage.get(255)); + } + + @Test + public void terrainHeightmapClampsToDimensionStorageRange() { + int height = 384; + long[] rawData = ModdedHeightmaps.terrainRawData(height, + (x, z) -> x == 0 ? -20 : 900); + SimpleBitStorage storage = new SimpleBitStorage(Mth.ceillog2(height + 1), 256, rawData); + + assertEquals(0, storage.get(0)); + assertEquals(height, storage.get(1)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java new file mode 100644 index 000000000..82ee526bf --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/IrisModdedStructureParityTest.java @@ -0,0 +1,193 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRange; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.server.Bootstrap; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class IrisModdedStructureParityTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void surfaceBiomeFastPathBeginsAboveTheCaveSwitch() { + assertFalse(IrisModdedBiomeSource.isGuaranteedSurfaceBiome(-2, -256)); + assertTrue(IrisModdedBiomeSource.isGuaranteedSurfaceBiome(-1, -256)); + assertFalse(IrisModdedBiomeSource.isGuaranteedSurfaceBiome(10, 0)); + assertTrue(IrisModdedBiomeSource.isGuaranteedSurfaceBiome(11, 0)); + } + + @Test + public void visibleCaveBiomeBeginsEightBlocksBelowTheSurface() { + assertFalse(IrisModdedBiomeSource.isUnderground(93, 100)); + assertTrue(IrisModdedBiomeSource.isUnderground(92, 100)); + assertTrue(IrisModdedBiomeSource.isUnderground(-20, 100)); + } + + @Test + public void monumentBiomeCubeUsesSurfaceBiomesAtShiftedSeaLevel() { + assertTrue(IrisModdedBiomeSource.isMonumentSurfaceBiomeQuery(50, 29, -256, 306)); + assertFalse(IrisModdedBiomeSource.isMonumentSurfaceBiomeQuery(51, 29, -256, 306)); + assertFalse(IrisModdedBiomeSource.isMonumentSurfaceBiomeQuery(50, 28, -256, 306)); + } + + @Test + public void spawnHeightMatchesPaperFixedSpawnClamp() { + assertEquals(96, IrisModdedChunkGenerator.clampSpawnHeight(-64, 384)); + assertEquals(96, IrisModdedChunkGenerator.clampSpawnHeight(0, 128)); + assertEquals(88, IrisModdedChunkGenerator.clampSpawnHeight(80, 10)); + assertEquals(101, IrisModdedChunkGenerator.clampSpawnHeight(100, 20)); + } + + @Test + public void freshAndStudioWorldsReconcileToOriginWhileCustomSpawnsRemain() { + assertTrue(ModdedEngineBootstrap.shouldReconcileSpawn(true, false, 120, -64)); + assertTrue(ModdedEngineBootstrap.shouldReconcileSpawn(false, true, 120, -64)); + assertTrue(ModdedEngineBootstrap.shouldReconcileSpawn(false, false, 0, 0)); + assertFalse(ModdedEngineBootstrap.shouldReconcileSpawn(false, false, 1, 0)); + assertFalse(ModdedEngineBootstrap.shouldReconcileSpawn(false, false, 0, -1)); + } + + @Test + public void reconciledSpawnUsesOriginAndClampedSurfaceHeight() { + assertEquals(new BlockPos(0, 73, 0), ModdedEngineBootstrap.reconciledSpawnPosition(73, -64, 384)); + assertEquals(new BlockPos(0, -63, 0), ModdedEngineBootstrap.reconciledSpawnPosition(-100, -64, 384)); + assertEquals(new BlockPos(0, 318, 0), ModdedEngineBootstrap.reconciledSpawnPosition(400, -64, 384)); + } + + @Test + public void biomeResolutionUsesRawPlatformSeedFormula() { + long worldSeed = 998877665544L; + int blockX = -124; + int blockY = 48; + int blockZ = 712; + long expected = worldSeed + ^ ((long) blockX * 341873128712L) + ^ ((long) blockY * 132897987541L) + ^ ((long) blockZ * 42317861L); + + assertEquals(expected, IrisModdedBiomeSource.biomeResolutionSeed(worldSeed, blockX, blockY, blockZ)); + } + + @Test + public void configuredBiomeKeysContainOnlyPackDerivativesAndCustomBiomes() { + IrisBiome ocean = new IrisBiome() + .setDerivative("minecraft:desert") + .setVanillaDerivative("minecraft:deep_ocean"); + IrisBiome custom = new IrisBiome() + .setDerivative("forest") + .setCustomDerivitives(new KList<>(new IrisBiomeCustom().setId("Aurora"))); + + Set keys = IrisModdedChunkGenerator.collectConfiguredBiomeKeys( + List.of(ocean, custom), "OverWorld"); + + assertEquals(Set.of("minecraft:deep_ocean", "minecraft:forest", "overworld:aurora"), keys); + assertFalse(keys.contains("minecraft:desert")); + assertFalse(keys.contains("minecraft:plains")); + } + + @Test + public void structureStateRejectsBiomesOutsideThePackContract() { + Set generated = Set.of("minecraft:deep_ocean", "minecraft:dark_forest"); + + assertTrue(IrisModdedBiomeSource.isGeneratedBiomeKey("minecraft:deep_ocean", generated)); + assertTrue(IrisModdedBiomeSource.isGeneratedBiomeKey("MINECRAFT:DARK_FOREST", generated)); + assertFalse(IrisModdedBiomeSource.isGeneratedBiomeKey("minecraft:desert", generated)); + assertFalse(IrisModdedBiomeSource.isGeneratedBiomeKey(null, generated)); + } + + @Test + public void possibleBiomeFallbackIsOnlyRequiredForMissingOrEmptyConfigurations() { + Set registered = Set.of("minecraft:deep_ocean", "minecraft:dark_forest", "minecraft:plains"); + + assertFalse(IrisModdedBiomeSource.requiresPossibleBiomeFallback( + Set.of("minecraft:deep_ocean", "minecraft:dark_forest"), registered)); + assertTrue(IrisModdedBiomeSource.requiresPossibleBiomeFallback( + Set.of("minecraft:deep_ocean", "overworld:missing"), registered)); + assertTrue(IrisModdedBiomeSource.requiresPossibleBiomeFallback(Set.of(), registered)); + } + + @Test + public void configuredDimensionMetadataIsExactBeforeEngineBinding() { + IrisDimension dimension = new IrisDimension() + .setDimensionHeight(new IrisRange(-256, 512)) + .setFluidHeight(50); + dimension.setLoadKey("bootstrap_contract"); + + IrisModdedChunkGenerator.DimensionMetadata metadata = + IrisModdedChunkGenerator.dimensionMetadata(dimension); + + assertEquals(-256, metadata.minY()); + assertEquals(512, metadata.maxY()); + assertEquals(768, metadata.depth()); + assertEquals(50, metadata.seaLevel()); + } + + @Test + public void structureRingWorkersWaitWithoutBlockingLifecycleBinding() throws Exception { + IrisModdedChunkGenerator.EngineBinding binding = + new IrisModdedChunkGenerator.EngineBinding<>(5L, TimeUnit.SECONDS); + String exactEngine = "exact-engine"; + CountDownLatch workerStarted = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future ringWorker = executor.submit(() -> { + workerStarted.countDown(); + return binding.await("overworld:overworld"); + }); + + assertTrue(workerStarted.await(1L, TimeUnit.SECONDS)); + assertFalse(ringWorker.isDone()); + binding.complete(exactEngine); + + assertSame(exactEngine, ringWorker.get(1L, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void structureRingBindingPropagatesBootstrapFailure() { + IrisModdedChunkGenerator.EngineBinding binding = + new IrisModdedChunkGenerator.EngineBinding<>(1L, TimeUnit.SECONDS); + IllegalArgumentException failure = new IllegalArgumentException("broken pack"); + binding.fail(failure); + + try { + binding.await("overworld:overworld"); + } catch (IllegalStateException error) { + assertSame(failure, error.getCause()); + return; + } + throw new AssertionError("Expected failed engine binding to propagate"); + } + + @Test + public void initialEntitySpawnsUseThePaperCompletionMarker() { + assertSame(MantleFlag.INITIAL_SPAWNED_MARKER, ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG); + } + +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockBreakHandlerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockBreakHandlerTest.java new file mode 100644 index 000000000..36569bced --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedBlockBreakHandlerTest.java @@ -0,0 +1,41 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import net.minecraft.core.Direction; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.StairBlock; +import net.minecraft.world.level.block.state.BlockState; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedBlockBreakHandlerTest { + @Test + public void exactMatchingIncludesPropertiesWhileTypeMatchingDoesNot() { + BlockState north = Blocks.OAK_STAIRS.defaultBlockState().setValue(StairBlock.FACING, Direction.NORTH); + BlockState east = Blocks.OAK_STAIRS.defaultBlockState().setValue(StairBlock.FACING, Direction.EAST); + + assertTrue(ModdedBlockBreakHandler.matchesState(north, north, true)); + assertFalse(ModdedBlockBreakHandler.matchesState(north, east, true)); + assertTrue(ModdedBlockBreakHandler.matchesState(north, east, false)); + assertFalse(ModdedBlockBreakHandler.matchesState(north, Blocks.COBBLESTONE.defaultBlockState(), false)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDeferredBlockParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDeferredBlockParityTest.java new file mode 100644 index 000000000..943706fe6 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDeferredBlockParityTest.java @@ -0,0 +1,76 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.modded.api.ModdedBlockData; +import art.arcane.iris.modded.api.ModdedCustomContentRegistry; +import art.arcane.iris.modded.api.ModdedDataProvider; +import art.arcane.iris.modded.api.ModdedDataType; +import art.arcane.iris.spi.PlatformBlockState; +import net.minecraft.SharedConstants; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.StairBlock; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class ModdedDeferredBlockParityTest { + private static final Identifier BLOCK_ID = Identifier.parse("iris_deferred_test:oak_stairs"); + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + ModdedCustomContentRegistry.register(new DeferredProvider()); + } + + @Test + public void deferredProviderStateCarriesPlacementMetadataThroughMutation() { + ModdedBlockState resolved = ModdedBlockResolution.getOrNull( + "iris_deferred_test:oak_stairs[facing=north,half=bottom]"); + + assertNotNull(resolved); + assertTrue(resolved.isCustom()); + assertEquals("iris_deferred_test:oak_stairs[facing=north,half=bottom]", resolved.deferredPlacementKey()); + assertEquals(Blocks.OAK_STAIRS, resolved.handle().getBlock()); + + PlatformBlockState mutated = resolved.withProperty("facing", "west"); + assertTrue(mutated.isCustom()); + assertEquals(resolved.deferredPlacementKey(), mutated.deferredPlacementKey()); + assertEquals("west", ((ModdedBlockState) mutated).handle().getValue(StairBlock.FACING).getName()); + + PlatformBlockState base = mutated.placementBaseState(); + assertFalse(base.isCustom()); + assertEquals(Blocks.OAK_STAIRS, ((ModdedBlockState) base).handle().getBlock()); + } + + private static final class DeferredProvider implements ModdedDataProvider { + @Override + public String modId() { + return "iris_deferred_test"; + } + + @Override + public Collection getTypes(ModdedDataType type) { + return type == ModdedDataType.BLOCK ? List.of(BLOCK_ID) : List.of(); + } + + @Override + public boolean isValidProvider(Identifier id, ModdedDataType type) { + return type == ModdedDataType.BLOCK && BLOCK_ID.equals(id); + } + + @Override + public ModdedBlockData getBlockData(Identifier blockId, Map state) { + return ModdedBlockData.deferred(Blocks.OAK_STAIRS.defaultBlockState()); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTypeParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTypeParityTest.java new file mode 100644 index 000000000..72bf68f54 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedDimensionTypeParityTest.java @@ -0,0 +1,184 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import art.arcane.iris.core.nms.datapack.DataVersion; +import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; +import art.arcane.iris.engine.object.IrisDimensionTypeOptions; +import art.arcane.iris.engine.object.IrisEnvironment; +import art.arcane.iris.engine.object.IrisRange; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static art.arcane.iris.engine.object.IrisDimensionTypeOptions.TriState.FALSE; +import static art.arcane.iris.engine.object.IrisDimensionTypeOptions.TriState.TRUE; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ModdedDimensionTypeParityTest { + @Test + public void everyPackDimensionUsesItsExactTypeReference() { + IrisDimension overworld = dimension("overworld", IrisEnvironment.NORMAL, -64, 320, 320, new IrisDimensionTypeOptions()); + IrisDimension nether = dimension("nether", IrisEnvironment.NETHER, 0, 256, 256, new IrisDimensionTypeOptions()); + IrisDimension end = dimension("the_end", IrisEnvironment.THE_END, 0, 256, 256, new IrisDimensionTypeOptions()); + + assertEquals("irisworldgen:overworld", ModdedForcedDatapack.dimensionTypeRef(overworld)); + assertEquals("irisworldgen:nether", ModdedForcedDatapack.dimensionTypeRef(nether)); + assertEquals("irisworldgen:the_end", ModdedForcedDatapack.dimensionTypeRef(end)); + } + + @Test + public void writesExactOverworldNetherEndAndCustomContracts() throws IOException { + IrisDimensionTypeOptions customOptions = new IrisDimensionTypeOptions() + .coordinateScale(3.5D) + .ambientLight(0.4F) + .skylight(FALSE) + .ceiling(TRUE); + IrisDimension overworld = dimension("overworld", IrisEnvironment.NORMAL, -64, 320, 320, new IrisDimensionTypeOptions()); + IrisDimension nether = dimension("nether", IrisEnvironment.NETHER, 0, 256, 256, new IrisDimensionTypeOptions()); + IrisDimension end = dimension("the_end", IrisEnvironment.THE_END, 0, 256, 256, new IrisDimensionTypeOptions()); + IrisDimension custom = dimension("custom_contract", IrisEnvironment.CUSTOM, -128, 384, 384, customOptions); + List dimensions = List.of(overworld, nether, end, custom); + IDataFixer fixer = DataVersion.getLatest().get(); + Path packDirectory = Files.createTempDirectory("iris-dimension-contracts"); + KList roots = new KList<>(); + roots.add(packDirectory.toFile()); + try { + for (IrisDimension dimension : dimensions) { + ModdedForcedDatapack.writeDimensionType(roots, fixer, dimension); + Path output = packDirectory.resolve("data/irisworldgen/dimension_type/" + + dimension.getDimensionTypeKey() + ".json"); + assertTrue(Files.isRegularFile(output)); + assertEquals(dimension.getDimensionType().toJson(fixer), + Files.readString(output, StandardCharsets.UTF_8)); + } + + JSONObject overworldJson = readType(packDirectory, overworld); + JSONObject netherJson = readType(packDirectory, nether); + JSONObject endJson = readType(packDirectory, end); + JSONObject customJson = readType(packDirectory, custom); + + assertTrue(overworldJson.getBoolean("has_skylight")); + assertFalse(overworldJson.getBoolean("has_ceiling")); + assertEquals(1D, overworldJson.getDouble("coordinate_scale"), 0D); + assertFalse(netherJson.getBoolean("has_skylight")); + assertTrue(netherJson.getBoolean("has_ceiling")); + assertEquals(8D, netherJson.getDouble("coordinate_scale"), 0D); + assertTrue(endJson.getBoolean("has_ender_dragon_fight")); + assertFalse(endJson.getBoolean("has_skylight")); + assertEquals(-128, customJson.getInt("min_y")); + assertEquals(512, customJson.getInt("height")); + assertEquals(384, customJson.getInt("logical_height")); + assertEquals(3.5D, customJson.getDouble("coordinate_scale"), 0D); + assertEquals(0.4D, customJson.getDouble("ambient_light"), 0.000001D); + assertFalse(customJson.getBoolean("has_skylight")); + assertTrue(customJson.getBoolean("has_ceiling")); + } finally { + deleteTree(packDirectory); + } + } + + @Test + public void missingExactDimensionTypeIsRejected() { + try { + ModdedForcedDatapack.requireRegisteredDimensionType( + "irisworldgen:overworld", Optional.empty(), "overworld", "overworld"); + fail("Missing dimension type must be rejected"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("irisworldgen:overworld")); + assertTrue(e.getMessage().contains("Restart the server")); + } + } + + @Test + public void loadedLevelMustMatchExactPackHeightRange() { + IrisDimension dimension = dimension("tall", IrisEnvironment.NORMAL, -128, 384, 384, new IrisDimensionTypeOptions()); + IrisDimensionRuntimeContract contract = IrisDimensionRuntimeContract.expected(dimension, "irisworldgen"); + + contract.requireHeight("test level", -128, 512); + + try { + contract.requireHeight("test level", -256, 768); + fail("Oversized fallback height must be rejected"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("terrain clipping is not allowed")); + } + } + + @Test + public void worldCheckRejectsFallbackHeightAndSemantics() { + IrisDimensionTypeOptions options = new IrisDimensionTypeOptions() + .coordinateScale(2.5D) + .ambientLight(0.3F) + .skylight(FALSE) + .ceiling(TRUE); + IrisDimension dimension = dimension("runtime_contract", IrisEnvironment.CUSTOM, + -128, 384, 384, options); + ModdedWorldCheck.DimensionContract expected = ModdedWorldCheck.expectedDimensionContract(dimension); + ModdedWorldCheck.DimensionContract fallback = new ModdedWorldCheck.DimensionContract( + -256, 768, 512, 1D, 0F, true, false, false, 0); + + assertTrue(ModdedWorldCheck.matchesDimensionContract(-128, 512, expected, expected)); + assertFalse(ModdedWorldCheck.matchesDimensionContract(-256, 768, expected, fallback)); + assertFalse(ModdedWorldCheck.matchesDimensionContract(-128, 512, expected, fallback)); + } + + private static IrisDimension dimension(String key, IrisEnvironment environment, int minY, int maxY, + int logicalHeight, IrisDimensionTypeOptions options) { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey(key); + dimension.setEnvironment(environment); + dimension.setDimensionHeight(new IrisRange(minY, maxY)); + dimension.setLogicalHeight(logicalHeight); + dimension.setDimensionOptions(options); + return dimension; + } + + private static JSONObject readType(Path packDirectory, IrisDimension dimension) throws IOException { + Path output = packDirectory.resolve("data/irisworldgen/dimension_type/" + + dimension.getDimensionTypeKey() + ".json"); + return new JSONObject(Files.readString(output, StandardCharsets.UTF_8)); + } + + private static void deleteTree(Path root) throws IOException { + List paths = new ArrayList<>(); + try (Stream walk = Files.walk(root)) { + walk.sorted(Comparator.comparingInt(Path::getNameCount).reversed()).forEach(paths::add); + } + for (Path path : paths) { + Files.deleteIfExists(path); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineEffectsTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineEffectsTest.java new file mode 100644 index 000000000..c2da0c46b --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEngineEffectsTest.java @@ -0,0 +1,62 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ModdedEngineEffectsTest { + @Test + public void normalizesRegistryKeys() { + assertNull(ModdedEngineEffects.normalizeRegistryKey(null)); + assertNull(ModdedEngineEffects.normalizeRegistryKey(" ")); + assertEquals("minecraft:block_amethyst_block_chime", + ModdedEngineEffects.normalizeRegistryKey("BLOCK AMETHYST BLOCK CHIME")); + assertEquals("example:custom_sound", + ModdedEngineEffects.normalizeRegistryKey("Example:Custom_Sound")); + } + + @Test + public void normalizesLegacyPotionAliases() { + assertEquals("minecraft:luck", ModdedEngineEffects.normalizePotionEffectKey(null)); + assertEquals("minecraft:slowness", ModdedEngineEffects.normalizePotionEffectKey("SLOW")); + assertEquals("minecraft:mining_fatigue", ModdedEngineEffects.normalizePotionEffectKey("SLOW_DIGGING")); + assertEquals("minecraft:instant_health", ModdedEngineEffects.normalizePotionEffectKey("minecraft:HEAL")); + assertEquals("example:resistance", ModdedEngineEffects.normalizePotionEffectKey("example:DAMAGE_RESISTANCE")); + } + + @Test + public void samplesInitiallyAndAfterMovingPastThreshold() { + assertTrue(ModdedEngineEffects.needsSample(false, 0L, 0.0D)); + assertFalse(ModdedEngineEffects.needsSample(true, 56L, 81.0D)); + assertFalse(ModdedEngineEffects.needsSample(true, 55L, 82.0D)); + assertTrue(ModdedEngineEffects.needsSample(true, 56L, 82.0D)); + } + + @Test + public void preservesOnlyStrictlyStrongerPotionEffects() { + assertFalse(ModdedEngineEffects.shouldReplacePotionEffect(3, 2)); + assertTrue(ModdedEngineEffects.shouldReplacePotionEffect(2, 2)); + assertTrue(ModdedEngineEffects.shouldReplacePotionEffect(1, 2)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityAwarenessTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityAwarenessTest.java new file mode 100644 index 000000000..547c020d9 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityAwarenessTest.java @@ -0,0 +1,35 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedEntityAwarenessTest { + @Test + public void unawareConfigurationAddsThePartialAiTag() { + Set tags = new HashSet<>(); + + ModdedEntityAwareness.configureTags(tags, false); + + assertFalse(ModdedEntityAwareness.isAware(tags)); + } + + @Test + public void awareConfigurationRemovesThePartialAiTag() { + Set tags = new HashSet<>(); + ModdedEntityAwareness.configureTags(tags, false); + + ModdedEntityAwareness.configureTags(tags, true); + + assertTrue(ModdedEntityAwareness.isAware(tags)); + } + + @Test + public void mobsAreAwareWithoutAnIrisTag() { + assertTrue(ModdedEntityAwareness.isAware(Set.of())); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityCommandRunnerTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityCommandRunnerTest.java new file mode 100644 index 000000000..7bf576c9e --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityCommandRunnerTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class ModdedEntityCommandRunnerTest { + @Test + public void commandPreparationRemovesOneLeadingSlashAndExpandsCoordinates() { + String command = ModdedEntityCommandRunner.prepareCommand( + "/summon pig {x} {y} {z} {x}", -12, 65, 99); + + assertEquals("summon pig -12 65 99 -12", command); + } + + @Test + public void blankCommandsAreIgnored() { + assertNull(ModdedEntityCommandRunner.prepareCommand(" ", 0, 0, 0)); + assertNull(ModdedEntityCommandRunner.prepareCommand(null, 0, 0, 0)); + } + + @Test + public void delaysMatchPaperClampingRules() { + assertEquals(0, ModdedEntityCommandRunner.clampDelay(-10L, 0)); + assertEquals(1, ModdedEntityCommandRunner.clampDelay(-10L, 1)); + assertEquals(73, ModdedEntityCommandRunner.clampDelay(73L, 0)); + assertEquals(Integer.MAX_VALUE, ModdedEntityCommandRunner.clampDelay(Long.MAX_VALUE, 0)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityPersistenceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityPersistenceTest.java new file mode 100644 index 000000000..94b0dea05 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntityPersistenceTest.java @@ -0,0 +1,39 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedEntityPersistenceTest { + @Test + public void generatedNonPersistentEntityIsExcludedFromVanillaSaves() { + Set tags = new HashSet<>(); + + ModdedEntityPersistence.configureTags(tags, false); + + assertFalse(ModdedEntityPersistence.shouldSave(tags, true)); + } + + @Test + public void positivePersistenceRemovesTheSaveExclusion() { + Set tags = new HashSet<>(); + ModdedEntityPersistence.configureTags(tags, false); + + ModdedEntityPersistence.configureTags(tags, true); + + assertTrue(ModdedEntityPersistence.shouldSave(tags, true)); + } + + @Test + public void interceptionNeverOverridesVanillaSaveRejection() { + Set tags = new HashSet<>(); + + ModdedEntityPersistence.configureTags(tags, true); + + assertFalse(ModdedEntityPersistence.shouldSave(tags, false)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnerParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnerParityTest.java new file mode 100644 index 000000000..9c5478b44 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedEntitySpawnerParityTest.java @@ -0,0 +1,165 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.object.IrisEffect; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.monster.Zoglin; +import net.minecraft.world.entity.monster.piglin.Piglin; +import org.junit.Test; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedEntitySpawnerParityTest { + @Test + public void collisionCheckUsesPaperInclusiveIntegerDimensions() { + Set checked = new HashSet<>(); + + boolean clear = ModdedEntitySpawner.isAreaClearForSpawn( + 10, 64, 20, 0.6F, 1.95F, position -> { + checked.add(position); + return true; + }); + + assertTrue(clear); + assertEquals(Set.of(new BlockPos(10, 64, 20), new BlockPos(10, 65, 20)), checked); + } + + @Test + public void collisionCheckCoversWideEntityVolumeAndRejectsOneBlockedCell() { + AtomicInteger checked = new AtomicInteger(); + boolean fullyClear = ModdedEntitySpawner.isAreaClearForSpawn( + 0, 10, 0, 4F, 3.2F, position -> { + checked.incrementAndGet(); + return true; + }); + + assertTrue(fullyClear); + assertEquals(100, checked.get()); + + BlockPos obstruction = new BlockPos(2, 12, -2); + + boolean clear = ModdedEntitySpawner.isAreaClearForSpawn( + 0, 10, 0, 4F, 3.2F, position -> !position.equals(obstruction)); + + assertFalse(clear); + } + + @Test + public void spawnSafetyRequiresTheTargetChunkAndAllEightNeighbors() { + Set loaded = new HashSet<>(); + for (int x = 4; x <= 6; x++) { + for (int z = -3; z <= -1; z++) { + loaded.add(pack(x, z)); + } + } + + assertTrue(ModdedEntitySpawner.allNeighborChunksLoaded(5, -2, + (x, z) -> loaded.contains(pack(x, z)))); + + loaded.remove(pack(4, -3)); + + assertFalse(ModdedEntitySpawner.allNeighborChunksLoaded(5, -2, + (x, z) -> loaded.contains(pack(x, z)))); + } + + @Test + public void onlyAiFalseUsesVanillaNoAi() { + assertFalse(ModdedEntitySpawner.shouldDisableAi(true)); + assertTrue(ModdedEntitySpawner.shouldDisableAi(false)); + } + + @Test + public void piglinAndZoglinUseTheVirtualMobBabyContract() throws NoSuchMethodException { + assertTrue(Mob.class.isAssignableFrom(Piglin.class)); + assertTrue(Mob.class.isAssignableFrom(Zoglin.class)); + assertEquals(Piglin.class, Piglin.class.getMethod("setBaby", boolean.class).getDeclaringClass()); + assertEquals(Zoglin.class, Zoglin.class.getMethod("setBaby", boolean.class).getDeclaringClass()); + } + + @Test + public void everyAcceptedBukkitSpawnReasonHasAnExplicitNmsMapping() { + Map expected = Map.ofEntries( + Map.entry("NATURAL", EntitySpawnReason.NATURAL), + Map.entry("JOCKEY", EntitySpawnReason.JOCKEY), + Map.entry("CHUNK_GEN", EntitySpawnReason.CHUNK_GENERATION), + Map.entry("SPAWNER", EntitySpawnReason.SPAWNER), + Map.entry("TRIAL_SPAWNER", EntitySpawnReason.TRIAL_SPAWNER), + Map.entry("EGG", EntitySpawnReason.TRIGGERED), + Map.entry("SPAWNER_EGG", EntitySpawnReason.SPAWN_ITEM_USE), + Map.entry("LIGHTNING", EntitySpawnReason.EVENT), + Map.entry("BUILD_SNOWMAN", EntitySpawnReason.TRIGGERED), + Map.entry("BUILD_IRONGOLEM", EntitySpawnReason.TRIGGERED), + Map.entry("BUILD_COPPERGOLEM", EntitySpawnReason.TRIGGERED), + Map.entry("BUILD_WITHER", EntitySpawnReason.TRIGGERED), + Map.entry("VILLAGE_DEFENSE", EntitySpawnReason.MOB_SUMMONED), + Map.entry("VILLAGE_INVASION", EntitySpawnReason.EVENT), + Map.entry("BREEDING", EntitySpawnReason.BREEDING), + Map.entry("SLIME_SPLIT", EntitySpawnReason.TRIGGERED), + Map.entry("REINFORCEMENTS", EntitySpawnReason.REINFORCEMENT), + Map.entry("NETHER_PORTAL", EntitySpawnReason.STRUCTURE), + Map.entry("DISPENSE_EGG", EntitySpawnReason.DISPENSER), + Map.entry("INFECTION", EntitySpawnReason.CONVERSION), + Map.entry("CURED", EntitySpawnReason.CONVERSION), + Map.entry("OCELOT_BABY", EntitySpawnReason.BREEDING), + Map.entry("SILVERFISH_BLOCK", EntitySpawnReason.TRIGGERED), + Map.entry("MOUNT", EntitySpawnReason.JOCKEY), + Map.entry("TRAP", EntitySpawnReason.TRIGGERED), + Map.entry("ENDER_PEARL", EntitySpawnReason.TRIGGERED), + Map.entry("SHOULDER_ENTITY", EntitySpawnReason.LOAD), + Map.entry("DROWNED", EntitySpawnReason.CONVERSION), + Map.entry("SHEARED", EntitySpawnReason.CONVERSION), + Map.entry("EXPLOSION", EntitySpawnReason.TRIGGERED), + Map.entry("RAID", EntitySpawnReason.EVENT), + Map.entry("PATROL", EntitySpawnReason.PATROL), + Map.entry("BEEHIVE", EntitySpawnReason.LOAD), + Map.entry("PIGLIN_ZOMBIFIED", EntitySpawnReason.CONVERSION), + Map.entry("SPELL", EntitySpawnReason.MOB_SUMMONED), + Map.entry("FROZEN", EntitySpawnReason.CONVERSION), + Map.entry("METAMORPHOSIS", EntitySpawnReason.CONVERSION), + Map.entry("DUPLICATION", EntitySpawnReason.BREEDING), + Map.entry("COMMAND", EntitySpawnReason.COMMAND), + Map.entry("ENCHANTMENT", EntitySpawnReason.TRIGGERED), + Map.entry("OMINOUS_ITEM_SPAWNER", EntitySpawnReason.TRIGGERED), + Map.entry("BUCKET", EntitySpawnReason.BUCKET), + Map.entry("POTION_EFFECT", EntitySpawnReason.TRIGGERED), + Map.entry("REANIMATE", EntitySpawnReason.TRIGGERED), + Map.entry("REHYDRATION", EntitySpawnReason.BREEDING), + Map.entry("CUSTOM", EntitySpawnReason.EVENT), + Map.entry("DEFAULT", EntitySpawnReason.NATURAL)); + + assertEquals(47, expected.size()); + for (Map.Entry entry : expected.entrySet()) { + assertEquals(entry.getKey(), entry.getValue(), ModdedEntitySpawner.reasonFor(entry.getKey())); + assertEquals(entry.getKey(), entry.getValue(), ModdedEntitySpawner.reasonFor(entry.getKey().toLowerCase())); + } + } + + @Test + public void invalidConfiguredSpawnReasonIntentionallyDefaultsToNatural() { + assertEquals(EntitySpawnReason.NATURAL, ModdedEntitySpawner.reasonFor(null)); + assertEquals(EntitySpawnReason.NATURAL, ModdedEntitySpawner.reasonFor("")); + assertEquals(EntitySpawnReason.NATURAL, ModdedEntitySpawner.reasonFor("not_a_spawn_reason")); + } + + @Test + public void entityEffectExposesNeutralRegistryKeys() { + IrisEffect effect = new IrisEffect() + .setSound("minecraft:block.amethyst_block.chime") + .setParticleEffect("minecraft:flame"); + + assertEquals("minecraft:block.amethyst_block.chime", effect.getSoundKey()); + assertEquals("minecraft:flame", effect.getParticleEffectKey()); + } + + private static long pack(int x, int z) { + return (((long) x) & 0xFFFFFFFFL) | ((((long) z) & 0xFFFFFFFFL) << 32); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java new file mode 100644 index 000000000..cc1c1eef1 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedForcedDatapackTest.java @@ -0,0 +1,87 @@ +/* + * 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 . + */ + +package art.arcane.iris.modded; + +import art.arcane.volmlib.util.collection.KSet; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +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.assertTrue; + +public class ModdedForcedDatapackTest { + @Test + public void scopesSharedCustomBiomeIdsByNamespace() { + Map> seenBiomes = new LinkedHashMap<>(); + + KSet firstNamespace = ModdedForcedDatapack.biomesForNamespace(seenBiomes, "first_dimension"); + KSet secondNamespace = ModdedForcedDatapack.biomesForNamespace(seenBiomes, "second_dimension"); + + assertTrue(firstNamespace.add("shared_biome")); + assertTrue(secondNamespace.add("shared_biome")); + assertFalse(firstNamespace.add("shared_biome")); + assertNotSame(firstNamespace, secondNamespace); + assertEquals(2, seenBiomes.size()); + } + + @Test + public void writesForgeBlockLootModifierListAndInstance() throws IOException { + Path packDirectory = Files.createTempDirectory("iris-forge-loot-modifier"); + try { + ModdedForcedDatapack.writeForgeBlockLootModifier(packDirectory); + + Path list = packDirectory.resolve("data/forge/loot_modifiers/global_loot_modifiers.json"); + Path modifier = packDirectory.resolve("data/irisworldgen/loot_modifiers/block_drops.json"); + assertTrue(Files.isRegularFile(list)); + assertTrue(Files.isRegularFile(modifier)); + assertEquals("{\n" + + " \"replace\": false,\n" + + " \"entries\": [\"irisworldgen:block_drops\"]\n" + + "}\n", Files.readString(list, StandardCharsets.UTF_8)); + assertEquals("{\n" + + " \"type\": \"irisworldgen:block_drops\",\n" + + " \"conditions\": []\n" + + "}\n", Files.readString(modifier, StandardCharsets.UTF_8)); + } finally { + deleteTree(packDirectory); + } + } + + private void deleteTree(Path root) throws IOException { + List paths = new ArrayList<>(); + try (Stream walk = Files.walk(root)) { + walk.sorted(Comparator.comparingInt(Path::getNameCount).reversed()).forEach(paths::add); + } + for (Path path : paths) { + Files.deleteIfExists(path); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java new file mode 100644 index 000000000..f3a6e251e --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedLootApplierTest.java @@ -0,0 +1,158 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.object.IrisLootMode; +import art.arcane.volmlib.util.math.RNG; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.storage.loot.LootTable; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ModdedLootApplierTest { + @Test + public void addAppendsSourcesInOrder() { + List sources = new ArrayList<>(List.of("placement-native")); + + ModdedLootApplier.injectSources(sources, List.of("dimension-iris", "region-iris"), IrisLootMode.ADD, false); + + assertEquals(List.of("placement-native", "dimension-iris", "region-iris"), sources); + } + + @Test + public void clearRemovesNativeAndIrisSourcesBeforeAdding() { + List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); + + ModdedLootApplier.injectSources(sources, List.of("biome-iris"), IrisLootMode.CLEAR, false); + + assertEquals(List.of("biome-iris"), sources); + } + + @Test + public void replaceRemovesNativeAndIrisSourcesBeforeAdding() { + List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); + + ModdedLootApplier.injectSources(sources, List.of("biome-iris"), IrisLootMode.REPLACE, false); + + assertEquals(List.of("biome-iris"), sources); + } + + @Test + public void entityLootBindingTargetsOnlyTheBaseLootInjection() { + assertTrue(ModdedDeathLoot.hasBindingTag(Set.of("unrelated", "iris_loot|17|1|2|3|test"))); + assertFalse(ModdedDeathLoot.hasBindingTag(Set.of("unrelated", "iris_other"))); + } + + @Test + public void fallbackDoesNotOverrideExistingNativeOrIrisSources() { + List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); + + ModdedLootApplier.injectSources(sources, List.of("fallback-iris"), IrisLootMode.FALLBACK, false); + + assertEquals(List.of("placement-native", "dimension-iris"), sources); + } + + @Test + public void fallbackAddsSourcesWhenPlacementIsEmpty() { + List sources = new ArrayList<>(); + + ModdedLootApplier.injectSources(sources, List.of("fallback-iris"), IrisLootMode.FALLBACK, true); + + assertEquals(List.of("fallback-iris"), sources); + } + + @Test + public void zeroMultiplierRemovesNativeAndIrisSources() { + List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); + + ModdedLootApplier.scaleSources(sources, 0D, new RNG(17L)); + + assertTrue(sources.isEmpty()); + } + + @Test + public void unitMultiplierPreservesNativeAndIrisSources() { + List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); + + ModdedLootApplier.scaleSources(sources, 1D, new RNG(17L)); + + assertEquals(List.of("placement-native", "dimension-iris"), sources); + } + + @Test + public void doubleMultiplierScalesTheUnifiedSourceList() { + List sources = new ArrayList<>(List.of("placement-native", "dimension-iris")); + + ModdedLootApplier.scaleSources(sources, 2D, new RNG(17L)); + + assertEquals(4, sources.size()); + assertEquals("placement-native", sources.get(0)); + assertEquals("dimension-iris", sources.get(1)); + Set expectedSources = Set.of("placement-native", "dimension-iris"); + for (String source : sources) { + assertTrue(expectedSources.contains(source)); + } + } + + @Test + public void nativeKeyIsReturnedWhenTheLiveRegistryContainsIt() { + ResourceKey key = ModdedLootApplier.resolveNativeKey("minecraft:chests/simple_dungeon", candidate -> true); + + assertNotNull(key); + assertEquals(Identifier.parse("minecraft:chests/simple_dungeon"), key.identifier()); + } + + @Test + public void nativeKeyIsRejectedWhenTheLiveRegistryDoesNotContainIt() { + ResourceKey key = ModdedLootApplier.resolveNativeKey("minecraft:chests/missing", candidate -> false); + + assertNull(key); + } + + @Test + public void malformedNativeKeyIsRejectedBeforeRegistryLookup() { + AtomicBoolean registryConsulted = new AtomicBoolean(false); + + ResourceKey key = ModdedLootApplier.resolveNativeKey("not a valid identifier", candidate -> { + registryConsulted.set(true); + return true; + }); + + assertNull(key); + assertFalse(registryConsulted.get()); + } + + @Test + public void nativeOnlyFillMarksTheContainerChanged() { + TrackingContainer container = new TrackingContainer(); + + ModdedLootApplier.fillContainer(container, List.of(), new RNG(17L)); + + assertTrue(container.changed); + } + + private static final class TrackingContainer extends SimpleContainer { + private boolean changed; + + private TrackingContainer() { + super(0); + } + + @Override + public void setChanged() { + changed = true; + super.setChanged(); + } + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java new file mode 100644 index 000000000..13697d795 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedStructureHooksTest.java @@ -0,0 +1,54 @@ +package art.arcane.iris.modded; + +import net.minecraft.SharedConstants; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ModdedStructureHooksTest { + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void objectFeaturesMatchPaperGroups() { + assertEquals("trees", ModdedStructureHooks.classifyFeature(Feature.TREE)); + assertEquals("fallen_trees", ModdedStructureHooks.classifyFeature(Feature.FALLEN_TREE)); + assertEquals("mushrooms", ModdedStructureHooks.classifyFeature(Feature.HUGE_BROWN_MUSHROOM)); + assertEquals("mushrooms", ModdedStructureHooks.classifyFeature(Feature.HUGE_RED_MUSHROOM)); + assertNull(ModdedStructureHooks.classifyFeature(Feature.ORE)); + } + + @Test + public void structureSpanGuardUsesInclusiveBoundingBoxDimensions() { + BoundingBox box = new BoundingBox(-16, -64, 32, 15, 63, 47); + + assertTrue(ModdedStructureHooks.isWithinSpan(box, 128)); + assertFalse(ModdedStructureHooks.isWithinSpan(box, 127)); + assertTrue(ModdedStructureHooks.isWithinSpan(box, 0)); + assertTrue(ModdedStructureHooks.isWithinSpan(box, -1)); + } + + @Test + public void structurePlacementReturnsPaperOrderedBounds() { + BoundingBox box = new BoundingBox(-16, -64, 32, 15, 63, 47); + + assertArrayEquals(new int[]{-16, -64, 32, 15, 63, 47}, ModdedStructureHooks.bounds(box)); + } + + @Test + public void platformWorldMaximumHeightIsExclusive() { + assertEquals(320, ModdedPlatformWorld.exclusiveMaxHeight(-64, 384)); + assertEquals(384, ModdedPlatformWorld.exclusiveMaxHeight(0, 384)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java new file mode 100644 index 000000000..37c609e8f --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedTileParityTest.java @@ -0,0 +1,218 @@ +package art.arcane.iris.modded; + +import art.arcane.iris.engine.object.TileData; +import art.arcane.volmlib.util.collection.KMap; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.resources.Identifier; +import net.minecraft.server.Bootstrap; +import net.minecraft.util.ProblemReporter; +import net.minecraft.world.RandomizableContainer; +import net.minecraft.world.item.DyeColor; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.entity.BarrelBlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.entity.SignBlockEntity; +import net.minecraft.world.level.block.entity.SpawnerBlockEntity; +import net.minecraft.world.level.storage.TagValueInput; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class ModdedTileParityTest { + private static RegistryAccess registries; + + @BeforeClass + public static void bootstrapMinecraftRegistries() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + registries = RegistryAccess.fromRegistryOfRegistries(BuiltInRegistries.REGISTRY); + TileData.bindFallbackReader(new ModdedTileReader(() -> null)); + TileData.bindFallbackFactory(ModdedTileData::fromProperties); + } + + @Test + public void legacySignPayloadLoadsIntoNativeSignBlockEntity() throws Exception { + ModdedTileData tile = legacyTile(out -> { + out.writeShort(0); + out.writeUTF("Iris"); + out.writeUTF("modded"); + out.writeUTF("tile"); + out.writeUTF("parity"); + out.writeByte(DyeColor.BLUE.getId()); + }); + SignBlockEntity sign = new SignBlockEntity(BlockPos.ZERO, Blocks.OAK_SIGN.defaultBlockState()); + + sign.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, tile.payload())); + + assertEquals("Iris", sign.getFrontText().getMessage(0, false).getString()); + assertEquals("parity", sign.getBackText().getMessage(3, false).getString()); + assertEquals(DyeColor.BLUE, sign.getFrontText().getColor()); + } + + @Test + public void legacySpawnerPayloadLoadsNamespacedEntity() throws Exception { + ModdedTileData tile = legacyTile(out -> { + out.writeShort(1); + out.writeUTF("minecraft:zombie"); + }); + SpawnerBlockEntity spawner = new SpawnerBlockEntity(BlockPos.ZERO, Blocks.SPAWNER.defaultBlockState()); + + spawner.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, tile.payload())); + CompoundTag saved = spawner.saveWithoutMetadata(registries); + + assertTrue(saved.toString().contains("minecraft:zombie")); + } + + @Test + public void legacySpawnerOrdinalUsesPaperEntityTypeOrdering() throws Exception { + ModdedTileData tile = legacyTile(out -> { + out.writeShort(1); + out.writeShort(28); + }); + SpawnerBlockEntity spawner = new SpawnerBlockEntity(BlockPos.ZERO, Blocks.SPAWNER.defaultBlockState()); + + spawner.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, tile.payload())); + CompoundTag saved = spawner.saveWithoutMetadata(registries); + + assertTrue(saved.toString().contains("minecraft:command_block_minecart")); + } + + @Test + public void invalidLegacySpawnerOrdinalFallsBackToPig() throws Exception { + ModdedTileData tile = legacyTile(out -> { + out.writeShort(1); + out.writeShort(-1); + }); + SpawnerBlockEntity spawner = new SpawnerBlockEntity(BlockPos.ZERO, Blocks.SPAWNER.defaultBlockState()); + + spawner.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, tile.payload())); + CompoundTag saved = spawner.saveWithoutMetadata(registries); + + assertTrue(saved.toString().contains("minecraft:pig")); + } + + @Test + public void legacyLootablePayloadLoadsTableAndSeed() throws Exception { + ModdedTileData tile = legacyTile(out -> { + out.writeShort(3); + out.writeUTF("minecraft:chest"); + out.writeUTF("minecraft:chests/simple_dungeon"); + out.writeLong(4123L); + }); + ChestBlockEntity chest = new ChestBlockEntity(BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); + + chest.loadWithComponents(TagValueInput.create(ProblemReporter.DISCARDING, registries, tile.payload())); + RandomizableContainer lootable = chest; + + assertNotNull(lootable.getLootTable()); + assertEquals("minecraft:chests/simple_dungeon", lootable.getLootTable().identifier().toString()); + assertEquals(4123L, lootable.getLootTableSeed()); + } + + @Test + public void legacyBannerBaseColorChangesTheGeneratedBlockState() throws Exception { + ModdedTileData tile = legacyTile(out -> { + out.writeShort(2); + out.writeByte(DyeColor.RED.getId()); + out.writeByte(0); + }); + + assertEquals( + BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:red_banner")), + tile.adjustBlockState(BuiltInRegistries.BLOCK.getValue(Identifier.parse("minecraft:white_banner")).defaultBlockState()).getBlock()); + assertEquals(Blocks.BARREL, tile.adjustBlockState(Blocks.BARREL.defaultBlockState()).getBlock()); + } + + @Test + public void paper26_2LegacyBannerFirstOrdinalIsSmallStripes() { + assertEquals( + Identifier.parse("minecraft:small_stripes"), + ModdedTileReader.legacyBannerPatternKey(0)); + } + + @Test + public void paper26_2LegacyBannerMiddleOrdinalIsStripeLeft() { + assertEquals( + Identifier.parse("minecraft:stripe_left"), + ModdedTileReader.legacyBannerPatternKey(21)); + } + + @Test + public void paper26_2LegacyBannerLastOrdinalIsHalfVerticalRight() { + assertEquals( + Identifier.parse("minecraft:half_vertical_right"), + ModdedTileReader.legacyBannerPatternKey(42)); + } + + @Test + public void invalidLegacyBannerOrdinalFallsBackToBase() { + assertEquals(Identifier.parse("minecraft:base"), ModdedTileReader.legacyBannerPatternKey(-1)); + assertEquals(Identifier.parse("minecraft:base"), ModdedTileReader.legacyBannerPatternKey(43)); + } + + @Test + public void modernPackTilePropertiesUseTheModdedFactoryAndLoadNatively() throws Exception { + KMap properties = new KMap<>(); + properties.put("LootTable", "minecraft:chests/abandoned_mineshaft"); + properties.put("LootTableSeed", 9124L); + TileData tile = TileData.of(ModdedBlockState.of(Blocks.CHEST.defaultBlockState(), null), properties); + + assertTrue(tile instanceof ModdedTileData); + ChestBlockEntity chest = new ChestBlockEntity(BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); + chest.loadWithComponents(TagValueInput.create( + ProblemReporter.DISCARDING, registries, ((ModdedTileData) tile).payload())); + + assertNotNull(chest.getLootTable()); + assertEquals("minecraft:chests/abandoned_mineshaft", chest.getLootTable().identifier().toString()); + assertEquals(9124L, chest.getLootTableSeed()); + assertTrue(((ModdedTileData) tile).isApplicable(Blocks.CHEST.defaultBlockState(), chest)); + assertFalse(((ModdedTileData) tile).isApplicable( + Blocks.BARREL.defaultBlockState(), + new BarrelBlockEntity(BlockPos.ZERO, Blocks.BARREL.defaultBlockState()))); + } + + @Test + public void legacyTileFamiliesRejectUnrelatedBlockEntities() throws Exception { + ModdedTileData tile = legacyTile(out -> { + out.writeShort(0); + out.writeUTF("one"); + out.writeUTF("two"); + out.writeUTF("three"); + out.writeUTF("four"); + out.writeByte(DyeColor.BLACK.getId()); + }); + SignBlockEntity sign = new SignBlockEntity(BlockPos.ZERO, Blocks.OAK_SIGN.defaultBlockState()); + ChestBlockEntity chest = new ChestBlockEntity(BlockPos.ZERO, Blocks.CHEST.defaultBlockState()); + + assertTrue(tile.isApplicable(Blocks.OAK_SIGN.defaultBlockState(), sign)); + assertFalse(tile.isApplicable(Blocks.CHEST.defaultBlockState(), chest)); + } + + private static ModdedTileData legacyTile(TileWriter writer) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bytes)) { + writer.write(out); + } + TileData tile = TileData.read(new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); + assertTrue(tile instanceof ModdedTileData); + return (ModdedTileData) tile; + } + + @FunctionalInterface + private interface TileWriter { + void write(DataOutputStream out) throws Exception; + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java index 3c94f2272..c04143942 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldCheckTest.java @@ -1,5 +1,8 @@ package art.arcane.iris.modded; +import net.minecraft.resources.Identifier; +import net.minecraft.world.level.ChunkPos; +import net.minecraft.world.level.levelgen.structure.BoundingBox; import org.junit.Test; import java.util.ArrayList; @@ -21,38 +24,224 @@ public class ModdedWorldCheckTest { } @Test - public void passStopsServerBeforeZeroExit() { + public void validStructureStartIsGenerationEvidence() { + assertTrue(ModdedWorldCheck.hasNativeStructureEvidence(true, 0)); + } + + @Test + public void structureReferenceIsGenerationEvidence() { + assertTrue(ModdedWorldCheck.hasNativeStructureEvidence(false, 1)); + } + + @Test + public void absentStartAndReferencesFailGenerationEvidence() { + assertFalse(ModdedWorldCheck.hasNativeStructureEvidence(false, 0)); + } + + @Test + public void characteristicMaterialsCoverEveryNativeStructureFamily() { + assertTrue(characteristic("stronghold", "minecraft:stronghold", "minecraft:cracked_stone_bricks")); + assertTrue(characteristic("trial_chambers", "minecraft:trial_chambers", "minecraft:oxidized_copper_grate")); + assertTrue(characteristic("mansion", "minecraft:mansion", "minecraft:dark_oak_planks")); + assertTrue(characteristic("mansion", "minecraft:mansion", "minecraft:birch_planks")); + assertTrue(characteristic("village", "minecraft:village_plains", "minecraft:oak_planks")); + assertTrue(characteristic("village", "minecraft:village_desert", "minecraft:cut_sandstone")); + assertTrue(characteristic("village", "minecraft:village_savanna", "minecraft:acacia_stairs")); + assertTrue(characteristic("village", "minecraft:village_snowy", "minecraft:spruce_planks")); + assertTrue(characteristic("village", "minecraft:village_snowy", "minecraft:stripped_spruce_log")); + assertTrue(characteristic("village", "minecraft:village_taiga", "minecraft:cobblestone")); + assertTrue(characteristic("monument", "minecraft:monument", "minecraft:dark_prismarine")); + } + + @Test + public void naturalTerrainAndWrongVillageWoodAreNotCharacteristic() { + assertFalse(characteristic("stronghold", "minecraft:stronghold", "minecraft:stone")); + assertFalse(characteristic("trial_chambers", "minecraft:trial_chambers", "minecraft:tuff")); + assertFalse(characteristic("mansion", "minecraft:mansion", "minecraft:dark_oak_leaves")); + assertFalse(characteristic("village", "minecraft:village_desert", "minecraft:sandstone")); + assertFalse(characteristic("village", "minecraft:village_savanna", "minecraft:oak_planks")); + assertFalse(characteristic("monument", "minecraft:monument", "minecraft:water")); + } + + @Test + public void materialEvidenceMustExist() { + assertFalse(ModdedWorldCheck.hasCharacteristicMaterialEvidence(0, 0, 1)); + assertFalse(ModdedWorldCheck.hasCharacteristicMaterialEvidence(8, 0, 1)); + assertFalse(ModdedWorldCheck.hasCharacteristicMaterialEvidence(8, 1, 0)); + assertFalse(ModdedWorldCheck.hasCharacteristicMaterialEvidence(8, 2, 1)); + } + + @Test + public void singleChunkStructureAcceptsMaterialInItsOnlyChunk() { + assertTrue(ModdedWorldCheck.hasCharacteristicMaterialEvidence(8, 1, 1)); + } + + @Test + public void multiChunkStructureRejectsMaterialConfinedToOneChunk() { + assertFalse(ModdedWorldCheck.hasCharacteristicMaterialEvidence(8, 1, 4)); + assertTrue(ModdedWorldCheck.hasCharacteristicMaterialEvidence(8, 2, 4)); + } + + @Test + public void configuredVerticalShiftRequiresSafetyClampedGenerationEvidence() { + assertTrue(ModdedWorldCheck.verticalShiftMatches(0, null, -32, 20, -64, 320)); + assertFalse(ModdedWorldCheck.verticalShiftMatches(0, null, -112, -80, -64, 320)); + assertTrue(ModdedWorldCheck.verticalShiftMatches(0, 0, -32, 20, -64, 320)); + assertTrue(ModdedWorldCheck.verticalShiftMatches(0, 48, -64, -32, -64, 320)); + assertTrue(ModdedWorldCheck.verticalShiftMatches(-64, -64, -48, 4, -64, 320)); + assertTrue(ModdedWorldCheck.verticalShiftMatches(-64, -16, -64, -12, -64, 320)); + assertFalse(ModdedWorldCheck.verticalShiftMatches(-64, null, -48, 4, -64, 320)); + assertFalse(ModdedWorldCheck.verticalShiftMatches(-64, -15, -63, -11, -64, 320)); + assertFalse(ModdedWorldCheck.verticalShiftMatches(0, -1, -33, 19, -64, 320)); + } + + @Test + public void mansionVegetationGateRejectsRemainingLeaves() { + assertTrue(ModdedWorldCheck.mansionVegetationPass(0)); + assertFalse(ModdedWorldCheck.mansionVegetationPass(1)); + } + + @Test + public void mansionVegetationAuditIgnoresTemplateBlocksAndRejectsVegetationAbovePieces() { + assertFalse(ModdedWorldCheck.mansionVegetationAbovePiece(true, 80, 80)); + assertFalse(ModdedWorldCheck.mansionVegetationAbovePiece(true, 79, 80)); + assertTrue(ModdedWorldCheck.mansionVegetationAbovePiece(true, 81, 80)); + assertFalse(ModdedWorldCheck.mansionVegetationAbovePiece(false, 81, 80)); + } + + @Test + public void villageFoundationGateRejectsUnsupportedColumns() { + assertTrue(ModdedWorldCheck.villageFoundationPass(0)); + assertFalse(ModdedWorldCheck.villageFoundationPass(1)); + } + + @Test + public void villageFoundationAuditRejectsMissingBaseAndInvalidSupport() { + assertTrue(ModdedWorldCheck.villageFoundationSupported(true, false, true, false)); + assertTrue(ModdedWorldCheck.villageFoundationSupported(true, true, false, false)); + assertFalse(ModdedWorldCheck.villageFoundationSupported(false, false, true, false)); + assertFalse(ModdedWorldCheck.villageFoundationSupported(true, false, false, false)); + assertFalse(ModdedWorldCheck.villageFoundationSupported(true, false, true, true)); + } + + @Test + public void villagePoiGateRequiresInBoundsPoiWithoutOutOfBoundsRecords() { + assertTrue(ModdedWorldCheck.villagePoiPass(1, 0)); + assertFalse(ModdedWorldCheck.villagePoiPass(0, 0)); + assertFalse(ModdedWorldCheck.villagePoiPass(1, 1)); + } + + @Test + public void smallStructureFootprintIncludesEveryChunk() { + BoundingBox bounds = new BoundingBox(-16, -20, -16, 31, 120, 31); + + List chunks = ModdedWorldCheck.boundedFootprintChunks(bounds, ChunkPos.ZERO, 96); + + assertEquals(9, chunks.size()); + assertTrue(chunks.contains(new ChunkPos(-1, -1))); + assertTrue(chunks.contains(new ChunkPos(1, 1))); + } + + @Test + public void largeStructureFootprintIsBoundedAndSamplesEdges() { + BoundingBox bounds = new BoundingBox(-512, -64, -512, 511, 300, 511); + + List chunks = ModdedWorldCheck.boundedFootprintChunks(bounds, ChunkPos.ZERO, 20); + + assertTrue(chunks.size() <= 20); + assertTrue(chunks.size() >= 16); + assertTrue(chunks.contains(ChunkPos.ZERO)); + assertTrue(chunks.contains(new ChunkPos(-32, -32))); + assertTrue(chunks.contains(new ChunkPos(31, 31))); + } + + @Test + public void qaEventsEscapeStructuredValues() { + String event = ModdedWorldCheck.qaEventJson("locate\"", "village\n", false, "x\\y\t"); + + assertEquals("QA_EVT {\"event\":\"locate\\\"\",\"structure\":\"village\\n\"," + + "\"pass\":false,\"detail\":\"x\\\\y\\t\"}", event); + } + + @Test + public void passRequestsStopAfterValidation() { List events = new ArrayList<>(); - ModdedWorldCheck.stopAndExit( - () -> events.add("stop"), + int status = ModdedWorldCheck.runAndRequestStop( + () -> { + events.add("check"); + return true; + }, + () -> events.add("request-stop") + ); + + assertEquals(0, status); + assertEquals(List.of("check", "request-stop"), events); + } + + @Test + public void failureStillRequestsStop() { + List events = new ArrayList<>(); + + int status = ModdedWorldCheck.runAndRequestStop( + () -> { + events.add("check"); + return false; + }, + () -> events.add("request-stop") + ); + + assertEquals(1, status); + assertEquals(List.of("check", "request-stop"), events); + } + + @Test + public void thrownCheckStillRequestsStop() { + AtomicBoolean stopRequested = new AtomicBoolean(false); + + int status = ModdedWorldCheck.runAndRequestStop( + () -> { + throw new IllegalStateException("check failed"); + }, + () -> stopRequested.set(true) + ); + + assertEquals(1, status); + assertTrue(stopRequested.get()); + } + + @Test + public void stopRequestFailureForcesNonzeroResult() { + int status = ModdedWorldCheck.runAndRequestStop( + () -> true, + () -> { + throw new IllegalStateException("stop request failed"); + } + ); + + assertEquals(1, status); + } + + @Test + public void coordinatorAwaitsServerBeforeExit() { + List events = new ArrayList<>(); + + ModdedWorldCheck.awaitStopAndExit( + () -> events.add("await-stop"), 0, status -> events.add("exit:" + status) ); - assertEquals(List.of("stop", "exit:0"), events); + assertEquals(List.of("await-stop", "exit:0"), events); } @Test - public void failureStopsServerBeforeNonzeroExit() { - List events = new ArrayList<>(); - - ModdedWorldCheck.stopAndExit( - () -> events.add("stop"), - 1, - status -> events.add("exit:" + status) - ); - - assertEquals(List.of("stop", "exit:1"), events); - } - - @Test - public void shutdownFailureForcesNonzeroExit() { + public void shutdownWaitFailureForcesNonzeroExit() { AtomicInteger status = new AtomicInteger(-1); - ModdedWorldCheck.stopAndExit( + ModdedWorldCheck.awaitStopAndExit( () -> { - throw new IllegalStateException("shutdown failed"); + throw new IllegalStateException("shutdown wait failed"); }, 0, status::set @@ -68,7 +257,7 @@ public class ModdedWorldCheckTest { AtomicInteger status = new AtomicInteger(-1); Thread.currentThread().interrupt(); try { - ModdedWorldCheck.stopAndExit( + ModdedWorldCheck.awaitStopAndExit( () -> interruptedDuringStop.set(Thread.currentThread().isInterrupted()), 0, exitStatus -> { @@ -84,4 +273,9 @@ public class ModdedWorldCheckTest { Thread.interrupted(); } } + + private static boolean characteristic(String structureLabel, String structureKey, String blockKey) { + return ModdedWorldCheck.isCharacteristicMaterial(structureLabel, + Identifier.parse(structureKey), Identifier.parse(blockKey)); + } } diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldManagerParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldManagerParityTest.java new file mode 100644 index 000000000..537a52728 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedWorldManagerParityTest.java @@ -0,0 +1,20 @@ +package art.arcane.iris.modded; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedWorldManagerParityTest { + @Test + public void normalWorldAlwaysAllowsEntitySpawning() { + assertTrue(ModdedWorldManager.entitySpawningEnabled(false, false)); + assertTrue(ModdedWorldManager.entitySpawningEnabled(false, true)); + } + + @Test + public void studioWorldRequiresItsEntitySpawningSetting() { + assertFalse(ModdedWorldManager.entitySpawningEnabled(true, false)); + assertTrue(ModdedWorldManager.entitySpawningEnabled(true, true)); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java new file mode 100644 index 000000000..9cf0e9cb1 --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java @@ -0,0 +1,81 @@ +package art.arcane.iris.modded.command; + +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisModdedStructureCommandTest { + @Test + public void gotoStructureSupportsIrisAndNativeRegistryTargets() throws IOException { + String source = source("IrisModdedCommands.java"); + + assertTrue(source.contains("IrisStructureLocator.isPlaced(engine, key)")); + assertTrue(source.contains("registry.get(identifier)")); + assertTrue(source.contains("getPlacementsForStructure(holder)")); + assertTrue(source.contains("generator.findNearestMapStructure(")); + assertTrue(source.contains("NATIVE_STRUCTURE_LOCATE_RADIUS = 100")); + assertTrue(source.contains("HolderSet.direct(target.holder())")); + assertTrue(source.contains("boolean teleported = player.teleportTo(")); + assertTrue(source.contains("combineStructureKeys(irisKeys, nativeKeys)")); + assertTrue(source.contains("irisGenerator.isNativeStructureReachable(holder)")); + assertTrue(source.contains("LocateStatus.SEARCH_LIMIT_REACHED")); + assertTrue(source.contains("the density search safety limit was reached")); + assertTrue(source.contains("int targetX = result.originX()")); + assertTrue(source.contains("int targetY = result.baseY() + 2")); + assertTrue(source.contains("int targetZ = result.originZ()")); + assertFalse(source.contains("at[0] + 8")); + assertFalse(source.contains("at[2] + 8")); + } + + @Test + public void generatorLocatePrefersIrisPlacementsAndRejectsDormantNativeStarts() throws IOException { + String source = moddedSource("IrisModdedChunkGenerator.java"); + + assertTrue(source.contains("public Pair> findNearestMapStructure(")); + assertTrue(source.contains("findNearestIrisStructure(")); + assertTrue(source.contains("filterReachableNativeStructures(")); + assertTrue(source.contains("IrisStructureLocator.suppressesVanilla(current, key)")); + assertTrue(source.contains("structureBiomeSource.isStructureReachable(holder)")); + assertTrue(source.contains("LocateStatus.SEARCH_LIMIT_REACHED")); + assertTrue(source.contains("new BlockPos(result.originX(), result.baseY(), result.originZ())")); + } + + @Test + public void structureVerificationNoLongerClaimsNativeGenerationIsMissing() throws IOException { + String source = source("ModdedStructureCommands.java"); + + assertTrue(source.contains("verifyTree(\"verify\")")); + assertTrue(source.contains("verifyTree(\"locateall\")")); + assertTrue(source.contains("IrisModdedCommands.verifyStructures(")); + assertFalse(source.contains("Vanilla structure locate is meaningless here")); + } + + private String source(String fileName) throws IOException { + Path source = commonSourceRoot().resolve("art/arcane/iris/modded/command/") + .resolve(fileName) + .normalize(); + return Files.readString(source); + } + + private String moddedSource(String fileName) throws IOException { + Path source = commonSourceRoot().resolve("art/arcane/iris/modded/") + .resolve(fileName) + .normalize(); + return Files.readString(source); + } + + private Path commonSourceRoot() { + String configuredRoot = System.getProperty("iris.moddedCommonSources"); + if (configuredRoot != null && !configuredRoot.isBlank()) { + return Path.of(configuredRoot); + } + return Path.of(System.getProperty("user.dir")) + .resolve("../modded-common/src/main/java") + .normalize(); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java new file mode 100644 index 000000000..298fdbf7a --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedStudioHotloadServiceTest.java @@ -0,0 +1,29 @@ +package art.arcane.iris.modded.service; + +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ModdedStudioHotloadServiceTest { + @Test + public void detectsDatapackImportsAcrossLoadedDimensions() { + IrisDimension empty = new IrisDimension(); + IrisDimension imported = new IrisDimension(); + imported.setDatapackImports(new KList().qadd("https://modrinth.com/datapack/example")); + + assertTrue(ModdedStudioHotloadService.hasDatapackImports(List.of(empty, imported))); + } + + @Test + public void rejectsMissingOrEmptyDatapackImports() { + IrisDimension empty = new IrisDimension(); + + assertFalse(ModdedStudioHotloadService.hasDatapackImports(null)); + assertFalse(ModdedStudioHotloadService.hasDatapackImports(List.of(empty))); + } +} diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 59f550a8e..b2105616f 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -30,8 +30,8 @@ Properties rootProperties = new Properties() file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.0.0-26.2')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2')) -String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.6-beta')) -String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1')) +String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.12-beta')) +String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:4c38988b344792a79f925f57f8a675bc85fc1bed')) Closure irisArtifactName = { String platform, String targetVersion -> return "Iris v${project.version} [${platform}] ${targetVersion}.jar" } @@ -49,12 +49,18 @@ sourceSets { main { java { srcDir '../modded-common/src/main/java' + srcDir '../minecraft-common/src/main/java' srcDir '../client-common/src/main/java' } resources { srcDir '../modded-common/src/main/resources' } } + test { + java { + srcDir '../modded-common/src/test/java' + } + } } repositories { @@ -94,30 +100,39 @@ configurations.named('bundle').configure { configurations.compileClasspath.extendsFrom(configurations.devBundle) configurations.runtimeClasspath.extendsFrom(configurations.devBundle) +configurations.testCompileClasspath.extendsFrom(configurations.compileClasspath, configurations.devBundle) +configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath, configurations.devBundle) dependencies { + testImplementation('junit:junit:4.13.2') + testRuntimeOnly('org.junit.platform:junit-platform-launcher:6.1.2') + testRuntimeOnly('org.junit.vintage:junit-vintage-engine:6.1.2') compileOnly('org.slf4j:slf4j-api:2.0.17') compileOnly(libs.spigot) { transitive = false } + add('bundle', volmLibCoordinate) { + transitive = false + } + add('devBundle', volmLibCoordinate) { + transitive = false + } + add('bundle', libs.zip) { + transitive = false + } + add('devBundle', libs.zip) { + transitive = false + } List shared = [ "art.arcane:core:${irisVersion}".toString(), "art.arcane:spi:${irisVersion}".toString(), - volmLibCoordinate, libs.paralithic, libs.lru, - libs.kotlin.stdlib, - libs.kotlin.coroutines, - libs.commons.lang, - libs.commons.math3, libs.caffeine, - libs.lz4, - libs.zip, - libs.sentry, - libs.oshi, - libs.byteBuddy.core, - libs.byteBuddy.agent + libs.dom4j, + libs.jaxen, + libs.sentry ] shared.each { Object notation -> add('bundle', notation) @@ -159,6 +174,11 @@ neoForge { sourceSet(sourceSets.main) } } + + unitTest { + enable() + testedMod = mods.irisworldgen + } } tasks.named('compileJava', JavaCompile).configure { @@ -166,6 +186,11 @@ tasks.named('compileJava', JavaCompile).configure { options.release.set(25) } +tasks.named('test').configure { + useJUnitPlatform() + systemProperty('iris.moddedCommonSources', file('../modded-common/src/main/java').absolutePath) +} + processResources { inputs.property('version', project.version) inputs.property('minecraftVersion', minecraftVersion) diff --git a/adapters/neoforge/logs/latest.log b/adapters/neoforge/logs/latest.log new file mode 100644 index 000000000..bb273f245 --- /dev/null +++ b/adapters/neoforge/logs/latest.log @@ -0,0 +1,141 @@ +[15:22:44] [Test worker/ERROR]: [worldcheck] server stop request failed +java.lang.IllegalStateException: stop request failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$stopRequestFailureForcesNonzeroResult$1(ModdedWorldCheckTest.java:169) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:165) + at art.arcane.iris.modded.ModdedWorldCheckTest.stopRequestFailureForcesNonzeroResult(ModdedWorldCheckTest.java:166) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[15:22:44] [Test worker/ERROR]: [worldcheck] waiting for server shutdown failed +java.lang.IllegalStateException: shutdown wait failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$shutdownWaitFailureForcesNonzeroExit$0(ModdedWorldCheckTest.java:195) + at art.arcane.iris.modded.ModdedWorldCheck.awaitStopAndExit(ModdedWorldCheck.java:180) + at art.arcane.iris.modded.ModdedWorldCheckTest.shutdownWaitFailureForcesNonzeroExit(ModdedWorldCheckTest.java:193) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) +[15:22:44] [Test worker/ERROR]: [worldcheck] check failed +java.lang.IllegalStateException: check failed + at art.arcane.iris.modded.ModdedWorldCheckTest.lambda$thrownCheckStillRequestsStop$0(ModdedWorldCheckTest.java:155) + at art.arcane.iris.modded.ModdedWorldCheck.runAndRequestStop(ModdedWorldCheck.java:160) + at art.arcane.iris.modded.ModdedWorldCheckTest.thrownCheckStillRequestsStop(ModdedWorldCheckTest.java:153) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59) + at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12) + at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56) + at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100) + at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103) + at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63) + at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331) + at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79) + at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329) + at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66) + at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293) + at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306) + at org.junit.runners.ParentRunner.run(ParentRunner.java:413) + at org.junit.runner.JUnitCore.run(JUnitCore.java:137) + at org.junit.runner.JUnitCore.run(JUnitCore.java:115) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.runRequest(JUnitTestExecutor.java:175) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:84) + at org.gradle.api.internal.tasks.testing.junit.JUnitTestExecutor.accept(JUnitTestExecutor.java:47) + at org.gradle.api.internal.tasks.testing.junit.AbstractJUnitTestDefinitionProcessor.processTestDefinition(AbstractJUnitTestDefinitionProcessor.java:65) + at org.gradle.api.internal.tasks.testing.SuiteTestDefinitionProcessor.processTestDefinition(SuiteTestDefinitionProcessor.java:53) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.gradle.internal.dispatch.MethodInvocation.invokeOn(MethodInvocation.java:77) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:28) + at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:19) + at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33) + at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:88) + at jdk.proxy1/jdk.proxy1.$Proxy4.processTestDefinition(Unknown Source) + at org.gradle.api.internal.tasks.testing.worker.TestWorker$2.run(TestWorker.java:178) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:126) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:103) + at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:63) + at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:122) + at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:72) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69) + at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74) diff --git a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java index 9b30fe4a9..b342e84a3 100644 --- a/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java +++ b/adapters/neoforge/src/main/java/art/arcane/iris/neoforge/IrisNeoForgeBootstrap.java @@ -19,15 +19,19 @@ package art.arcane.iris.neoforge; import art.arcane.iris.modded.IrisModdedChunkGenerator; -import art.arcane.iris.modded.ModdedDeathLoot; +import art.arcane.iris.modded.ModdedBlockBreakHandler; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.command.IrisModdedCommands; import art.arcane.iris.modded.command.ModdedWandService; import com.mojang.serialization.MapCodec; import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerLevel; import net.minecraft.server.packs.PackType; import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.chunk.ChunkGenerator; import net.neoforged.api.distmarker.Dist; import net.neoforged.bus.api.IEventBus; @@ -37,8 +41,12 @@ import net.neoforged.fml.loading.FMLLoader; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.AddPackFindersEvent; import net.neoforged.neoforge.event.RegisterCommandsEvent; -import net.neoforged.neoforge.event.entity.living.LivingDropsEvent; import net.neoforged.neoforge.event.entity.player.PlayerInteractEvent; +import net.neoforged.neoforge.event.level.BlockDropsEvent; +import net.neoforged.neoforge.event.level.LevelEvent; +import net.neoforged.neoforge.event.level.block.BreakBlockEvent; +import net.neoforged.neoforge.event.server.ServerAboutToStartEvent; +import net.neoforged.neoforge.event.server.ServerStartedEvent; import net.neoforged.neoforge.event.server.ServerStartingEvent; import net.neoforged.neoforge.event.server.ServerStoppingEvent; import net.neoforged.neoforge.event.tick.ServerTickEvent; @@ -65,10 +73,37 @@ public final class IrisNeoForgeBootstrap { IrisNeoForgeClient.init(modBus); } + NeoForge.EVENT_BUS.addListener((ServerAboutToStartEvent event) -> ModdedEngineBootstrap.serverAboutToStart(event.getServer())); NeoForge.EVENT_BUS.addListener((ServerStartingEvent event) -> ModdedEngineBootstrap.start(event.getServer())); + NeoForge.EVENT_BUS.addListener((ServerStartedEvent event) -> ModdedEngineBootstrap.serverStarted(event.getServer())); NeoForge.EVENT_BUS.addListener((ServerStoppingEvent event) -> ModdedEngineBootstrap.stop()); + NeoForge.EVENT_BUS.addListener((LevelEvent.Load event) -> { + if (event.getLevel() instanceof ServerLevel level) { + ModdedEngineBootstrap.levelLoaded(level); + } + }); NeoForge.EVENT_BUS.addListener((RegisterCommandsEvent event) -> IrisModdedCommands.register(event.getDispatcher())); - NeoForge.EVENT_BUS.addListener((LivingDropsEvent event) -> ModdedDeathLoot.handle(event.getEntity())); + NeoForge.EVENT_BUS.addListener((BreakBlockEvent event) -> { + if (event.getLevel() instanceof ServerLevel level && !event.isCanceled()) { + ModdedBlockBreakHandler.prepare(level, event.getPos(), event.getState()); + } + }); + NeoForge.EVENT_BUS.addListener((BlockDropsEvent event) -> { + if (!(event.getBreaker() instanceof Player)) { + return; + } + ServerLevel level = event.getLevel(); + ModdedBlockBreakHandler.Result result = ModdedBlockBreakHandler.complete(level, event.getPos(), event.getState()); + if (result.replaceVanillaDrops()) { + event.getDrops().clear(); + } + for (ItemStack stack : result.drops()) { + ItemEntity item = ModdedBlockBreakHandler.createDrop(level, event.getPos(), stack); + if (item != null) { + event.getDrops().add(item); + } + } + }); NeoForge.EVENT_BUS.addListener((PlayerInteractEvent.LeftClickBlock event) -> { if (ModdedWandService.attackBlock(event.getEntity(), event.getLevel(), event.getHand(), event.getPos())) { event.setCanceled(true); diff --git a/adapters/neoforge/src/main/resources/META-INF/neoforge.mods.toml b/adapters/neoforge/src/main/resources/META-INF/neoforge.mods.toml index c9d115b67..61728f775 100644 --- a/adapters/neoforge/src/main/resources/META-INF/neoforge.mods.toml +++ b/adapters/neoforge/src/main/resources/META-INF/neoforge.mods.toml @@ -2,6 +2,9 @@ modLoader = "javafml" loaderVersion = "[3,)" license = "GPL-3.0" +[[mixins]] +config = "irisworldgen.entity.mixins.json" + [[mods]] modId = "irisworldgen" version = "${version}" diff --git a/build.gradle b/build.gradle index d7d1d2269..366efb58e 100644 --- a/build.gradle +++ b/build.gradle @@ -49,10 +49,10 @@ project(':core') { } String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse('26.2') String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse('0.19.3') -String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.0.0') -String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.6-beta') +String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse('26.2-65.0.4') +String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse('26.2.0.12-beta') String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1') + .orElse('com.github.VolmitSoftware:VolmLib:4c38988b344792a79f925f57f8a675bc85fc1bed') .get() String useLocalVolmLib = providers.gradleProperty('useLocalVolmLib').getOrElse('true') String localVolmLibDirectory = providers.gradleProperty('localVolmLibDirectory').getOrNull() @@ -95,6 +95,8 @@ nmsBindings.each { key, value -> extensions.extraProperties.set('nms', nmsConfig) plugins.apply(NMSBinding) + sourceSets.main.java.srcDir(rootProject.file('adapters/minecraft-common/src/main/java')) + dependencies { compileOnly(project(':core')) compileOnly(volmLibCoordinate) { @@ -102,6 +104,14 @@ nmsBindings.each { key, value -> } compileOnly(rootProject.libs.annotations) compileOnly(rootProject.libs.byteBuddy.core) + testImplementation('junit:junit:4.13.2') + } + + tasks.withType(org.gradle.api.tasks.testing.Test).configureEach { + systemProperty('iris.nmsChunkGeneratorSource', + rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/IrisChunkGenerator.java").absolutePath) + systemProperty('iris.nativeStructurePostProcessorSource', + rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java').absolutePath) } } } @@ -143,6 +153,9 @@ tasks.register('buildBukkit', Copy) { from(layout.buildDirectory.file("libs/${bukkitArtifactName}")) into(layout.projectDirectory.dir('dist')) doFirst { + delete(fileTree(layout.projectDirectory.dir('dist')) { + include('Iris v* [CraftBukkit] *.jar') + }) delete(layout.projectDirectory.file("dist/Iris-${project.version}+mc${minecraftVersion}.jar")) } } @@ -173,6 +186,9 @@ tasks.register('buildFabric', Copy) { from(layout.projectDirectory.file("adapters/fabric/build/libs/${fabricArtifactName}")) into(layout.projectDirectory.dir('dist')) doFirst { + delete(fileTree(layout.projectDirectory.dir('dist')) { + include('Iris v* [Fabric] *.jar') + }) delete(layout.projectDirectory.file("dist/Iris-${project.version}+mc${minecraftVersion}-fabric.jar")) } } @@ -203,6 +219,9 @@ tasks.register('buildForge', Copy) { from(layout.projectDirectory.file("adapters/forge/build/libs/${forgeArtifactName}")) into(layout.projectDirectory.dir('dist')) doFirst { + delete(fileTree(layout.projectDirectory.dir('dist')) { + include('Iris v* [Forge] *.jar') + }) delete(layout.projectDirectory.file("dist/Iris-${project.version}+mc${minecraftVersion}-forge.jar")) } } @@ -233,6 +252,9 @@ tasks.register('buildNeoforge', Copy) { from(layout.projectDirectory.file("adapters/neoforge/build/libs/${neoForgeArtifactName}")) into(layout.projectDirectory.dir('dist')) doFirst { + delete(fileTree(layout.projectDirectory.dir('dist')) { + include('Iris v* [NeoForge] *.jar') + }) delete(layout.projectDirectory.file("dist/Iris-${project.version}+mc${minecraftVersion}-neoforge.jar")) } } @@ -382,7 +404,8 @@ allprojects { maven { url = uri('https://repo.papermc.io/repository/maven-public/') } maven { url = uri('https://repo.codemc.org/repository/maven-public/') } - maven { url = uri('https://jitpack.io') } // EcoItems, score + maven { url = uri('https://jitpack.io') } // score + maven { url = uri('https://repo.auxilor.io/repository/maven-releases/') } // eco maven { url = uri('https://repo.nexomc.com/releases/') } // nexo maven { url = uri('https://maven.devs.beer/') } // itemsadder maven { url = uri('https://repo.extendedclip.com/releases/') } // placeholderapi @@ -392,12 +415,6 @@ allprojects { maven { url = uri('https://repo.momirealms.net/releases/') } // CraftEngine } - dependencies { - // Provided or Classpath - compileOnly(rootProject.libs.lombok) - annotationProcessor(rootProject.libs.lombok) - } - /** * We need parameter meta for the decree command system */ diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 45099a76e..56f3c26d6 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -19,7 +19,7 @@ repositories { } dependencies { - implementation('org.ow2.asm:asm:9.8') + implementation('org.ow2.asm:asm:9.10.1') implementation('com.github.VolmitSoftware:NMSTools:c88961416f') implementation('io.papermc.paperweight:paperweight-userdev:2.0.0-beta.21') } diff --git a/core/build.gradle b/core/build.gradle index d7f7a21e5..3dca928d9 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,14 +1,13 @@ import io.github.slimjar.resolver.data.Mirror -import org.ajoberstar.grgit.Grgit import org.gradle.api.Task import org.gradle.api.tasks.Copy import org.gradle.api.tasks.TaskProvider import org.gradle.api.tasks.compile.JavaCompile import org.gradle.jvm.tasks.Jar import org.gradle.jvm.toolchain.JavaLanguageVersion -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.net.URI +import java.nio.charset.StandardCharsets /* * Iris is a World Generator for Minecraft Bukkit Servers @@ -34,14 +33,11 @@ plugins { alias(libs.plugins.shadow) alias(libs.plugins.sentry) alias(libs.plugins.slimjar) - alias(libs.plugins.grgit) - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.lombok) } def lib = 'art.arcane.iris.util' String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1') + .orElse('com.github.VolmitSoftware:VolmLib:4c38988b344792a79f925f57f8a675bc85fc1bed') .get() String sentryAuthToken = findProperty('sentry.auth.token') as String ?: System.getenv('SENTRY_AUTH_TOKEN') boolean hasSentryAuthToken = sentryAuthToken != null && !sentryAuthToken.isBlank() @@ -62,19 +58,22 @@ dependencies { api(project(':spi')) // Provided or Classpath + compileOnly(libs.lombok) + annotationProcessor(libs.lombok) compileOnly(libs.paper.api) compileOnly(libs.log4j.api) compileOnly(libs.log4j.core) + compileOnly(libs.guava) // Third Party Integrations compileOnly(libs.nexo) compileOnly(libs.itemsadder) - compileOnly(libs.placeholderApi) compileOnly(libs.score) compileOnly(libs.mmoitems) - compileOnly(libs.ecoitems) + compileOnly(libs.mythiclib) + compileOnly(libs.eco) compileOnly(libs.mythic) - compileOnly(libs.mythicChrucible) + compileOnly(libs.mythicCrucible) compileOnly(libs.kgenerators) { transitive = false } @@ -101,25 +100,21 @@ dependencies { slim(libs.sentry) slim(libs.commons.io) - slim(libs.commons.lang) slim(libs.commons.lang3) - slim(libs.commons.math3) slim(libs.oshi) slim(libs.lz4) slim(libs.fastutil) - slim(libs.zip) + slim(libs.zip) { + exclude(group: 'org.slf4j', module: 'slf4j-api') + } slim(libs.asm) slim(libs.byteBuddy.core) slim(libs.byteBuddy.agent) slim(libs.dom4j) slim(libs.jaxen) - // Script Engine - slim(libs.kotlin.stdlib) - slim(libs.kotlin.coroutines) - testImplementation('junit:junit:4.13.2') - testImplementation('org.mockito:mockito-core:5.16.1') + testImplementation('org.mockito:mockito-core:5.23.0') testImplementation(libs.paper.api) testRuntimeOnly(libs.paper.api) } @@ -130,13 +125,6 @@ java { } } -kotlin { - jvmToolchain(25) - compilerOptions { - jvmTarget.set(JvmTarget.fromTarget('25')) - } -} - sentry { url = 'http://sentry.volmit.com:8080' autoInstallation.enabled = false @@ -188,7 +176,16 @@ def generateTemplates = tasks.register('generateTemplates', Copy) { String commitId = null Exception failure = null try { - commitId = project.extensions.getByType(Grgit).head().id + Process process = new ProcessBuilder('git', '-C', rootProject.projectDir.absolutePath, 'rev-parse', 'HEAD') + .redirectErrorStream(true) + .start() + String output = new String(process.inputStream.readAllBytes(), StandardCharsets.UTF_8).trim() + int exitCode = process.waitFor() + if (exitCode == 0) { + commitId = output + } else { + failure = new GradleException("git rev-parse exited with code ${exitCode}: ${output}") + } } catch (Exception ex) { failure = ex } diff --git a/core/purity-allowlist.txt b/core/purity-allowlist.txt index fe8deeb95..0d1ad24b0 100644 --- a/core/purity-allowlist.txt +++ b/core/purity-allowlist.txt @@ -17,7 +17,6 @@ art/arcane/iris/core/lifecycle/WorldLifecycleService.java art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java art/arcane/iris/core/lifecycle/WorldLifecycleSupport.java art/arcane/iris/core/lifecycle/WorldsProviderBackend.java -art/arcane/iris/core/link/CustomItemsDataProvider.java art/arcane/iris/core/link/ExternalDataProvider.java art/arcane/iris/core/link/Identifier.java art/arcane/iris/core/link/MultiverseCoreLink.java @@ -25,7 +24,6 @@ art/arcane/iris/core/link/WorldEditLink.java art/arcane/iris/core/link/data/CraftEngineDataProvider.java art/arcane/iris/core/link/data/EcoItemsDataProvider.java art/arcane/iris/core/link/data/ExecutableItemsDataProvider.java -art/arcane/iris/core/link/data/HMCLeavesDataProvider.java art/arcane/iris/core/link/data/ItemAdderDataProvider.java art/arcane/iris/core/link/data/KGeneratorsDataProvider.java art/arcane/iris/core/link/data/MMOItemsDataProvider.java diff --git a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java index 874ae3ace..a597718f6 100644 --- a/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java +++ b/core/src/main/java/art/arcane/iris/core/datapack/DatapackIngestService.java @@ -124,7 +124,7 @@ public final class DatapackIngestService { Manifest manifest = readManifest(root); boolean stripOverrides = resolveStripOverrides(); - message(sender, C.GRAY + "Ingesting " + C.WHITE + urls.size() + C.GRAY + " datapack import(s)" + (mcVersion == null ? "" : " for MC " + mcVersion) + (stripOverrides ? C.GRAY + " (datapackOverrides=false: vanilla-key overrides will be stripped)" : "") + "..."); + message(sender, C.GRAY + "Ingesting " + C.WHITE + urls.size() + C.GRAY + " datapack import(s)" + (mcVersion == null ? "" : " for MC " + mcVersion) + (stripOverrides ? C.GRAY + " (datapackOverrides=false: minecraft-namespaced structure overrides will be stripped)" : "") + "..."); for (String url : urls) { try { @@ -142,7 +142,7 @@ public final class DatapackIngestService { if (report.changed()) { message(sender, C.YELLOW + "New datapack structures were installed. A server restart is required for them to register and generate."); message(sender, C.GRAY + "After the restart their jigsaw pools, pieces & objects are imported automatically (set general.autoImportDatapackStructures=false to disable), or run /iris structure import to import everything on demand. Reference an imported key from a 'structures' placement to position it manually."); - message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys and generate their own structures by default; set the dimension 'importedStructures.datapackOverrides' to false to keep vanilla untouched and stop ALL datapack structures from generating - they stay importable for manual placement only."); + message(sender, C.GRAY + "Datapacks replace matching vanilla structure keys by default. Set 'importedStructures.datapackOverrides' to false to keep minecraft-namespaced structure definitions untouched; control non-minecraft datapack and mod structures with importedStructures.enabled/disabled."); if (restart) { ServerConfigurator.restart(); } else { diff --git a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java index 3de379158..b4ab75221 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java +++ b/core/src/main/java/art/arcane/iris/core/gui/PregeneratorJob.java @@ -36,8 +36,6 @@ import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.scheduling.ChronoLatch; import art.arcane.iris.util.common.scheduling.J; -import art.arcane.volmlib.util.bukkit.WorldIdentity; -import org.bukkit.generator.WorldInfo; import java.awt.Color; import java.util.concurrent.ExecutorService; @@ -218,14 +216,6 @@ public class PregeneratorJob implements PregenListener, PregenRenderSource { return engine.getWorld().name(); } - public boolean targetsWorld(WorldInfo world) { - if (world == null || engine == null || engine.getWorld() == null) { - return false; - } - - return WorldIdentity.key(world).equals(engine.getWorld().key()); - } - public boolean targetsWorldIdentity(String worldIdentity) { if (worldIdentity == null || engine == null || engine.getWorld() == null) { return false; diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java index 36999fe32..68c3f86da 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/WorldLifecycleStaging.java @@ -7,13 +7,11 @@ import org.jetbrains.annotations.Nullable; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicReference; public final class WorldLifecycleStaging { private static final Map stagedGenerators = new ConcurrentHashMap<>(); private static final Map stagedBiomeProviders = new ConcurrentHashMap<>(); private static final Map stagedStemGenerators = new ConcurrentHashMap<>(); - private static final AtomicReference pendingStemGenerator = new AtomicReference<>(); private WorldLifecycleStaging() { } @@ -29,7 +27,6 @@ public final class WorldLifecycleStaging { public static void stageStemGenerator(@NotNull String worldName, @NotNull ChunkGenerator generator) { stagedStemGenerators.put(worldName, generator); - pendingStemGenerator.set(generator); } @Nullable @@ -44,16 +41,7 @@ public final class WorldLifecycleStaging { @Nullable public static ChunkGenerator consumeStemGenerator(@NotNull String worldName) { - ChunkGenerator generator = stagedStemGenerators.remove(worldName); - if (generator != null) { - pendingStemGenerator.compareAndSet(generator, null); - return generator; - } - ChunkGenerator pending = pendingStemGenerator.getAndSet(null); - if (pending != null) { - stagedStemGenerators.values().remove(pending); - } - return pending; + return stagedStemGenerators.remove(worldName); } public static void clearGenerator(@NotNull String worldName) { @@ -62,10 +50,7 @@ public final class WorldLifecycleStaging { } public static void clearStem(@NotNull String worldName) { - ChunkGenerator generator = stagedStemGenerators.remove(worldName); - if (generator != null) { - pendingStemGenerator.compareAndSet(generator, null); - } + stagedStemGenerators.remove(worldName); } public static void clearAll(@NotNull String worldName) { diff --git a/core/src/main/java/art/arcane/iris/core/link/CustomItemsDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/CustomItemsDataProvider.java deleted file mode 100644 index 3f6cb9512..000000000 --- a/core/src/main/java/art/arcane/iris/core/link/CustomItemsDataProvider.java +++ /dev/null @@ -1,102 +0,0 @@ -//package art.arcane.iris.core.link; -// -//import com.jojodmo.customitems.api.CustomItemsAPI; -//import com.jojodmo.customitems.item.custom.CustomItem; -//import com.jojodmo.customitems.item.custom.block.CustomMushroomBlock; -//import com.jojodmo.customitems.version.SafeMaterial; -//import art.arcane.volmlib.util.collection.KList; -//import art.arcane.iris.util.common.reflect.WrappedField; -//import art.arcane.iris.util.common.reflect.WrappedReturningMethod; -//import org.bukkit.block.BlockFace; -//import org.bukkit.block.data.BlockData; -//import org.bukkit.block.data.MultipleFacing; -//import org.bukkit.inventory.ItemStack; -// -//import java.util.Map; -//import java.util.MissingResourceException; -// -//public class CustomItemsDataProvider extends ExternalDataProvider { -// -// private static final String FIELD_FACES = "faces"; -// private static final String METHOD_GET_MATERIAL = "getMaterial"; -// -// private WrappedField> mushroomFaces; -// private WrappedReturningMethod mushroomMaterial; -// -// public CustomItemsDataProvider() { -// super("CustomItems"); -// } -// -// @Override -// public void init() { -// this.mushroomFaces = new WrappedField<>(CustomMushroomBlock.class, FIELD_FACES); -// this.mushroomMaterial = new WrappedReturningMethod<>(CustomMushroomBlock.class, METHOD_GET_MATERIAL); -// } -// -// @Override -// public BlockData getBlockData(Identifier blockId) throws MissingResourceException { -// CustomItem item = CustomItem.get(blockId.key()); -// if(item == null) { -// throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); -// } else if(item.getBlockTexture().isSpawner()) { -// throw new MissingResourceException("Iris does not yet support SpawnerBlocks from CustomItems.", blockId.namespace(), blockId.key()); -// } else if(item.getBlockTexture() != null && item.getBlockTexture().isValid()) { -// throw new MissingResourceException("Tried to fetch BlockData for a CustomItem that is not placeable!", blockId.namespace(), blockId.key()); -// } -// return getMushroomData(item); -// } -// -// @Override -// public ItemStack getItemStack(Identifier itemId) throws MissingResourceException { -// ItemStack stack = CustomItemsAPI.getCustomItem(itemId.key()); -// if(stack == null) { -// throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key()); -// } -// return stack; -// } -// -// @Override -// public Identifier[] getBlockTypes() { -// KList names = new KList<>(); -// for (String name : CustomItemsAPI.listBlockCustomItemIDs()) { -// try { -// Identifier key = new Identifier("cui", name); -// if (getItemStack(key) != null) -// names.add(key); -// } catch (MissingResourceException ignored) { } -// } -// -// return names.toArray(new Identifier[0]); -// } -// -// @Override -// public Identifier[] getItemTypes() { -// KList names = new KList<>(); -// for (String name : CustomItemsAPI.listCustomItemIDs()) { -// try { -// Identifier key = new Identifier("cui", name); -// if (getItemStack(key) != null) -// names.add(key); -// } catch (MissingResourceException ignored) { } -// } -// -// return names.toArray(new Identifier[0]); -// } -// -// @Override -// public boolean isValidProvider(Identifier key, boolean isItem) { -// return key.namespace().equalsIgnoreCase("cui"); -// } -// -// private BlockData getMushroomData(CustomItem item) { -// MultipleFacing data = (MultipleFacing)mushroomMaterial.invoke(item.getBlockTexture().getMushroomId()).parseMaterial().createBlockData(); -// boolean[] values = mushroomFaces.get().get(item.getBlockTexture().getMushroomId()); -// data.setFace(BlockFace.DOWN, values[0]); -// data.setFace(BlockFace.EAST, values[1]); -// data.setFace(BlockFace.NORTH, values[2]); -// data.setFace(BlockFace.SOUTH, values[3]); -// data.setFace(BlockFace.UP, values[4]); -// data.setFace(BlockFace.WEST, values[5]); -// return data; -// } -//} diff --git a/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java index bb94049f0..9b4269a3b 100644 --- a/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/ExternalDataProvider.java @@ -18,7 +18,6 @@ import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; import org.bukkit.entity.Entity; -import org.bukkit.event.Listener; import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; @@ -31,7 +30,7 @@ import java.util.MissingResourceException; @Getter @RequiredArgsConstructor -public abstract class ExternalDataProvider implements Listener { +public abstract class ExternalDataProvider { @NonNull private final String pluginId; diff --git a/core/src/main/java/art/arcane/iris/core/link/Identifier.java b/core/src/main/java/art/arcane/iris/core/link/Identifier.java index 6c6378416..65ea5ca6a 100644 --- a/core/src/main/java/art/arcane/iris/core/link/Identifier.java +++ b/core/src/main/java/art/arcane/iris/core/link/Identifier.java @@ -1,37 +1,18 @@ -package art.arcane.iris.core.link; - -import org.bukkit.NamespacedKey; - -public record Identifier(String namespace, String key) { - - private static final String DEFAULT_NAMESPACE = "minecraft"; - - public static Identifier fromNamespacedKey(NamespacedKey key) { - return new Identifier(key.getNamespace(), key.getKey()); - } - - public static Identifier fromString(String id) { - String[] strings = id.split(":", 2); - if (strings.length == 1) { - return new Identifier(DEFAULT_NAMESPACE, strings[0]); - } else { - return new Identifier(strings[0], strings[1]); - } - } - - @Override - public String toString() { - return namespace + ":" + key; - } - - @Override - public boolean equals(Object obj) { - if (obj instanceof Identifier i) { - return i.namespace().equals(this.namespace) && i.key().equals(this.key); - } else if (obj instanceof NamespacedKey i) { - return i.getNamespace().equals(this.namespace) && i.getKey().equals(this.key); - } else { - return false; - } - } -} +package art.arcane.iris.core.link; + +public record Identifier(String namespace, String key) { + private static final String DEFAULT_NAMESPACE = "minecraft"; + + public static Identifier fromString(String id) { + String[] strings = id.split(":", 2); + if (strings.length == 1) { + return new Identifier(DEFAULT_NAMESPACE, strings[0]); + } + return new Identifier(strings[0], strings[1]); + } + + @Override + public String toString() { + return namespace + ":" + key; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java index aaff1e605..ab2b10625 100644 --- a/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java +++ b/core/src/main/java/art/arcane/iris/core/link/MultiverseCoreLink.java @@ -25,43 +25,48 @@ import org.mvplugins.multiverse.core.MultiverseCoreApi; import org.mvplugins.multiverse.core.world.MultiverseWorld; import org.mvplugins.multiverse.core.world.WorldManager; import org.mvplugins.multiverse.core.world.options.ImportWorldOptions; +import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions; + +import java.lang.reflect.Field; public class MultiverseCoreLink { - private final boolean active; - - public MultiverseCoreLink() { - active = Bukkit.getPluginManager().getPlugin("Multiverse-Core") != null; - } - public void removeFromConfig(World world) { removeFromConfig(world.getName()); } public void removeFromConfig(String world) { - if (!active) return; - var manager = worldManager(); - manager.removeWorld(world).onSuccess(manager::saveWorldsConfig); + if (!isActive()) { + return; + } + WorldManager manager = worldManager(); + MultiverseWorld multiverseWorld = manager.getWorld(world).getOrElse((MultiverseWorld) null); + if (multiverseWorld == null) { + return; + } + manager.removeWorld(RemoveWorldOptions.world(multiverseWorld)).onSuccess(ignored -> manager.saveWorldsConfig()); } @SneakyThrows public void updateWorld(World bukkitWorld, String pack) { - if (!active) return; - var generator = "Iris:" + pack; - var manager = worldManager(); - var world = manager.getWorld(bukkitWorld).getOrElse(() -> { - var options = ImportWorldOptions.worldName(bukkitWorld.getName()) + if (!isActive()) { + return; + } + String generator = "Iris:" + pack; + WorldManager manager = worldManager(); + MultiverseWorld multiverseWorld = manager.getWorld(bukkitWorld).getOrElse(() -> { + ImportWorldOptions options = ImportWorldOptions.worldName(bukkitWorld.getName()) .generator(generator) .environment(bukkitWorld.getEnvironment()) .useSpawnAdjust(false); return manager.importWorld(options).get(); }); - world.setAutoLoad(false); - if (!generator.equals(world.getGenerator())) { - var field = MultiverseWorld.class.getDeclaredField("worldConfig"); + multiverseWorld.setAutoLoad(false); + if (!generator.equals(multiverseWorld.getGenerator())) { + Field field = MultiverseWorld.class.getDeclaredField("worldConfig"); field.setAccessible(true); - var config = field.get(world); + Object config = field.get(multiverseWorld); config.getClass() .getDeclaredMethod("setGenerator", String.class) .invoke(config, generator); @@ -71,7 +76,11 @@ public class MultiverseCoreLink { } private WorldManager worldManager() { - var api = MultiverseCoreApi.get(); + MultiverseCoreApi api = MultiverseCoreApi.get(); return api.getWorldManager(); } + + private boolean isActive() { + return Bukkit.getPluginManager().isPluginEnabled("Multiverse-Core"); + } } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java index 50411a82d..4882e9866 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/CraftEngineDataProvider.java @@ -14,10 +14,13 @@ import art.arcane.volmlib.util.math.RNG; import net.momirealms.craftengine.bukkit.api.CraftEngineBlocks; import net.momirealms.craftengine.bukkit.api.CraftEngineFurniture; import net.momirealms.craftengine.bukkit.api.CraftEngineItems; +import net.momirealms.craftengine.bukkit.item.BukkitItemDefinition; +import net.momirealms.craftengine.core.block.BlockDefinition; import net.momirealms.craftengine.core.block.ImmutableBlockState; -import net.momirealms.craftengine.core.block.properties.BooleanProperty; -import net.momirealms.craftengine.core.block.properties.IntegerProperty; -import net.momirealms.craftengine.core.block.properties.Property; +import net.momirealms.craftengine.core.block.property.BooleanProperty; +import net.momirealms.craftengine.core.block.property.IntegerProperty; +import net.momirealms.craftengine.core.block.property.Property; +import net.momirealms.craftengine.core.entity.furniture.FurnitureDefinition; import net.momirealms.craftengine.core.util.Key; import org.bukkit.Location; import org.bukkit.block.Block; @@ -52,12 +55,12 @@ public class CraftEngineDataProvider extends ExternalDataProvider { @Override public @NotNull List getBlockProperties(@NotNull Identifier blockId) throws MissingResourceException { Key key = Key.of(blockId.namespace(), blockId.key()); - net.momirealms.craftengine.core.block.CustomBlock block = CraftEngineBlocks.byId(key); + BlockDefinition block = CraftEngineBlocks.byId(key); if (block != null) { return block.properties().stream().map(CraftEngineDataProvider::convert).toList(); } - net.momirealms.craftengine.core.entity.furniture.CustomFurniture furniture = CraftEngineFurniture.byId(key); + FurnitureDefinition furniture = CraftEngineFurniture.byId(key); if (furniture != null) { BlockProperty[] properties = Arrays.copyOf(FURNITURE_PROPERTIES, 5); properties[4] = new BlockProperty( @@ -75,12 +78,12 @@ public class CraftEngineDataProvider extends ExternalDataProvider { @Override public @NotNull ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap customNbt) throws MissingResourceException { - net.momirealms.craftengine.core.item.CustomItem item = CraftEngineItems.byId(Key.of(itemId.namespace(), itemId.key())); + BukkitItemDefinition item = CraftEngineItems.byId(Key.of(itemId.namespace(), itemId.key())); if (item == null) { throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key()); } - return item.buildItemStack(); + return item.buildBukkitItem(); } @Override @@ -100,7 +103,7 @@ public class CraftEngineDataProvider extends ExternalDataProvider { KMap state = statePair.getB(); Key key = Key.of(baseBlockId.namespace(), baseBlockId.key()); - net.momirealms.craftengine.core.block.CustomBlock customBlock = CraftEngineBlocks.byId(key); + BlockDefinition customBlock = CraftEngineBlocks.byId(key); if (customBlock != null) { ImmutableBlockState blockState = customBlock.defaultState(); @@ -122,7 +125,7 @@ public class CraftEngineDataProvider extends ExternalDataProvider { return; } - net.momirealms.craftengine.core.entity.furniture.CustomFurniture furniture = CraftEngineFurniture.byId(key); + FurnitureDefinition furniture = CraftEngineFurniture.byId(key); if (furniture == null) { return; } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/EcoItemsDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/EcoItemsDataProvider.java index 3baca5782..5beb4cbf9 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/EcoItemsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/EcoItemsDataProvider.java @@ -4,10 +4,8 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.link.ExternalDataProvider; import art.arcane.iris.core.link.Identifier; import art.arcane.volmlib.util.collection.KMap; -import art.arcane.iris.util.common.reflect.WrappedField; -import com.willfp.ecoitems.items.EcoItem; -import com.willfp.ecoitems.items.EcoItems; -import org.bukkit.NamespacedKey; +import com.willfp.eco.core.items.CustomItem; +import com.willfp.eco.core.items.Items; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; @@ -16,9 +14,6 @@ import java.util.List; import java.util.MissingResourceException; public class EcoItemsDataProvider extends ExternalDataProvider { - private WrappedField itemStack; - private WrappedField id; - public EcoItemsDataProvider() { super("EcoItems"); } @@ -26,30 +21,26 @@ public class EcoItemsDataProvider extends ExternalDataProvider { @Override public void init() { IrisLogging.info("Setting up EcoItems Link..."); - itemStack = new WrappedField<>(EcoItem.class, "_itemStack"); - if (this.itemStack.hasFailed()) { - IrisLogging.error("Failed to set up EcoItems Link: Unable to fetch ItemStack field!"); - } - id = new WrappedField<>(EcoItem.class, "id"); - if (this.id.hasFailed()) { - IrisLogging.error("Failed to set up EcoItems Link: Unable to fetch id field!"); - } } @NotNull @Override public ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap customNbt) throws MissingResourceException { - EcoItem item = EcoItems.INSTANCE.getByID(itemId.key()); - if (item == null) throw new MissingResourceException("Failed to find Item!", itemId.namespace(), itemId.key()); - return itemStack.get(item).clone(); + ItemStack item = Items.lookup(itemId.namespace() + ":" + itemId.key()).getItem(); + if (item.getType().isAir()) { + throw new MissingResourceException("Failed to find Item!", itemId.namespace(), itemId.key()); + } + return item; } @Override public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) { if (dataType != DataType.ITEM) return List.of(); - return EcoItems.INSTANCE.values() + return Items.getCustomItems() .stream() - .map(x -> Identifier.fromNamespacedKey(id.get(x))) + .map(CustomItem::getKey) + .filter(key -> key.getNamespace().equalsIgnoreCase("ecoitems")) + .map(key -> new Identifier(key.getNamespace(), key.getKey())) .filter(dataType.asPredicate(this)) .toList(); } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/HMCLeavesDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/HMCLeavesDataProvider.java deleted file mode 100644 index be82f0634..000000000 --- a/core/src/main/java/art/arcane/iris/core/link/data/HMCLeavesDataProvider.java +++ /dev/null @@ -1,126 +0,0 @@ -package art.arcane.iris.core.link.data; - -import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.core.link.ExternalDataProvider; -import art.arcane.iris.core.link.Identifier; -import art.arcane.iris.core.service.ExternalDataSVC; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.volmlib.util.collection.KMap; -import art.arcane.iris.util.common.data.IrisCustomData; -import art.arcane.iris.util.common.reflect.WrappedField; -import art.arcane.iris.util.common.reflect.WrappedReturningMethod; -import org.bukkit.Bukkit; -import org.bukkit.Location; -import org.bukkit.Material; -import org.bukkit.block.Block; -import org.bukkit.block.data.BlockData; -import org.bukkit.block.data.type.Leaves; -import org.bukkit.inventory.ItemStack; -import org.jetbrains.annotations.NotNull; - -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.MissingResourceException; -import java.util.function.Supplier; - -public class HMCLeavesDataProvider extends ExternalDataProvider { - private Object apiInstance; - private WrappedReturningMethod worldBlockType; - private WrappedReturningMethod setCustomBlock; - private Map blockDataMap = Map.of(); - private Map> itemDataField = Map.of(); - - public HMCLeavesDataProvider() { - super("HMCLeaves"); - } - - @Override - public String getPluginId() { - return "HMCLeaves"; - } - - @Override - public void init() { - try { - worldBlockType = new WrappedReturningMethod<>((Class) Class.forName("io.github.fisher2911.hmcleaves.data.BlockData"), "worldBlockType"); - apiInstance = getApiInstance(Class.forName("io.github.fisher2911.hmcleaves.api.HMCLeavesAPI")); - setCustomBlock = new WrappedReturningMethod<>((Class) apiInstance.getClass(), "setCustomBlock", Location.class, String.class, boolean.class); - Object config = getLeavesConfig(apiInstance.getClass()); - blockDataMap = getMap(config, "blockDataMap"); - itemDataField = getMap(config, "itemSupplierMap"); - } catch (Throwable e) { - IrisLogging.error("Failed to initialize HMCLeavesDataProvider: " + e.getMessage()); - } - } - - @NotNull - @Override - public BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap state) throws MissingResourceException { - Object o = blockDataMap.get(blockId.key()); - if (o == null) - throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); - Material material = worldBlockType.invoke(o, new Object[0]); - if (material == null) - throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key()); - BlockData blockData = Bukkit.createBlockData(material); - if (IrisSettings.get().getGenerator().preventLeafDecay && blockData instanceof Leaves leaves) - leaves.setPersistent(true); - return IrisCustomData.of(blockData, ExternalDataSVC.buildState(blockId, state)); - } - - @NotNull - @Override - public ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap customNbt) throws MissingResourceException { - if (!itemDataField.containsKey(itemId.key())) - throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key()); - return itemDataField.get(itemId.key()).get(); - } - - @Override - public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { - var pair = ExternalDataSVC.parseState(blockId); - blockId = pair.getA(); - Boolean result = setCustomBlock.invoke(apiInstance, new Object[]{block.getLocation(), blockId.key(), false}); - if (result == null || !result) - IrisLogging.warn("Failed to set custom block! " + blockId.key() + " " + block.getX() + " " + block.getY() + " " + block.getZ()); - else if (IrisSettings.get().getGenerator().preventLeafDecay) { - BlockData blockData = block.getBlockData(); - if (blockData instanceof Leaves leaves) - leaves.setPersistent(true); - } - } - - @Override - public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) { - if (dataType == DataType.ENTITY) return List.of(); - return (dataType == DataType.BLOCK ? blockDataMap.keySet() : itemDataField.keySet()) - .stream() - .map(x -> new Identifier("hmcleaves", x)) - .filter(dataType.asPredicate(this)) - .toList(); - } - - @Override - public boolean isValidProvider(@NotNull Identifier id, DataType dataType) { - if (dataType == DataType.ENTITY) return false; - return (dataType == DataType.ITEM ? itemDataField.keySet() : blockDataMap.keySet()).contains(id.key()); - } - - private Map getMap(C config, String name) { - WrappedField> field = new WrappedField<>((Class) config.getClass(), name); - return field.get(config); - } - - private A getApiInstance(Class apiClass) { - WrappedReturningMethod instance = new WrappedReturningMethod<>(apiClass, "getInstance"); - return instance.invoke(); - } - - private C getLeavesConfig(Class apiClass) { - WrappedReturningMethod instance = new WrappedReturningMethod<>(apiClass, "getInstance"); - WrappedField config = new WrappedField<>(apiClass, "config"); - return config.get(instance.invoke()); - } -} diff --git a/core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java index 4e63db242..1910670cd 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/ItemAdderDataProvider.java @@ -12,6 +12,7 @@ import dev.lone.itemsadder.api.Events.ItemsAdderLoadDataEvent; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; @@ -21,7 +22,7 @@ import java.util.MissingResourceException; import java.util.Set; import java.util.stream.Collectors; -public class ItemAdderDataProvider extends ExternalDataProvider { +public class ItemAdderDataProvider extends ExternalDataProvider implements Listener { private volatile Set itemNamespaces = Set.of(); private volatile Set blockNamespaces = Set.of(); @@ -93,7 +94,7 @@ public class ItemAdderDataProvider extends ExternalDataProvider { } private void updateNamespaces(DataType dataType) { - var namespaces = getTypes(dataType).stream().map(Identifier::namespace).collect(Collectors.toSet()); + Set namespaces = getTypes(dataType).stream().map(Identifier::namespace).collect(Collectors.toSet()); if (dataType == DataType.ITEM) itemNamespaces = namespaces; else blockNamespaces = namespaces; IrisLogging.debug("Updated ItemAdder namespaces: " + dataType + " - " + namespaces); diff --git a/core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java index 7f05c16bc..ec4846a25 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/KGeneratorsDataProvider.java @@ -10,6 +10,8 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.data.IrisCustomData; import me.kryniowesegryderiusz.kgenerators.Main; import me.kryniowesegryderiusz.kgenerators.api.KGeneratorsAPI; +import me.kryniowesegryderiusz.kgenerators.api.interfaces.IGeneratorLocation; +import me.kryniowesegryderiusz.kgenerators.generators.generator.objects.Generator; import me.kryniowesegryderiusz.kgenerators.generators.locations.objects.GeneratorLocation; import org.bukkit.Material; import org.bukkit.block.Block; @@ -39,23 +41,23 @@ public class KGeneratorsDataProvider extends ExternalDataProvider { @Override public @NotNull ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap customNbt) throws MissingResourceException { - var gen = Main.getGenerators().get(itemId.key()); - if (gen == null) throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key()); - return gen.getGeneratorItem(); + Generator generator = Main.getGenerators().get(itemId.key()); + if (generator == null) throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key()); + return generator.getGeneratorItem(); } @Override public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { if (block.getType() != Material.STRUCTURE_VOID) return; - var existing = KGeneratorsAPI.getLoadedGeneratorLocation(block.getLocation()); + IGeneratorLocation existing = KGeneratorsAPI.getLoadedGeneratorLocation(block.getLocation()); if (existing != null) return; block.setBlockData(BukkitBlockResolution.getAir(), false); - var gen = Main.getGenerators().get(blockId.key()); - if (gen == null) return; - var loc = new GeneratorLocation(-1, gen, block.getLocation(), Main.getPlacedGenerators().getChunkInfo(block.getChunk()), null, null); - Main.getDatabases().getDb().saveGenerator(loc); - Main.getPlacedGenerators().addLoaded(loc); - Main.getSchedules().schedule(loc, true); + Generator generator = Main.getGenerators().get(blockId.key()); + if (generator == null) return; + GeneratorLocation location = new GeneratorLocation(-1, generator, block.getLocation(), Main.getPlacedGenerators().getChunkInfo(block.getChunk()), null, null); + Main.getDatabases().getDb().saveGenerator(location); + Main.getPlacedGenerators().addLoaded(location); + Main.getSchedules().schedule(location, true); } @Override diff --git a/core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java index 7d19e0932..99f517df3 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/MMOItemsDataProvider.java @@ -7,6 +7,7 @@ import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.scheduling.J; import net.Indyuce.mmoitems.MMOItems; import net.Indyuce.mmoitems.api.ItemTier; +import net.Indyuce.mmoitems.api.Type; import net.Indyuce.mmoitems.api.block.CustomBlock; import org.bukkit.Bukkit; import org.bukkit.block.data.BlockData; @@ -52,7 +53,7 @@ public class MMOItemsDataProvider extends ExternalDataProvider { CompletableFuture future = new CompletableFuture<>(); Runnable run = () -> { try { - var type = api().getTypes().get(parts[1]); + Type type = api().getTypes().get(parts[1]); int level = -1; ItemTier tier = null; diff --git a/core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java index c927759fe..4b5317d10 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java @@ -26,6 +26,7 @@ import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.container.BiomeColor; import art.arcane.iris.core.nms.container.BlockProperty; +import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.framework.Engine; import art.arcane.volmlib.util.collection.KMap; @@ -38,10 +39,12 @@ import io.lumine.mythiccrucible.items.ItemManager; import io.lumine.mythiccrucible.items.blocks.CustomBlockItemContext; import io.lumine.mythiccrucible.items.furniture.FurnitureItemContext; import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; +import java.awt.Color; import java.util.Collection; import java.util.List; import java.util.MissingResourceException; @@ -58,11 +61,7 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider { @Override public void init() { IrisLogging.info("Setting up MythicCrucible Link..."); - try { - this.itemManager = MythicCrucible.inst().getItemManager(); - } catch (Exception e) { - IrisLogging.error("Failed to set up MythicCrucible Link: Unable to fetch MythicCrucible instance!"); - } + itemManager = MythicCrucible.inst().getItemManager(); } @NotNull @@ -114,8 +113,8 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider { @Override public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { - var parsedState = ExternalDataSVC.parseState(blockId); - var state = parsedState.getB(); + Pair> parsedState = ExternalDataSVC.parseState(blockId); + KMap state = parsedState.getB(); blockId = parsedState.getA(); Optional item = itemManager.getItem(blockId.key()); @@ -123,14 +122,14 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider { FurnitureItemContext furniture = item.get().getFurnitureData(); if (furniture == null) return; - var pair = parseYawAndFace(engine, block, state); + Pair pair = parseYawAndFace(engine, block, state); BiomeColor type = null; Chroma color = null; try { type = BiomeColor.valueOf(state.get("matchBiome").toUpperCase()); } catch (NullPointerException | IllegalArgumentException ignored) {} if (type != null) { - var biomeColor = INMS.get().getBiomeColor(block.getLocation(), type); + Color biomeColor = INMS.get().getBiomeColor(block.getLocation(), type); if (biomeColor == null) return; color = Chroma.of(biomeColor.getRGB()); } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java index 94df44fc4..7ff2bc134 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/MythicMobsDataProvider.java @@ -3,8 +3,13 @@ package art.arcane.iris.core.link.data; import art.arcane.iris.core.link.ExternalDataProvider; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.tools.IrisToolbelt; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.platform.PlatformChunkGenerator; import io.lumine.mythic.api.adapters.AbstractLocation; import io.lumine.mythic.api.config.MythicLineConfig; +import io.lumine.mythic.api.mobs.MythicMob; import io.lumine.mythic.api.mobs.entities.SpawnReason; import io.lumine.mythic.api.skills.conditions.ILocationCondition; import io.lumine.mythic.bukkit.BukkitAdapter; @@ -12,6 +17,7 @@ import io.lumine.mythic.bukkit.MythicBukkit; import io.lumine.mythic.bukkit.adapters.BukkitWorld; import io.lumine.mythic.bukkit.events.MythicConditionLoadEvent; import io.lumine.mythic.core.mobs.ActiveMob; +import io.lumine.mythic.core.mobs.MobExecutor; import io.lumine.mythic.core.mobs.MobStack; import io.lumine.mythic.core.skills.SkillCondition; import io.lumine.mythic.core.utils.annotations.MythicCondition; @@ -19,6 +25,7 @@ import io.lumine.mythic.core.utils.annotations.MythicField; import org.bukkit.Location; import org.bukkit.entity.Entity; import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,7 +37,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; -public class MythicMobsDataProvider extends ExternalDataProvider { +public class MythicMobsDataProvider extends ExternalDataProvider implements Listener { public MythicMobsDataProvider() { super("MythicMobs"); } @@ -41,25 +48,25 @@ public class MythicMobsDataProvider extends ExternalDataProvider { @Override public @Nullable Entity spawnMob(@NotNull Location location, @NotNull Identifier entityId) throws MissingResourceException { - var mm = spawnMob(BukkitAdapter.adapt(location), entityId); - return mm == null ? null : mm.getEntity().getBukkitEntity(); + ActiveMob activeMob = spawnMob(BukkitAdapter.adapt(location), entityId); + return activeMob == null ? null : activeMob.getEntity().getBukkitEntity(); } private ActiveMob spawnMob(AbstractLocation location, Identifier entityId) throws MissingResourceException { - var manager = MythicBukkit.inst().getMobManager(); - var mm = manager.getMythicMob(entityId.key()).orElse(null); - if (mm == null) { - var stack = manager.getMythicMobStack(entityId.key()); + MobExecutor manager = MythicBukkit.inst().getMobManager(); + MythicMob mythicMob = manager.getMythicMob(entityId.key()).orElse(null); + if (mythicMob == null) { + MobStack stack = manager.getMythicMobStack(entityId.key()); if (stack == null) throw new MissingResourceException("Failed to find Mob!", entityId.namespace(), entityId.key()); return stack.spawn(location, 1d, SpawnReason.OTHER, null); } - return mm.spawn(location, 1d, SpawnReason.OTHER, null, null); + return mythicMob.spawn(location, 1d, SpawnReason.OTHER, null, null); } @Override public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) { if (dataType != DataType.ENTITY) return List.of(); - var manager = MythicBukkit.inst().getMobManager(); + MobExecutor manager = MythicBukkit.inst().getMobManager(); return Stream.concat(manager.getMobNames().stream(), manager.getMobStacks() .stream() @@ -99,11 +106,11 @@ public class MythicMobsDataProvider extends ExternalDataProvider { @Override public boolean check(AbstractLocation target) { - var access = IrisToolbelt.access(((BukkitWorld) target.getWorld()).getBukkitWorld()); + PlatformChunkGenerator access = IrisToolbelt.access(((BukkitWorld) target.getWorld()).getBukkitWorld()); if (access == null) return false; - var engine = access.getEngine(); + Engine engine = access.getEngine(); if (engine == null) return false; - var biome = surface ? + IrisBiome biome = surface ? engine.getSurfaceBiome(target.getBlockX(), target.getBlockZ()) : engine.getBiomeOrMantle(target.getBlockX(), target.getBlockY() - engine.getMinHeight(), target.getBlockZ()); return biomes.contains(biome.getLoadKey()); @@ -123,11 +130,11 @@ public class MythicMobsDataProvider extends ExternalDataProvider { @Override public boolean check(AbstractLocation target) { - var access = IrisToolbelt.access(((BukkitWorld) target.getWorld()).getBukkitWorld()); + PlatformChunkGenerator access = IrisToolbelt.access(((BukkitWorld) target.getWorld()).getBukkitWorld()); if (access == null) return false; - var engine = access.getEngine(); + Engine engine = access.getEngine(); if (engine == null) return false; - var region = engine.getRegion(target.getBlockX(), target.getBlockZ()); + IrisRegion region = engine.getRegion(target.getBlockX(), target.getBlockZ()); return regions.contains(region.getLoadKey()); } } diff --git a/core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java b/core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java index c9390a1bc..c65c7bd60 100644 --- a/core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java +++ b/core/src/main/java/art/arcane/iris/core/link/data/NexoDataProvider.java @@ -11,24 +11,29 @@ import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.nms.INMS; import art.arcane.iris.core.nms.container.BiomeColor; import art.arcane.iris.core.nms.container.BlockProperty; +import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.service.ExternalDataSVC; import art.arcane.iris.engine.framework.Engine; import art.arcane.volmlib.util.collection.KMap; import art.arcane.iris.util.common.data.IrisCustomData; -import org.bukkit.Color; import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; import org.bukkit.block.data.BlockData; import org.bukkit.entity.ItemDisplay; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.LeatherArmorMeta; import org.bukkit.inventory.meta.MapMeta; +import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.PotionMeta; import org.jetbrains.annotations.NotNull; +import java.awt.Color; import java.util.Collection; import java.util.List; import java.util.MissingResourceException; +import static org.bukkit.Color.fromARGB; + public class NexoDataProvider extends ExternalDataProvider { public NexoDataProvider() { super("Nexo"); @@ -84,8 +89,8 @@ public class NexoDataProvider extends ExternalDataProvider { @Override public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) { - var statePair = ExternalDataSVC.parseState(blockId); - var state = statePair.getB(); + Pair> statePair = ExternalDataSVC.parseState(blockId); + KMap state = statePair.getB(); blockId = statePair.getA(); if (NexoBlocks.isCustomBlock(blockId.key())) { @@ -96,7 +101,7 @@ public class NexoDataProvider extends ExternalDataProvider { if (!NexoFurniture.isFurniture(blockId.key())) return; - var pair = parseYawAndFace(engine, block, state); + Pair pair = parseYawAndFace(engine, block, state); ItemDisplay display = NexoFurniture.place(blockId.key(), block.getLocation(), pair.getA(), pair.getB()); if (display == null) return; ItemStack itemStack = display.getItemStack(); @@ -108,14 +113,13 @@ public class NexoDataProvider extends ExternalDataProvider { } catch (NullPointerException | IllegalArgumentException ignored) {} if (type != null) { - var biomeColor = INMS.get().getBiomeColor(block.getLocation(), type); + Color biomeColor = INMS.get().getBiomeColor(block.getLocation(), type); if (biomeColor == null) return; - var potionColor = Color.fromARGB(biomeColor.getAlpha(), biomeColor.getRed(), biomeColor.getGreen(), biomeColor.getBlue()); - var meta = itemStack.getItemMeta(); + ItemMeta meta = itemStack.getItemMeta(); switch (meta) { - case LeatherArmorMeta armor -> armor.setColor(potionColor); - case PotionMeta potion -> potion.setColor(potionColor); - case MapMeta map -> map.setColor(potionColor); + case LeatherArmorMeta armor -> armor.setColor(fromARGB(biomeColor.getAlpha(), biomeColor.getRed(), biomeColor.getGreen(), biomeColor.getBlue())); + case PotionMeta potion -> potion.setColor(fromARGB(biomeColor.getAlpha(), biomeColor.getRed(), biomeColor.getGreen(), biomeColor.getBlue())); + case MapMeta map -> map.setColor(fromARGB(biomeColor.getAlpha(), biomeColor.getRed(), biomeColor.getGreen(), biomeColor.getBlue())); case null, default -> {} } itemStack.setItemMeta(meta); diff --git a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java index af7cd0402..70396e2c0 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java +++ b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java @@ -40,8 +40,9 @@ import java.util.zip.ZipInputStream; public final class DefaultPackBootstrapProvisioner { private static final URI DEFAULT_SOURCE = URI.create("https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"); + private static final String WORLD_DATAPACK_DIRECTORY = "iris"; private static final int PACK_FORMAT = 107; - private static final int MARKER_SCHEMA = 1; + private static final int MARKER_SCHEMA = 2; private static final int MAX_ARCHIVE_ENTRIES = 100_000; private static final long MAX_ARCHIVE_BYTES = 512L * 1024L * 1024L; private static final long MAX_EXPANDED_BYTES = 2L * 1024L * 1024L * 1024L; @@ -77,24 +78,38 @@ public final class DefaultPackBootstrapProvisioner { if (dataDirectory == null) { return false; } - Path bootstrapRoot = dataDirectory.toAbsolutePath().normalize().resolve("bootstrap"); - Path datapackRoot = bootstrapRoot.resolve("datapack"); - Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld"); - Path markerFile = bootstrapRoot.resolve("provisioned.properties"); - if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) { + try { + return isProvisioned( + dataDirectory, + resolveLevelRoot(Path.of("").toAbsolutePath().normalize()) + ); + } catch (IOException | RuntimeException exception) { return false; } + } + + static boolean isProvisioned(Path dataDirectory, Path levelRoot) { try { + Path normalizedData = dataDirectory.toAbsolutePath().normalize(); + Path normalizedLevel = levelRoot.toAbsolutePath().normalize(); + Path bootstrapRoot = normalizedData.resolve("bootstrap"); + Path datapackRoot = worldDatapackRoot(normalizedLevel); + Path packRoot = normalizedData.resolve("packs/overworld"); + Path markerFile = bootstrapRoot.resolve("provisioned.properties"); + if (!Files.isRegularFile(markerFile) || !isPackRoot(packRoot) || !isDatapackRoot(datapackRoot)) { + return false; + } Properties marker = loadProperties(markerFile); if (!Integer.toString(MARKER_SCHEMA).equals(marker.getProperty("schema"))) { return false; } return directoryFingerprint(packRoot).equals(marker.getProperty("defaultPackFingerprint")) && directoryFingerprint(datapackRoot).equals(marker.getProperty("datapackFingerprint")) + && datapackRoot.toString().equals(marker.getProperty("datapackPath")) && packRootsFingerprint(IrisDatapackCompiler.collectPackRoots( - dataDirectory.toAbsolutePath().normalize(), - resolveLevelRoot(Path.of("").toAbsolutePath().normalize()) - )).equals(marker.getProperty("aggregateFingerprint")); + normalizedData, + normalizedLevel + )).equals(marker.getProperty("aggregateFingerprint")); } catch (IOException | RuntimeException exception) { return false; } @@ -109,12 +124,15 @@ public final class DefaultPackBootstrapProvisioner { Path packsRoot = normalizedData.resolve("packs"); Path packRoot = packsRoot.resolve("overworld"); Path bootstrapRoot = normalizedData.resolve("bootstrap"); - Path datapackRoot = bootstrapRoot.resolve("datapack"); + Path legacyDatapackRoot = bootstrapRoot.resolve("datapack"); + Path datapacksRoot = options.levelRoot().toAbsolutePath().normalize().resolve("datapacks"); + Path datapackRoot = datapacksRoot.resolve(WORLD_DATAPACK_DIRECTORY); Path markerFile = bootstrapRoot.resolve("provisioned.properties"); Path cacheRoot = normalizedData.resolve("cache/bootstrap"); Files.createDirectories(packsRoot); Files.createDirectories(bootstrapRoot); Files.createDirectories(cacheRoot); + Files.createDirectories(datapacksRoot); Properties previousMarker = Files.isRegularFile(markerFile) ? loadProperties(markerFile) : new Properties(); boolean existingPack = isPackRoot(packRoot); @@ -137,7 +155,6 @@ public final class DefaultPackBootstrapProvisioner { Path stagedPack = null; Path extractionRoot = null; Path compileContainer = null; - Path stagedDatapack = null; Path packBackup = null; Path datapackBackup = null; boolean packReplaced = false; @@ -162,9 +179,10 @@ public final class DefaultPackBootstrapProvisioner { boolean rebuildDatapack = replacePack || !existingDatapack || !aggregateFingerprint.equals(previousMarker.getProperty("aggregateFingerprint")) + || !datapackRoot.toString().equals(previousMarker.getProperty("datapackPath")) || !directoryFingerprint(datapackRoot).equals(previousMarker.getProperty("datapackFingerprint")); if (rebuildDatapack) { - compileContainer = bootstrapRoot.resolve(".compile-" + UUID.randomUUID()); + compileContainer = datapacksRoot.resolve("." + WORLD_DATAPACK_DIRECTORY + "-stage-" + UUID.randomUUID()); Files.createDirectories(compileContainer); KList outputFolders = new KList().qadd(compileContainer.toFile()); IDataFixer fixer = DataVersion.getLatest().get(); @@ -172,14 +190,11 @@ public final class DefaultPackBootstrapProvisioner { throw new IOException("Latest Iris datapack fixer is unavailable during bootstrap"); } IrisDatapackCompiler.compile(packRoots, outputFolders, fixer, PACK_FORMAT, false); - Path canonicalOutput = compileContainer; - if (!isDatapackRoot(canonicalOutput)) { - throw new IOException("Canonical Iris datapack compiler produced incomplete output at " + canonicalOutput); + if (!isDatapackRoot(compileContainer)) { + throw new IOException("Canonical Iris datapack compiler produced incomplete output at " + compileContainer); } - stagedDatapack = bootstrapRoot.resolve(".datapack-stage-" + UUID.randomUUID()); - move(canonicalOutput, stagedDatapack, false); + datapackBackup = replaceWithBackup(compileContainer, datapackRoot); compileContainer = null; - datapackBackup = replaceWithBackup(stagedDatapack, datapackRoot); datapackReplaced = true; } @@ -200,11 +215,13 @@ public final class DefaultPackBootstrapProvisioner { marker.setProperty("defaultPackFingerprint", finalPackFingerprint); marker.setProperty("aggregateFingerprint", finalAggregateFingerprint); marker.setProperty("datapackFingerprint", finalDatapackFingerprint); + marker.setProperty("datapackPath", datapackRoot.toString()); marker.setProperty("completedAt", Long.toString(options.clock().millis())); storePropertiesAtomic(markerFile, marker); PROVISIONED_THIS_STARTUP.set(true); deleteQuietly(packBackup, feedback); deleteQuietly(datapackBackup, feedback); + deleteQuietly(legacyDatapackRoot, feedback); ProvisionStatus status; if (!existingPack && !existingDatapack) { @@ -230,7 +247,6 @@ public final class DefaultPackBootstrapProvisioner { throw new IOException("Iris bootstrap provisioning failed", failure); } finally { deleteQuietly(stagedPack, feedback); - deleteQuietly(stagedDatapack, feedback); deleteQuietly(extractionRoot, feedback); deleteQuietly(compileContainer, feedback); } @@ -329,7 +345,7 @@ public final class DefaultPackBootstrapProvisioner { feedback.accept("Default overworld download failed; using the validated cached archive."); return new Archive(archivePath, sha256(archivePath)); } - if (isProvisionedOutputUsable(marker, cacheRoot.getParent().getParent())) { + if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent())) { String sourceSha = marker.getProperty("sourceSha256"); return new Archive(null, sourceSha); } @@ -338,9 +354,18 @@ public final class DefaultPackBootstrapProvisioner { : new IOException("Default overworld archive is unavailable and no valid cache exists", networkFailure); } - private static boolean isProvisionedOutputUsable(Properties marker, Path dataDirectory) { + private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory) { String sourceSha = marker.getProperty("sourceSha256"); - return sourceSha != null && !sourceSha.isBlank() && isProvisioned(dataDirectory); + String expectedFingerprint = marker.getProperty("defaultPackFingerprint"); + if (sourceSha == null || sourceSha.isBlank() || expectedFingerprint == null || expectedFingerprint.isBlank()) { + return false; + } + Path packRoot = dataDirectory.toAbsolutePath().normalize().resolve("packs/overworld"); + try { + return isPackRoot(packRoot) && directoryFingerprint(packRoot).equals(expectedFingerprint); + } catch (IOException | RuntimeException exception) { + return false; + } } static Path resolveLevelRoot(Path serverRoot) throws IOException { @@ -505,6 +530,10 @@ public final class DefaultPackBootstrapProvisioner { && Files.isDirectory(path.resolve("data/iris/dimension_type")); } + static Path worldDatapackRoot(Path levelRoot) { + return levelRoot.toAbsolutePath().normalize().resolve("datapacks").resolve(WORLD_DATAPACK_DIRECTORY); + } + private static String packRootsFingerprint(List packRoots) throws IOException { MessageDigest digest = digest(); List roots = packRoots.stream() diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 9df668218..8c4569de2 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -27,6 +27,7 @@ import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; import java.io.File; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -51,6 +52,9 @@ public final class PackValidator { private static final String CACHE_FOLDER = "cache"; private static final String OBJECTS_FOLDER = "objects"; private static final String DIMENSIONS_FOLDER = "dimensions"; + private static final String STRUCTURES_FOLDER = "structures"; + private static final String JIGSAW_POOLS_FOLDER = "jigsaw-pools"; + private static final String JIGSAW_PIECES_FOLDER = "jigsaw-pieces"; private static final List STRUCTURE_HOST_FOLDERS = List.of(DIMENSIONS_FOLDER, "regions", "biomes"); private static final List UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS = List.of("rotation", "translate", "scale"); private static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); @@ -83,6 +87,7 @@ public final class PackValidator { validateDimensions(packFolder, dimensionFiles, blockingErrors, warnings); blockingErrors.addAll(validateUnsupportedStructureTransforms(packFolder)); + blockingErrors.addAll(validateStructureGraph(packFolder)); blockingErrors.addAll(validateSpawnerEntityReferences( new File(packFolder, "spawners"), new File(packFolder, "entities"))); blockingErrors.addAll(validateCustomBiomeSpawns( @@ -138,6 +143,206 @@ public final class PackValidator { return blockingErrors; } + static List validateStructureGraph(File packFolder) { + List blockingErrors = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory()) { + return blockingErrors; + } + + File structuresFolder = new File(packFolder, STRUCTURES_FOLDER); + File poolsFolder = new File(packFolder, JIGSAW_POOLS_FOLDER); + File piecesFolder = new File(packFolder, JIGSAW_PIECES_FOLDER); + File objectsFolder = new File(packFolder, OBJECTS_FOLDER); + Set structureKeys = deriveRegistrantKeysExact(structuresFolder); + Set poolKeys = deriveRegistrantKeysExact(poolsFolder); + Set pieceKeys = deriveRegistrantKeysExact(piecesFolder); + Set objectKeys = deriveObjectKeysExact(objectsFolder); + + validateStructurePlacements(packFolder, structureKeys, blockingErrors); + validateStructureStartPools(structuresFolder, poolKeys, blockingErrors); + validateJigsawPools(poolsFolder, poolKeys, pieceKeys, blockingErrors); + validateJigsawPieces(piecesFolder, poolKeys, objectKeys, blockingErrors); + return blockingErrors; + } + + private static void validateStructurePlacements(File packFolder, + Set structureKeys, + List blockingErrors) { + for (String folderName : STRUCTURE_HOST_FOLDERS) { + File resourceFolder = new File(packFolder, folderName); + if (!resourceFolder.isDirectory()) { + continue; + } + List resourceFiles = listJsonRecursive(resourceFolder); + resourceFiles.sort(Comparator.comparing(File::getPath)); + String resourceType = structureHostType(folderName); + for (File resourceFile : resourceFiles) { + JSONObject resource = readJson(resourceFile); + if (resource == null) { + continue; + } + JSONArray placements = resource.optJSONArray("structures"); + if (placements == null) { + continue; + } + String resourceKey = deriveKey(resourceFolder, resourceFile); + for (int placementIndex = 0; placementIndex < placements.length(); placementIndex++) { + JSONObject placement = placements.optJSONObject(placementIndex); + if (placement == null) { + continue; + } + JSONArray references = placement.optJSONArray("structures"); + if (references == null) { + continue; + } + for (int referenceIndex = 0; referenceIndex < references.length(); referenceIndex++) { + Object rawReference = references.opt(referenceIndex); + if (!(rawReference instanceof String structureKey) || structureKey.isBlank()) { + continue; + } + if (!structureKeys.contains(structureKey)) { + blockingErrors.add(resourceType + " '" + resourceKey + "' structures[" + + placementIndex + "].structures[" + referenceIndex + + "] references missing structure '" + structureKey + "'."); + } + } + } + } + } + } + + private static void validateStructureStartPools(File structuresFolder, + Set poolKeys, + List blockingErrors) { + if (!structuresFolder.isDirectory()) { + return; + } + List structureFiles = listJsonRecursive(structuresFolder); + structureFiles.sort(Comparator.comparing(File::getPath)); + for (File structureFile : structureFiles) { + String structureKey = deriveKey(structuresFolder, structureFile); + JSONObject structure = readGraphJson(structureFile, "Structure", structureKey, blockingErrors); + if (structure == null || isLegacyStructureIndex(structureKey, structure)) { + continue; + } + String startPool = structure.optString("startPool", "").trim(); + if (startPool.isEmpty()) { + blockingErrors.add("Structure '" + structureKey + "' does not declare a startPool."); + } else if (!poolKeys.contains(startPool)) { + blockingErrors.add("Structure '" + structureKey + "' references missing start pool '" + + startPool + "'."); + } + } + } + + private static void validateJigsawPools(File poolsFolder, + Set poolKeys, + Set pieceKeys, + List blockingErrors) { + if (!poolsFolder.isDirectory()) { + return; + } + List poolFiles = listJsonRecursive(poolsFolder); + poolFiles.sort(Comparator.comparing(File::getPath)); + for (File poolFile : poolFiles) { + String poolKey = deriveKey(poolsFolder, poolFile); + JSONObject pool = readGraphJson(poolFile, "Jigsaw pool", poolKey, blockingErrors); + if (pool == null) { + continue; + } + JSONArray entries = pool.optJSONArray("pieces"); + if (entries != null) { + for (int entryIndex = 0; entryIndex < entries.length(); entryIndex++) { + JSONObject entry = entries.optJSONObject(entryIndex); + if (entry == null) { + continue; + } + String pieceKey = entry.optString("piece", "").trim(); + if (!pieceKey.isEmpty() && !pieceKeys.contains(pieceKey)) { + blockingErrors.add("Jigsaw pool '" + poolKey + "' pieces[" + entryIndex + + "] references missing piece '" + pieceKey + "'."); + } + } + } + String fallback = pool.optString("fallback", "").trim(); + if (!fallback.isEmpty() && !poolKeys.contains(fallback)) { + blockingErrors.add("Jigsaw pool '" + poolKey + "' references missing fallback pool '" + + fallback + "'."); + } + } + } + + private static void validateJigsawPieces(File piecesFolder, + Set poolKeys, + Set objectKeys, + List blockingErrors) { + if (!piecesFolder.isDirectory()) { + return; + } + List pieceFiles = listJsonRecursive(piecesFolder); + pieceFiles.sort(Comparator.comparing(File::getPath)); + for (File pieceFile : pieceFiles) { + String pieceKey = deriveKey(piecesFolder, pieceFile); + JSONObject piece = readGraphJson(pieceFile, "Jigsaw piece", pieceKey, blockingErrors); + if (piece == null) { + continue; + } + String objectKey = piece.optString("object", "").trim(); + if (objectKey.isEmpty()) { + blockingErrors.add("Jigsaw piece '" + pieceKey + "' does not declare an object."); + } else if (!objectKeys.contains(objectKey)) { + blockingErrors.add("Jigsaw piece '" + pieceKey + "' references missing object '" + + objectKey + "'."); + } + JSONArray connectors = piece.optJSONArray("connectors"); + if (connectors == null) { + continue; + } + for (int connectorIndex = 0; connectorIndex < connectors.length(); connectorIndex++) { + JSONObject connector = connectors.optJSONObject(connectorIndex); + if (connector == null) { + continue; + } + String poolKey = connector.optString("pool", "").trim(); + if (!poolKey.isEmpty() && !poolKeys.contains(poolKey)) { + blockingErrors.add("Jigsaw piece '" + pieceKey + "' connectors[" + connectorIndex + + "] references missing pool '" + poolKey + "'."); + } + } + } + } + + private static JSONObject readGraphJson(File file, + String resourceType, + String resourceKey, + List blockingErrors) { + try { + return new JSONObject(Files.readString(file.toPath(), StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException e) { + String reason = e.getMessage(); + if (reason == null || reason.isBlank()) { + reason = e.getClass().getSimpleName(); + } + blockingErrors.add(resourceType + " '" + resourceKey + "' has invalid JSON: " + reason); + return null; + } + } + + private static JSONObject readJson(File file) { + try { + return new JSONObject(Files.readString(file.toPath(), StandardCharsets.UTF_8)); + } catch (Throwable ignored) { + return null; + } + } + + private static boolean isLegacyStructureIndex(String structureKey, JSONObject structure) { + return "structure-index".equals(structureKey) + && structure.has("counts") + && structure.has("structureSets") + && structure.has("iris"); + } + private static String structureHostType(String folderName) { return switch (folderName) { case "dimensions" -> "Dimension"; @@ -546,6 +751,38 @@ public final class PackValidator { return keys; } + private static Set deriveRegistrantKeysExact(File folder) { + Set keys = new HashSet<>(); + if (!folder.isDirectory()) { + return keys; + } + for (File file : listJsonRecursive(folder)) { + String key = deriveKey(folder, file); + if (key != null && !key.isBlank()) { + keys.add(key); + } + } + return keys; + } + + private static Set deriveObjectKeysExact(File folder) { + Set keys = new HashSet<>(); + if (!folder.isDirectory()) { + return keys; + } + try (Stream stream = Files.walk(folder.toPath())) { + stream.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".iob")) + .forEach(path -> { + Path relative = folder.toPath().relativize(path); + String key = relative.toString().replace(File.separatorChar, '/'); + keys.add(key.substring(0, key.length() - ".iob".length())); + }); + } catch (IOException ignored) { + } + return keys; + } + private record ReferencedContentKeys(Set blocks, Set items, Set entities) { } diff --git a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java index fabdb56fe..13b760d3b 100644 --- a/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java +++ b/core/src/main/java/art/arcane/iris/core/runtime/StudioOpenCoordinator.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.runtime; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -74,7 +75,7 @@ public final class StudioOpenCoordinator { return new StudioCloseResult(null, true, true, false, null); } - World world = provider.getTarget().getWorld().realWorld(); + World world = BukkitWorldBinding.world(provider.getTarget().getWorld()); String worldName = world == null ? provider.getTarget().getWorld().name() : world.getName(); try { return closeWorld(provider, worldName, world, true, project); diff --git a/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java b/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java index b69cf899e..a283a79f8 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/ExternalDataSVC.java @@ -19,7 +19,6 @@ package art.arcane.iris.core.service; import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.link.ExternalDataProvider; import art.arcane.iris.core.link.Identifier; @@ -29,79 +28,91 @@ import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.engine.framework.Engine; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; -import art.arcane.volmlib.util.io.JarScanner; import art.arcane.iris.util.common.plugin.IrisService; -import art.arcane.iris.util.common.scheduling.J; -import lombok.Data; -import lombok.NonNull; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; import org.bukkit.entity.Entity; import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; import org.bukkit.event.server.PluginEnableEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.MissingResourceException; +import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; -@Data public class ExternalDataSVC implements IrisService { + private static final String PROVIDER_PACKAGE = "art.arcane.iris.core.link.data."; + private static final List BUILT_IN_PROVIDERS = List.of( + new ProviderDefinition("CraftEngine", PROVIDER_PACKAGE + "CraftEngineDataProvider"), + new ProviderDefinition("Nexo", PROVIDER_PACKAGE + "NexoDataProvider"), + new ProviderDefinition("ItemsAdder", PROVIDER_PACKAGE + "ItemAdderDataProvider"), + new ProviderDefinition("ExecutableItems", PROVIDER_PACKAGE + "ExecutableItemsDataProvider"), + new ProviderDefinition("MMOItems", PROVIDER_PACKAGE + "MMOItemsDataProvider"), + new ProviderDefinition("EcoItems", PROVIDER_PACKAGE + "EcoItemsDataProvider"), + new ProviderDefinition("MythicMobs", PROVIDER_PACKAGE + "MythicMobsDataProvider"), + new ProviderDefinition("MythicCrucible", PROVIDER_PACKAGE + "MythicCrucibleDataProvider"), + new ProviderDefinition("KGenerators", PROVIDER_PACKAGE + "KGeneratorsDataProvider") + ); - private KList providers = new KList<>(), activeProviders = new KList<>(); + private final KList providers = new KList<>(); + private final KList activeProviders = new KList<>(); @Override public void onEnable() { IrisLogging.info("Loading ExternalDataProvider..."); Bukkit.getPluginManager().registerEvents(this, BukkitPlatform.plugin()); - providers.addAll(createProviders()); - for (ExternalDataProvider p : providers) { - if (p.isReady()) { - activeProviders.add(p); - p.init(); - BukkitPlatform.volmitPlugin().registerListener(p); - IrisLogging.info("Enabled ExternalDataProvider for %s.", p.getPluginId()); - } + for (ProviderDefinition definition : BUILT_IN_PROVIDERS) { + activateConfiguredProvider(definition); } } @Override public void onDisable() { + activeProviders.clear(); + providers.clear(); } @EventHandler - public void onPluginEnable(PluginEnableEvent e) { - if (activeProviders.stream().noneMatch(p -> e.getPlugin().equals(p.getPlugin()))) { - providers.stream().filter(p -> p.isReady() && e.getPlugin().equals(p.getPlugin())).findFirst().ifPresent(edp -> { - activeProviders.add(edp); - edp.init(); - BukkitPlatform.volmitPlugin().registerListener(edp); - IrisLogging.info("Enabled ExternalDataProvider for %s.", edp.getPluginId()); - }); + public void onPluginEnable(PluginEnableEvent event) { + String pluginId = event.getPlugin().getName(); + Optional existingProvider = findProvider(pluginId); + if (existingProvider.isPresent()) { + activateProvider(existingProvider.get()); + return; } + + BUILT_IN_PROVIDERS.stream() + .filter(definition -> definition.pluginId().equalsIgnoreCase(pluginId)) + .findFirst() + .ifPresent(this::activateConfiguredProvider); } - public void registerProvider(@NonNull ExternalDataProvider provider) { - String plugin = provider.getPluginId(); - if (providers.stream().map(ExternalDataProvider::getPluginId).anyMatch(plugin::equals)) + public void registerProvider(ExternalDataProvider provider) { + ExternalDataProvider registeredProvider = Objects.requireNonNull(provider); + String pluginId = registeredProvider.getPluginId(); + boolean builtIn = BUILT_IN_PROVIDERS.stream() + .anyMatch(definition -> definition.pluginId().equalsIgnoreCase(pluginId)); + if (builtIn || findProvider(pluginId).isPresent()) { throw new IllegalArgumentException("A provider with the same plugin id already exists."); + } - providers.add(provider); - if (provider.isReady()) { - activeProviders.add(provider); - provider.init(); - BukkitPlatform.volmitPlugin().registerListener(provider); + providers.add(registeredProvider); + if (registeredProvider.isReady()) { + activateProvider(registeredProvider); } } public Optional getBlockData(final Identifier key) { - var pair = parseState(key); + Pair> pair = parseState(key); Identifier mod = pair.getA(); Optional provider = activeProviders.stream().filter(p -> p.isValidProvider(mod, DataType.BLOCK)).findFirst(); @@ -201,20 +212,52 @@ public class ExternalDataSVC implements IrisService { return new Identifier(key.namespace(), path); } - private static KList createProviders() { - JarScanner jar = new JarScanner(IrisPlatforms.get().pluginJar(), "art.arcane.iris.core.link.data", false); - J.attempt(jar::scan); - KList providers = new KList<>(); + private Optional findProvider(String pluginId) { + return providers.stream() + .filter(provider -> provider.getPluginId().equalsIgnoreCase(pluginId)) + .findFirst(); + } - for (Class c : jar.getClasses()) { - if (ExternalDataProvider.class.isAssignableFrom(c)) { - try { - ExternalDataProvider p = (ExternalDataProvider) c.getDeclaredConstructor().newInstance(); - if (p.getPlugin() != null) IrisLogging.info(p.getPluginId() + " found, loading " + c.getSimpleName() + "..."); - providers.add(p); - } catch (Throwable ignored) {} - } + private void activateConfiguredProvider(ProviderDefinition definition) { + if (!isPluginEnabled(definition.pluginId()) || findProvider(definition.pluginId()).isPresent()) { + return; } - return providers; + + try { + Class rawProviderClass = Class.forName(definition.className(), true, ExternalDataSVC.class.getClassLoader()); + Class providerClass = rawProviderClass.asSubclass(ExternalDataProvider.class); + ExternalDataProvider provider = providerClass.getDeclaredConstructor().newInstance(); + IrisLogging.info(definition.pluginId() + " found, loading " + providerClass.getSimpleName() + "..."); + activateProvider(provider); + } catch (Throwable error) { + IrisLogging.reportError("Failed to create Iris external data provider " + definition.className() + ".", error); + } + } + + private void activateProvider(ExternalDataProvider provider) { + if (activeProviders.contains(provider)) { + return; + } + try { + provider.init(); + if (provider instanceof Listener listener) { + BukkitPlatform.volmitPlugin().registerListener(listener); + } + if (!providers.contains(provider)) { + providers.add(provider); + } + activeProviders.add(provider); + IrisLogging.info("Enabled ExternalDataProvider for %s.", provider.getPluginId()); + } catch (Throwable error) { + IrisLogging.reportError("Failed to enable Iris external data provider " + provider.getPluginId() + ".", error); + } + } + + private boolean isPluginEnabled(String pluginId) { + Plugin plugin = Bukkit.getPluginManager().getPlugin(pluginId); + return plugin != null && plugin.isEnabled(); + } + + private record ProviderDefinition(String pluginId, String className) { } } diff --git a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java index 11fbb8791..98975818a 100644 --- a/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java +++ b/core/src/main/java/art/arcane/iris/core/service/ObjectStudioSaveService.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.service; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.core.loader.IrisData; @@ -75,7 +76,7 @@ public class ObjectStudioSaveService implements IrisService { } public void register(Engine engine, ObjectStudioGenerator generator) { - World world = engine.getTarget().getWorld().realWorld(); + World world = BukkitWorldBinding.world(engine.getTarget().getWorld()); if (world == null) return; ObjectStudioLayout layout = generator.getLayout(); if (layout == null) return; diff --git a/core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java b/core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java index c94b77337..b0a1715cf 100644 --- a/core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java +++ b/core/src/main/java/art/arcane/iris/core/structure/StructureIndexService.java @@ -21,7 +21,8 @@ package art.arcane.iris.core.structure; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.util.project.matter.IrisMatterSupport; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisStructure; import com.google.gson.GsonBuilder; import java.io.File; @@ -36,14 +37,16 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public final class StructureIndexService { + private static final String INDEX_PATH = ".iris/structure-index.json"; + private static final String LEGACY_INDEX_PATH = "structures/structure-index.json"; + private static final String LEGACY_INDEX_KEY = "structure-index"; private static final Set GENERATED = ConcurrentHashMap.newKeySet(); - private static final boolean BUKKIT_PRESENT = IrisMatterSupport.isBukkitPresent(); private StructureIndexService() { } public static File writeOnce(IrisData data) { - if (!BUKKIT_PRESENT || data == null) { + if (data == null || !IrisPlatforms.isBound() || IrisPlatforms.get().structureHooks() == null) { return null; } @@ -63,6 +66,7 @@ public final class StructureIndexService { } public static File write(IrisData data) { + removeLegacyIndex(data); List structures = IrisPlatforms.get().structureHooks().structureKeys(); List sets = IrisPlatforms.get().structureHooks().structureSetKeys(); @@ -89,6 +93,7 @@ public final class StructureIndexService { List iris = new ArrayList<>(); if (data.getStructureLoader() != null) { Collections.addAll(iris, data.getStructureLoader().getPossibleKeys()); + iris.removeIf(LEGACY_INDEX_KEY::equals); } Collections.sort(vanilla); @@ -118,7 +123,7 @@ public final class StructureIndexService { root.put("structureSets", setsNode); root.put("iris", iris); - File file = new File(data.getDataFolder(), "structures/structure-index.json"); + File file = indexFile(data.getDataFolder()); try { file.getParentFile().mkdirs(); String json = new GsonBuilder().setPrettyPrinting().create().toJson(root); @@ -128,4 +133,27 @@ public final class StructureIndexService { } return file; } + + static File indexFile(File dataFolder) { + return new File(dataFolder, INDEX_PATH); + } + + static File legacyIndexFile(File dataFolder) { + return new File(dataFolder, LEGACY_INDEX_PATH); + } + + static void removeLegacyIndex(IrisData data) { + File legacyFile = legacyIndexFile(data.getDataFolder()); + try { + Files.deleteIfExists(legacyFile.toPath()); + ResourceLoader structureLoader = data.getStructureLoader(); + if (structureLoader != null) { + structureLoader.getLoadCache().invalidate(LEGACY_INDEX_KEY); + structureLoader.clearList(); + } + } catch (Throwable e) { + IrisLogging.reportError("Failed to remove legacy structure index '" + + legacyFile.getAbsolutePath() + "'", e); + } + } } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java index c0d1c3617..fe1ebe899 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisToolbelt.java @@ -18,6 +18,7 @@ package art.arcane.iris.core.tools; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisPlatforms; @@ -230,7 +231,7 @@ public class IrisToolbelt { if (activeProject != null) { PlatformChunkGenerator activeProvider = activeProject.getActiveProvider(); if (activeProvider != null) { - World activeWorld = activeProvider.getTarget().getWorld().realWorld(); + World activeWorld = BukkitWorldBinding.world(activeProvider.getTarget().getWorld()); if (activeWorld != null && WorldIdentity.key(activeWorld).equals(WorldIdentity.key(world))) { if (activeProvider instanceof BukkitChunkGenerator bukkit) { bukkit.touch(world); @@ -273,7 +274,7 @@ public class IrisToolbelt { } private static PregenCache resolvePregenCache(IrisWorld world) { - String worldIdentity = world.key() == null ? null : world.key().toString(); + String worldIdentity = world.identity(); if (worldIdentity == null) { throw new IllegalStateException("Pregeneration requires a namespaced world identity."); } @@ -306,14 +307,14 @@ public class IrisToolbelt { * @return the pregenerator job (already started) */ public static PregeneratorJob pregenerate(PregenTask task, PlatformChunkGenerator gen) { - World world = gen.getEngine().getWorld().realWorld(); + World world = BukkitWorldBinding.world(gen.getEngine().getWorld()); int threads = IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism()); PregeneratorMethod method = new HybridPregenMethod(world, threads); return pregenerate(task, method, gen.getEngine()); } public static PregeneratorJob pregenerateSerial(PregenTask task, PlatformChunkGenerator gen) { - World world = gen.getEngine().getWorld().realWorld(); + World world = BukkitWorldBinding.world(gen.getEngine().getWorld()); PregeneratorMethod method = HybridPregenMethod.strictSerial(world); return pregenerate(task, method, gen.getEngine()); } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java index 5378c02c9..e2c824adf 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisWorldCreator.java @@ -23,6 +23,7 @@ import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.platform.BukkitChunkGenerator; +import art.arcane.iris.platform.bukkit.BukkitEnvironment; import org.bukkit.NamespacedKey; import org.bukkit.World; import org.bukkit.WorldCreator; @@ -76,15 +77,15 @@ public class IrisWorldCreator { public WorldCreator create() { IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension; NamespacedKey worldKey = IrisWorldStorage.keyFromLegacyName(name); + World.Environment environment = findEnvironment(); IrisWorld w = IrisWorld.builder() - .key(worldKey) + .platformIdentity(worldKey.toString()) .name(name) .minHeight(dim.getMinHeight()) .maxHeight(dim.getMaxHeight()) .seed(seed) .worldFolder(IrisWorldStorage.dimensionRoot(worldKey)) - .environment(findEnvironment()) .build(); ChunkGenerator g = new BukkitChunkGenerator(w, studio, studio ? dim.getLoader().getDataFolder() : @@ -92,18 +93,14 @@ public class IrisWorldCreator { return WorldCreator.ofKey(worldKey) - .environment(w.environment()) + .environment(environment) .generateStructures(true) .generator(g).seed(seed); } private World.Environment findEnvironment() { IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension; - if (dim == null || dim.getEnvironment() == null) { - return World.Environment.NORMAL; - } else { - return dim.getEnvironment(); - } + return BukkitEnvironment.from(dim == null ? null : dim.getEnvironment()); } public IrisWorldCreator studio(boolean studio) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 0b3f200b1..ba155078b 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -20,16 +20,20 @@ package art.arcane.iris.engine; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineEffects; +import art.arcane.iris.engine.framework.EngineEffectsProvider; import art.arcane.iris.engine.framework.EngineMetrics; import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.EngineWorldManagerProvider; import art.arcane.iris.engine.framework.GenerationSessionException; import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.GenerationSessionManager; +import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.PreservationRegistry; import art.arcane.iris.engine.framework.SeedManager; +import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomePaletteLayer; @@ -46,18 +50,12 @@ import com.google.gson.Gson; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.protocol.IrisMessage; -import art.arcane.iris.core.ServerConfigurator; -import art.arcane.iris.core.events.IrisEngineHotloadEvent; -import art.arcane.iris.core.datapack.DatapackIngestService; -import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.protocol.IrisProtocolServer; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.Pair; -import art.arcane.iris.core.project.IrisProject; import art.arcane.iris.core.structure.StructureIndexService; -import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.util.common.data.B; @@ -112,6 +110,7 @@ public class IrisEngine implements Engine { private final ChronoLatch cleanLatch; private final SeedManager seedManager; private final GenerationSessionManager generationSessions; + private final EnginePlatformHooks platformHooks; private final AtomicBoolean closing; private CompletableFuture hash32; private EngineMode mode; @@ -132,6 +131,7 @@ public class IrisEngine implements Engine { public IrisEngine(EngineTarget target, boolean studio) { this.studio = studio; this.target = target; + this.platformHooks = IrisServices.get(EnginePlatformHooks.class); getEngineData(); verifySeed(); this.seedManager = new SeedManager(target.getWorld().getRawWorldSeed()); @@ -233,13 +233,15 @@ public class IrisEngine implements Engine { currentMode.close(); } - if (getWorld().hasRealWorld()) { - J.a(() -> new IrisProject(getData().getDataFolder()).updateWorkspace()); + if (getWorld().hasPlatformWorld()) { + J.a(() -> platformHooks.refreshWorkspace(this)); } } private void setupEngine() { try { + IrisStructureLocator.invalidate(this); + StructureReachability.invalidate(this); generationSessions.activateNextSession(); closing.set(false); IrisLogging.debug("Setup Engine " + getCacheID()); @@ -251,8 +253,8 @@ public class IrisEngine implements Engine { upperContext = buildUpperContext(); IrisLogging.debug("[IrisEngine timing] buildUpperContext=" + (M.ms() - t0) + "ms"); t0 = M.ms(); - effects = new IrisEngineEffects(this); - IrisLogging.debug("[IrisEngine timing] IrisEngineEffects=" + (M.ms() - t0) + "ms"); + effects = IrisServices.get(EngineEffectsProvider.class).create(this); + IrisLogging.debug("[IrisEngine timing] EngineEffects=" + (M.ms() - t0) + "ms"); hash32 = new CompletableFuture<>(); t0 = M.ms(); mantle.hotload(); @@ -276,7 +278,7 @@ public class IrisEngine implements Engine { .toArray(File[]::new); hash32.complete(IO.hashRecursiveMeta(roots)); }); - J.a(() -> DatapackIngestService.refreshWorkspace(getData())); + J.a(() -> platformHooks.refreshDatapackWorkspace(this)); } catch (Throwable e) { IrisLogging.error("FAILED TO SETUP ENGINE!"); IrisLogging.reportError(e); @@ -409,7 +411,7 @@ public class IrisEngine implements Engine { @Override public void hotload() { hotloadSilently(); - IrisPlatforms.get().callEvent(new IrisEngineHotloadEvent(this)); + platformHooks.fireHotloadEvent(this); } public void hotloadComplex() { @@ -422,15 +424,16 @@ public class IrisEngine implements Engine { try { getData().dump(); getData().clearLists(); - getTarget().setDimension(getData().getDimensionLoader().load(getDimension().getLoadKey())); + IrisDimension replacement = getData().getDimensionLoader().load(getDimension().getLoadKey()); + if (replacement == null) { + throw new IllegalStateException("Studio hotload could not reload Iris dimension '" + getDimension().getLoadKey() + "'"); + } + platformHooks.validateDimensionHotload(this, replacement); + getTarget().setDimension(replacement); prehotload(); setupEngine(); - if (getWorld().hasRealWorld()) { - J.a(() -> { - synchronized (ServerConfigurator.class) { - ServerConfigurator.installDataPacks(false); - } - }); + if (getWorld().hasPlatformWorld()) { + J.a(() -> platformHooks.reloadDatapacks(this)); } broadcastStudioHotload(false, ""); } catch (RuntimeException | Error e) { @@ -526,7 +529,7 @@ public class IrisEngine implements Engine { } private void computeBiomeMaxes() { - for (IrisBiome i : getDimension().getAllBiomes(this)) { + for (IrisBiome i : getDimension().getReachableBiomes(this)) { double density = 0; for (IrisObjectPlacement j : i.getObjects()) { @@ -613,7 +616,7 @@ public class IrisEngine implements Engine { @Override public void close() { - PregeneratorJob.shutdownInstance(); + platformHooks.shutdownPregenerator(this); closing.set(true); closed = true; J.car(art); @@ -649,8 +652,7 @@ public class IrisEngine implements Engine { } private boolean isPregeneratorActiveForThisWorld() { - PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - return pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(getWorld().identity()); + return platformHooks.isPregeneratorActive(this); } private void savePrefetchOnce() { @@ -728,7 +730,7 @@ public class IrisEngine implements Engine { activeMode.generate(x, z, blocks, vbiomes, multicore, lease.sessionId()); } - boolean skipRealFlag = J.isFolia() && getWorld().hasRealWorld() && IrisToolbelt.isWorldMaintenanceBypassingMantleStages(getWorld().realWorld()); + boolean skipRealFlag = platformHooks.shouldBypassMantleStages(this); if (!skipRealFlag) { getMantle().getMantle().flag(x >> 4, z >> 4, MantleFlag.REAL, true); } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java index 86f5d461d..b36cd785c 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineEffects.java @@ -22,69 +22,93 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedComponent; import art.arcane.iris.engine.framework.EngineEffects; import art.arcane.iris.engine.framework.EnginePlayer; -import art.arcane.volmlib.util.collection.KMap; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; +import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.math.M; -import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import org.bukkit.entity.Player; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicBoolean; public class IrisEngineEffects extends EngineAssignedComponent implements EngineEffects { - private final KMap players; + private static final long EFFECT_BUDGET_NANOS = 1_500_000L; + + private final ConcurrentHashMap players; private final Semaphore limit; + private final AtomicBoolean playerMapUpdateQueued; public IrisEngineEffects(Engine engine) { super(engine, "FX"); - players = new KMap<>(); + players = new ConcurrentHashMap<>(); limit = new Semaphore(1); + playerMapUpdateQueued = new AtomicBoolean(false); } @Override public void updatePlayerMap() { - List pr = getEngine().getWorld().getPlayers(); + if (!playerMapUpdateQueued.compareAndSet(false, true)) { + return; + } + if (!J.runGlobal(() -> { + try { + syncPlayerMap(); + } finally { + playerMapUpdateQueued.set(false); + } + })) { + playerMapUpdateQueued.set(false); + } + } + + private void syncPlayerMap() { + List pr = BukkitWorldBinding.players(getEngine().getWorld()); if (pr == null) { return; } - for (Player i : pr) { - boolean pcc = players.containsKey(i.getUniqueId()); - if (!pcc) { - players.put(i.getUniqueId(), new EnginePlayer(getEngine(), i)); + Set activeIds = new HashSet<>(Math.max(16, pr.size() * 2)); + for (Player player : pr) { + UUID playerId = player.getUniqueId(); + activeIds.add(playerId); + EnginePlayer existing = players.get(playerId); + if (existing == null || existing.getPlayer() != player) { + players.put(playerId, new EnginePlayer(getEngine(), player)); } } - for (UUID i : players.k()) { - if (!pr.contains(players.get(i).getPlayer())) { - players.remove(i); - } - } + players.keySet().removeIf((UUID playerId) -> !activeIds.contains(playerId)); } @Override public void tickRandomPlayer() { - if (limit.tryAcquire()) { - if (M.r(0.02)) { + if (!limit.tryAcquire()) { + return; + } + try { + if (players.isEmpty() || M.r(0.02)) { updatePlayerMap(); - limit.release(); return; } - if (players.isEmpty()) { - limit.release(); + List snapshot = new ArrayList<>(players.values()); + if (snapshot.isEmpty()) { return; } - double limitms = 1.5; - int max = players.size(); - PrecisionStopwatch p = new PrecisionStopwatch(); - - while (max-- > 0 && M.ms() - p.getMilliseconds() < limitms) { - players.v().getRandom().tick(); + long started = System.nanoTime(); + int remaining = snapshot.size(); + while (remaining-- > 0 && System.nanoTime() - started < EFFECT_BUDGET_NANOS) { + snapshot.get(ThreadLocalRandom.current().nextInt(snapshot.size())).tick(); } - + } finally { limit.release(); } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java index 0af5a2029..0c022b74b 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisWorldManager.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; @@ -35,6 +36,7 @@ import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisSpawner; import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.bukkit.WorldIdentity; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KSet; @@ -149,11 +151,11 @@ public class IrisWorldManager extends EngineAssignedWorldManager { interrupt(); } - if (!getEngine().getWorld().hasRealWorld() && clw.flip()) { - J.runGlobal(() -> getEngine().getWorld().tryGetRealWorld()); + if (!getEngine().getWorld().hasPlatformWorld() && clw.flip()) { + J.runGlobal(() -> BukkitWorldBinding.tryBind(getEngine().getWorld())); } - if (getEngine().getWorld().hasRealWorld()) { + if (getEngine().getWorld().hasPlatformWorld()) { if (chunkUpdater.flip()) { updateChunks(); } @@ -191,7 +193,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } private void discoverChunks() { - World world = getEngine().getWorld().realWorld(); + World world = BukkitWorldBinding.world(getEngine().getWorld()); if (world == null) { return; } @@ -206,7 +208,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { boolean scheduled = J.runGlobal(() -> { try { - if (getEngine().isClosed() || !world.equals(getEngine().getWorld().realWorld())) { + if (getEngine().isClosed() || !world.equals(BukkitWorldBinding.world(getEngine().getWorld()))) { return; } @@ -273,7 +275,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } private void updateChunks() { - World world = getEngine().getWorld().realWorld(); + World world = BukkitWorldBinding.world(getEngine().getWorld()); if (world == null) { return; } @@ -294,7 +296,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { private void updateChunksOnGlobal(World world) { try { - if (getEngine().isClosed() || !world.equals(getEngine().getWorld().realWorld())) { + if (getEngine().isClosed() || !world.equals(BukkitWorldBinding.world(getEngine().getWorld()))) { return; } @@ -496,7 +498,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { actuallySpawned = 0; - if (!getEngine().getWorld().hasRealWorld()) { + if (!getEngine().getWorld().hasPlatformWorld()) { IrisLogging.debug("Can't spawn. No real world"); J.sleep(5000); return false; @@ -504,7 +506,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { if (cl.flip()) { try { - World realWorld = getEngine().getWorld().realWorld(); + World realWorld = BukkitWorldBinding.world(getEngine().getWorld()); if (realWorld == null) { entityCount = 0; } else if (J.isFolia()) { @@ -540,7 +542,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } int spawnBuffer = RNG.r.i(2, 12); - World world = getEngine().getWorld().realWorld(); + World world = BukkitWorldBinding.world(getEngine().getWorld()); if (world == null) { return false; } @@ -560,7 +562,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } private boolean isPregenActiveForThisWorld() { - World world = getEngine().getWorld().realWorld(); + World world = BukkitWorldBinding.world(getEngine().getWorld()); if (world == null) { return false; } @@ -574,7 +576,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { return false; } - return job.targetsWorld(world); + return job.targetsWorldIdentity(WorldIdentity.serialize(world)); } private Position2[] getLoadedChunkPositionsSnapshot(World world) { @@ -1122,9 +1124,12 @@ public class IrisWorldManager extends EngineAssignedWorldManager { @Override public void onBlockBreak(BlockBreakEvent e) { - if (e.getBlock().getWorld().equals(getTarget().getWorld().realWorld())) { + if (e.getBlock().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { + int blockX = e.getBlock().getX(); + int mantleY = toMantleY(e.getBlock().getY(), getEngine().getWorld().minHeight()); + int blockZ = e.getBlock().getZ(); J.a(() -> { - MatterMarker marker = getMantle().get(e.getBlock().getX(), e.getBlock().getY(), e.getBlock().getZ(), MatterMarker.class); + MatterMarker marker = getMantle().get(blockX, mantleY, blockZ, MatterMarker.class); if (marker != null) { if (marker.getTag().equals("cave_floor") || marker.getTag().equals("cave_ceiling")) { @@ -1134,7 +1139,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { IrisMarker mark = getData().getMarkerLoader().load(marker.getTag()); if (mark == null || mark.isRemoveOnChange()) { - getMantle().remove(e.getBlock().getX(), e.getBlock().getY(), e.getBlock().getZ(), MatterMarker.class); + getMantle().remove(blockX, mantleY, blockZ, MatterMarker.class); } } }); @@ -1168,6 +1173,10 @@ public class IrisWorldManager extends EngineAssignedWorldManager { } } + static int toMantleY(int worldY, int minHeight) { + return worldY - minHeight; + } + private List filterDrops(KList drops, BlockBreakEvent e, IrisData data) { return new KList<>(drops.stream().filter(d -> d.shouldDropFor(e.getBlock().getBlockData(), data)).toList()); } @@ -1193,7 +1202,7 @@ public class IrisWorldManager extends EngineAssignedWorldManager { @Override public double getEntitySaturation() { - if (!getEngine().getWorld().hasRealWorld()) { + if (!getEngine().getWorld().hasPlatformWorld()) { return 1; } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/BukkitEngineWorldManager.java b/core/src/main/java/art/arcane/iris/engine/framework/BukkitEngineWorldManager.java new file mode 100644 index 000000000..8f60c26fd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/BukkitEngineWorldManager.java @@ -0,0 +1,36 @@ +/* + * Iris is a World Generator for Minecraft Bukkit 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 . + */ + +package art.arcane.iris.engine.framework; + +import org.bukkit.Chunk; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerTeleportEvent; + +public interface BukkitEngineWorldManager { + void onBlockBreak(BlockBreakEvent event); + + void onBlockPlace(BlockPlaceEvent event); + + void onChunkLoad(Chunk chunk, boolean generated); + + void onChunkUnload(Chunk chunk); + + void teleportAsync(PlayerTeleportEvent event); +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index 33ddc2f17..a5a5c3daa 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -22,12 +22,10 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.framework.render.RenderType; import art.arcane.iris.engine.framework.render.Renderer; -import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.Pair; -import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.data.chunk.TerrainChunk; @@ -94,6 +92,8 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat EngineMode getMode(); + EnginePlatformHooks getPlatformHooks(); + int getBlockUpdatesPerSecond(); void printMetrics(VolmitSender sender); @@ -352,7 +352,7 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat KMap v = new KMap<>(); IrisDimension dim = getDimension(); - dim.getAllBiomes(this).forEach((i) -> v.put(i.getLoadKey(), i)); + dim.getReachableBiomes(this).forEach((i) -> v.put(i.getLoadKey(), i)); return v.v(); } @@ -389,7 +389,7 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat } default IrisPosition lookForBiome(IrisBiome biome, long timeout, Consumer triesc) { - if (!getWorld().hasRealWorld()) { + if (!getWorld().hasPlatformWorld()) { IrisLogging.error("Cannot GOTO without a bound world (headless mode)"); return null; } @@ -398,7 +398,7 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat long s = M.ms(); int cpus = (Runtime.getRuntime().availableProcessors()); - if (!getDimension().getAllBiomes(this).contains(biome)) { + if (!getDimension().getReachableBiomes(this).contains(biome)) { return null; } @@ -460,7 +460,7 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat } default IrisPosition lookForRegion(IrisRegion reg, long timeout, Consumer triesc) { - if (!getWorld().hasRealWorld()) { + if (!getWorld().hasPlatformWorld()) { IrisLogging.error("Cannot GOTO without a bound world (headless mode)"); return null; } @@ -626,7 +626,7 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat return false; } - Set biomeKeys = getDimension().getAllBiomes(this).stream() + Set biomeKeys = getDimension().getReachableBiomes(this).stream() .filter((i) -> containsObjectPlacement(i.getObjects(), normalizedObjectKey)) .map(IrisRegistrant::getLoadKey) .collect(Collectors.toSet()); @@ -678,24 +678,11 @@ public interface Engine extends DataProvider, Fallible, LootProvider, BlockUpdat } default void cleanupMantleChunk(int x, int z) { - if (shouldSkipMaintenanceCleanup()) { + if (getPlatformHooks().shouldSkipMantleCleanup(this)) { return; } if (IrisSettings.get().getPerformance().isTrimMantleInStudio() || !isStudio()) { getMantle().cleanupChunk(x, z); } } - - private boolean shouldSkipMaintenanceCleanup() { - IrisWorld world = getWorld(); - if (world == null) { - return false; - } - String worldIdentity = world.identity(); - if (!WorldMaintenance.isWorldMaintenanceActive(worldIdentity)) { - return false; - } - PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - return pregeneratorJob == null || !pregeneratorJob.targetsWorldIdentity(worldIdentity); - } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java index 25f7233a4..13fc1ff46 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineAssignedWorldManager.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.core.events.IrisEngineHotloadEvent; import art.arcane.iris.util.common.format.C; @@ -36,7 +37,7 @@ import org.bukkit.event.world.WorldUnloadEvent; import java.util.concurrent.atomic.AtomicBoolean; -public abstract class EngineAssignedWorldManager extends EngineAssignedComponent implements EngineWorldManager, Listener { +public abstract class EngineAssignedWorldManager extends EngineAssignedComponent implements EngineWorldManager, BukkitEngineWorldManager, Listener { private final int taskId; protected AtomicBoolean ignoreTP = new AtomicBoolean(false); @@ -53,7 +54,7 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent @EventHandler public void on(IrisEngineHotloadEvent e) { - for (Player i : e.getEngine().getWorld().getPlayers()) { + for (Player i : BukkitWorldBinding.players(e.getEngine().getWorld())) { i.playSound(i.getLocation(), Sound.BLOCK_AMETHYST_BLOCK_BREAK, 1f, 1.8f); VolmitSender s = new VolmitSender(i); s.sendTitle(C.IRIS + "Engine " + C.AQUA + "Hotloaded", 70, 60, 410); @@ -62,42 +63,42 @@ public abstract class EngineAssignedWorldManager extends EngineAssignedComponent @EventHandler public void on(WorldSaveEvent e) { - if (e.getWorld().equals(getTarget().getWorld().realWorld())) { + if (e.getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { getEngine().save(); } } @EventHandler public void on(WorldUnloadEvent e) { - if (e.getWorld().equals(getTarget().getWorld().realWorld())) { + if (e.getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { getEngine().close(); } } @EventHandler public void on(BlockBreakEvent e) { - if (e.getPlayer().getWorld().equals(getTarget().getWorld().realWorld())) { + if (e.getPlayer().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { onBlockBreak(e); } } @EventHandler public void on(BlockPlaceEvent e) { - if (e.getPlayer().getWorld().equals(getTarget().getWorld().realWorld())) { + if (e.getPlayer().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { onBlockPlace(e); } } @EventHandler public void on(ChunkLoadEvent e) { - if (e.getChunk().getWorld().equals(getTarget().getWorld().realWorld())) { + if (e.getChunk().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { onChunkLoad(e.getChunk(), e.isNewChunk()); } } @EventHandler public void on(ChunkUnloadEvent e) { - if (e.getChunk().getWorld().equals(getTarget().getWorld().realWorld())) { + if (e.getChunk().getWorld().equals(BukkitWorldBinding.world(getTarget().getWorld()))) { onChunkUnload(e.getChunk()); } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineComponent.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineComponent.java index 8fa727274..a7d34420a 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineComponent.java @@ -21,8 +21,8 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisServices; import art.arcane.volmlib.util.math.RollingSequence; import art.arcane.iris.util.common.parallel.MultiBurst; @@ -39,10 +39,12 @@ public interface EngineComponent { default void close() { try { - BukkitPlatform.unregisterListener(this); + EngineComponentCleanup cleanup = IrisServices.getOrNull(EngineComponentCleanup.class); + if (cleanup != null) { + cleanup.release(this); + } } catch (Throwable e) { IrisLogging.reportError(e); - } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineComponentCleanup.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineComponentCleanup.java new file mode 100644 index 000000000..9b828a691 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineComponentCleanup.java @@ -0,0 +1,23 @@ +/* + * 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 . + */ + +package art.arcane.iris.engine.framework; + +public interface EngineComponentCleanup { + void release(EngineComponent component); +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineEffectsProvider.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineEffectsProvider.java new file mode 100644 index 000000000..dbbcd1772 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineEffectsProvider.java @@ -0,0 +1,23 @@ +/* + * 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 . + */ + +package art.arcane.iris.engine.framework; + +public interface EngineEffectsProvider { + EngineEffects create(Engine engine); +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java index 465cff9d1..5fd9e6059 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineMode.java @@ -18,8 +18,6 @@ package art.arcane.iris.engine.framework; -import art.arcane.iris.core.tools.WorldMaintenance; -import art.arcane.iris.core.gui.PregeneratorJob; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.util.project.context.ChunkContext; @@ -31,7 +29,6 @@ import art.arcane.iris.util.common.parallel.BurstExecutor; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.iris.util.common.scheduling.J; public interface EngineMode extends Staged { RollingSequence r = new RollingSequence(64); @@ -76,10 +73,7 @@ public interface EngineMode extends Staged { @BlockCoordinates default void generate(int x, int z, Hunk blocks, Hunk biomes, boolean multicore, long generationSessionId) { - boolean cacheContext = true; - if (J.isFolia() && getEngine().getWorld().hasRealWorld() && shouldDisableContextCacheForMaintenance()) { - cacheContext = false; - } + boolean cacheContext = !getEngine().getPlatformHooks().shouldDisableChunkContextCache(getEngine()); ChunkContext.PrefillPlan prefillPlan = cacheContext ? ChunkContext.PrefillPlan.NO_CAVE : ChunkContext.PrefillPlan.NONE; ChunkContext ctx = new ChunkContext(x, z, getComplex(), generationSessionId, cacheContext, prefillPlan, getEngine().getMetrics()); @@ -94,15 +88,4 @@ public interface EngineMode extends Staged { static boolean shouldDisableContextCacheForMaintenance(boolean maintenanceActive, boolean pregeneratorTargetsWorld) { return maintenanceActive && !pregeneratorTargetsWorld; } - - private boolean shouldDisableContextCacheForMaintenance() { - boolean maintenanceActive = WorldMaintenance.isWorldMaintenanceActive(getEngine().getWorld().identity()); - if (!maintenanceActive) { - return false; - } - - PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - boolean pregeneratorTargetsWorld = pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(getEngine().getWorld().identity()); - return shouldDisableContextCacheForMaintenance(maintenanceActive, pregeneratorTargetsWorld); - } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java new file mode 100644 index 000000000..c982c425c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java @@ -0,0 +1,61 @@ +/* + * 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 . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.iris.engine.object.IrisDimension; + +public interface EnginePlatformHooks { + default void refreshWorkspace(Engine engine) { + } + + default void refreshDatapackWorkspace(Engine engine) { + } + + default void reloadDatapacks(Engine engine) { + } + + default void fireHotloadEvent(Engine engine) { + } + + default void validateDimensionHotload(Engine engine, IrisDimension replacement) { + } + + default boolean isPregeneratorActive(Engine engine) { + return false; + } + + default void shutdownPregenerator(Engine engine) { + } + + default boolean shouldDisableChunkContextCache(Engine engine) { + return false; + } + + default boolean shouldSkipMantleCleanup(Engine engine) { + return false; + } + + default boolean shouldSkipMantleMarkerRead(Engine engine, int chunkX, int chunkZ) { + return false; + } + + default boolean shouldBypassMantleStages(Engine engine) { + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java index 6b0f84f36..aefc2be5f 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlayer.java @@ -19,13 +19,14 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.IrisSettings; -import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisEffect; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.platform.EngineBukkitOps; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; -import art.arcane.volmlib.util.math.M; import art.arcane.iris.util.common.scheduling.J; +import art.arcane.volmlib.util.math.M; import lombok.Data; import org.bukkit.Location; import org.bukkit.entity.Player; @@ -42,38 +43,40 @@ public class EnginePlayer { public EnginePlayer(Engine engine, Player player) { this.engine = engine; this.player = player; - lastLocation = player.getLocation().clone(); + lastLocation = null; lastSample = -1; - sample(); } public void tick() { - if (sample() || !IrisSettings.get().getWorld().isEffectSystem()) + J.runEntity(player, this::tickOnEntity); + } + + private void tickOnEntity() { + if (sample() || !IrisSettings.get().getWorld().isEffectSystem()) { return; + } - J.a(() -> { - if (region != null) { - for (IrisEffect j : region.getEffects()) { - try { - j.apply(player, getEngine()); - } catch (Throwable e) { - IrisLogging.reportError(e); + if (region != null) { + for (IrisEffect effect : region.getEffects()) { + try { + effect.apply(player, getEngine()); + } catch (Throwable e) { + IrisLogging.reportError(e); - } } } + } - if (biome != null) { - for (IrisEffect j : biome.getEffects()) { - try { - j.apply(player, getEngine()); - } catch (Throwable e) { - IrisLogging.reportError(e); + if (biome != null) { + for (IrisEffect effect : biome.getEffects()) { + try { + effect.apply(player, getEngine()); + } catch (Throwable e) { + IrisLogging.reportError(e); - } } } - }); + } } public long ticksSinceLastSample() { @@ -82,10 +85,13 @@ public class EnginePlayer { public boolean sample() { Location current = player.getLocation().clone(); - if (current.getWorld() != engine.getWorld().realWorld()) + if (current.getWorld() != BukkitWorldBinding.world(engine.getWorld())) { return true; + } try { - if (ticksSinceLastSample() > 55 && current.distanceSquared(lastLocation) > 9 * 9) { + boolean sampled = lastLocation != null; + double distanceSquared = sampled ? current.distanceSquared(lastLocation) : 0D; + if (needsSample(sampled, ticksSinceLastSample(), distanceSquared)) { lastLocation = current; lastSample = M.ms(); biome = EngineBukkitOps.getBiome(engine, current); @@ -98,4 +104,8 @@ public class EnginePlayer { } return true; } + + static boolean needsSample(boolean sampled, long elapsedMillis, double distanceSquared) { + return !sampled || elapsedMillis > 55L && distanceSquared > 81D; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java b/core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java index ff8cc8991..7d6c80e9a 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EngineWorldManager.java @@ -18,11 +18,6 @@ package art.arcane.iris.engine.framework; -import org.bukkit.Chunk; -import org.bukkit.event.block.BlockBreakEvent; -import org.bukkit.event.block.BlockPlaceEvent; -import org.bukkit.event.player.PlayerTeleportEvent; - @SuppressWarnings("EmptyMethod") public interface EngineWorldManager { void close(); @@ -36,14 +31,4 @@ public interface EngineWorldManager { void onTick(); void onSave(); - - void onBlockBreak(BlockBreakEvent e); - - void onBlockPlace(BlockPlaceEvent e); - - void onChunkLoad(Chunk e, boolean generated); - - void onChunkUnload(Chunk e); - - void teleportAsync(PlayerTeleportEvent e); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index 499fde4b2..bbecdfdb5 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -19,20 +19,22 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.ObjectPlaceMode; import art.arcane.iris.engine.object.StructureDistribution; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Set; /** @@ -40,17 +42,24 @@ import java.util.Set; * structure's own load key or its {@code vanillaSource} (so a vanilla key like * {@code minecraft:ancient_city} resolves to the imported {@code minecraft_ancient_city}). * - * A per-pack index of placed keys is cached so {@code /locate} and {@code /iris goto} skip + * A per-engine index of placed keys is cached so {@code /locate} and {@code /iris goto} skip * the grid scan entirely for keys that are not placed (keeping them as fast as vanilla). * * For RANDOM_SPREAD placements (the common, vanilla-style spaced grid) the locator jumps * straight to the single candidate chunk in each spacing-sized grid cell rather than testing * every chunk -- roughly a {@code spacing^2} reduction in placement checks, matching vanilla - * locate performance. Non-grid distributions (DENSITY / CONCENTRIC_RINGS) fall back to the - * exhaustive per-chunk ring scan. + * locate performance. Density placements use a bounded per-chunk ring scan, while concentric + * placements enumerate their finite configured starts directly. */ public final class IrisStructureLocator { - private static final com.github.benmanes.caffeine.cache.Cache INDEX_CACHE = Caffeine.newBuilder().weakKeys().build(); + private static final int DENSITY_CANDIDATE_BUDGET = 4_096; + + private static final Cache INDEX_CACHE = Caffeine.newBuilder().weakKeys().build(); + private static final PlacementIndex EMPTY_INDEX = new PlacementIndex( + Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyList()); + private static final LocateResult NOT_FOUND_RESULT = new LocateResult(LocateStatus.NOT_FOUND, 0, 0, 0); + private static final LocateResult SEARCH_LIMIT_RESULT = + new LocateResult(LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0); private IrisStructureLocator() { } @@ -67,70 +76,60 @@ public final class IrisStructureLocator { if (engine == null || key == null || key.isEmpty()) { return false; } - PlacementIndex idx = index(engine); - return idx.loadKeys.contains(key) || idx.vanillaSources.contains(key); + PlacementIndex placementIndex = index(engine); + String normalizedKey = normalize(key); + return placementIndex.normalizedLoadKeys.contains(normalizedKey) + || placementIndex.vanillaSources.contains(normalizedKey); } public static boolean suppressesVanilla(Engine engine, String vanillaKey) { if (engine == null || vanillaKey == null || vanillaKey.isEmpty()) { return false; } - for (String source : index(engine).vanillaSources) { - if (source.equalsIgnoreCase(vanillaKey)) { - return true; - } + return index(engine).vanillaSources.contains(normalize(vanillaKey)); + } + + public static void invalidate(Engine engine) { + if (engine != null) { + INDEX_CACHE.invalidate(engine); } - return false; } public static boolean startsInChunk(Engine engine, String key, int cx, int cz) { if (!isPlaced(engine, key)) { return false; } - long seed = engine.getSeedManager().getMantle(); - RNG rng = new RNG(Cache.key(cx, cz) + seed); - for (IrisStructurePlacement placement : placementsAt(engine, cx, cz)) { - if (!matches(placement, key, engine.getData())) { - continue; - } - if (StructurePlacementGrid.startsInChunk(placement, cx, cz, seed, rng)) { - return true; - } - } - return false; + return resolveInChunk(engine, key, cx, cz) != null; } - public static int[] locate(Engine engine, String key, int fromBlockX, int fromBlockZ, int maxRadiusChunks) { + public static LocateResult locate(Engine engine, String key, int fromBlockX, int fromBlockZ, int maxRadiusChunks) { if (!isPlaced(engine, key)) { - return null; + return NOT_FOUND_RESULT; } int max = Math.max(1, Math.min(maxRadiusChunks, 2048)); - - List gridParams = collectRandomSpreadParams(engine, key); - if (gridParams == null || gridParams.isEmpty()) { - // A non-grid distribution (DENSITY / CONCENTRIC_RINGS) matches this key, or no grid - // params could be resolved; fall back to the exhaustive per-chunk scan. - return locateByChunkScan(engine, key, fromBlockX, fromBlockZ, max); - } - - long seed = engine.getSeedManager().getMantle(); int pcx = fromBlockX >> 4; int pcz = fromBlockZ >> 4; long maxDistSq = (long) max * (long) max; - long bestDistSq = Long.MAX_VALUE; - int[] best = null; + LocatedCandidate best = null; + PlacementCatalog catalog = collectPlacementCatalog(engine, key); + if (catalog.randomSpread().isEmpty() && catalog.concentricRings().isEmpty() && !catalog.hasDensity()) { + return NOT_FOUND_RESULT; + } + SeedManager seedManager = engine.getSeedManager(); + if (seedManager == null) { + return NOT_FOUND_RESULT; + } + long seed = seedManager.getMantle(); - for (int[] params : gridParams) { - int spacing = Math.max(1, params[0]); - int separation = params[1]; - int salt = params[2]; + for (RandomSpreadParameters parameters : catalog.randomSpread) { + int spacing = Math.max(1, parameters.spacing()); int centerCellX = Math.floorDiv(pcx, spacing); int centerCellZ = Math.floorDiv(pcz, spacing); int cellRadius = (max / spacing) + 2; - int firstHitRing = -1; for (int r = 0; r <= cellRadius; r++) { - if (firstHitRing >= 0 && r > firstHitRing + 1) { + long lowerBound = cellRingDistanceLowerBound(r, spacing, pcx, pcz, centerCellX, centerCellZ); + if (best != null && lowerBound * lowerBound >= best.distanceSquared()) { break; } for (int dx = -r; dx <= r; dx++) { @@ -139,98 +138,360 @@ public final class IrisStructureLocator { continue; } int[] candidate = StructurePlacementGrid.randomSpreadCellChunk( - centerCellX + dx, centerCellZ + dz, spacing, separation, salt, seed); + centerCellX + dx, centerCellZ + dz, spacing, parameters.separation(), parameters.salt(), seed); int cx = candidate[0]; int cz = candidate[1]; - long ddx = (long) cx - pcx; - long ddz = (long) cz - pcz; - long distSq = ddx * ddx + ddz * ddz; - if (distSq > maxDistSq || distSq >= bestDistSq) { + long distSq = distanceSquared(cx, cz, pcx, pcz); + if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) { continue; } - if (startsInChunk(engine, key, cx, cz)) { - bestDistSq = distSq; - best = new int[]{cx << 4, structureY(engine, key, cx, cz), cz << 4}; - if (firstHitRing < 0) { - firstHitRing = r; - } + ResolvedPlacement resolved = resolveInChunk(engine, key, cx, cz); + if (resolved != null) { + best = new LocatedCandidate(resolved, distSq); } } } } } - return best; - } + Set checkedRingChunks = new LinkedHashSet<>(); + for (IrisStructurePlacement placement : catalog.concentricRings) { + int count = Math.max(1, placement.getRingCount()); + for (int placementIndex = 0; placementIndex < count; placementIndex++) { + int[] candidate = StructurePlacementGrid.concentricRingChunk(placement, placementIndex, seed); + if (candidate == null || !checkedRingChunks.add(chunkKey(candidate[0], candidate[1]))) { + continue; + } + long distSq = distanceSquared(candidate[0], candidate[1], pcx, pcz); + if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) { + continue; + } + ResolvedPlacement resolved = resolveInChunk(engine, key, candidate[0], candidate[1]); + if (resolved != null) { + best = new LocatedCandidate(resolved, distSq); + } + } + } - private static int[] locateByChunkScan(Engine engine, String key, int fromBlockX, int fromBlockZ, int max) { - int pcx = fromBlockX >> 4; - int pcz = fromBlockZ >> 4; - for (int r = 0; r <= max; r++) { - for (int dx = -r; dx <= r; dx++) { - for (int dz = -r; dz <= r; dz++) { - if (Math.max(Math.abs(dx), Math.abs(dz)) != r) { - continue; - } - int cx = pcx + dx; - int cz = pcz + dz; - if (startsInChunk(engine, key, cx, cz)) { - return new int[]{cx << 4, structureY(engine, key, cx, cz), cz << 4}; + if (catalog.hasDensity) { + int checkedDensityCandidates = 0; + for (int r = 0; r <= max; r++) { + if (best != null && (long) r * r > best.distanceSquared()) { + break; + } + for (int dx = -r; dx <= r; dx++) { + for (int dz = -r; dz <= r; dz++) { + if (Math.max(Math.abs(dx), Math.abs(dz)) != r) { + continue; + } + int cx = pcx + dx; + int cz = pcz + dz; + long distSq = distanceSquared(cx, cz, pcx, pcz); + if (distSq > maxDistSq || best != null && distSq >= best.distanceSquared()) { + continue; + } + if (checkedDensityCandidates >= DENSITY_CANDIDATE_BUDGET) { + return SEARCH_LIMIT_RESULT; + } + checkedDensityCandidates++; + ResolvedPlacement resolved = resolveInChunk(engine, key, cx, cz); + if (resolved != null) { + best = new LocatedCandidate(resolved, distSq); + } } } } } + + if (best == null) { + return NOT_FOUND_RESULT; + } + ResolvedPlacement resolved = best.resolved(); + return new LocateResult(LocateStatus.FOUND, resolved.originX(), resolved.baseY(), resolved.originZ()); + } + + public static ResolvedPlacement resolvePlacement(Engine engine, IrisStructurePlacement placement, int cx, int cz, int placementOrdinal) { + if (engine == null || placement == null || placement.getDistribution() == null + || placement.getStructures() == null || placement.getStructures().isEmpty() + || engine.getData() == null || engine.getSeedManager() == null) { + return null; + } + long seed = engine.getSeedManager().getMantle(); + if (!StructurePlacementGrid.startsInChunk(placement, cx, cz, seed, placementOrdinal)) { + return null; + } + + RNG rng = StructurePlacementGrid.placementRng(placement, cx, cz, seed, placementOrdinal); + int originX = (cx << 4) + rng.nextInt(16); + int originZ = (cz << 4) + rng.nextInt(16); + Integer baseY = resolveBaseY(engine, placement, originX, originZ, rng); + if (baseY == null) { + return null; + } + + String selectedKey = selectStructureKey(placement, rng); + if (selectedKey == null || selectedKey.isBlank()) { + return null; + } + IrisStructure structure = IrisData.loadAnyStructure(selectedKey, engine.getData()); + if (structure == null) { + return null; + } + + StructureAssembler assembler = new StructureAssembler(engine.getData(), structure, originX, baseY, originZ); + KList pieces = assembler.assemble(rng); + if (pieces == null || pieces.isEmpty()) { + return null; + } + pieces = alignSurfacePieces(pieces, placement, structure, baseY); + boolean exactY = hasExactY(placement, structure, pieces); + + int worldMin = engine.getMinHeight() + 1; + int worldMax = engine.getMinHeight() + engine.getHeight() - 1; + if (exactY) { + Integer verticalShift = resolveVerticalShift(pieces, placement, baseY, worldMin, worldMax); + if (verticalShift == null) { + return null; + } + if (verticalShift != 0) { + pieces = shiftPieces(pieces, verticalShift); + baseY += verticalShift; + } + } + + long configuredRadius = (long) Math.max(1, structure.getMaxSizeChunks()) * 16L; + int structureRadius = (int) Math.min(Integer.MAX_VALUE, configuredRadius); + if (!fitsHorizontalBounds(pieces, originX, originZ, structureRadius) + || (exactY && !fitsVerticalBounds(pieces, worldMin, worldMax))) { + return null; + } + return new ResolvedPlacement(placement, selectedKey, structure, pieces, rng, originX, baseY, originZ, exactY); + } + + static boolean fitsWorldBounds(KList pieces, int worldMin, int worldMax, + int originX, int originZ, int structureRadius) { + int[] bounds = computeBounds(pieces); + if (bounds == null) { + return false; + } + return fitsVerticalBounds(bounds, worldMin, worldMax) + && fitsHorizontalBounds(bounds, originX, originZ, structureRadius); + } + + private static ResolvedPlacement resolveInChunk(Engine engine, String key, int cx, int cz) { + IrisData data = engine.getData(); + KList placements = placementsAt(engine, cx, cz); + for (int placementOrdinal = 0; placementOrdinal < placements.size(); placementOrdinal++) { + IrisStructurePlacement placement = placements.get(placementOrdinal); + if (!matches(placement, key, data)) { + continue; + } + ResolvedPlacement resolved = resolvePlacement(engine, placement, cx, cz, placementOrdinal); + if (resolved != null && matchesResolved(resolved, key)) { + return resolved; + } + } return null; } - /** - * Collects the distinct {spacing, separation, salt} tuples of every RANDOM_SPREAD placement that - * matches the key across the dimension, its regions and biomes. Returns {@code null} if any - * matching placement uses a non-grid distribution (DENSITY / CONCENTRIC_RINGS), signalling the - * caller to fall back to the exhaustive per-chunk scan. - */ - private static List collectRandomSpreadParams(Engine engine, String key) { - IrisData data = engine.getData(); - List params = new ArrayList<>(); - Set seen = new LinkedHashSet<>(); - - KList all = new KList<>(); - all.addAll(engine.getDimension().getStructures()); - for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { - all.addAll(region.getStructures()); + private static Integer resolveBaseY(Engine engine, IrisStructurePlacement placement, int originX, int originZ, RNG rng) { + int worldMin = engine.getMinHeight() + 1; + int worldMax = engine.getMinHeight() + engine.getHeight() - 1; + if (worldMin > worldMax) { + return null; } - for (IrisBiome biome : engine.getDimension().getAllBiomes(engine)) { - all.addAll(biome.getStructures()); + if (!placement.isUnderground()) { + int surfaceY = engine.getHeight(originX, originZ, true) + engine.getMinHeight(); + return surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight() ? null : surfaceY; } + int bandMin = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight())); + int bandMax = Math.min(worldMax, Math.max(placement.getMinHeight(), placement.getMaxHeight())); + if (bandMin > bandMax) { + return null; + } + return randomInclusive(rng, bandMin, bandMax); + } - for (IrisStructurePlacement placement : all) { - if (placement == null) { + static Integer resolveVerticalShift(KList pieces, IrisStructurePlacement placement, + int baseY, int worldMin, int worldMax) { + int[] bounds = computeBounds(pieces); + if (bounds == null) { + return null; + } + if (!placement.isUnderground()) { + return bounds[1] >= worldMin && bounds[4] <= worldMax ? 0 : null; + } + int bandMin = Math.max(worldMin, Math.min(placement.getMinHeight(), placement.getMaxHeight())); + int bandMax = Math.min(worldMax, Math.max(placement.getMinHeight(), placement.getMaxHeight())); + int minimumShift = Math.max(worldMin - bounds[1], bandMin - baseY); + int maximumShift = Math.min(worldMax - bounds[4], bandMax - baseY); + if (minimumShift > maximumShift) { + return null; + } + if (minimumShift > 0) { + return minimumShift; + } + if (maximumShift < 0) { + return maximumShift; + } + return 0; + } + + static String selectStructureKey(IrisStructurePlacement placement, RNG rng) { + if (placement == null || placement.getStructures() == null || placement.getStructures().isEmpty()) { + return null; + } + return placement.getStructures().get(rng.nextInt(placement.getStructures().size())); + } + + private static KList alignSurfacePieces(KList pieces, + IrisStructurePlacement placement, + IrisStructure structure, int baseY) { + if (placement.isUnderground() || pieces.size() <= 1 + || structure.getPlaceMode() == ObjectPlaceMode.STRUCTURE_PIECE + || structure.getPlaceMode() == ObjectPlaceMode.FLOATING) { + return pieces; + } + int[] bounds = computeBounds(pieces); + return bounds == null ? pieces : shiftPieces(pieces, baseY - bounds[1]); + } + + static boolean hasExactY(IrisStructurePlacement placement, IrisStructure structure, + KList pieces) { + return placement.isUnderground() || pieces.size() > 1 + || structure.getPlaceMode() == ObjectPlaceMode.STRUCTURE_PIECE + || structure.getPlaceMode() == ObjectPlaceMode.FLOATING; + } + + private static boolean fitsVerticalBounds(KList pieces, int worldMin, int worldMax) { + int[] bounds = computeBounds(pieces); + return bounds != null && fitsVerticalBounds(bounds, worldMin, worldMax); + } + + private static boolean fitsVerticalBounds(int[] bounds, int worldMin, int worldMax) { + return bounds[1] >= worldMin && bounds[4] <= worldMax; + } + + private static boolean fitsHorizontalBounds(KList pieces, int originX, int originZ, + int structureRadius) { + int[] bounds = computeBounds(pieces); + return bounds != null && fitsHorizontalBounds(bounds, originX, originZ, structureRadius); + } + + private static boolean fitsHorizontalBounds(int[] bounds, int originX, int originZ, int structureRadius) { + return bounds[0] >= originX - structureRadius && bounds[3] <= originX + structureRadius + && bounds[2] >= originZ - structureRadius && bounds[5] <= originZ + structureRadius; + } + + private static KList shiftPieces(KList pieces, int shiftY) { + KList shifted = new KList<>(); + for (PlacedStructurePiece piece : pieces) { + shifted.add(new PlacedStructurePiece( + piece.getPiece(), piece.getObject(), piece.getX(), piece.getY() + shiftY, piece.getZ(), piece.getRotation(), + piece.getMinX(), piece.getMinY() + shiftY, piece.getMinZ(), + piece.getMaxX(), piece.getMaxY() + shiftY, piece.getMaxZ())); + } + return shifted; + } + + private static int[] computeBounds(KList pieces) { + if (pieces == null || pieces.isEmpty()) { + return null; + } + int minX = Integer.MAX_VALUE; + int minY = Integer.MAX_VALUE; + int minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE; + int maxY = Integer.MIN_VALUE; + int maxZ = Integer.MIN_VALUE; + for (PlacedStructurePiece piece : pieces) { + minX = Math.min(minX, piece.getMinX()); + minY = Math.min(minY, piece.getMinY()); + minZ = Math.min(minZ, piece.getMinZ()); + maxX = Math.max(maxX, piece.getMaxX()); + maxY = Math.max(maxY, piece.getMaxY()); + maxZ = Math.max(maxZ, piece.getMaxZ()); + } + return new int[]{minX, minY, minZ, maxX, maxY, maxZ}; + } + + private static int randomInclusive(RNG rng, int minimum, int maximum) { + if (minimum == maximum) { + return minimum; + } + return minimum + rng.nextInt((maximum - minimum) + 1); + } + + private static PlacementCatalog collectPlacementCatalog(Engine engine, String key) { + Set randomSpread = new LinkedHashSet<>(); + List concentricRings = new ArrayList<>(); + boolean hasDensity = false; + for (IrisStructurePlacement placement : index(engine).placements) { + if (!matches(placement, key, engine.getData())) { continue; } - if (!matches(placement, key, data)) { - continue; - } - if (placement.getDistribution() != StructureDistribution.RANDOM_SPREAD) { - return null; - } - int spacing = Math.max(1, placement.getSpacing()); - int separation = placement.getSeparation(); - int salt = placement.getSalt(); - long packed = ((long) spacing << 42) ^ ((long) (separation & 0x1FFFFF) << 21) ^ (salt & 0x1FFFFFL); - if (seen.add(packed)) { - params.add(new int[]{spacing, separation, salt}); + if (placement.getDistribution() == StructureDistribution.RANDOM_SPREAD) { + randomSpread.add(new RandomSpreadParameters( + Math.max(1, placement.getSpacing()), placement.getSeparation(), placement.getSalt())); + } else if (placement.getDistribution() == StructureDistribution.CONCENTRIC_RINGS) { + concentricRings.add(placement); + } else if (isSearchableDensityPlacement(engine, placement)) { + hasDensity = true; } } + return new PlacementCatalog(List.copyOf(randomSpread), List.copyOf(concentricRings), hasDensity); + } - return params; + static boolean isSearchableDensityPlacement(Engine engine, IrisStructurePlacement placement) { + if (engine == null || placement == null || placement.getDistribution() != StructureDistribution.DENSITY + || !(placement.getDensity() > 0.0) || engine.getHeight() <= 0) { + return false; + } + long worldMin = (long) engine.getMinHeight() + (placement.isUnderground() ? 1L : 0L); + long worldMax = (long) engine.getMinHeight() + engine.getHeight() - 1L; + long configuredMin = placement.isUnderground() + ? Math.min(placement.getMinHeight(), placement.getMaxHeight()) + : placement.getMinHeight(); + long configuredMax = placement.isUnderground() + ? Math.max(placement.getMinHeight(), placement.getMaxHeight()) + : placement.getMaxHeight(); + return configuredMin <= configuredMax && configuredMin <= worldMax && configuredMax >= worldMin; + } + + private static long distanceSquared(int cx, int cz, int pcx, int pcz) { + long dx = (long) cx - pcx; + long dz = (long) cz - pcz; + return dx * dx + dz * dz; + } + + static long cellRingDistanceLowerBound(int ring, int spacing, int pcx, int pcz, + int centerCellX, int centerCellZ) { + if (ring <= 0) { + return 0L; + } + int localX = pcx - (centerCellX * spacing); + int localZ = pcz - (centerCellZ * spacing); + long ringOffset = (long) ring * spacing; + long positiveX = ringOffset - localX; + long negativeX = ringOffset + localX - (spacing - 1L); + long positiveZ = ringOffset - localZ; + long negativeZ = ringOffset + localZ - (spacing - 1L); + return Math.min(Math.min(positiveX, negativeX), Math.min(positiveZ, negativeZ)); + } + + private static long chunkKey(int cx, int cz) { + return ((long) cx << 32) ^ (cz & 0xffffffffL); } private static KList placementsAt(Engine engine, int cx, int cz) { + KList placements = new KList<>(); + if (engine.getDimension() == null || engine.getComplex() == null) { + return placements; + } int bx = 8 + (cx << 4); int bz = 8 + (cz << 4); IrisBiome biome = engine.getComplex().getTrueBiomeStream().get(bx, bz); IrisRegion region = engine.getComplex().getRegionStream().get(bx, bz); - KList placements = new KList<>(); if (biome != null) { placements.addAll(biome.getStructures()); } @@ -242,90 +503,140 @@ public final class IrisStructureLocator { } private static boolean matches(IrisStructurePlacement placement, String key, IrisData data) { + if (placement == null || placement.getStructures() == null || key == null || data == null) { + return false; + } + String normalizedKey = normalize(key); for (String structureKey : placement.getStructures()) { - if (structureKey == null) { + if (structureKey == null || structureKey.isBlank()) { continue; } - if (structureKey.equalsIgnoreCase(key)) { - return true; - } IrisStructure structure = IrisData.loadAnyStructure(structureKey, data); - if (structure != null) { - String vanillaSource = structure.getVanillaSource(); - if (vanillaSource != null && !vanillaSource.isEmpty() && vanillaSource.equalsIgnoreCase(key)) { - return true; - } + if (structure == null) { + continue; + } + if (normalize(structureKey).equals(normalizedKey) + || normalize(structure.getLoadKey()).equals(normalizedKey) + || normalize(structure.getVanillaSource()).equals(normalizedKey)) { + return true; } } return false; } - private static int structureY(Engine engine, String key, int cx, int cz) { - IrisData data = engine.getData(); - int bx = 8 + (cx << 4); - int bz = 8 + (cz << 4); - for (IrisStructurePlacement placement : placementsAt(engine, cx, cz)) { - if (!matches(placement, key, data)) { - continue; - } - if (placement.isUnderground()) { - int lo = Math.max(engine.getMinHeight() + 1, Math.min(placement.getMinHeight(), placement.getMaxHeight())); - int hi = Math.min(engine.getMinHeight() + engine.getHeight() - 1, Math.max(placement.getMinHeight(), placement.getMaxHeight())); - return (lo + hi) / 2; - } - } - return engine.getHeight(bx, bz) + engine.getMinHeight(); + static boolean matchesResolved(ResolvedPlacement resolved, String key) { + String normalizedKey = normalize(key); + return normalize(resolved.structureKey()).equals(normalizedKey) + || normalize(resolved.structure().getLoadKey()).equals(normalizedKey) + || normalize(resolved.structure().getVanillaSource()).equals(normalizedKey); + } + + private static String normalize(String key) { + return key == null ? "" : key.toLowerCase(Locale.ROOT); } private static PlacementIndex index(Engine engine) { + if (engine == null || engine.getData() == null || engine.getDimension() == null) { + return EMPTY_INDEX; + } + return INDEX_CACHE.get(engine, ignored -> build(engine)); + } + + private static PlacementIndex build(Engine engine) { IrisData data = engine.getData(); - return INDEX_CACHE.get(data, (IrisData keyData) -> build(engine, keyData)); - } - - private static PlacementIndex build(Engine engine, IrisData data) { Set loadKeys = new LinkedHashSet<>(); + Set normalizedLoadKeys = new LinkedHashSet<>(); Set vanillaSources = new LinkedHashSet<>(); - collect(engine.getDimension().getStructures(), data, loadKeys, vanillaSources); + List placements = new ArrayList<>(); + collect(engine.getDimension().getStructures(), data, loadKeys, normalizedLoadKeys, vanillaSources, placements); for (IrisRegion region : engine.getDimension().getAllRegions(engine)) { - collect(region.getStructures(), data, loadKeys, vanillaSources); + collect(region.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaSources, placements); } - for (IrisBiome biome : engine.getDimension().getAllBiomes(engine)) { - collect(biome.getStructures(), data, loadKeys, vanillaSources); + for (IrisBiome biome : engine.getDimension().getReachableBiomes(engine)) { + collect(biome.getStructures(), data, loadKeys, normalizedLoadKeys, vanillaSources, placements); } - return new PlacementIndex(loadKeys, vanillaSources); + return new PlacementIndex( + Collections.unmodifiableSet(loadKeys), + Collections.unmodifiableSet(normalizedLoadKeys), + Collections.unmodifiableSet(vanillaSources), + List.copyOf(placements)); } - private static void collect(KList placements, IrisData data, Set loadKeys, Set vanillaSources) { - if (placements == null) { + private static void collect(KList source, IrisData data, Set loadKeys, + Set normalizedLoadKeys, Set vanillaSources, + List placements) { + if (source == null) { return; } - for (IrisStructurePlacement placement : placements) { - if (placement == null) { + for (IrisStructurePlacement placement : source) { + if (placement == null || placement.getStructures() == null) { continue; } + boolean validPlacement = false; for (String structureKey : placement.getStructures()) { - if (structureKey == null || structureKey.isEmpty()) { + if (structureKey == null || structureKey.isBlank()) { continue; } - loadKeys.add(structureKey); IrisStructure structure = IrisData.loadAnyStructure(structureKey, data); - if (structure != null) { - String vanillaSource = structure.getVanillaSource(); - if (vanillaSource != null && !vanillaSource.isEmpty()) { - vanillaSources.add(vanillaSource); - } + if (structure == null) { + continue; + } + validPlacement = true; + loadKeys.add(structureKey); + normalizedLoadKeys.add(normalize(structureKey)); + if (structure.getLoadKey() != null && !structure.getLoadKey().isBlank()) { + loadKeys.add(structure.getLoadKey()); + normalizedLoadKeys.add(normalize(structure.getLoadKey())); + } + if (structure.getVanillaSource() != null && !structure.getVanillaSource().isBlank()) { + vanillaSources.add(normalize(structure.getVanillaSource())); } } + if (validPlacement) { + placements.add(placement); + } } } + public record ResolvedPlacement(IrisStructurePlacement placement, String structureKey, IrisStructure structure, + KList pieces, RNG rng, + int originX, int baseY, int originZ, boolean exactY) { + } + + public enum LocateStatus { + FOUND, + NOT_FOUND, + SEARCH_LIMIT_REACHED + } + + public record LocateResult(LocateStatus status, int originX, int baseY, int originZ) { + public boolean found() { + return status == LocateStatus.FOUND; + } + } + + private record RandomSpreadParameters(int spacing, int separation, int salt) { + } + + private record PlacementCatalog(List randomSpread, + List concentricRings, boolean hasDensity) { + } + + private record LocatedCandidate(ResolvedPlacement resolved, long distanceSquared) { + } + private static final class PlacementIndex { private final Set loadKeys; + private final Set normalizedLoadKeys; private final Set vanillaSources; + private final List placements; - private PlacementIndex(Set loadKeys, Set vanillaSources) { + private PlacementIndex(Set loadKeys, Set normalizedLoadKeys, + Set vanillaSources, List placements) { this.loadKeys = loadKeys; + this.normalizedLoadKeys = normalizedLoadKeys; this.vanillaSources = vanillaSources; + this.placements = placements; } } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java index 38e40b8f7..7eed38c96 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructurePlacementGrid.java @@ -22,17 +22,23 @@ import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.volmlib.util.math.RNG; public final class StructurePlacementGrid { + private static final long DENSITY_SIGNATURE = 0x6A09E667F3BCC909L; + private StructurePlacementGrid() { } - public static boolean startsInChunk(IrisStructurePlacement placement, int cx, int cz, long seed, RNG chunkRng) { + public static boolean startsInChunk(IrisStructurePlacement placement, int cx, int cz, long seed, int placementOrdinal) { return switch (placement.getDistribution()) { case RANDOM_SPREAD -> randomSpreadStart(cx, cz, placement.getSpacing(), placement.getSeparation(), placement.getSalt(), seed); - case DENSITY -> chunkRng.chance(placement.getDensity()); + case DENSITY -> densityStart(placement, cx, cz, seed, placementOrdinal); case CONCENTRIC_RINGS -> concentricRingsStart(cx, cz, placement, seed); }; } + public static RNG placementRng(IrisStructurePlacement placement, int cx, int cz, long seed, int placementOrdinal) { + return new RNG(placementSeed(placement, cx, cz, seed, placementOrdinal)); + } + public static boolean randomSpreadStart(int cx, int cz, int spacing, int separation, int salt, long seed) { int sp = Math.max(1, spacing); int cellX = Math.floorDiv(cx, sp); @@ -51,11 +57,36 @@ public final class StructurePlacementGrid { int sep = Math.max(0, Math.min(separation, sp - 1)); RNG r = new RNG(mix(seed, cellX, cellZ, salt)); int range = Math.max(1, sp - sep); - int startCx = cellX * sp + r.i(0, range - 1); - int startCz = cellZ * sp + r.i(0, range - 1); + int startCx = cellX * sp + r.nextInt(range); + int startCz = cellZ * sp + r.nextInt(range); return new int[]{startCx, startCz}; } + public static int[] concentricRingChunk(IrisStructurePlacement placement, int placementIndex, long seed) { + int count = Math.max(1, placement.getRingCount()); + if (placementIndex < 0 || placementIndex >= count) { + return null; + } + int distance = Math.max(1, placement.getRingDistance()); + int spread = Math.max(1, placement.getRingSpread()); + int ring = Math.floorDiv(placementIndex, spread) + 1; + int firstIndex = (ring - 1) * spread; + int slots = Math.min(spread, count - firstIndex); + int slot = placementIndex - firstIndex; + double slotAngle = (2.0 * Math.PI) / slots; + double offset = unitDouble(mix(seed, ring, count, placement.getSalt())) * slotAngle; + double angle = offset + (slot * slotAngle); + long configuredRadius = (long) ring * distance; + if (configuredRadius > Integer.MAX_VALUE) { + return null; + } + int radius = (int) configuredRadius; + return new int[]{ + (int) Math.round(Math.cos(angle) * radius), + (int) Math.round(Math.sin(angle) * radius) + }; + } + private static boolean concentricRingsStart(int cx, int cz, IrisStructurePlacement placement, long seed) { if (cx == 0 && cz == 0) { return false; @@ -64,23 +95,61 @@ public final class StructurePlacementGrid { int count = Math.max(1, placement.getRingCount()); int spread = Math.max(1, placement.getRingSpread()); double dist = Math.sqrt((double) cx * cx + (double) cz * cz); - int ring = (int) Math.round(dist / distance); - if (ring <= 0) { + int estimatedRing = (int) Math.round(dist / distance); + int ringCount = Math.ceilDiv(count, spread); + int firstRing = Math.max(1, estimatedRing - 1); + int lastRing = Math.min(ringCount, estimatedRing + 1); + for (int ring = firstRing; ring <= lastRing; ring++) { + int firstIndex = (ring - 1) * spread; + int slots = Math.min(spread, count - firstIndex); + if (slots <= 0) { + continue; + } + double slotAngle = (2.0 * Math.PI) / slots; + double offset = unitDouble(mix(seed, ring, count, placement.getSalt())) * slotAngle; + double slotPosition = (Math.atan2(cz, cx) - offset) / slotAngle; + int nearestSlot = Math.floorMod((int) Math.round(slotPosition), slots); + for (int delta = -1; delta <= 1; delta++) { + int slot = Math.floorMod(nearestSlot + delta, slots); + int[] candidate = concentricRingChunk(placement, firstIndex + slot, seed); + if (candidate != null && candidate[0] == cx && candidate[1] == cz) { + return true; + } + } + } + return false; + } + + private static boolean densityStart(IrisStructurePlacement placement, int cx, int cz, long seed, int placementOrdinal) { + double density = placement.getDensity(); + if (density <= 0.0) { return false; } - int ringRadius = ring * distance; - int slots = Math.min(spread * ring, count); - if (slots <= 0) { - return false; + if (density >= 1.0) { + return true; } - double slotAngle = (2 * Math.PI) / slots; - double offset = (mix(seed, ring, 0, 0) & 0xFFFFL) / 65536.0 * slotAngle; - double angle = Math.atan2(cz, cx); - int slotIndex = (int) Math.round((angle - offset) / slotAngle); - double idealAngle = offset + slotIndex * slotAngle; - int idealCx = (int) Math.round(Math.cos(idealAngle) * ringRadius); - int idealCz = (int) Math.round(Math.sin(idealAngle) * ringRadius); - return idealCx == cx && idealCz == cz; + RNG rng = new RNG(placementSeed(placement, cx, cz, seed, placementOrdinal) ^ DENSITY_SIGNATURE); + return rng.chance(density); + } + + private static long placementSeed(IrisStructurePlacement placement, int cx, int cz, long seed, int placementOrdinal) { + long signature = 1469598103934665603L; + signature = (signature ^ placement.getDistribution().ordinal()) * 1099511628211L; + signature = (signature ^ placement.getSalt()) * 1099511628211L; + signature = (signature ^ placementOrdinal) * 1099511628211L; + for (String key : placement.getStructures()) { + if (key == null) { + continue; + } + for (int i = 0; i < key.length(); i++) { + signature = (signature ^ key.charAt(i)) * 1099511628211L; + } + } + return mix(seed ^ signature, cx, cz, placement.getSalt() ^ placementOrdinal); + } + + private static double unitDouble(long value) { + return (value >>> 11) * 0x1.0p-53; } public static long mix(long seed, int a, int b, int salt) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java index 79be4da4b..94f35f27a 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureReachability.java @@ -18,17 +18,15 @@ package art.arcane.iris.engine.framework; -import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisWorld; -import art.arcane.iris.platform.bukkit.BukkitWorld; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.volmlib.util.collection.KList; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import java.util.Collections; import java.util.LinkedHashSet; -import java.util.Map; import java.util.Set; -import java.util.WeakHashMap; /** * Determines which vanilla/datapack structures can ever generate in a world, using the same biome @@ -37,10 +35,10 @@ import java.util.WeakHashMap; * never produced cannot generate and must not be scanned for by {@code /locate} or {@code /iris find} * (an unbounded scan for an absent biome is what stalls the server). * - *

The reachable set is fixed per pack/world, so it is cached per {@link IrisData}. + *

The reachable set is fixed per live engine/world and is rebuilt after hotload. */ public final class StructureReachability { - private static final Map> REACHABLE_CACHE = Collections.synchronizedMap(new WeakHashMap<>()); + private static final Cache> REACHABLE_CACHE = Caffeine.newBuilder().weakKeys().build(); private StructureReachability() { } @@ -49,17 +47,10 @@ public final class StructureReachability { if (engine == null) { return Collections.emptySet(); } - IrisData data = engine.getData(); - if (data == null) { + if (engine.getData() == null) { return Collections.emptySet(); } - Set cached = REACHABLE_CACHE.get(data); - if (cached != null) { - return cached; - } - Set built = build(engine); - REACHABLE_CACHE.put(data, built); - return built; + return REACHABLE_CACHE.get(engine, ignored -> build(engine)); } public static boolean isReachable(Engine engine, String structureKey) { @@ -73,24 +64,21 @@ public final class StructureReachability { if (engine == null) { return; } - IrisData data = engine.getData(); - if (data != null) { - REACHABLE_CACHE.remove(data); - } + REACHABLE_CACHE.invalidate(engine); } private static Set build(Engine engine) { IrisWorld world = engine.getWorld(); - if (world == null || world.realWorld() == null) { + if (world == null || world.platformWorld() == null) { return Collections.emptySet(); } Set reachable = new LinkedHashSet<>(); - for (String key : IrisPlatforms.get().structureHooks().reachableStructureKeys(new BukkitWorld(world.realWorld()))) { + for (String key : IrisPlatforms.get().structureHooks().reachableStructureKeys(world.platformWorld())) { if (key != null && !key.isEmpty()) { reachable.add(key.toLowerCase()); } } - return reachable; + return Collections.unmodifiableSet(reachable); } /** @@ -104,11 +92,11 @@ public final class StructureReachability { return missing; } IrisWorld world = engine.getWorld(); - if (world == null || world.realWorld() == null) { + if (world == null || world.platformWorld() == null) { return missing; } Set possible = new LinkedHashSet<>(); - for (String key : IrisPlatforms.get().structureHooks().possibleBiomeKeys(new BukkitWorld(world.realWorld()))) { + for (String key : IrisPlatforms.get().structureHooks().possibleBiomeKeys(world.platformWorld())) { if (key != null) { possible.add(key.toLowerCase()); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureVerticalBounds.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureVerticalBounds.java new file mode 100644 index 000000000..05e67668c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureVerticalBounds.java @@ -0,0 +1,51 @@ +/* + * 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 . + */ + +package art.arcane.iris.engine.framework; + +public final class StructureVerticalBounds { + private StructureVerticalBounds() { + } + + public static int clampOffset( + int structureMinY, + int structureMaxY, + int requestedOffset, + int worldMinY, + int worldMaxYExclusive + ) { + if (structureMaxY < structureMinY) { + throw new IllegalArgumentException("Structure maximum Y cannot be below its minimum Y"); + } + if (worldMaxYExclusive <= worldMinY) { + throw new IllegalArgumentException("World maximum Y must be above its minimum Y"); + } + + long structureHeight = (long) structureMaxY - structureMinY + 1L; + long worldHeight = (long) worldMaxYExclusive - worldMinY; + if (structureHeight > worldHeight) { + throw new IllegalArgumentException("Structure height " + structureHeight + + " exceeds writable world height " + worldHeight); + } + + long minimumOffset = (long) worldMinY - structureMinY; + long maximumOffset = (long) worldMaxYExclusive - 1L - structureMaxY; + long clamped = Math.max(minimumOffset, Math.min(maximumOffset, (long) requestedOffset)); + return Math.toIntExact(clamped); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 9975b410e..349b0dc73 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -44,7 +44,6 @@ import art.arcane.volmlib.util.matter.MatterMarker; import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.slices.UpdateMatter; import art.arcane.iris.util.common.parallel.MultiBurst; -import art.arcane.iris.util.common.scheduling.J; import art.arcane.iris.spi.PlatformBlockState; import org.jetbrains.annotations.UnmodifiableView; @@ -83,13 +82,13 @@ public interface EngineMantle extends MatterGenerator { @ChunkCoordinates default KList findMarkers(int x, int z, MatterMarker marker) { KList p = new KList<>(); - if (J.isFolia() && getEngine().getWorld().hasRealWorld() && J.isOwnedByCurrentRegion(getEngine().getWorld().realWorld(), x, z)) { + if (getEngine().getPlatformHooks().shouldSkipMantleMarkerRead(getEngine(), x, z)) { return p; } getMantle().iterateChunk(x, z, MatterMarker.class, (xx, yy, zz, mm) -> { if (marker.equals(mm)) { - p.add(new IrisPosition(xx + (x << 4), yy, zz + (z << 4))); + p.add(new IrisPosition(xx + (x << 4), yy + getEngine().getMinHeight(), zz + (z << 4))); } }); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java index 077c69089..7c2fe557a 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java @@ -23,6 +23,7 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.project.matter.TileWrapper; import com.google.common.collect.ImmutableList; import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.Cache; @@ -33,7 +34,6 @@ import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.TileData; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KSet; -import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.function.Function3; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -46,12 +46,12 @@ import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.common.scheduling.J; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import lombok.Data; -import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import org.bukkit.util.Vector; import java.util.HashSet; +import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; @@ -82,7 +82,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (foliaMaintenance && IrisSettings.get().getGeneral().isDebug()) { IrisLogging.info("MantleWriter using sequential chunk prefetch for maintenance regen at " + x + "," + z + "."); } - final var map = multicore ? cachedChunks : new KMap>(d * d, 1f, parallelism); + Map> map = multicore + ? cachedChunks + : new KMap>(d * d, 1f, parallelism); mantle.getChunks( x - radius, x + radius, @@ -177,6 +179,9 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (chunk == null) return; Matter matter = chunk.getOrCreate(y >> 4); + if (t instanceof PlatformBlockState) { + clearDeferredPlacement(matter, x, y, z); + } Class sliceType = t instanceof PlatformBlockState ? PlatformBlockState.class : matter.getClass(t); matter.slice(sliceType).set(x & 15, y & 15, z & 15, t); } @@ -208,6 +213,29 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { return true; } + public boolean carveDataIfAbsent(int x, int y, int z, MatterCavern value) { + if (value == null || y < 0 || y >= mantle.getWorldHeight()) { + return false; + } + + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); + if (chunk == null) { + return false; + } + + Matter matter = chunk.getOrCreate(y >> 4); + if (matter.hasSlice(PlatformBlockState.class)) { + matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); + } + clearDeferredPlacement(matter, x, y, z); + MatterSlice cavernSlice = matter.slice(MatterCavern.class); + if (cavernSlice.get(x & 15, y & 15, z & 15) != null) { + return false; + } + cavernSlice.set(x & 15, y & 15, z & 15, value); + return true; + } + public void clearBlock(int x, int y, int z) { if (y < 0 || y >= mantle.getWorldHeight()) { return; @@ -224,7 +252,10 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (matter == null) { return; } - matter.slice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); + if (matter.hasSlice(PlatformBlockState.class)) { + matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); + } + clearDeferredPlacement(matter, x, y, z); } public T getData(int x, int y, int z, Class type) { @@ -245,6 +276,48 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { .get(x & 15, y & 15, z & 15); } + public T getDataIfPresent(int x, int y, int z, Class type) { + if (y < 0 || y >= mantle.getWorldHeight()) { + return null; + } + + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); + int section = y >> 4; + if (chunk == null || !chunk.exists(section)) { + return null; + } + + Matter matter = chunk.get(section); + if (matter == null || !matter.hasSlice(type)) { + return null; + } + return matter.getSlice(type).get(x & 15, y & 15, z & 15); + } + + public void clearData(int x, int y, int z, Class type) { + if (y < 0 || y >= mantle.getWorldHeight()) { + return; + } + + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); + int section = y >> 4; + if (chunk == null || !chunk.exists(section)) { + return; + } + + Matter matter = chunk.get(section); + if (matter == null || !matter.hasSlice(type)) { + return; + } + matter.getSlice(type).set(x & 15, y & 15, z & 15, null); + } + + private static void clearDeferredPlacement(Matter matter, int x, int y, int z) { + if (matter.hasSlice(Identifier.class)) { + matter.getSlice(Identifier.class).set(x & 15, y & 15, z & 15, null); + } + } + @ChunkCoordinates public MantleChunk acquireChunk(int cx, int cz) { if (cx < this.x - radius || cx > this.x + radius @@ -256,7 +329,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { MantleChunk chunk = cachedChunks.get(key); if (chunk == null) { chunk = mantle.getChunk(cx, cz).use(); - var old = cachedChunks.put(key, chunk); + MantleChunk old = cachedChunks.put(key, chunk); if (old != null) old.release(); } return chunk; @@ -277,9 +350,11 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (s == null) { return; } - if (s.isCustom() && s.nativeHandle() instanceof IrisCustomData data) { - setData(x, y, z, BukkitBlockState.of(data.getBase())); - setData(x, y, z, data.getCustom()); + String placementKey = s.deferredPlacementKey(); + PlatformBlockState baseState = s.placementBaseState(); + if (s.isCustom() && placementKey != null && baseState != null) { + setData(x, y, z, baseState); + setData(x, y, z, Identifier.fromString(placementKey)); return; } setData(x, y, z, s); @@ -842,7 +917,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { @Override public void close() { - var iterator = cachedChunks.values().iterator(); + Iterator> iterator = cachedChunks.values().iterator(); while (iterator.hasNext()) { iterator.next().release(); iterator.remove(); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index 667e99e54..41ad84b35 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -21,10 +21,9 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.data.cache.Cache; +import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.PlacedStructurePiece; -import art.arcane.iris.engine.framework.StructureAssembler; import art.arcane.iris.engine.framework.StructurePlacementMarker; -import art.arcane.iris.engine.framework.StructurePlacementGrid; import art.arcane.iris.engine.mantle.ComponentFlag; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.IrisMantleComponent; @@ -47,10 +46,18 @@ import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.mantle.flag.ReservedFlag; import art.arcane.volmlib.util.math.RNG; +import java.util.HashSet; +import java.util.Set; + @ComponentFlag(ReservedFlag.JIGSAW) public class IrisStructureComponent extends IrisMantleComponent { private static final long MAX_BORE_VOLUME = 6_000_000L; private static final long MAX_OVERBORE_VOLUME = 48_000_000L; + private static final double OVERBORE_MIN_BOUNDARY = 0.2; + private static final double OVERBORE_MIN_BOUNDARY_SQUARED = OVERBORE_MIN_BOUNDARY * OVERBORE_MIN_BOUNDARY; + private static final double OVERBORE_BOUNDARY_SPAN = 0.8; + private static final double OVERBORE_MAX_UP_REACH = 1.8; + private static final int DYNAMIC_STRUCTURE_Y_TOLERANCE = 32; private static final MatterCavern CARVE_CAVERN = new MatterCavern(true, "", (byte) 3); public IrisStructureComponent(EngineMantle engineMantle) { @@ -65,8 +72,6 @@ public class IrisStructureComponent extends IrisMantleComponent { int zzz = 8 + (z << 4); IrisRegion region = complex.getRegionStream().get(xxx, zzz); IrisBiome biome = complex.getTrueBiomeStream().get(xxx, zzz); - RNG rng = new RNG(Cache.key(x, z) + seed()); - KList placements = new KList<>(); if (biome != null) { placements.addAll(biome.getStructures()); @@ -76,17 +81,16 @@ public class IrisStructureComponent extends IrisMantleComponent { } placements.addAll(getDimension().getStructures()); - for (IrisStructurePlacement placement : placements) { - placeFromPlacement(writer, placement, x, z, rng); + for (int placementOrdinal = 0; placementOrdinal < placements.size(); placementOrdinal++) { + placeFromPlacement(writer, placements.get(placementOrdinal), x, z, placementOrdinal); } } @ChunkCoordinates - private void placeFromPlacement(MantleWriter writer, IrisStructurePlacement placement, int cx, int cz, RNG rng) { - if (placement.getStructures().isEmpty()) { - return; - } - if (!StructurePlacementGrid.startsInChunk(placement, cx, cz, seed(), rng)) { + private void placeFromPlacement(MantleWriter writer, IrisStructurePlacement placement, int cx, int cz, int placementOrdinal) { + IrisStructureLocator.ResolvedPlacement resolved = IrisStructureLocator.resolvePlacement( + getEngineMantle().getEngine(), placement, cx, cz, placementOrdinal); + if (resolved == null) { return; } @@ -96,52 +100,19 @@ public class IrisStructureComponent extends IrisMantleComponent { + " underground=" + placement.isUnderground() + " band=" + placement.getMinHeight() + ".." + placement.getMaxHeight()); } - int sx = (cx << 4) + rng.i(0, 15); - int sz = (cz << 4) + rng.i(0, 15); - int baseY; - if (placement.isUnderground()) { - int worldMinY = getEngineMantle().getEngine().getMinHeight() + 1; - int worldMaxY = getEngineMantle().getEngine().getMinHeight() + getEngineMantle().getEngine().getHeight() - 1; - int bandMin = Math.max(worldMinY, Math.min(placement.getMinHeight(), placement.getMaxHeight())); - int bandMax = Math.min(worldMaxY, Math.max(placement.getMinHeight(), placement.getMaxHeight())); - if (bandMin > bandMax) { - if (trace) { - IrisLogging.info("[StructTrace] BAIL band-inverted chunk=" + cx + "," + cz + " bandMin=" + bandMin + " bandMax=" + bandMax - + " worldMinY=" + worldMinY + " worldMaxY=" + worldMaxY); - } - return; - } - baseY = bandMin == bandMax ? bandMin : rng.i(bandMin, bandMax); - } else { - int surfaceY = getEngineMantle().getEngine().getHeight(sx, sz, true) + getEngineMantle().getEngine().getMinHeight(); - if (surfaceY < placement.getMinHeight() || surfaceY > placement.getMaxHeight()) { - return; - } - baseY = surfaceY; - } - - String key = placement.getStructures().get(rng.i(0, placement.getStructures().size() - 1)); - IrisStructure structure = art.arcane.iris.core.loader.IrisData.loadAnyStructure(key, getData()); - if (structure == null) { - if (trace) { - IrisLogging.info("[StructTrace] BAIL structure-load-null chunk=" + cx + "," + cz + " key=" + key); - } - return; - } - - StructureAssembler assembler = new StructureAssembler(getData(), structure, sx, baseY, sz); - KList pieces = assembler.assemble(rng); - if (pieces == null || pieces.isEmpty()) { - if (trace) { - IrisLogging.info("[StructTrace] BAIL no-pieces chunk=" + cx + "," + cz + " key=" + key + " baseY=" + baseY - + " pieces=" + (pieces == null ? "null" : "empty")); - } - return; - } + String key = resolved.structureKey(); + IrisStructure structure = resolved.structure(); + KList pieces = resolved.pieces(); + RNG rng = resolved.rng(); + int baseY = resolved.baseY(); if (trace) { IrisLogging.info("[StructTrace] ASSEMBLED chunk=" + cx + "," + cz + " key=" + key + " baseY=" + baseY + " pieces=" + pieces.size()); } + if (!placement.isUnderground()) { + clearIntersectingObjectTrees(writer, resolved); + } + if (placement.isOverbore()) { overboreStructure(writer, pieces, placement.getOverboreRadius(), placement.getOverboreHeight(), placement.getOverboreFloor()); } else if (placement.isBore()) { @@ -162,13 +133,8 @@ public class IrisStructureComponent extends IrisMantleComponent { } else if (pieces.size() == 1) { placeObject(writer, structure, pieces.getFirst(), mode, -1, rng); } else { - int lowest = Integer.MAX_VALUE; for (PlacedStructurePiece p : pieces) { - lowest = Math.min(lowest, p.getMinY()); - } - int shift = baseY - lowest; - for (PlacedStructurePiece p : pieces) { - placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY() + shift, rng); + placeObject(writer, structure, p, ObjectPlaceMode.STRUCTURE_PIECE, p.getY(), rng); } } } @@ -221,14 +187,13 @@ public class IrisStructureComponent extends IrisMantleComponent { double freq = 0.07; double rollFreq = 0.03; - double clearMax = 1.45; double reachSide = margin; double reachUp = head < 1 ? 1.0 : head; double reachDown = floorCut < 1 ? 1.0 : floorCut; double upReachMin = 0.4; double upReachSpan = 1.4; - int sideExt = (int) Math.ceil(reachSide * clearMax); - int upExt = (int) Math.ceil(reachUp * (upReachMin + upReachSpan) * clearMax); + int sideExt = overboreSideExtension(margin); + int upExt = overboreUpExtension(reachUp); long work = 0L; for (PlacedStructurePiece p : pieces) { @@ -270,6 +235,9 @@ public class IrisStructureComponent extends IrisMantleComponent { double dz = bz < pMinZ ? pMinZ - bz : bz > pMaxZ ? bz - pMaxZ : 0; double nz = dz / reachSide; double nxz = nx * nx + nz * nz; + if (nxz > 1.0) { + continue; + } double w = roll.fitDouble(0.0, 1.0, bx * rollFreq, bz * rollFreq) * 0.7 + roll.fitDouble(0.0, 1.0, bx * rollFreq * 3.0, bz * rollFreq * 3.0) * 0.3; double contrast = (w - 0.5) * 2.6 + 0.5; @@ -291,25 +259,103 @@ public class IrisStructureComponent extends IrisMantleComponent { } else { ny = 0.0; } - double nd = Math.sqrt(nxz + ny * ny); - if (nd > clearMax) { + double distanceSquared = nxz + ny * ny; + if (distanceSquared > 1.0) { continue; } - boolean carve = nd <= 0.45; - if (!carve) { + if (distanceSquared > OVERBORE_MIN_BOUNDARY_SQUARED) { double n = blob.fitDouble(0.0, 1.0, bx * freq, by * freq, bz * freq); - carve = nd <= 0.5 + 0.5 * n; - } - writer.clearBlock(bx, by - mantleOffset, bz); - if (carve) { - writer.setDataIfAbsent(bx, by - mantleOffset, bz, CARVE_CAVERN); + if (!shouldCarveOverboreCell(distanceSquared, n)) { + continue; + } } + writer.carveDataIfAbsent(bx, by - mantleOffset, bz, CARVE_CAVERN); } } } } } + static int overboreSideExtension(int radius) { + return Math.max(1, radius); + } + + static int overboreUpExtension(double reachUp) { + return (int) Math.ceil(Math.max(1.0, reachUp) * OVERBORE_MAX_UP_REACH); + } + + static double overboreBoundaryLimit(double noise) { + double clampedNoise = Math.max(0.0, Math.min(1.0, noise)); + return OVERBORE_MIN_BOUNDARY + OVERBORE_BOUNDARY_SPAN * clampedNoise; + } + + static boolean shouldCarveOverboreCell(double distanceSquared, double noise) { + if (distanceSquared <= 0.0) { + return true; + } + if (distanceSquared > 1.0) { + return false; + } + double limit = overboreBoundaryLimit(noise); + return distanceSquared <= limit * limit; + } + + private void clearIntersectingObjectTrees(MantleWriter writer, IrisStructureLocator.ResolvedPlacement resolved) { + int mantleOffset = getEngineMantle().getEngine().getMinHeight(); + int worldHeight = getEngineMantle().getEngine().getHeight(); + int verticalTolerance = resolved.exactY() ? 0 : DYNAMIC_STRUCTURE_Y_TOLERANCE; + for (PlacedStructurePiece piece : resolved.pieces()) { + int minY = Math.max(0, piece.getMinY() - mantleOffset - verticalTolerance); + int maxY = Math.min(worldHeight - 1, piece.getMaxY() - mantleOffset + verticalTolerance); + if (minY > maxY) { + continue; + } + for (int x = piece.getMinX(); x <= piece.getMaxX(); x++) { + for (int z = piece.getMinZ(); z <= piece.getMaxZ(); z++) { + clearIntersectingObjectTreeColumn(writer, x, z, minY, maxY, worldHeight); + } + } + } + } + + private void clearIntersectingObjectTreeColumn(MantleWriter writer, int x, int z, + int minY, int maxY, int worldHeight) { + Set intersectingMarkers = null; + for (int y = minY; y <= maxY; y++) { + PlatformBlockState state = writer.getDataIfPresent(x, y, z, PlatformBlockState.class); + if (state == null || !state.isTreeBlock()) { + continue; + } + String marker = writer.getDataIfPresent(x, y, z, String.class); + if (isOrdinaryObjectMarker(marker)) { + if (intersectingMarkers == null) { + intersectingMarkers = new HashSet<>(); + } + intersectingMarkers.add(marker); + } + } + if (intersectingMarkers == null) { + return; + } + for (int y = 0; y < worldHeight; y++) { + String marker = writer.getDataIfPresent(x, y, z, String.class); + if (!intersectingMarkers.contains(marker)) { + continue; + } + PlatformBlockState state = writer.getDataIfPresent(x, y, z, PlatformBlockState.class); + if (state == null || !state.isTreeBlock()) { + continue; + } + writer.clearBlock(x, y, z); + writer.clearData(x, y, z, String.class); + } + } + + static boolean isOrdinaryObjectMarker(String marker) { + StructurePlacementMarker.Decoded decoded = StructurePlacementMarker.decode(marker); + return decoded != null && !decoded.structureAware(); + } + private int[] computePieceBounds(KList pieces) { if (pieces == null || pieces.isEmpty()) { return null; @@ -383,7 +429,7 @@ public class IrisStructureComponent extends IrisMantleComponent { for (IrisRegion region : dimension.getAllRegions(this::getData)) { maxBlocks = Math.max(maxBlocks, maxBlocksFrom(region.getStructures())); } - for (IrisBiome biome : dimension.getAllBiomes(this::getData)) { + for (IrisBiome biome : dimension.getReachableBiomes(this::getData)) { maxBlocks = Math.max(maxBlocks, maxBlocksFrom(biome.getStructures())); } maxBlocks = Math.max(maxBlocks, maxBlocksFrom(dimension.getStructures())); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index 6bf83456e..355f23096 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -541,7 +541,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { Set objectKeys = new HashSet<>(); try { IrisData data = getData(); - for (IrisBiome biome : getDimension().getAllBiomes(this::getData)) { + for (IrisBiome biome : getDimension().getReachableBiomes(this::getData)) { KList entries = biome.getFloatingChildBiomes(); if (entries == null || entries.isEmpty()) { continue; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java index 3987d7fed..88ba199ee 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFluidBodyComponent.java @@ -70,7 +70,7 @@ public class MantleFluidBodyComponent extends IrisMantleComponent { max = Math.max(max, i.getFluidBodies().getMaxRange(getData())); } - for (IrisBiome i : getDimension().getAllBiomes(this::getData)) { + for (IrisBiome i : getDimension().getReachableBiomes(this::getData)) { max = Math.max(max, i.getFluidBodies().getMaxRange(getData())); } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index 34a57028a..dddf546c5 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -42,7 +42,6 @@ import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisObjectRotation; import art.arcane.iris.engine.object.IrisObjectScale; -import art.arcane.iris.engine.object.IrisObjectTranslate; import art.arcane.iris.engine.object.IrisObjectVacuum; import art.arcane.iris.engine.object.IrisProceduralObjects; import art.arcane.iris.engine.object.IrisProceduralPlacement; @@ -53,9 +52,7 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KSet; -import art.arcane.iris.util.project.context.ChunkedDoubleDataCache; import art.arcane.iris.util.project.context.ChunkContext; -import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.format.Form; @@ -64,7 +61,6 @@ import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterStructurePOI; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.project.noise.NoiseType; -import it.unimi.dsi.fastutil.longs.Long2ByteOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import art.arcane.iris.spi.PlatformBlockState; @@ -83,7 +79,6 @@ import java.util.concurrent.atomic.AtomicLong; public class MantleObjectComponent extends IrisMantleComponent { private static final long CAVE_REJECT_LOG_THROTTLE_MS = 5000L; private static final int BEDROCK_CLEARANCE = 6; - private static final int SURFACE_HEIGHT_CHUNK_FILL_THRESHOLD = 128; private static final Map CAVE_REJECT_LOG_STATE = new ConcurrentHashMap<>(); private static final Set MISSING_LOAD_KEY_WARNED = ConcurrentHashMap.newKeySet(); private static final int[] GOLDEN_DEBUG_TARGET = parseGoldenDebugTarget(resolveGoldenDebugSpec()); @@ -156,7 +151,6 @@ public class MantleObjectComponent extends IrisMantleComponent { IrisBiome surfaceBiome = complex.getTrueBiomeStream().get(xxx, zzz); int surfaceY = getEngineMantle().getEngine().getHeight(xxx, zzz, true); IrisBiome caveBiome = resolveCaveObjectBiome(xxx, zzz, surfaceY, surfaceBiome); - SurfaceHeightLookup surfaceHeightLookup = new SurfaceHeightLookup(context); if (IrisSettings.get().getGeneral().isDebug() && (x & 31) == 0 && (z & 31) == 0) { int carvedBlocks = 0; int minY = 1; @@ -186,7 +180,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " regionSurfacePlacers=" + region.getSurfaceObjects().size() + " regionCavePlacers=" + region.getCarvingObjects().size()); } - ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, complex, traceRegen, surfaceHeightLookup); + ObjectPlacementSummary summary = placeObjects(writer, rng, x, z, surfaceBiome, caveBiome, region, complex, traceRegen); placeProceduralObjects(writer, rng, x, z, surfaceBiome, caveBiome, region); UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); IrisDimension dimension = getDimension(); @@ -252,7 +246,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } @ChunkCoordinates - private ObjectPlacementSummary placeObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, IrisComplex complex, boolean traceRegen, SurfaceHeightLookup surfaceHeightLookup) { + private ObjectPlacementSummary placeObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, IrisComplex complex, boolean traceRegen) { int biomeSurfaceChecked = 0; int biomeSurfaceTriggered = 0; int biomeCaveChecked = 0; @@ -268,9 +262,6 @@ public class MantleObjectComponent extends IrisMantleComponent { int errors = 0; IrisCaveProfile biomeCaveProfile = resolveCaveProfile(caveBiome.getCaveProfile(), region.getCaveProfile()); IrisCaveProfile regionCaveProfile = resolveCaveProfile(region.getCaveProfile(), caveBiome.getCaveProfile()); - int biomeSurfaceExclusionDepth = resolveSurfaceObjectExclusionDepth(biomeCaveProfile); - int regionSurfaceExclusionDepth = resolveSurfaceObjectExclusionDepth(regionCaveProfile); - SurfaceExposureCache surfaceExposureCache = new SurfaceExposureCache(); CaveAnchorCache caveAnchorCache = new CaveAnchorCache(); for (IrisObjectPlacement i : surfaceBiome.getSurfaceObjects()) { @@ -287,7 +278,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (chance) { biomeSurfaceTriggered++; try { - ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, biomeSurfaceExclusionDepth, complex, traceRegen, x, z, "biome-surface", surfaceHeightLookup, surfaceExposureCache, caveAnchorCache); + ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, complex, traceRegen, x, z, "biome-surface"); attempts += result.attempts(); placed += result.placed(); rejected += result.rejected(); @@ -352,7 +343,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (chance) { regionSurfaceTriggered++; try { - ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, regionSurfaceExclusionDepth, complex, traceRegen, x, z, "region-surface", surfaceHeightLookup, surfaceExposureCache, caveAnchorCache); + ObjectPlacementResult result = placeObject(writer, rng, x << 4, z << 4, i, complex, traceRegen, x, z, "region-surface"); attempts += result.attempts(); placed += result.placed(); rejected += result.rejected(); @@ -538,15 +529,11 @@ public class MantleObjectComponent extends IrisMantleComponent { int x, int z, IrisObjectPlacement objectPlacement, - int surfaceObjectExclusionBaseDepth, IrisComplex complex, boolean traceRegen, int chunkX, int chunkZ, - String scope, - SurfaceHeightLookup surfaceHeightLookup, - SurfaceExposureCache surfaceExposureCache, - CaveAnchorCache caveAnchorCache + String scope ) { int attempts = 0; int placed = 0; @@ -572,12 +559,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } int xx = rng.i(x, x + 15); int zz = rng.i(z, z + 15); - int surfaceObjectExclusionDepth = resolveSurfaceObjectExclusionDepth(surfaceObjectExclusionBaseDepth, v, objectPlacement); - int surfaceObjectExclusionRadius = resolveSurfaceObjectExclusionRadius(v, objectPlacement); IrisObjectPlacement effectivePlacement = resolveEffectivePlacement(objectPlacement, v); - boolean overCave = effectivePlacement.getMode() != ObjectPlaceMode.FLOATING - && surfaceObjectExclusionDepth > 0 - && hasSurfaceCarveExposure(writer, surfaceHeightLookup, xx, zz, surfaceObjectExclusionDepth, surfaceObjectExclusionRadius, surfaceExposureCache); int id = rng.i(0, Integer.MAX_VALUE); IObjectPlacer placePlacer = golden ? new GoldenDebugPlacer(writer, scope + "/" + v.getLoadKey()) : writer; if (golden) { @@ -587,57 +569,18 @@ public class MantleObjectComponent extends IrisMantleComponent { + " densityIndex=" + i + " xx=" + xx + " zz=" + zz - + " overCave=" + overCave - + " exclusionDepth=" + surfaceObjectExclusionDepth - + " exclusionRadius=" + surfaceObjectExclusionRadius + " mode=" + effectivePlacement.getMode()); } try { - int result = -1; - String fallbackPath = "surface"; - - if (overCave) { - int caveFloorY = findNearestCaveFloor(writer, xx, zz, caveAnchorCache); - if (caveFloorY > 0) { - IrisObjectPlacement floorPlacement = effectivePlacement.toPlacement(v.getLoadKey()); - floorPlacement.setMode(ObjectPlaceMode.FAST_MIN_HEIGHT); - result = v.place(xx, caveFloorY, zz, placePlacer, floorPlacement, rng, (b, data) -> { - String marker = placementMarker(v, id, "cave-floor"); - if (marker != null) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); - } - if (effectivePlacement.isDolphinTarget() && effectivePlacement.isUnderwater() && B.isStorageChest(data)) { - writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); - } - }, null, getData()); - fallbackPath = "cave-floor"; + int result = v.place(xx, -1, zz, placePlacer, effectivePlacement, rng, (b, data) -> { + String marker = placementMarker(v, id, "surface"); + if (marker != null) { + writer.setData(b.getX(), b.getY(), b.getZ(), marker); } - - if (result < 0) { - IrisObjectPlacement stiltPlacement = effectivePlacement.toPlacement(v.getLoadKey()); - stiltPlacement.setMode(ObjectPlaceMode.FAST_MIN_STILT); - result = v.place(xx, -1, zz, placePlacer, stiltPlacement, rng, (b, data) -> { - String marker = placementMarker(v, id, "stilt"); - if (marker != null) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); - } - if (effectivePlacement.isDolphinTarget() && effectivePlacement.isUnderwater() && B.isStorageChest(data)) { - writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); - } - }, null, getData()); - fallbackPath = "stilt"; + if (effectivePlacement.isDolphinTarget() && effectivePlacement.isUnderwater() && B.isStorageChest(data)) { + writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); } - } else { - result = v.place(xx, -1, zz, placePlacer, effectivePlacement, rng, (b, data) -> { - String marker = placementMarker(v, id, "surface"); - if (marker != null) { - writer.setData(b.getX(), b.getY(), b.getZ(), marker); - } - if (effectivePlacement.isDolphinTarget() && effectivePlacement.isUnderwater() && B.isStorageChest(data)) { - writer.setData(b.getX(), b.getY(), b.getZ(), MatterStructurePOI.BURIED_TREASURE); - } - }, null, getData()); - } + }, null, getData()); if (result >= 0) { placed++; @@ -650,7 +593,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " scope=" + scope + " object=" + v.getLoadKey() + " resultY=" + result - + " fallback=" + fallbackPath); + + " fallback=surface"); } if (traceRegen) { @@ -660,8 +603,7 @@ public class MantleObjectComponent extends IrisMantleComponent { + " resultY=" + result + " px=" + xx + " pz=" + zz - + " overCave=" + overCave - + " fallback=" + fallbackPath + + " fallback=surface" + " densityIndex=" + i + " density=" + density); } @@ -1466,40 +1408,6 @@ public class MantleObjectComponent extends IrisMantleComponent { return Math.max(0, caveProfile.getObjectMinDepthBelowSurface()); } - private int resolveSurfaceObjectExclusionDepth(IrisCaveProfile caveProfile) { - if (caveProfile == null) { - return 5; - } - - return Math.max(0, caveProfile.getSurfaceObjectExclusionDepth()); - } - - private int resolveSurfaceObjectExclusionDepth(int baseDepth, IrisObject object, IrisObjectPlacement placement) { - if (object == null) { - return baseDepth; - } - - int horizontalReach = resolveSurfaceObjectExclusionRadius(object, placement) + 2; - int verticalReach = Math.max(4, Math.min(16, Math.floorDiv(Math.max(1, object.getH()), 2))); - return Math.max(baseDepth, Math.max(horizontalReach, verticalReach)); - } - - static int computeSurfaceExclusionRadius(int maxDimension, int absTranslateX, int absTranslateZ) { - return Math.max(1, Math.floorDiv(Math.max(1, maxDimension), 2) + absTranslateX + absTranslateZ + 1); - } - - private int resolveSurfaceObjectExclusionRadius(IrisObject object, IrisObjectPlacement placement) { - if (object == null) { - return 1; - } - - int maxDimension = Math.max(object.getW(), object.getD()); - IrisObjectTranslate t = placement != null ? placement.getTranslate() : null; - int absX = t != null ? Math.abs(t.getX()) : 0; - int absZ = t != null ? Math.abs(t.getZ()) : 0; - return computeSurfaceExclusionRadius(maxDimension, absX, absZ); - } - private int resolveAnchorSearchAttempts(IrisCaveProfile caveProfile) { if (caveProfile == null) { return 6; @@ -1508,66 +1416,6 @@ public class MantleObjectComponent extends IrisMantleComponent { return Math.max(1, caveProfile.getAnchorSearchAttempts()); } - private boolean hasSurfaceCarveExposure(MantleWriter writer, SurfaceHeightLookup surfaceHeightLookup, int x, int z, int depth, int radius, SurfaceExposureCache surfaceExposureCache) { - int horizontalRadius = Math.max(0, radius); - int maxY = getEngineMantle().getEngine().getHeight() - 1; - if (surfaceExposureCache.get(writer, surfaceHeightLookup, x, z, depth, maxY)) { - return true; - } - - int sampleCount = surfaceExposureSampleCount(horizontalRadius); - for (int sx = 0; sx < sampleCount; sx++) { - int dx = surfaceExposureSampleOffset(sx, sampleCount, horizontalRadius); - for (int sz = 0; sz < sampleCount; sz++) { - int dz = surfaceExposureSampleOffset(sz, sampleCount, horizontalRadius); - if (dx == 0 && dz == 0) { - continue; - } - - int columnX = x + dx; - int columnZ = z + dz; - if (surfaceExposureCache.get(writer, surfaceHeightLookup, columnX, columnZ, depth, maxY)) { - return true; - } - } - } - - return false; - } - - private int surfaceExposureSampleCount(int radius) { - if (radius <= 0) { - return 1; - } - - if (radius <= 3) { - return (radius << 1) + 1; - } - - return 3; - } - - private int surfaceExposureSampleOffset(int sample, int sampleCount, int radius) { - if (sampleCount <= 1) { - return 0; - } - - return -radius + Math.floorDiv((sample * radius * 2) + ((sampleCount - 1) >> 1), sampleCount - 1); - } - - private boolean hasSurfaceCarveExposureColumn(MantleWriter writer, SurfaceHeightLookup surfaceHeightLookup, int x, int z, int depth, int maxY) { - int surfaceY = surfaceHeightLookup.getRoundedHeight(x, z); - int fromY = Math.max(1, surfaceY - Math.max(0, depth)); - int toY = Math.min(maxY, surfaceY + 1); - for (int y = fromY; y <= toY; y++) { - if (writer.isCarved(x, y, z)) { - return true; - } - } - - return false; - } - private boolean isRegenTraceThread() { return Thread.currentThread().getName().startsWith("Iris-Regen-") && IrisSettings.get().getGeneral().isDebug(); @@ -1656,7 +1504,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } updateProceduralRadiusBounds(region.getProceduralObjects(), xg, zg); } - for (IrisBiome biome : dimension.getAllBiomes(this::getData)) { + for (IrisBiome biome : dimension.getReachableBiomes(this::getData)) { updateProceduralRadiusBounds(biome.getProceduralObjects(), xg, zg); for (IrisObjectPlacement j : biome.getObjects()) { if (j.getScale().canScaleBeyond()) { @@ -1781,34 +1629,6 @@ public class MantleObjectComponent extends IrisMantleComponent { }); } - private final class SurfaceExposureCache { - private final Long2ObjectOpenHashMap depthCaches; - - private SurfaceExposureCache() { - this.depthCaches = new Long2ObjectOpenHashMap<>(); - } - - private boolean get(MantleWriter writer, SurfaceHeightLookup surfaceHeightLookup, int x, int z, int depth, int maxY) { - long depthKey = depth; - Long2ByteOpenHashMap columnCache = depthCaches.get(depthKey); - if (columnCache == null) { - columnCache = new Long2ByteOpenHashMap(); - columnCache.defaultReturnValue((byte) -1); - depthCaches.put(depthKey, columnCache); - } - - long columnKey = Cache.key(x, z); - byte cached = columnCache.get(columnKey); - if (cached != -1) { - return cached == 1; - } - - boolean exposed = hasSurfaceCarveExposureColumn(writer, surfaceHeightLookup, x, z, depth, maxY); - columnCache.put(columnKey, (byte) (exposed ? 1 : 0)); - return exposed; - } - } - private final class CaveAnchorCache { private final Long2ObjectOpenHashMap>> settingCaches; private final Long2ObjectOpenHashMap carvedColumns; @@ -1869,84 +1689,4 @@ public class MantleObjectComponent extends IrisMantleComponent { } } - private static final class SurfaceHeightLookup { - private final ChunkContext context; - private final ProceduralStream heightStream; - private final Long2ObjectOpenHashMap foreignChunkHeights; - - private SurfaceHeightLookup(ChunkContext context) { - this.context = context; - this.heightStream = context.getComplex().getHeightStream(); - this.foreignChunkHeights = new Long2ObjectOpenHashMap<>(); - } - - private int getRoundedHeight(int worldX, int worldZ) { - int chunkBlockX = worldX & ~15; - int chunkBlockZ = worldZ & ~15; - if (chunkBlockX == context.getX() && chunkBlockZ == context.getZ()) { - return context.getRoundedHeight(worldX & 15, worldZ & 15); - } - - long chunkKey = Cache.key(chunkBlockX, chunkBlockZ); - ForeignChunkHeights chunkHeights = foreignChunkHeights.get(chunkKey); - if (chunkHeights == null) { - chunkHeights = new ForeignChunkHeights(heightStream, chunkBlockX, chunkBlockZ); - foreignChunkHeights.put(chunkKey, chunkHeights); - } - return chunkHeights.getRoundedHeight(worldX, worldZ); - } - } - - private static final class ForeignChunkHeights { - private final ProceduralStream heightStream; - private final int chunkBlockX; - private final int chunkBlockZ; - private final Long2IntOpenHashMap sparseColumnHeights; - private int uniqueColumnCount; - private int[] roundedHeights; - - private ForeignChunkHeights(ProceduralStream heightStream, int chunkBlockX, int chunkBlockZ) { - this.heightStream = heightStream; - this.chunkBlockX = chunkBlockX; - this.chunkBlockZ = chunkBlockZ; - this.sparseColumnHeights = new Long2IntOpenHashMap(); - this.sparseColumnHeights.defaultReturnValue(Integer.MIN_VALUE); - this.uniqueColumnCount = 0; - } - - private int getRoundedHeight(int worldX, int worldZ) { - int[] localRoundedHeights = roundedHeights; - if (localRoundedHeights != null) { - int localX = worldX - chunkBlockX; - int localZ = worldZ - chunkBlockZ; - return localRoundedHeights[(localZ << 4) + localX]; - } - - long columnKey = Cache.key(worldX, worldZ); - int cachedHeight = sparseColumnHeights.get(columnKey); - if (cachedHeight != Integer.MIN_VALUE) { - return cachedHeight; - } - - int roundedHeight = (int) Math.round(heightStream.getDouble(worldX, worldZ)); - sparseColumnHeights.put(columnKey, roundedHeight); - uniqueColumnCount++; - if (uniqueColumnCount >= SURFACE_HEIGHT_CHUNK_FILL_THRESHOLD) { - promoteToChunkCache(); - } - - return roundedHeight; - } - - private void promoteToChunkCache() { - if (roundedHeights != null) { - return; - } - - int[] filledHeights = new int[256]; - new ChunkedDoubleDataCache(heightStream, chunkBlockX, chunkBlockZ, true).fillRounded(filledHeights); - roundedHeights = filledHeights; - sparseColumnHeights.clear(); - } - } } diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java index c761aeaac..b70772e7e 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java @@ -18,12 +18,12 @@ package art.arcane.iris.engine.mode; -import art.arcane.iris.core.tools.IrisToolbelt; import art.arcane.iris.engine.actuator.IrisBiomeActuator; import art.arcane.iris.engine.actuator.IrisDecorantActuator; import art.arcane.iris.engine.actuator.IrisTerrainNormalActuator; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.engine.framework.EngineStage; import art.arcane.iris.engine.framework.IrisEngineMode; import art.arcane.iris.engine.modifier.IrisCarveModifier; @@ -33,27 +33,28 @@ import art.arcane.iris.engine.modifier.IrisFloatingChildBiomeModifier; import art.arcane.iris.engine.modifier.IrisPerfectionModifier; import art.arcane.iris.engine.modifier.IrisPostModifier; import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.util.common.scheduling.J; import java.util.concurrent.atomic.AtomicLong; public class ModeOverworld extends IrisEngineMode implements EngineMode { private static final AtomicLong lastMaintenanceBypassLog = new AtomicLong(0L); + private final EnginePlatformHooks platformHooks; public ModeOverworld(Engine engine) { super(engine); - var terrain = new IrisTerrainNormalActuator(getEngine()); - var biome = new IrisBiomeActuator(getEngine()); - var decorant = new IrisDecorantActuator(getEngine()); - var cave = new IrisCarveModifier(getEngine()); - var post = new IrisPostModifier(getEngine()); - var deposit = new IrisDepositModifier(getEngine()); - var perfection = new IrisPerfectionModifier(getEngine()); - var custom = new IrisCustomModifier(getEngine()); - var floatingChildBiomes = new IrisFloatingChildBiomeModifier(getEngine()); + platformHooks = engine.getPlatformHooks(); + IrisTerrainNormalActuator terrain = new IrisTerrainNormalActuator(getEngine()); + IrisBiomeActuator biome = new IrisBiomeActuator(getEngine()); + IrisDecorantActuator decorant = new IrisDecorantActuator(getEngine()); + IrisCarveModifier cave = new IrisCarveModifier(getEngine()); + IrisPostModifier post = new IrisPostModifier(getEngine()); + IrisDepositModifier deposit = new IrisDepositModifier(getEngine()); + IrisPerfectionModifier perfection = new IrisPerfectionModifier(getEngine()); + IrisCustomModifier custom = new IrisCustomModifier(getEngine()); + IrisFloatingChildBiomeModifier floatingChildBiomes = new IrisFloatingChildBiomeModifier(getEngine()); EngineStage sBiome = (x, z, k, p, m, c) -> biome.actuate(x, z, p, m, c); EngineStage sGenMatter = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages(getEngine())) { + if (shouldBypassMantleStages()) { return; } generateMatter(x >> 4, z >> 4, m, c); @@ -61,25 +62,25 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { EngineStage sTerrain = (x, z, k, p, m, c) -> terrain.actuate(x, z, k, m, c); EngineStage sDecorant = (x, z, k, p, m, c) -> decorant.actuate(x, z, k, m, c); EngineStage sCave = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages(getEngine())) { + if (shouldBypassMantleStages()) { return; } cave.modify(x >> 4, z >> 4, k, m, c); }; EngineStage sDeposit = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages(getEngine())) { + if (shouldBypassMantleStages()) { return; } deposit.modify(x, z, k, m, c); }; EngineStage sPost = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages(getEngine())) { + if (shouldBypassMantleStages()) { return; } post.modify(x, z, k, m, c); }; EngineStage sInsertMatter = (x, z, K, p, m, c) -> { - if (shouldBypassMantleStages(getEngine())) { + if (shouldBypassMantleStages()) { return; } getMantle().insertMatter(x >> 4, z >> 4, K, m); @@ -88,7 +89,7 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { EngineStage sFloatingDecorate = (x, z, k, p, m, c) -> floatingChildBiomes.decorateColumns(x, z, k, m, c); EngineStage sPerfection = (x, z, k, p, m, c) -> perfection.modify(x, z, k, m, c); EngineStage sCustom = (x, z, k, p, m, c) -> { - if (shouldBypassMantleStages(getEngine())) { + if (shouldBypassMantleStages()) { return; } custom.modify(x, z, k, m, c); @@ -114,13 +115,8 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { registerStage(sCustom); } - private static boolean shouldBypassMantleStages(Engine engine) { - if (!J.isFolia()) { - return false; - } - - var world = engine.getWorld().realWorld(); - boolean active = world != null && IrisToolbelt.isWorldMaintenanceBypassingMantleStages(world); + private boolean shouldBypassMantleStages() { + boolean active = platformHooks.shouldBypassMantleStages(getEngine()); if (active) { long now = System.currentTimeMillis(); long last = lastMaintenanceBypassLog.get(); diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCustomModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCustomModifier.java index 94316f756..5fc90b90d 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCustomModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCustomModifier.java @@ -1,23 +1,28 @@ package art.arcane.iris.engine.modifier; + import art.arcane.iris.core.link.Identifier; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedModifier; -import art.arcane.iris.util.project.context.ChunkContext; -import art.arcane.iris.util.common.data.IrisCustomData; -import art.arcane.iris.util.project.hunk.Hunk; -import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.parallel.BurstExecutor; import art.arcane.iris.util.common.parallel.MultiBurst; -import art.arcane.iris.platform.bukkit.BukkitBlockState; -import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; + public class IrisCustomModifier extends EngineAssignedModifier { public IrisCustomModifier(Engine engine) { super(engine, "Custom"); } + @Override public void onModify(int x, int z, Hunk output, boolean multicore, ChunkContext context) { - var mc = getEngine().getMantle().getMantle().getChunk(x >> 4, z >> 4); - if (!mc.isFlagged(MantleFlag.CUSTOM_ACTIVE)) return; + MantleChunk mc = getEngine().getMantle().getMantle().getChunk(x >> 4, z >> 4); + if (!mc.isFlagged(MantleFlag.CUSTOM_ACTIVE)) { + return; + } mc.use(); BurstExecutor burst = MultiBurst.burst.burst(output.getHeight()); @@ -28,12 +33,19 @@ public class IrisCustomModifier extends EngineAssignedModifier> 4) .slice(Identifier.class) - .set(rX, finalY & 15, rZ, d.getCustom()); - output.set(rX, finalY, rZ, BukkitBlockState.of(d.getBase())); + .set(rX, finalY & 15, rZ, Identifier.fromString(placementKey)); + output.set(rX, finalY, rZ, baseState); } } }); @@ -41,4 +53,4 @@ public class IrisCustomModifier extends EngineAssignedModifier modules = new KList<>(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index d0042dc20..b2f6dbbd5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -35,6 +35,7 @@ import art.arcane.iris.engine.object.annotations.RegistryListResource; import art.arcane.iris.engine.object.annotations.Required; import art.arcane.iris.engine.object.annotations.functions.ComponentFlagFunction; import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.collection.KSet; import art.arcane.iris.util.common.data.DataProvider; import art.arcane.volmlib.util.io.IO; @@ -50,8 +51,6 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import lombok.experimental.Accessors; -import org.bukkit.World.Environment; -import org.bukkit.block.Biome; import java.io.File; import java.io.IOException; @@ -422,19 +421,6 @@ public class IrisDimension extends IrisRegistrant { return rad.aquire(() -> Math.toRadians(dimensionAngleDeg)); } - public Environment getEnvironment() { - if (environment == null) { - return null; - } - - return switch (environment) { - case NORMAL -> Environment.NORMAL; - case NETHER -> Environment.NETHER; - case THE_END -> Environment.THE_END; - case CUSTOM -> Environment.CUSTOM; - }; - } - public boolean hasFocusRegion() { return !focusRegion.equals(""); } @@ -454,8 +440,16 @@ public class IrisDimension extends IrisRegistrant { public KList getAllRegions(DataProvider g) { KList r = new KList<>(); + if (g == null) { + return r; + } + IrisData data = g.getData(); + if (data == null || data.getRegionLoader() == null) { + return r; + } + for (String i : getRegions()) { - r.add(g.getData().getRegionLoader().load(i)); + r.add(data.getRegionLoader().load(i)); } return r; @@ -472,7 +466,31 @@ public class IrisDimension extends IrisRegistrant { } public KList getAllBiomes(DataProvider g) { - return g.getData().getBiomeLoader().loadAll(g.getData().getBiomeLoader().getPossibleKeys()); + if (g == null) { + return new KList<>(); + } + IrisData data = g.getData(); + if (data == null || data.getBiomeLoader() == null) { + return new KList<>(); + } + return data.getBiomeLoader().loadAll(data.getBiomeLoader().getPossibleKeys()); + } + + public KList getReachableBiomes(DataProvider g) { + KMap biomes = new KMap<>(); + + for (IrisRegion region : getAllRegions(g)) { + if (region == null) { + continue; + } + for (IrisBiome biome : region.getAllBiomes(g)) { + if (biome != null) { + biomes.put(biome.getLoadKey(), biome); + } + } + } + + return biomes.v(); } public KList getAllAnyBiomes() { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionContractException.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionContractException.java new file mode 100644 index 000000000..8d432d410 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionContractException.java @@ -0,0 +1,25 @@ +/* + * 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 . + */ + +package art.arcane.iris.engine.object; + +public final class IrisDimensionContractException extends IllegalStateException { + public IrisDimensionContractException(String message) { + super(message); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionRuntimeContract.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionRuntimeContract.java new file mode 100644 index 000000000..5c4e226ae --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionRuntimeContract.java @@ -0,0 +1,98 @@ +/* + * 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 . + */ + +package art.arcane.iris.engine.object; + +import java.util.Objects; + +public record IrisDimensionRuntimeContract( + String typeKey, + int minHeight, + int height, + int logicalHeight +) { + public IrisDimensionRuntimeContract { + Objects.requireNonNull(typeKey, "typeKey"); + if (typeKey.isBlank()) { + throw new IllegalArgumentException("Dimension type key cannot be blank"); + } + if (height <= 0) { + throw new IllegalArgumentException("Dimension height must be positive"); + } + if (logicalHeight < 0 || logicalHeight > height) { + throw new IllegalArgumentException("Logical height must be inside the dimension height"); + } + } + + public static IrisDimensionRuntimeContract expected(IrisDimension dimension, String namespace) { + Objects.requireNonNull(dimension, "dimension"); + Objects.requireNonNull(namespace, "namespace"); + return new IrisDimensionRuntimeContract( + namespace + ":" + dimension.getDimensionTypeKey(), + dimension.getMinHeight(), + dimension.getMaxHeight() - dimension.getMinHeight(), + dimension.getLogicalHeight()); + } + + public static void requireHotloadCompatible( + String runtimeName, + IrisDimension active, + IrisDimension replacement, + String namespace + ) { + IrisDimensionRuntimeContract actual = expected(active, namespace); + IrisDimensionRuntimeContract proposed = expected(replacement, namespace); + proposed.requireExact(runtimeName, actual); + } + + public int maxHeight() { + return Math.addExact(minHeight, height); + } + + public void requireExact(String runtimeName, IrisDimensionRuntimeContract actual) { + Objects.requireNonNull(actual, "actual"); + if (equals(actual)) { + return; + } + throw mismatch(runtimeName, actual.typeKey(), actual.minHeight(), actual.height(), actual.logicalHeight()); + } + + public void requireHeight(String runtimeName, int actualMinHeight, int actualHeight) { + if (minHeight == actualMinHeight && height == actualHeight) { + return; + } + throw mismatch(runtimeName, "unknown", actualMinHeight, actualHeight, -1); + } + + private IrisDimensionContractException mismatch( + String runtimeName, + String actualTypeKey, + int actualMinHeight, + int actualHeight, + int actualLogicalHeight + ) { + String expectedRange = minHeight + ".." + maxHeight(); + String actualRange = actualMinHeight + ".." + Math.addExact(actualMinHeight, actualHeight); + String actualLogical = actualLogicalHeight < 0 ? "unknown" : Integer.toString(actualLogicalHeight); + return new IrisDimensionContractException(runtimeName + " requires Iris dimension type " + typeKey + + " with range " + expectedRange + " and logical height " + logicalHeight + + ", but the loaded runtime uses " + actualTypeKey + " with range " + actualRange + + " and logical height " + actualLogical + + ". Generation was refused before any chunk writes. Restart after installing the exact Iris dimension type; terrain clipping is not allowed."); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java index 5a48f40cc..beeb45fc4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEffect.java @@ -165,6 +165,18 @@ public class IrisEffect { return latch.aquire(() -> new ChronoLatch(interval)).flip(); } + public boolean shouldApplyNow() { + return canTick() && RNG.r.nextInt(chance) == 0; + } + + public String getParticleEffectKey() { + return particleEffect; + } + + public String getSoundKey() { + return sound; + } + public Particle getParticleEffect() { if (particleEffect == null) { return null; @@ -215,11 +227,7 @@ public class IrisEffect { } public void apply(Player p, Engine g) { - if (!canTick()) { - return; - } - - if (RNG.r.nextInt(chance) != 0) { + if (!shouldApplyNow()) { return; } @@ -278,11 +286,7 @@ public class IrisEffect { } public void apply(Entity p) { - if (!canTick()) { - return; - } - - if (RNG.r.nextInt(chance) != 0) { + if (!shouldApplyNow()) { return; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java index 708272719..21ba56ed7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntity.java @@ -383,41 +383,40 @@ public class IrisEntity extends IrisRegistrant { J.runEntity(e, () -> { if (isSpawnEffectRiseOutOfGround() && e instanceof LivingEntity && Chunks.hasPlayersNearby(finalAt1)) { + LivingEntity living = (LivingEntity) e; Location start = finalAt1.clone(); + boolean originalInvulnerable = e.isInvulnerable(); + boolean originalAi = living.hasAI(); + boolean originalCollidable = living.isCollidable(); + int originalNoDamageTicks = living.getNoDamageTicks(); e.setInvulnerable(true); - ((LivingEntity) e).setAI(false); - ((LivingEntity) e).setCollidable(false); - ((LivingEntity) e).setNoDamageTicks(100000); + living.setAI(false); + living.setCollidable(false); + living.setNoDamageTicks(100000); AtomicInteger t = new AtomicInteger(0); Runnable[] loop = new Runnable[1]; loop[0] = () -> { if (t.get() > 100 || e.isDead()) { - ((LivingEntity) e).setNoDamageTicks(0); - ((LivingEntity) e).setCollidable(true); - ((LivingEntity) e).setAI(true); - e.setInvulnerable(false); + restoreRiseState(e, living, originalInvulnerable, originalAi, + originalCollidable, originalNoDamageTicks); return; } t.incrementAndGet(); - if (e.getLocation().getBlock().getType().isSolid() || ((LivingEntity) e).getEyeLocation().getBlock().getType().isSolid()) { + if (e.getLocation().getBlock().getType().isSolid() || living.getEyeLocation().getBlock().getType().isSolid()) { e.teleport(start.add(new Vector(0, 0.1, 0))); - ItemStack itemCrackData = new ItemStack(((LivingEntity) e).getEyeLocation().clone().subtract(0, 2, 0).getBlock().getBlockData().getMaterial()); - e.getWorld().spawnParticle(ITEM, ((LivingEntity) e).getEyeLocation(), 6, 0.2, 0.4, 0.2, 0.06f, itemCrackData); + ItemStack itemCrackData = new ItemStack(living.getEyeLocation().clone().subtract(0, 2, 0).getBlock().getBlockData().getMaterial()); + e.getWorld().spawnParticle(ITEM, living.getEyeLocation(), 6, 0.2, 0.4, 0.2, 0.06f, itemCrackData); if (M.r(0.2)) { e.getWorld().playSound(e.getLocation(), Sound.BLOCK_CHORUS_FLOWER_GROW, 0.8f, 0.1f); } if (!J.runEntity(e, loop[0], 1)) { - ((LivingEntity) e).setNoDamageTicks(0); - ((LivingEntity) e).setCollidable(true); - ((LivingEntity) e).setAI(true); - e.setInvulnerable(false); + restoreRiseState(e, living, originalInvulnerable, originalAi, + originalCollidable, originalNoDamageTicks); } } else { - ((LivingEntity) e).setNoDamageTicks(0); - ((LivingEntity) e).setCollidable(true); - ((LivingEntity) e).setAI(true); - e.setInvulnerable(false); + restoreRiseState(e, living, originalInvulnerable, originalAi, + originalCollidable, originalNoDamageTicks); } }; J.runEntity(e, loop[0]); @@ -428,6 +427,14 @@ public class IrisEntity extends IrisRegistrant { return e; } + private static void restoreRiseState(Entity entity, LivingEntity living, boolean invulnerable, + boolean ai, boolean collidable, int noDamageTicks) { + living.setNoDamageTicks(noDamageTicks); + living.setCollidable(collidable); + living.setAI(ai); + entity.setInvulnerable(invulnerable); + } + private int surfaceY(Location l) { int m = l.getBlockY(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java index 316e3a4f0..ffba18139 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.framework.Engine; @@ -76,8 +77,9 @@ public class IrisEntitySpawn implements IRare { for (int id = 0; id < spawns; id++) { int x = (c.getX() * 16) + rng.i(15); int z = (c.getZ() * 16) + rng.i(15); - int h = gen.getHeight(x, z, true) + (gen.getWorld().tryGetRealWorld() ? gen.getWorld().realWorld().getMinHeight() : -64); - int hf = gen.getHeight(x, z, false) + (gen.getWorld().tryGetRealWorld() ? gen.getWorld().realWorld().getMinHeight() : -64); + World world = BukkitWorldBinding.tryBind(gen.getWorld()) ? BukkitWorldBinding.world(gen.getWorld()) : null; + int h = gen.getHeight(x, z, true) + (world == null ? -64 : world.getMinHeight()); + int hf = gen.getHeight(x, z, false) + (world == null ? -64 : world.getMinHeight()); Location l = switch (getReferenceSpawner().getGroup()) { case NORMAL -> new Location(c.getWorld(), x, hf + 1, z); case CAVE -> { @@ -114,11 +116,11 @@ public class IrisEntitySpawn implements IRare { int spawns = minSpawns == maxSpawns ? minSpawns : rng.i(Math.min(minSpawns, maxSpawns), Math.max(minSpawns, maxSpawns)); int s = 0; - if (!gen.getWorld().tryGetRealWorld()) { + if (!BukkitWorldBinding.tryBind(gen.getWorld())) { return 0; } - World world = gen.getWorld().realWorld(); + World world = BukkitWorldBinding.world(gen.getWorld()); if (spawns > 0) { if (referenceMarker != null && referenceMarker.shouldExhaust()) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java index 1e77a87d4..e76cad700 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisImportedStructureControl.java @@ -29,13 +29,15 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import java.util.Locale; + @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Desc("Controls native vanilla & ingested datapack structure generation for this dimension (set as the dimension's 'importedStructures' field). Default mode is ALL_ON (everything generates). Blacklist a few: mode=ALL_ON + list them in 'disabled'. Whitelist a few: mode=ALL_OFF + list them in 'enabled'. Both lists autocomplete every live vanilla AND ingested datapack structure key. Key matching is exact OR prefix (an entry matches when the structure key equals it or starts with it), so 'minecraft:village' covers every village variant. Run '/iris structure list ' to dump every valid key. Only affects NEWLY generated chunks, and is separate from the Iris 'structures' placement system (imported structures are placed by biome/region/dimension 'structures' lists, not here).") +@Desc("Controls native vanilla, mod, and ingested datapack structure generation for this dimension (set as the dimension's 'importedStructures' field). Default mode is ALL_ON. Blacklist keys with 'disabled', or use ALL_OFF with 'enabled' as a whitelist. Family matching uses namespace, slash, or underscore boundaries, so 'minecraft:village' covers every village variant without matching unrelated names. Run '/iris structure list ' to dump every valid key. Only affects newly generated chunks and is separate from Iris structure placements.") @Data public class IrisImportedStructureControl { - @Desc("Master toggle. ALL_ON generates every vanilla & datapack structure except those in 'disabled'. ALL_OFF (or CUSTOM) generates nothing except those in 'enabled'.") + @Desc("Master toggle. ALL_ON generates every native structure except those in 'disabled'. ALL_OFF generates nothing except those in 'enabled'.") private VanillaStructureMode mode = VanillaStructureMode.ALL_ON; @ArrayType(type = String.class, min = 1) @@ -45,19 +47,19 @@ public class IrisImportedStructureControl { @ArrayType(type = String.class, min = 1) @RegistryListVanillaStructure - @Desc("Structure keys to turn ON while mode is ALL_OFF (or CUSTOM), e.g. 'minecraft:village_plains'. A namespace:path prefix also matches, so 'minecraft:village' enables every village variant. Ignored when mode is ALL_ON.") + @Desc("Structure keys to turn ON while mode is ALL_OFF, e.g. 'minecraft:village_plains'. A family prefix also matches at namespace, slash, or underscore boundaries, so 'minecraft:village' enables every village variant. Ignored when mode is ALL_ON.") private KList enabled = new KList<>(); @MinNumber(-512) @MaxNumber(512) - @Desc("Vertical block offset applied only to UNDERGROUND vanilla structures (the UNDERGROUND_STRUCTURES and STRONGHOLDS generation steps: strongholds, trial chambers, mineshafts, ancient cities, etc.). Surface structures (villages, outposts, etc.) are never shifted. Use a negative value to push deep structures lower when your dimension's sea/terrain level differs from vanilla's (e.g. -64 if you lowered the fluid height to 0). 0 = no shift.") + @Desc("Vertical block offset applied only to UNDERGROUND vanilla structures (the UNDERGROUND_STRUCTURES, UNDERGROUND_DECORATION, and STRONGHOLDS generation steps: strongholds, trial chambers, mineshafts, ancient cities, etc.). Surface structures (villages, outposts, etc.) are never shifted. Use a negative value to push deep structures lower when your dimension's sea/terrain level differs from vanilla's (e.g. -64 if you lowered the fluid height to 0). 0 = no shift.") private int undergroundYShift = 0; - @Desc("When true (the default), ingested datapacks generate normally: a datapack that redefines a VANILLA structure key (e.g. an ancient-city datapack overriding 'minecraft:ancient_city') REPLACES the vanilla placement, structure definition, jigsaw pools and pieces exactly as the datapack intends, and the datapack's OWN new structures generate too. When false, datapack structures do NOT generate at all - Iris strips the vanilla-key overrides from the installed datapack copy so the original vanilla structures generate untouched, and holds every datapack-namespaced structure out of natural generation so it never appears over or beside the vanilla one. Datapacks stay installed and importable either way, so when false the only way to get a datapack structure is to run '/iris structure import' and place it manually from a biome/region/dimension 'structures' list. Resolved globally across every loaded pack: if ANY dimension sets this false, vanilla-key overrides are stripped for every installed datapack.") + @Desc("Controls whether ingested datapacks may replace minecraft-namespaced structure definitions, sets, pools, and templates. When false, those overrides are stripped from installed datapack copies so vanilla definitions stay intact. Non-minecraft structures from datapacks and mods remain governed by mode, enabled, and disabled because namespace alone cannot identify their origin. Resolved globally across loaded packs: if any dimension sets this false, minecraft-namespaced overrides are stripped from every installed datapack copy.") private boolean datapackOverrides = true; @ArrayType(type = IrisVanillaStructureAdjustment.class, min = 1) - @Desc("Per-structure transforms applied to the vanilla & datapack structures that still generate natively. Each entry translates the matched structure by (xShift, yShift, zShift). Use this to relocate structures you do not own through the Iris 'structures' placement system - e.g. push 'minecraft:stronghold' down 64 blocks. Multiple matching entries stack, and any underground shift here adds on top of 'undergroundYShift'. A structure suppressed by an Iris placement is unaffected (Iris already controls its position).") + @Desc("Per-structure adjustments applied to vanilla, mod, and datapack structures that still generate natively. Vertical shifts from every matching entry stack. Surface-intersecting structures clear logs and leaves automatically; a matching vegetation option can force clearing for unusual placements. The last matching entry with stilt settings controls foundation columns. A structure suppressed by an Iris placement is unaffected.") private KList adjustments = new KList<>(); public boolean active() { @@ -65,31 +67,26 @@ public class IrisImportedStructureControl { } public boolean shouldGenerate(String key) { - if (!datapackOverrides && isDatapackKey(key)) { - return false; - } if (mode == VanillaStructureMode.ALL_ON) { return !matches(disabled, key); } return matches(enabled, key); } - public int[] resolveOffset(String key, boolean undergroundStep) { - int x = 0; + public IrisNativeStructureDecision resolve(String key, boolean undergroundStep) { int y = undergroundStep ? undergroundYShift : 0; - int z = 0; + boolean clearVegetation = false; + IrisVanillaStructureStiltSettings stilt = null; for (IrisVanillaStructureAdjustment adjustment : adjustments) { if (adjustment != null && adjustment.matches(key)) { - x += adjustment.getXShift(); y += adjustment.getYShift(); - z += adjustment.getZShift(); + clearVegetation |= adjustment.isClearVegetation(); + if (adjustment.getStilt() != null) { + stilt = adjustment.getStilt(); + } } } - return new int[]{x, y, z}; - } - - private static boolean isDatapackKey(String key) { - return key != null && key.contains(":") && !key.startsWith("minecraft:"); + return new IrisNativeStructureDecision(shouldGenerate(key), y, clearVegetation, stilt); } private boolean matches(KList list, String key) { @@ -97,13 +94,30 @@ public class IrisImportedStructureControl { return false; } for (String entry : list) { - if (entry == null || entry.isEmpty()) { - continue; - } - if (key.equals(entry) || key.startsWith(entry)) { + if (matchesKey(entry, key)) { return true; } } return false; } + + static boolean matchesKey(String pattern, String key) { + if (pattern == null || key == null) { + return false; + } + String normalizedPattern = pattern.trim().toLowerCase(Locale.ROOT); + String normalizedKey = key.trim().toLowerCase(Locale.ROOT); + if (normalizedPattern.isEmpty() || !normalizedKey.startsWith(normalizedPattern)) { + return false; + } + if (normalizedKey.length() == normalizedPattern.length()) { + return true; + } + char patternEnd = normalizedPattern.charAt(normalizedPattern.length() - 1); + if (patternEnd == ':' || patternEnd == '/' || patternEnd == '_') { + return true; + } + char boundary = normalizedKey.charAt(normalizedPattern.length()); + return boundary == '/' || boundary == '_'; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java b/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java new file mode 100644 index 000000000..4639a2aa1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisNativeStructureDecision.java @@ -0,0 +1,27 @@ +/* + * Iris is a World Generator for Minecraft Bukkit 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 . + */ + +package art.arcane.iris.engine.object; + +public record IrisNativeStructureDecision( + boolean generate, + int yShift, + boolean clearVegetation, + IrisVanillaStructureStiltSettings stilt +) { +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index b2923b1f0..25a511faa 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -100,6 +100,7 @@ public class IrisObject extends IrisRegistrant { protected transient volatile boolean smartBored = false; @Setter protected transient AtomicCache aabb = new AtomicCache<>(); + private transient final AtomicCache treeBlockPresence = new AtomicCache<>(); @Getter private VectorMap blocks; @Getter @@ -325,6 +326,7 @@ public class IrisObject extends IrisRegistrant { } public void readLegacy(InputStream in) throws IOException { + treeBlockPresence.reset(); DataInputStream din = new DataInputStream(in); this.w = din.readInt(); this.h = din.readInt(); @@ -356,6 +358,7 @@ public class IrisObject extends IrisRegistrant { } public void read(InputStream in) throws Throwable { + treeBlockPresence.reset(); DataInputStream din = new DataInputStream(in); this.w = din.readInt(); this.h = din.readInt(); @@ -621,6 +624,7 @@ public class IrisObject extends IrisRegistrant { } public void setUnsigned(int x, int y, int z, PlatformBlockState block) { + treeBlockPresence.reset(); IrisBlockVector v = getSigned(x, y, z); if (block == null) { @@ -642,6 +646,7 @@ public class IrisObject extends IrisRegistrant { } public void setUnsigned(int x, int y, int z, Block block, boolean legacy) { + treeBlockPresence.reset(); IrisBlockVector v = getSigned(x, y, z); if (block == null) { @@ -923,11 +928,13 @@ public class IrisObject extends IrisRegistrant { && !config.isFromBottom() && config.getMode() != ObjectPlaceMode.FLOATING && !rawStructurePiece - && !stilting && !vacuuming + && !config.isUnderwater() + && !config.isOnwater() && config.getCarvingSupport().supportsSurface() - && y > 0 - && lacksFootprintSupport(placer, config, x, y, z, spinx, spiny, spinz)) { + && hasTreeBlocks() + && IrisSurfaceOpening.isOpen(oplacer, getLoader(), x, z, config.getTranslate(), + config.getRotation(), spinx, spiny, spinz)) { return -1; } @@ -1493,38 +1500,27 @@ public class IrisObject extends IrisRegistrant { } private boolean shouldBailForCarvingAnchor(IObjectPlacer placer, IrisObjectPlacement placement, int x, int y, int z) { - boolean carved = isCarvedAnchor(placer, x, y, z); CarvingMode carvingMode = placement.getCarvingSupport(); return switch (carvingMode) { - case SURFACE_ONLY -> carved; - case CARVING_ONLY -> !carved; + case SURFACE_ONLY -> placer.isCarved(x, y, z); + case CARVING_ONLY -> !isCarvedCaveAnchor(placer, x, y, z); case ANYWHERE -> false; }; } - private boolean lacksFootprintSupport(IObjectPlacer placer, IrisObjectPlacement config, int x, int y, int z, int spinx, int spiny, int spinz) { - IrisBlockVector rot = config.getRotation().rotate(new IrisBlockVector(getW(), getH(), getD()), spinx, spiny, spinz).clone(); - int halfW = Math.max(0, Math.abs(rot.getBlockX()) / 2); - int halfD = Math.max(0, Math.abs(rot.getBlockZ()) / 2); - if (halfW == 0 && halfD == 0) { - return false; - } - int tx = config.getTranslate().getX(); - int tz = config.getTranslate().getZ(); - int cx = x + tx; - int cz = z + tz; - int[] sampleX = {cx, cx - halfW, cx + halfW, cx - halfW, cx + halfW}; - int[] sampleZ = {cz, cz - halfD, cz + halfD, cz + halfD, cz - halfD}; - for (int i = 0; i < sampleX.length; i++) { - int sh = placer.getHighest(sampleX[i], sampleZ[i], getLoader(), true); - if (isCarvedAnchor(placer, sampleX[i], sh, sampleZ[i])) { - return true; + boolean hasTreeBlocks() { + Boolean present = treeBlockPresence.aquire(() -> { + readLock.lock(); + try { + return IrisSurfaceOpening.containsTreeBlocks(blocks.values()); + } finally { + readLock.unlock(); } - } - return false; + }); + return Boolean.TRUE.equals(present); } - private boolean isCarvedAnchor(IObjectPlacer placer, int x, int y, int z) { + private boolean isCarvedCaveAnchor(IObjectPlacer placer, int x, int y, int z) { return placer.isCarved(x, y, z) || placer.isCarved(x, y - 1, z) || placer.isCarved(x, y - 2, z) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index 2a9ba1325..20f180f15 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -325,17 +325,17 @@ public class IrisObjectPlacement { * @param dataManager Iris Data Manager * @return The loot table it should use. */ - public IrisLootTable getTable(PlatformBlockState state, IrisData dataManager) { + public IrisLootTable getTable(PlatformBlockState state, IrisData dataManager, RNG rng) { TableCache cache = getCache(dataManager); BlockData data = (BlockData) state.nativeHandle(); if (BukkitBlockResolution.isStorageChest(data)) { IrisLootTable picked = null; if (cache.exact.containsKey(data.getMaterial()) && cache.exact.get(data.getMaterial()).containsKey(data)) { - picked = cache.exact.get(data.getMaterial()).get(data).pullRandom(); + picked = cache.exact.get(data.getMaterial()).get(data).pullRandom(rng); } else if (cache.basic.containsKey(data.getMaterial())) { - picked = cache.basic.get(data.getMaterial()).pullRandom(); + picked = cache.basic.get(data.getMaterial()).pullRandom(rng); } else if (cache.global.getSize() > 0) { - picked = cache.global.pullRandom(); + picked = cache.global.pullRandom(rng); } return picked; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java index 9009e5b1d..68622d25d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectScale.java @@ -18,13 +18,13 @@ package art.arcane.iris.engine.object; -import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; +import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java index 12995da12..982200bd6 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java @@ -22,7 +22,6 @@ import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.platform.bukkit.BukkitWorld; import art.arcane.iris.spi.PlatformWorld; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.json.JSONObject; @@ -95,10 +94,11 @@ public class IrisSpawner extends IrisRegistrant { } public boolean canSpawn(Engine engine) { - if (!isValid(new BukkitWorld(engine.getWorld().realWorld()))) + PlatformWorld world = engine.getWorld().platformWorld(); + if (world == null || !isValid(world)) return false; - var rate = getMaximumRate(); + IrisRate rate = getMaximumRate(); return rate.isInfinite() || engine.getEngineData().getCooldown(this).canSpawn(rate); } @@ -106,7 +106,7 @@ public class IrisSpawner extends IrisRegistrant { if (!canSpawn(engine)) return false; - var rate = getMaximumRatePerChunk(); + IrisRate rate = getMaximumRatePerChunk(); return rate.isInfinite() || engine.getEngineData().getChunk(x, z).getCooldown(this).canSpawn(rate); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceOpening.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceOpening.java new file mode 100644 index 000000000..8bc37da3d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceOpening.java @@ -0,0 +1,44 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.math.IrisBlockVector; + +final class IrisSurfaceOpening { + private static final int SUPPORT_RADIUS = 1; + + private IrisSurfaceOpening() { + } + + static boolean isOpen(IObjectPlacer placer, IrisData data, int x, int z, IrisObjectTranslate translate, + IrisObjectRotation rotation, int spinX, int spinY, int spinZ) { + IrisBlockVector offset = new IrisBlockVector(0, 0, 0); + if (translate != null) { + offset = rotation == null + ? translate.translate(offset) + : translate.translate(offset, rotation, spinX, spinY, spinZ); + } + int centerX = x + offset.getBlockX(); + int centerZ = z + offset.getBlockZ(); + for (int dx = -SUPPORT_RADIUS; dx <= SUPPORT_RADIUS; dx++) { + for (int dz = -SUPPORT_RADIUS; dz <= SUPPORT_RADIUS; dz++) { + int sampleX = centerX + dx; + int sampleZ = centerZ + dz; + int surfaceY = placer.getHighest(sampleX, sampleZ, data, true); + if (placer.isCarved(sampleX, surfaceY, sampleZ)) { + return true; + } + } + } + return false; + } + + static boolean containsTreeBlocks(Iterable states) { + for (PlatformBlockState state : states) { + if (state != null && state.isTreeBlock()) { + return true; + } + } + return false; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java index 641b026c1..b4cc57dc9 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureAdjustment.java @@ -32,7 +32,7 @@ import lombok.experimental.Accessors; @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Desc("A per-structure transform applied to vanilla & datapack structures that still generate natively (those NOT suppressed by an Iris 'structures' placement). Every block the structure writes is translated by (xShift, yShift, zShift). Use it to relocate a structure you do not control through the Iris placement system - e.g. push 'minecraft:stronghold' down 64 blocks when your terrain sits lower than vanilla's. Listed under the dimension's importedStructures 'adjustments'.") +@Desc("A per-structure adjustment applied to vanilla, mod, and datapack structures that still generate natively (those NOT suppressed by an Iris 'structures' placement). Vertical shifts move the structure start, pieces, bounds, and jigsaw metadata together before references and placement. Surface structures clear intersecting trees automatically; optional postprocessing can force vegetation clearing or build palette-driven foundation columns.") @Data public class IrisVanillaStructureAdjustment { @ArrayType(type = String.class, min = 1) @@ -42,28 +42,18 @@ public class IrisVanillaStructureAdjustment { @MinNumber(-512) @MaxNumber(512) - @Desc("Vertical block offset. Negative pushes the structure down, positive lifts it. Applied to every block the structure places.") + @Desc("Vertical block offset. Negative pushes the structure down, positive lifts it. The resolved shift is clamped so the structure remains inside the world's vertical build bounds.") private int yShift = 0; - @MinNumber(-512) - @MaxNumber(512) - @Desc("East/west block offset (positive = +X). Keep small; large horizontal shifts move the structure far from the position vanilla chose for it.") - private int xShift = 0; + @Desc("When true, force logs and leaves out of the structure footprint even when the structure does not reach the detected tree base. Normal surface-intersecting structures are protected automatically.") + private boolean clearVegetation = false; - @MinNumber(-512) - @MaxNumber(512) - @Desc("North/south block offset (positive = +Z). Keep small; large horizontal shifts move the structure far from the position vanilla chose for it.") - private int zShift = 0; + @Desc("Optional foundation columns placed beneath the native structure piece bases after placement.") + private IrisVanillaStructureStiltSettings stilt = null; public boolean matches(String key) { - if (key == null) { - return false; - } for (String entry : match) { - if (entry == null || entry.isEmpty()) { - continue; - } - if (key.equals(entry) || key.startsWith(entry)) { + if (IrisImportedStructureControl.matchesKey(entry, key)) { return true; } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettings.java b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettings.java new file mode 100644 index 000000000..d2044843a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettings.java @@ -0,0 +1,24 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@AllArgsConstructor +@Desc("Defines the foundation columns placed beneath a native vanilla, mod, or datapack structure.") +@Data +public class IrisVanillaStructureStiltSettings { + @MinNumber(1) + @MaxNumber(4064) + @Desc("Maximum number of blocks each foundation column may descend while searching for solid ground.") + private int maxDepth = 64; + + @Desc("Block palette used for foundation columns.") + private IrisMaterialPalette palette = new IrisMaterialPalette().qclear().qadd("minecraft:cobblestone"); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisWorld.java b/core/src/main/java/art/arcane/iris/engine/object/IrisWorld.java index 9a583dd6e..dd0bcc81a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisWorld.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisWorld.java @@ -18,35 +18,20 @@ package art.arcane.iris.engine.object; -import art.arcane.iris.core.IrisWorldStorage; -import art.arcane.iris.core.tools.IrisToolbelt; -import art.arcane.iris.spi.IrisLogging; -import art.arcane.volmlib.util.bukkit.WorldIdentity; -import art.arcane.volmlib.util.collection.KList; +import art.arcane.iris.spi.PlatformWorld; import lombok.AccessLevel; import lombok.Builder; import lombok.Data; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; -import org.bukkit.Location; -import org.bukkit.NamespacedKey; -import org.bukkit.World; -import org.bukkit.entity.Entity; -import org.bukkit.entity.Player; -import org.bukkit.generator.WorldInfo; import java.io.File; -import java.util.Collection; -import java.util.List; @Builder @Data @Accessors(chain = true, fluent = true) public class IrisWorld { - private static final KList NO_PLAYERS = new KList<>(); - private static final KList NO_ENTITIES = new KList<>(); - private NamespacedKey key; private String platformIdentity; private String name; private File worldFolder; @@ -54,106 +39,24 @@ public class IrisWorld { @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) private long seed; - private World.Environment environment; - private World realWorld; + private PlatformWorld platformWorld; private int minHeight; private int maxHeight; - public static IrisWorld fromWorld(World world) { - return bindWorld(IrisWorld.builder().build(), world); - } - - private static IrisWorld bindWorld(IrisWorld iw, World world) { - return iw.key(WorldIdentity.key(world)) - .name(world.getName()) - .worldFolder(world.getWorldFolder()) - .minHeight(world.getMinHeight()) - .maxHeight(world.getMaxHeight()) - .realWorld(world) - .environment(world.getEnvironment()); - } - public long getRawWorldSeed() { return seed; } public String identity() { - return key == null ? platformIdentity : key.toString(); + return platformIdentity; } public void setRawWorldSeed(long seed) { this.seed = seed; } - public boolean tryGetRealWorld() { - if (hasRealWorld()) { - return true; - } - - if (key == null) { - return false; - } - - World w = WorldIdentity.resolve(key).orElse(null); - - if (w != null) { - realWorld = w; - return true; - } - - return false; - } - - public boolean hasRealWorld() { - return realWorld != null; - } - - public List getPlayers() { - - if (hasRealWorld()) { - return realWorld().getPlayers(); - } - - return NO_PLAYERS; - } - - public void evacuate() { - if (hasRealWorld()) { - IrisToolbelt.evacuate(realWorld()); - } - } - - public void bind(WorldInfo worldInfo) { - key(WorldIdentity.key(worldInfo)) - .worldFolder(IrisWorldStorage.dimensionRoot(worldInfo)) - .minHeight(worldInfo.getMinHeight()) - .maxHeight(worldInfo.getMaxHeight()) - .environment(worldInfo.getEnvironment()); - } - - public void bind(World world) { - if (hasRealWorld()) { - return; - } - - bindWorld(this, world); - } - - public Location spawnLocation() { - if (hasRealWorld()) { - return realWorld().getSpawnLocation(); - } - - IrisLogging.error("This world is not real yet, cannot get spawn location! HEADLESS!"); - return null; - } - - public Collection getEntitiesByClass(Class t) { - if (hasRealWorld()) { - return realWorld().getEntitiesByClass(t); - } - - return (KList) NO_ENTITIES; + public boolean hasPlatformWorld() { + return platformWorld != null; } public int getHeight() { diff --git a/core/src/main/java/art/arcane/iris/engine/object/TileData.java b/core/src/main/java/art/arcane/iris/engine/object/TileData.java index a6e0b6e6e..82ca3bf6d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/TileData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/TileData.java @@ -51,15 +51,24 @@ public class TileData implements Cloneable { private static final Gson gson = new GsonBuilder().disableHtmlEscaping().setStrictness(Strictness.LENIENT).create(); private static final boolean BUKKIT_PRESENT = detectBukkit(); private static volatile TileReader FALLBACK_READER = null; + private static volatile TileFactory FALLBACK_FACTORY = null; public interface TileReader { TileData read(DataInputStream in) throws IOException; } + public interface TileFactory { + TileData create(PlatformBlockState state, KMap properties); + } + public static void bindFallbackReader(TileReader reader) { FALLBACK_READER = reader; } + public static void bindFallbackFactory(TileFactory factory) { + FALLBACK_FACTORY = factory; + } + private static boolean detectBukkit() { try { Class.forName("org.bukkit.Bukkit", false, TileData.class.getClassLoader()); @@ -84,7 +93,7 @@ public class TileData implements Cloneable { if (!BukkitPlatform.hasTile(block.getType())) return null; if (useLegacy) { - var legacy = LegacyTileData.fromBukkit(block.getState()); + LegacyTileData legacy = LegacyTileData.fromBukkit(block.getState()); if (legacy != null) return legacy; } @@ -93,6 +102,10 @@ public class TileData implements Cloneable { } public static TileData of(PlatformBlockState state, KMap properties) { + TileFactory factory = FALLBACK_FACTORY; + if (factory != null) { + return factory.create(state, properties); + } if (!BUKKIT_PRESENT) { return null; } @@ -104,11 +117,12 @@ public class TileData implements Cloneable { } public static TileData read(DataInputStream in) throws IOException { + TileReader reader = FALLBACK_READER; + if (reader != null) { + return reader.read(in); + } if (!BUKKIT_PRESENT) { - TileReader reader = FALLBACK_READER; - if (reader != null) { - return reader.read(in); - } + throw new IOException("No tile data reader is available for this platform"); } if (!in.markSupported()) throw new IOException("Mark not supported"); @@ -182,7 +196,7 @@ public class TileData implements Cloneable { @Override public TileData clone() { - var clone = new TileData(); + TileData clone = new TileData(); clone.material = material; clone.properties = properties.copy(); //TODO make a deep copy return clone; diff --git a/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java b/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java index 2121a684e..0652dd72b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java +++ b/core/src/main/java/art/arcane/iris/engine/object/VanillaStructureMode.java @@ -20,14 +20,11 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.object.annotations.Desc; -@Desc("Master toggle for vanilla & datapack structure generation in a dimension.") +@Desc("Master toggle for native vanilla, mod, and datapack structure generation in a dimension.") public enum VanillaStructureMode { - @Desc("All vanilla & datapack structures generate, except any keys listed in 'disabled'. This is the default. Use this to blacklist a few structures.") + @Desc("All native vanilla, mod, and datapack structures generate, except any keys listed in 'disabled'. This is the default. Use this to blacklist a few structures.") ALL_ON, - @Desc("No vanilla or datapack structures generate, except any keys listed in 'enabled'. Use this to whitelist a few structures.") - ALL_OFF, - - @Desc("Same as ALL_OFF: nothing generates except keys listed in 'enabled'.") - CUSTOM + @Desc("No native vanilla, mod, or datapack structures generate, except any keys listed in 'enabled'. Use this to whitelist a few structures.") + ALL_OFF } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index d9300d06f..ebe023c51 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -41,11 +41,13 @@ import art.arcane.iris.engine.data.chunk.TerrainChunk; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.GenerationSessionException; +import art.arcane.iris.engine.object.IrisDimensionContractException; import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.engine.object.VanillaStructureMode; +import art.arcane.iris.engine.object.IrisDimensionRuntimeContract; import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.object.StudioMode; import art.arcane.iris.engine.platform.studio.StudioGenerator; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBiome; @@ -110,6 +112,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun private volatile Looper hotloader; private volatile StudioMode lastMode; private volatile DummyBiomeProvider dummyBiomeProvider; + private volatile Throwable initializationFailure; + private volatile IrisDimension validatedDimension; + private volatile IrisDimensionRuntimeContract validatedWorldContract; private volatile boolean closing; @Setter private volatile StudioGenerator studioGenerator; @@ -132,15 +137,17 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun new KList<>(".iris"), new KList<>() ); + this.initializationFailure = null; + this.validatedDimension = null; + this.validatedWorldContract = null; this.closing = false; Bukkit.getServer().getPluginManager().registerEvents(this, BukkitPlatform.plugin()); } @EventHandler(priority = EventPriority.LOWEST) public void onWorldInit(WorldInitEvent event) { - if (!Objects.equals(world.key(), WorldIdentity.key(event.getWorld()))) return; + if (!Objects.equals(world.identity(), WorldIdentity.key(event.getWorld()).toString())) return; BukkitPlatform.volmitPlugin().unregisterListener(this); - world.bind(event.getWorld()); world.setRawWorldSeed(event.getWorld().getSeed()); if (initialize(event.getWorld())) return; @@ -153,18 +160,29 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private boolean initialize(World world) { - Engine engine = getEngine(world); - if (engine == null) return false; try { + Engine engine = getEngine(world); + if (engine == null) { + return false; + } INMS.get().inject(world.getSeed(), engine, world); IrisLogging.debug("Injected Iris Biome Source into " + world.getName()); if (!studio) { J.s(() -> updateSpawnLocation(world), 1); } } catch (Throwable e) { + initializationFailure = e; + spawnChunks.completeExceptionally(e); IrisLogging.reportError(e); - IrisLogging.error("Failed to inject biome source into " + world.getName()); + IrisLogging.error("Failed to initialize Iris generator for " + world.getName()); e.printStackTrace(); + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (e instanceof Error error) { + throw error; + } + throw new IllegalStateException("Failed to initialize Iris for world '" + world.getName() + "'", e); } spawnChunks.complete(INMS.get().getSpawnChunkCount(world)); BukkitPlatform.volmitPlugin().unregisterListener(this); @@ -280,6 +298,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private Engine getEngine(WorldInfo world) { + throwIfInitializationFailed(); + validateAndBindWorld(world); if (setup.get()) { return getEngine(); } @@ -322,6 +342,40 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } } + private void throwIfInitializationFailed() { + Throwable failure = initializationFailure; + if (failure == null) { + return; + } + if (failure instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (failure instanceof Error error) { + throw error; + } + throw new IllegalStateException("Iris initialization previously failed for world '" + world.name() + "'", failure); + } + + private void validateAndBindWorld(WorldInfo worldInfo) { + IrisDimension dimension = getTarget().getDimension(); + IrisDimensionRuntimeContract activeContract = validatedWorldContract; + if (activeContract == null || validatedDimension != dimension) { + IrisDimensionRuntimeContract expected = IrisDimensionRuntimeContract.expected(dimension, "iris"); + if (activeContract != null) { + expected.requireExact("Bukkit world '" + worldInfo.getName() + "'", activeContract); + } + int runtimeHeight = worldInfo.getMaxHeight() - worldInfo.getMinHeight(); + expected.requireHeight("Bukkit world '" + worldInfo.getName() + "'", worldInfo.getMinHeight(), runtimeHeight); + BukkitWorldBinding.bind(world, worldInfo); + validatedWorldContract = expected; + validatedDimension = dimension; + return; + } + if (!world.hasPlatformWorld() && worldInfo instanceof World) { + BukkitWorldBinding.bind(world, worldInfo); + } + } + @Override public void close() { closeAsync(); @@ -434,13 +488,13 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun if (closing) { return; } + throwIfInitializationFailed(); try { Engine engine = getEngine(world); lastChunkGenTime = System.currentTimeMillis(); computeStudioGenerator(); TerrainChunk tc = TerrainChunk.create(d); - this.world.bind(world); if (studioGenerator != null) { studioGenerator.generateChunk(engine, tc, x, z); } else { @@ -452,6 +506,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } IrisLogging.debug("Generated " + x + " " + z); + } catch (IrisDimensionContractException e) { + throw e; } catch (GenerationSessionException e) { if (closing || isExpectedTeardown(engine, e)) { return; @@ -494,7 +550,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private boolean isMaintenanceActive() { - World realWorld = this.world.realWorld(); + World realWorld = BukkitWorldBinding.world(this.world); return realWorld != null && IrisToolbelt.isWorldMaintenanceActive(realWorld); } @@ -525,17 +581,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun return true; } - World realWorld = this.world.realWorld(); + World realWorld = BukkitWorldBinding.world(this.world); PregeneratorJob pregeneratorJob = PregeneratorJob.getInstance(); - return realWorld != null && pregeneratorJob != null && pregeneratorJob.targetsWorld(realWorld); + return realWorld != null && pregeneratorJob != null && pregeneratorJob.targetsWorldIdentity(WorldIdentity.serialize(realWorld)); } @Override public int getBaseHeight(@NotNull WorldInfo worldInfo, @NotNull Random random, int x, int z, @NotNull HeightMap heightMap) { - Engine currentEngine = engine; - if (currentEngine == null || !setup.get()) { - currentEngine = getEngine(worldInfo); - } + Engine currentEngine = getEngine(worldInfo); boolean ignoreFluid = switch (heightMap) { case OCEAN_FLOOR, OCEAN_FLOOR_WG -> true; @@ -579,14 +632,19 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun @Override public boolean shouldGenerateStructures() { + if (initializationFailure != null) { + return false; + } try { Engine e = this.engine; if (e == null) { return true; } - return e.getDimension().getImportedStructures().getMode() != VanillaStructureMode.ALL_OFF; + return e.getDimension().getImportedStructures().active(); } catch (Throwable t) { - return true; + IrisLogging.reportError("Iris could not resolve native structure generation policy for " + + world.name() + ".", t); + return false; } } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index 1bfe9c80b..a6b0ca98e 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.platform; +import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.core.events.IrisLootEvent; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.service.ExternalDataSVC; @@ -343,7 +344,8 @@ public final class EngineBukkitOps { PlacedObject po = engine.getObjectPlacement(rx, ry, rz, mc); if (po != null && po.getPlacement() != null) { if (BukkitBlockResolution.isStorageChest(b.getBlockData())) { - IrisLootTable table = po.getPlacement().getTable(BukkitBlockState.of(b.getBlockData()), engine.getData()); + IrisLootTable table = po.getPlacement().getTable( + BukkitBlockState.of(b.getBlockData()), engine.getData(), rng); if (table != null) { tables.add(table); if (po.getPlacement().isOverrideGlobalLoot()) { @@ -390,8 +392,8 @@ public final class EngineBukkitOps { if (IrisLootEvent.callLootEvent(items, inv, world, x, y, z)) return; - if (PaperLib.isPaper() && engine.getWorld().hasRealWorld()) { - PaperLib.getChunkAtAsync(engine.getWorld().realWorld(), x >> 4, z >> 4).thenAccept((c) -> { + if (PaperLib.isPaper() && engine.getWorld().hasPlatformWorld()) { + PaperLib.getChunkAtAsync(BukkitWorldBinding.world(engine.getWorld()), x >> 4, z >> 4).thenAccept((c) -> { Runnable r = () -> { for (ItemStack i : items) { inv.addItem(i); diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java index 28e7365b3..9e0c82848 100644 --- a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitBlockState.java @@ -22,6 +22,7 @@ import art.arcane.iris.core.nms.INMS; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.IrisCustomData; import org.bukkit.Bukkit; +import org.bukkit.Tag; import org.bukkit.block.data.BlockData; import java.util.LinkedHashMap; @@ -46,6 +47,7 @@ public final class BukkitBlockState implements PlatformBlockState { private volatile Boolean lit; private volatile Boolean updatable; private volatile Boolean foliage; + private volatile Boolean treeBlock; private volatile Boolean foliagePlantable; private volatile Boolean decorant; private volatile Boolean storage; @@ -168,6 +170,16 @@ public final class BukkitBlockState implements PlatformBlockState { return data instanceof IrisCustomData; } + @Override + public String deferredPlacementKey() { + return data instanceof IrisCustomData custom ? custom.getCustom().toString() : null; + } + + @Override + public PlatformBlockState placementBaseState() { + return data instanceof IrisCustomData custom ? of(custom.getBase()) : this; + } + @Override public boolean isFluid() { Boolean cached = fluid; @@ -228,6 +240,16 @@ public final class BukkitBlockState implements PlatformBlockState { return cached; } + @Override + public boolean isTreeBlock() { + Boolean cached = treeBlock; + if (cached == null) { + cached = Tag.LOGS.isTagged(data.getMaterial()) || Tag.LEAVES.isTagged(data.getMaterial()); + treeBlock = cached; + } + return cached; + } + @Override public boolean isFoliagePlantable() { Boolean cached = foliagePlantable; diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEnvironment.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEnvironment.java new file mode 100644 index 000000000..602e0cfca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitEnvironment.java @@ -0,0 +1,39 @@ +/* + * Iris is a World Generator for Minecraft Bukkit 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 . + */ + +package art.arcane.iris.platform.bukkit; + +import art.arcane.iris.engine.object.IrisEnvironment; +import org.bukkit.World; + +public final class BukkitEnvironment { + private BukkitEnvironment() { + } + + public static World.Environment from(IrisEnvironment environment) { + if (environment == null) { + return World.Environment.NORMAL; + } + return switch (environment) { + case NORMAL -> World.Environment.NORMAL; + case NETHER -> World.Environment.NETHER; + case THE_END -> World.Environment.THE_END; + case CUSTOM -> World.Environment.CUSTOM; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitWorldBinding.java b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitWorldBinding.java new file mode 100644 index 000000000..c874849bc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/platform/bukkit/BukkitWorldBinding.java @@ -0,0 +1,92 @@ +/* + * Iris is a World Generator for Minecraft Bukkit 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 . + */ + +package art.arcane.iris.platform.bukkit; + +import art.arcane.iris.core.IrisWorldStorage; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.volmlib.util.bukkit.WorldIdentity; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.generator.WorldInfo; + +import java.util.Collection; +import java.util.List; + +public final class BukkitWorldBinding { + private BukkitWorldBinding() { + } + + public static IrisWorld bind(IrisWorld target, WorldInfo worldInfo) { + target.platformIdentity(WorldIdentity.key(worldInfo).toString()) + .name(worldInfo.getName()) + .worldFolder(IrisWorldStorage.dimensionRoot(worldInfo)) + .minHeight(worldInfo.getMinHeight()) + .maxHeight(worldInfo.getMaxHeight()); + if (worldInfo instanceof World world) { + target.platformWorld(new BukkitWorld(world)); + } + return target; + } + + public static boolean tryBind(IrisWorld target) { + if (target.hasPlatformWorld()) { + return true; + } + NamespacedKey key = NamespacedKey.fromString(target.identity()); + if (key == null) { + return false; + } + World world = WorldIdentity.resolve(key).orElse(null); + if (world == null) { + return false; + } + bind(target, world); + return true; + } + + public static World world(IrisWorld target) { + if (target == null || !target.hasPlatformWorld()) { + return null; + } + return BukkitPlatform.unwrapWorld(target.platformWorld()); + } + + public static List players(IrisWorld target) { + World world = world(target); + return world == null ? List.of() : world.getPlayers(); + } + + public static Collection entities(IrisWorld target, Class type) { + World world = world(target); + return world == null ? List.of() : world.getEntitiesByClass(type); + } + + public static Location spawnLocation(IrisWorld target) { + World world = world(target); + if (world != null) { + return world.getSpawnLocation(); + } + IrisLogging.error("This world is not real yet, cannot get spawn location! HEADLESS!"); + return null; + } +} diff --git a/core/src/main/java/art/arcane/iris/util/common/data/B.java b/core/src/main/java/art/arcane/iris/util/common/data/B.java index 4b36d0cfe..6959b57c8 100644 --- a/core/src/main/java/art/arcane/iris/util/common/data/B.java +++ b/core/src/main/java/art/arcane/iris/util/common/data/B.java @@ -76,6 +76,10 @@ public class B { return state != null && state.isFoliage(); } + public static boolean isTreeBlock(PlatformBlockState state) { + return state != null && state.isTreeBlock(); + } + public static boolean isFoliagePlantable(PlatformBlockState state) { return state != null && state.isFoliagePlantable(); } diff --git a/core/src/main/java/art/arcane/iris/util/common/format/C.java b/core/src/main/java/art/arcane/iris/util/common/format/C.java index da4d62243..034972894 100644 --- a/core/src/main/java/art/arcane/iris/util/common/format/C.java +++ b/core/src/main/java/art/arcane/iris/util/common/format/C.java @@ -23,7 +23,7 @@ import art.arcane.iris.util.common.plugin.VolmitSender; import net.kyori.adventure.text.minimessage.MiniMessage; import net.md_5.bungee.api.chat.BaseComponent; import net.md_5.bungee.api.chat.TextComponent; -import org.apache.commons.lang.Validate; +import org.apache.commons.lang3.Validate; import org.bukkit.ChatColor; import org.bukkit.Color; import org.bukkit.DyeColor; diff --git a/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java b/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java index 9256b58a8..dfb7cf9c9 100644 --- a/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java +++ b/core/src/main/java/art/arcane/iris/util/common/parallel/MultiBurst.java @@ -4,8 +4,6 @@ import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.core.IrisSettings; import art.arcane.volmlib.util.parallel.MultiBurstSupport; import art.arcane.volmlib.util.math.M; -import kotlinx.coroutines.CoroutineDispatcher; -import kotlinx.coroutines.ExecutorsKt; import java.util.concurrent.ExecutorService; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.ForkJoinWorkerThread; @@ -15,8 +13,6 @@ public class MultiBurst extends MultiBurstSupport { private static final long TIMEOUT = Long.getLong("iris.burst.timeout", 15000); public static final MultiBurst burst = new MultiBurst(); public static final MultiBurst ioBurst = new MultiBurst("Iris IO", () -> IrisSettings.get().getConcurrency().getIoParallelism()); - private volatile CoroutineDispatcher dispatcher; - private volatile ExecutorService dispatcherService; public MultiBurst() { this("Iris"); @@ -34,20 +30,6 @@ public class MultiBurst extends MultiBurstSupport { super(name, priority, parallelism, IrisSettings::getThreadCount, M::ms, IrisLogging::reportError, IrisLogging::info, IrisLogging::warn, TIMEOUT); } - public CoroutineDispatcher getDispatcher() { - ExecutorService service = service(); - if (dispatcherService != service || dispatcher == null) { - synchronized (this) { - if (dispatcherService != service || dispatcher == null) { - dispatcher = ExecutorsKt.from(service); - dispatcherService = service; - } - } - } - - return dispatcher; - } - public boolean ownsCurrentThread() { Thread thread = Thread.currentThread(); if (!(thread instanceof ForkJoinWorkerThread worker)) { diff --git a/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobPlatformIsolationTest.java b/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobPlatformIsolationTest.java new file mode 100644 index 000000000..5ab91acf8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/gui/PregeneratorJobPlatformIsolationTest.java @@ -0,0 +1,37 @@ +package art.arcane.iris.core.gui; + +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertFalse; + +public class PregeneratorJobPlatformIsolationTest { + @Test + public void pregeneratorJobHasNoBukkitSignatures() { + for (Field field : PregeneratorJob.class.getDeclaredFields()) { + assertPlatformNeutral(field.getType()); + } + for (Constructor constructor : PregeneratorJob.class.getDeclaredConstructors()) { + for (Class parameter : constructor.getParameterTypes()) { + assertPlatformNeutral(parameter); + } + } + for (Method method : PregeneratorJob.class.getDeclaredMethods()) { + assertPlatformNeutral(method.getReturnType()); + for (Class parameter : method.getParameterTypes()) { + assertPlatformNeutral(parameter); + } + } + } + + private static void assertPlatformNeutral(Class type) { + Class component = type; + while (component.isArray()) { + component = component.getComponentType(); + } + assertFalse(component.getName(), component.getName().startsWith("org.bukkit.")); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java index c6ba83a0a..fd1c700de 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/WorldLifecycleStagingTest.java @@ -31,13 +31,13 @@ public class WorldLifecycleStagingTest { } @Test - public void stagedStemGeneratorIsConsumedWhenLevelIdDiffersFromWorldName() { + public void stagedStemGeneratorCannotBeConsumedByDifferentWorldName() { ChunkGenerator generator = mock(ChunkGenerator.class); WorldLifecycleStaging.stageStemGenerator("iris_world", generator); - assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("world")); - assertNull(WorldLifecycleStaging.consumeStemGenerator("iris_world")); + assertNull(WorldLifecycleStaging.consumeStemGenerator("world")); + assertSame(generator, WorldLifecycleStaging.consumeStemGenerator("iris_world")); assertNull(WorldLifecycleStaging.consumeStemGenerator("world")); } diff --git a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java index 18f5d99ad..c7a1539fb 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java @@ -38,6 +38,9 @@ public class DefaultPackBootstrapProvisionerTest { Path root = Files.createTempDirectory("iris-bootstrap-cold"); try { Path dataDirectory = root.resolve("plugins/Iris"); + Path legacyDatapack = dataDirectory.resolve("bootstrap/datapack"); + Files.createDirectories(legacyDatapack); + Files.writeString(legacyDatapack.resolve("obsolete.txt"), "obsolete", StandardCharsets.UTF_8); DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1)); DefaultPackBootstrapProvisioner.ProvisionResult installed = DefaultPackBootstrapProvisioner.provision( dataDirectory, @@ -56,9 +59,11 @@ public class DefaultPackBootstrapProvisionerTest { assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UNCHANGED, unchanged.status()); assertEquals(1, requests.get()); assertTrue(Files.isRegularFile(installed.packRoot().resolve("dimensions/overworld.json"))); + assertEquals(root.resolve("datapacks/iris"), installed.datapackRoot()); assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("pack.mcmeta"))); assertTrue(Files.isRegularFile(installed.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json"))); - assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory)); + assertFalse(Files.exists(dataDirectory.resolve("bootstrap/datapack"))); + assertTrue(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root)); assertTrue(DefaultPackBootstrapProvisioner.wasProvisionedThisStartup()); } finally { server.stop(0); @@ -66,6 +71,46 @@ public class DefaultPackBootstrapProvisionerTest { } } + @Test + public void missingArchiveRebuildsWorldDatapackFromValidatedInstalledPack() throws Exception { + byte[] archive = packArchive("overworld", "bootstrap_biome"); + AtomicInteger requests = new AtomicInteger(); + HttpServer server = server(archive, requests); + boolean serverStopped = false; + Path root = Files.createTempDirectory("iris-bootstrap-offline-migration"); + try { + Path dataDirectory = root.resolve("plugins/Iris"); + DefaultPackBootstrapProvisioner.ProvisionOptions options = options(server, root, Duration.ofHours(1)); + DefaultPackBootstrapProvisioner.ProvisionResult installed = DefaultPackBootstrapProvisioner.provision( + dataDirectory, + ignored -> { + }, + options + ); + Files.delete(dataDirectory.resolve("cache/bootstrap/default-overworld.zip")); + delete(installed.datapackRoot()); + server.stop(0); + serverStopped = true; + + DefaultPackBootstrapProvisioner.ProvisionResult rebuilt = DefaultPackBootstrapProvisioner.provision( + dataDirectory, + ignored -> { + }, + options + ); + + assertEquals(DefaultPackBootstrapProvisioner.ProvisionStatus.UPDATED, rebuilt.status()); + assertEquals(1, requests.get()); + assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("pack.mcmeta"))); + assertTrue(Files.isRegularFile(rebuilt.datapackRoot().resolve("data/overworld/worldgen/biome/bootstrap_biome.json"))); + } finally { + if (!serverStopped) { + server.stop(0); + } + delete(root); + } + } + @Test public void corruptCacheRedownloadsAndRepairsArchive() throws Exception { byte[] archive = packArchive("overworld", "bootstrap_biome"); @@ -169,7 +214,7 @@ public class DefaultPackBootstrapProvisionerTest { assertTrue(Files.isRegularFile(result.datapackRoot().resolve("data/overworld/worldgen/biome/local_biome.json"))); Files.writeString(target.resolve("biomes/local.json"), biomeJson("changed_biome"), StandardCharsets.UTF_8); - assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory)); + assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root)); DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision( dataDirectory, ignored -> { @@ -198,7 +243,7 @@ public class DefaultPackBootstrapProvisionerTest { writePack(dataDirectory.resolve("packs/second"), "second", "second_biome"); writePack(root.resolve("dimensions/example/world/iris/pack"), "world_local", "world_local_biome"); - assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory)); + assertFalse(DefaultPackBootstrapProvisioner.isProvisioned(dataDirectory, root)); DefaultPackBootstrapProvisioner.ProvisionResult updated = DefaultPackBootstrapProvisioner.provision( dataDirectory, ignored -> { diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java new file mode 100644 index 000000000..3952762fa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorStructureGraphTest.java @@ -0,0 +1,109 @@ +package art.arcane.iris.core.pack; + +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.assertTrue; + +public class PackValidatorStructureGraphTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsCompleteStructureGraph() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"castle\"]}]}"); + write(pack, "structures/castle.json", "{\"startPool\":\"castle/start\"}"); + write(pack, "jigsaw-pools/castle/start.json", "{\"pieces\":[{\"piece\":\"castle/start\"}],\"fallback\":\"castle/end\"}"); + write(pack, "jigsaw-pools/castle/end.json", "{\"pieces\":[]}"); + write(pack, "jigsaw-pieces/castle/start.json", "{\"object\":\"castle/start\",\"connectors\":[{\"pool\":\"castle/end\"}]}"); + write(pack, "objects/castle/start.iob", "object"); + + assertTrue(PackValidator.validateStructureGraph(pack).isEmpty()); + } + + @Test + public void reportsMissingReferencesInDeterministicGraphOrder() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "dimensions/main.json", "{\"structures\":[{\"structures\":[\"missing-structure\"]}]}"); + write(pack, "structures/castle.json", "{\"startPool\":\"missing-start\"}"); + write(pack, "jigsaw-pools/castle/start.json", "{\"pieces\":[{\"piece\":\"missing-piece\"}],\"fallback\":\"missing-fallback\"}"); + write(pack, "jigsaw-pieces/castle/start.json", "{\"object\":\"castle/start\",\"connectors\":[{\"pool\":\"missing-connector-pool\"}]}"); + write(pack, "objects/castle/start.iob", "object"); + + List errors = PackValidator.validateStructureGraph(pack); + + assertEquals(List.of( + "Dimension 'main' structures[0].structures[0] references missing structure 'missing-structure'.", + "Structure 'castle' references missing start pool 'missing-start'.", + "Jigsaw pool 'castle/start' pieces[0] references missing piece 'missing-piece'.", + "Jigsaw pool 'castle/start' references missing fallback pool 'missing-fallback'.", + "Jigsaw piece 'castle/start' connectors[0] references missing pool 'missing-connector-pool'." + ), errors); + } + + @Test + public void ignoresLegacyGeneratedStructureIndex() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "structures/structure-index.json", "{\"counts\":{},\"structureSets\":{},\"iris\":[]}"); + + assertTrue(PackValidator.validateStructureGraph(pack).isEmpty()); + } + + @Test + public void reportsMalformedStructureJson() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "structures/castle.json", "{"); + + List errors = PackValidator.validateStructureGraph(pack); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).startsWith("Structure 'castle' has invalid JSON:")); + } + + @Test + public void reportsMalformedJigsawPoolJson() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "jigsaw-pools/castle/start.json", "{"); + + List errors = PackValidator.validateStructureGraph(pack); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).startsWith("Jigsaw pool 'castle/start' has invalid JSON:")); + } + + @Test + public void reportsMalformedJigsawPieceJson() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "jigsaw-pieces/castle/start.json", "{"); + + List errors = PackValidator.validateStructureGraph(pack); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).startsWith("Jigsaw piece 'castle/start' has invalid JSON:")); + } + + @Test + public void reportsMissingJigsawPieceObject() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + write(pack, "jigsaw-pieces/castle/start.json", "{\"object\":\"castle/missing\"}"); + + assertEquals(List.of( + "Jigsaw piece 'castle/start' references missing object 'castle/missing'." + ), PackValidator.validateStructureGraph(pack)); + } + + private void write(File root, String relative, String content) throws Exception { + Path path = root.toPath().resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCRegistryTest.java b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCRegistryTest.java new file mode 100644 index 000000000..effc8d30e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/service/ExternalDataSVCRegistryTest.java @@ -0,0 +1,43 @@ +package art.arcane.iris.core.service; + +import org.junit.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ExternalDataSVCRegistryTest { + @Test + public void registryLoadsWithoutResolvingOptionalProviderClasses() throws Exception { + Class serviceClass = Class.forName(ExternalDataSVC.class.getName(), true, ExternalDataSVC.class.getClassLoader()); + Field registryField = serviceClass.getDeclaredField("BUILT_IN_PROVIDERS"); + registryField.setAccessible(true); + List definitions = (List) registryField.get(null); + List pluginIds = new ArrayList<>(definitions.size()); + + for (Object definition : definitions) { + Method pluginIdMethod = definition.getClass().getDeclaredMethod("pluginId"); + Method classNameMethod = definition.getClass().getDeclaredMethod("className"); + pluginIdMethod.setAccessible(true); + classNameMethod.setAccessible(true); + pluginIds.add((String) pluginIdMethod.invoke(definition)); + assertTrue(((String) classNameMethod.invoke(definition)).startsWith("art.arcane.iris.core.link.data.")); + } + + assertEquals(List.of( + "CraftEngine", + "Nexo", + "ItemsAdder", + "ExecutableItems", + "MMOItems", + "EcoItems", + "MythicMobs", + "MythicCrucible", + "KGenerators" + ), pluginIds); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/structure/StructureIndexServiceTest.java b/core/src/test/java/art/arcane/iris/core/structure/StructureIndexServiceTest.java new file mode 100644 index 000000000..bb605f451 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/structure/StructureIndexServiceTest.java @@ -0,0 +1,148 @@ +package art.arcane.iris.core.structure; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.volmlib.util.data.KCache; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.InOrder; + +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +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.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class StructureIndexServiceTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private IrisPlatform previousPlatform; + + @Before + public void detachPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void generatedIndexLivesOutsideStructureRegistrantDirectory() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + File index = StructureIndexService.indexFile(pack); + + assertEquals(new File(pack, ".iris/structure-index.json"), index); + assertFalse(index.toPath().startsWith(new File(pack, "structures").toPath())); + } + + @Test + public void legacyIndexPathRemainsExplicitForCleanup() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + + assertEquals(new File(pack, "structures/structure-index.json"), + StructureIndexService.legacyIndexFile(pack)); + } + + @Test + public void legacyIndexIsDeletedAndStructureKeysAreRefreshed() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + File legacyIndex = StructureIndexService.legacyIndexFile(pack); + Files.createDirectories(legacyIndex.toPath().getParent()); + Files.writeString(legacyIndex.toPath(), "{}", StandardCharsets.UTF_8); + IrisData data = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader structureLoader = mock(ResourceLoader.class); + @SuppressWarnings("unchecked") + KCache loadCache = mock(KCache.class); + when(data.getDataFolder()).thenReturn(pack); + when(data.getStructureLoader()).thenReturn(structureLoader); + when(structureLoader.getLoadCache()).thenReturn(loadCache); + + StructureIndexService.removeLegacyIndex(data); + + assertFalse(legacyIndex.exists()); + verify(loadCache).invalidate("structure-index"); + verify(structureLoader).clearList(); + } + + @Test + public void writeOnceWaitsForBoundHooksThenCleansBeforeEnumerating() throws Exception { + File pack = temporaryFolder.newFolder("pack"); + File legacyIndex = StructureIndexService.legacyIndexFile(pack); + Files.createDirectories(legacyIndex.toPath().getParent()); + Files.writeString(legacyIndex.toPath(), "{}", StandardCharsets.UTF_8); + IrisData data = mock(IrisData.class); + @SuppressWarnings("unchecked") + ResourceLoader structureLoader = mock(ResourceLoader.class); + @SuppressWarnings("unchecked") + KCache loadCache = mock(KCache.class); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks structureHooks = mock(PlatformStructureHooks.class); + when(data.getDataFolder()).thenReturn(pack); + when(data.getStructureLoader()).thenReturn(structureLoader); + when(structureLoader.getLoadCache()).thenReturn(loadCache); + when(structureLoader.getPossibleKeys()).thenReturn(new String[]{"castle", "structure-index"}); + when(structureHooks.structureKeys()).thenReturn(List.of("minecraft:village", "example:castle")); + when(structureHooks.structureSetKeys()).thenReturn(List.of("minecraft:villages")); + + assertNull(StructureIndexService.writeOnce(data)); + assertTrue(legacyIndex.exists()); + + IrisPlatforms.bind(platform); + assertNull(StructureIndexService.writeOnce(data)); + assertTrue(legacyIndex.exists()); + + when(platform.structureHooks()).thenReturn(structureHooks); + File index = StructureIndexService.writeOnce(data); + + assertEquals(StructureIndexService.indexFile(pack), index); + assertTrue(index.isFile()); + assertFalse(legacyIndex.exists()); + InOrder order = inOrder(loadCache, structureLoader, structureHooks); + order.verify(loadCache).invalidate("structure-index"); + order.verify(structureLoader).clearList(); + order.verify(structureHooks).structureKeys(); + order.verify(structureHooks).structureSetKeys(); + assertNull(StructureIndexService.writeOnce(data)); + verify(structureHooks, times(1)).structureKeys(); + verify(structureHooks, times(1)).structureSetKeys(); + } + + @Test + public void sharedServiceDoesNotLinkBukkitRuntimeClasses() throws Exception { + InputStream stream = StructureIndexService.class.getResourceAsStream("StructureIndexService.class"); + assertNotNull(stream); + String bytecode; + try (InputStream input = stream) { + bytecode = new String(input.readAllBytes(), StandardCharsets.ISO_8859_1); + } + + assertFalse(bytecode.contains("art/arcane/iris/util/project/matter/IrisMatterSupport")); + assertFalse(bytecode.contains("org/bukkit/")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisEnginePlatformHookIsolationTest.java b/core/src/test/java/art/arcane/iris/engine/IrisEnginePlatformHookIsolationTest.java new file mode 100644 index 000000000..b0b7ba0e4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisEnginePlatformHookIsolationTest.java @@ -0,0 +1,69 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineMode; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mode.ModeOverworld; +import org.junit.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +public class IrisEnginePlatformHookIsolationTest { + private static final List BUKKIT_ENGINE_CLASSES = List.of( + "art/arcane/iris/core/gui/PregeneratorJob", + "art/arcane/iris/core/ServerConfigurator", + "art/arcane/iris/core/datapack/DatapackIngestService", + "art/arcane/iris/core/events/IrisEngineHotloadEvent", + "art/arcane/iris/core/project/IrisProject", + "art/arcane/iris/core/tools/IrisToolbelt", + "art/arcane/iris/platform/bukkit/", + "org/bukkit/" + ); + private static final List BUKKIT_MODE_CLASSES = List.of( + "art/arcane/iris/core/tools/IrisToolbelt", + "art/arcane/iris/spi/IrisServices", + "art/arcane/iris/util/common/scheduling/J", + "art/arcane/iris/platform/bukkit/", + "org/bukkit/" + ); + private static final List ENGINE_POLICY_CLASSES = List.of( + "art/arcane/iris/core/gui/PregeneratorJob", + "art/arcane/iris/core/tools/WorldMaintenance", + "art/arcane/iris/platform/bukkit/", + "org/bukkit/" + ); + private static final List PLATFORM_POLICY_CLASSES = List.of( + "art/arcane/iris/core/gui/PregeneratorJob", + "art/arcane/iris/core/tools/WorldMaintenance", + "art/arcane/iris/util/common/scheduling/J", + "art/arcane/iris/platform/bukkit/", + "org/bukkit/" + ); + + @Test + public void sharedGeneratorHotPathsDoNotLinkBukkitImplementations() throws IOException { + assertNoClassLinks(IrisEngine.class, BUKKIT_ENGINE_CLASSES); + assertNoClassLinks(Engine.class, ENGINE_POLICY_CLASSES); + assertNoClassLinks(EngineMode.class, PLATFORM_POLICY_CLASSES); + assertNoClassLinks(EngineMantle.class, PLATFORM_POLICY_CLASSES); + assertNoClassLinks(ModeOverworld.class, BUKKIT_MODE_CLASSES); + } + + private static void assertNoClassLinks(Class type, List forbiddenClasses) throws IOException { + InputStream stream = type.getResourceAsStream(type.getSimpleName() + ".class"); + assertNotNull(stream); + String bytecode; + try (InputStream input = stream) { + bytecode = new String(input.readAllBytes(), StandardCharsets.ISO_8859_1); + } + for (String className : forbiddenClasses) { + assertFalse(type.getName() + " -> " + className, bytecode.contains(className)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java new file mode 100644 index 000000000..e988f981d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisWorldManagerMarkerTest.java @@ -0,0 +1,14 @@ +package art.arcane.iris.engine; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class IrisWorldManagerMarkerTest { + @Test + public void worldBlockHeightIsTranslatedToMantleHeight() { + assertEquals(64, IrisWorldManager.toMantleY(0, -64)); + assertEquals(319, IrisWorldManager.toMantleY(255, -64)); + assertEquals(42, IrisWorldManager.toMantleY(42, 0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EngineComponentTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EngineComponentTest.java new file mode 100644 index 000000000..09e7121cb --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EngineComponentTest.java @@ -0,0 +1,68 @@ +/* + * 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 . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.iris.spi.IrisServices; +import art.arcane.volmlib.util.math.RollingSequence; +import org.junit.After; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.assertSame; + +public class EngineComponentTest { + @After + public void removeCleanup() { + IrisServices.remove(EngineComponentCleanup.class); + } + + @Test + public void closeDelegatesToRegisteredPlatformCleanup() { + AtomicReference released = new AtomicReference<>(); + IrisServices.register(EngineComponentCleanup.class, (EngineComponentCleanup) released::set); + EngineComponent component = new TestComponent(); + + component.close(); + + assertSame(component, released.get()); + } + + @Test + public void closeWithoutPlatformCleanupIsSafe() { + new TestComponent().close(); + } + + private static final class TestComponent implements EngineComponent { + @Override + public Engine getEngine() { + return null; + } + + @Override + public RollingSequence getMetrics() { + return null; + } + + @Override + public String getName() { + return "test"; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/EnginePlayerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/EnginePlayerTest.java new file mode 100644 index 000000000..4885a3aee --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/EnginePlayerTest.java @@ -0,0 +1,16 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class EnginePlayerTest { + @Test + public void samplesImmediatelyThenWaitsForTimeAndMovement() { + assertTrue(EnginePlayer.needsSample(false, 0L, 0D)); + assertFalse(EnginePlayer.needsSample(true, 56L, 81D)); + assertFalse(EnginePlayer.needsSample(true, 55L, 82D)); + assertTrue(EnginePlayer.needsSample(true, 56L, 82D)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java index 99af5e6ca..92a1f1e91 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java @@ -18,12 +18,28 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.ObjectPlaceMode; +import art.arcane.iris.engine.object.StructureDistribution; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; import org.junit.Test; +import java.util.Set; + +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class IrisStructureLocatorContractTest { @Test @@ -61,14 +77,230 @@ public class IrisStructureLocatorContractTest { } @Test - public void locateReturnsNullForNullEngine() { - assertNull(IrisStructureLocator.locate(null, "minecraft:village_taiga", 0, 0, 100)); + public void locateReturnsNotFoundForNullEngine() { + assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, + IrisStructureLocator.locate(null, "minecraft:village_taiga", 0, 0, 100).status()); } @Test - public void locateReturnsNullForNullOrEmptyKey() { + public void locateReturnsNotFoundForNullOrEmptyKey() { Engine engine = mock(Engine.class); - assertNull(IrisStructureLocator.locate(engine, null, 0, 0, 100)); - assertNull(IrisStructureLocator.locate(engine, "", 0, 0, 100)); + assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, + IrisStructureLocator.locate(engine, null, 0, 0, 100).status()); + assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, + IrisStructureLocator.locate(engine, "", 0, 0, 100).status()); + } + + @Test + public void zeroDensityLocateReturnsWithoutScanningPlacementGraphs() { + Engine engine = densityEngine(0.0, false, -64, 384, -2032, 2032); + + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locate(engine, "test:density", 0, 0, 100); + + assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, result.status()); + verify(engine, never()).getComplex(); + } + + @Test + public void impossibleDensityHeightBandReturnsWithoutScanningPlacementGraphs() { + Engine engine = densityEngine(1.0, true, -64, 384, 500, 600); + + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locate(engine, "test:density", 0, 0, 100); + + assertEquals(IrisStructureLocator.LocateStatus.NOT_FOUND, result.status()); + verify(engine, never()).getComplex(); + } + + @Test + public void densityLocateReportsSafetyLimitAfterFixedCandidateBudget() { + Engine engine = densityEngine(1.0, false, -64, 384, -2032, 2032); + + IrisStructureLocator.LocateResult result = + IrisStructureLocator.locate(engine, "test:density", 0, 0, 2048); + + assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status()); + assertFalse(result.found()); + verify(engine, times(4_096)).getComplex(); + } + + @Test + public void searchableDensityRequiresPositiveProbabilityAndWorldHeightOverlap() { + Engine engine = mock(Engine.class); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.setDistribution(StructureDistribution.DENSITY); + placement.setMinHeight(-32); + placement.setMaxHeight(128); + + placement.setDensity(Double.NaN); + assertFalse(IrisStructureLocator.isSearchableDensityPlacement(engine, placement)); + placement.setDensity(0.0); + assertFalse(IrisStructureLocator.isSearchableDensityPlacement(engine, placement)); + placement.setDensity(0.01); + assertTrue(IrisStructureLocator.isSearchableDensityPlacement(engine, placement)); + placement.setMinHeight(400); + placement.setMaxHeight(500); + assertFalse(IrisStructureLocator.isSearchableDensityPlacement(engine, placement)); + } + + @Test + public void invalidateIsNullSafe() { + IrisStructureLocator.invalidate(null); + } + + @Test + public void placementIndexIsScopedAndInvalidatedPerEngine() { + IrisData sharedData = mock(IrisData.class); + Engine firstEngine = emptyEngine(sharedData); + Engine secondEngine = emptyEngine(sharedData); + Set firstIndex = IrisStructureLocator.placedKeys(firstEngine); + Set secondIndex = IrisStructureLocator.placedKeys(secondEngine); + assertNotSame(firstIndex, secondIndex); + + IrisStructureLocator.invalidate(firstEngine); + assertNotSame(firstIndex, IrisStructureLocator.placedKeys(firstEngine)); + } + + @Test + public void assembledBoundsMustFitVerticallyAndHorizontally() { + KList fitting = new KList<>(); + fitting.add(piece(-4, 1, -4, 4, 20, 4)); + assertTrue(IrisStructureLocator.fitsWorldBounds(fitting, 0, 20, 0, 0, 4)); + + KList belowWorld = new KList<>(); + belowWorld.add(piece(-4, -1, -4, 4, 20, 4)); + assertFalse(IrisStructureLocator.fitsWorldBounds(belowWorld, 0, 20, 0, 0, 4)); + + KList outsideRadius = new KList<>(); + outsideRadius.add(piece(-4, 1, -4, 5, 20, 4)); + assertFalse(IrisStructureLocator.fitsWorldBounds(outsideRadius, 0, 20, 0, 0, 4)); + } + + @Test + public void emptyAssemblyDoesNotFit() { + assertFalse(IrisStructureLocator.fitsWorldBounds(new KList<>(), 0, 20, 0, 0, 4)); + } + + @Test + public void selectedStructureKeyDoesNotMatchUnselectedPlacementMember() { + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.getStructures().add("test:first"); + placement.getStructures().add("test:second"); + RNG selectsLast = new RNG(1L) { + @Override + public int nextInt(int bound) { + return bound - 1; + } + }; + String selectedKey = IrisStructureLocator.selectStructureKey(placement, selectsLast); + assertEquals("test:second", selectedKey); + + IrisStructure selected = new IrisStructure(); + selected.setLoadKey("test:second"); + selected.setVanillaSource("minecraft:second"); + IrisStructureLocator.ResolvedPlacement resolved = new IrisStructureLocator.ResolvedPlacement( + placement, selectedKey, selected, new KList<>(), selectsLast, 0, 64, 0, false); + assertFalse(IrisStructureLocator.matchesResolved(resolved, "test:first")); + assertTrue(IrisStructureLocator.matchesResolved(resolved, "test:second")); + assertTrue(IrisStructureLocator.matchesResolved(resolved, "minecraft:second")); + } + + @Test + public void undergroundAssemblyIsShiftedToFitOrRejectedWhole() { + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.setUnderground(true); + placement.setMinHeight(-10); + placement.setMaxHeight(10); + + KList movable = new KList<>(); + movable.add(piece(-4, -10, -4, 4, 5, 4)); + assertEquals(Integer.valueOf(10), IrisStructureLocator.resolveVerticalShift(movable, placement, -10, 0, 319)); + + KList tooTall = new KList<>(); + tooTall.add(piece(-4, -100, -4, 4, 400, 4)); + assertNull(IrisStructureLocator.resolveVerticalShift(tooTall, placement, 0, 0, 319)); + + placement.setUnderground(false); + KList clippedSurface = new KList<>(); + clippedSurface.add(piece(-4, 300, -4, 4, 330, 4)); + assertNull(IrisStructureLocator.resolveVerticalShift(clippedSurface, placement, 300, -64, 319)); + } + + @Test + public void exactYOnlyCoversPlacementPathsWithResolvedAbsoluteAnchors() { + IrisStructurePlacement placement = new IrisStructurePlacement(); + IrisStructure structure = new IrisStructure(); + structure.setPlaceMode(ObjectPlaceMode.CENTER_HEIGHT); + KList onePiece = new KList<>(); + onePiece.add(piece(-4, 1, -4, 4, 20, 4)); + assertFalse(IrisStructureLocator.hasExactY(placement, structure, onePiece)); + + structure.setPlaceMode(ObjectPlaceMode.STRUCTURE_PIECE); + assertTrue(IrisStructureLocator.hasExactY(placement, structure, onePiece)); + + structure.setPlaceMode(ObjectPlaceMode.CENTER_HEIGHT); + placement.setUnderground(true); + assertTrue(IrisStructureLocator.hasExactY(placement, structure, onePiece)); + + placement.setUnderground(false); + KList multiplePieces = new KList<>(onePiece); + multiplePieces.add(piece(5, 1, -4, 12, 20, 4)); + assertTrue(IrisStructureLocator.hasExactY(placement, structure, multiplePieces)); + } + + @Test + public void randomSpreadCellRingStopUsesGeometricLowerBound() { + assertEquals(0L, IrisStructureLocator.cellRingDistanceLowerBound(0, 32, 31, 8, 0, 0)); + assertEquals(1L, IrisStructureLocator.cellRingDistanceLowerBound(1, 32, 31, 8, 0, 0)); + assertEquals(33L, IrisStructureLocator.cellRingDistanceLowerBound(2, 32, 31, 8, 0, 0)); + assertEquals(1L, IrisStructureLocator.cellRingDistanceLowerBound(1, 32, 0, 0, 0, 0)); + } + + private PlacedStructurePiece piece(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) { + return new PlacedStructurePiece(null, null, 0, 0, 0, null, minX, minY, minZ, maxX, maxY, maxZ); + } + + private Engine emptyEngine(IrisData data) { + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getStructures()).thenReturn(new KList<>()); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); + return engine; + } + + private Engine densityEngine(double density, boolean underground, int worldMin, int worldHeight, + int placementMin, int placementMax) { + IrisData data = mock(IrisData.class); + IrisStructure structure = new IrisStructure(); + structure.setLoadKey("test:density"); + when(data.load(IrisStructure.class, "test:density", false)).thenReturn(structure); + + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.getStructures().add("test:density"); + placement.setDistribution(StructureDistribution.DENSITY); + placement.setDensity(density); + placement.setUnderground(underground); + placement.setMinHeight(placementMin); + placement.setMaxHeight(placementMax); + + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + KList placements = new KList<>(); + placements.add(placement); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getSeedManager()).thenReturn(new SeedManager(77L)); + when(engine.getMinHeight()).thenReturn(worldMin); + when(engine.getHeight()).thenReturn(worldHeight); + when(dimension.getStructures()).thenReturn(placements); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); + return engine; } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java index 74c339882..a166c60d4 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementGridTest.java @@ -18,10 +18,14 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.StructureDistribution; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; public class StructurePlacementGridTest { @@ -101,4 +105,69 @@ public class StructurePlacementGridTest { assertEquals(1, total); assertTrue(matches <= total); } + + @Test + public void densityStartIsDeterministicAndOrdinalScoped() { + IrisStructurePlacement placement = placement(StructureDistribution.DENSITY); + placement.setDensity(0.35); + boolean differsByOrdinal = false; + for (int cx = -32; cx <= 32; cx++) { + for (int cz = -32; cz <= 32; cz++) { + boolean first = StructurePlacementGrid.startsInChunk(placement, cx, cz, 912345L, 0); + boolean repeated = StructurePlacementGrid.startsInChunk(placement, cx, cz, 912345L, 0); + boolean secondPlacement = StructurePlacementGrid.startsInChunk(placement, cx, cz, 912345L, 1); + assertEquals(first, repeated); + differsByOrdinal |= first != secondPlacement; + } + } + assertTrue(differsByOrdinal); + } + + @Test + public void densityBoundariesNeverAndAlwaysStart() { + IrisStructurePlacement placement = placement(StructureDistribution.DENSITY); + placement.setDensity(0.0); + assertFalse(StructurePlacementGrid.startsInChunk(placement, 4, -9, 77L, 0)); + placement.setDensity(1.0); + assertTrue(StructurePlacementGrid.startsInChunk(placement, 4, -9, 77L, 0)); + } + + @Test + public void concentricRingCountIsFiniteTotalPlacementCount() { + IrisStructurePlacement placement = placement(StructureDistribution.CONCENTRIC_RINGS); + placement.setRingCount(10); + placement.setRingSpread(3); + placement.setRingDistance(12); + long seed = 484848L; + int lastRing = Math.ceilDiv(placement.getRingCount(), placement.getRingSpread()); + int scanRadius = (lastRing + 2) * placement.getRingDistance(); + int starts = 0; + for (int cx = -scanRadius; cx <= scanRadius; cx++) { + for (int cz = -scanRadius; cz <= scanRadius; cz++) { + if (StructurePlacementGrid.startsInChunk(placement, cx, cz, seed, 0)) { + starts++; + } + } + } + assertEquals(placement.getRingCount(), starts); + assertNull(StructurePlacementGrid.concentricRingChunk(placement, placement.getRingCount(), seed)); + } + + @Test + public void placementRngIsStableAndOrdinalScoped() { + IrisStructurePlacement placement = placement(StructureDistribution.RANDOM_SPREAD); + long first = StructurePlacementGrid.placementRng(placement, 7, -11, 123L, 0).getSeed(); + long repeated = StructurePlacementGrid.placementRng(placement, 7, -11, 123L, 0).getSeed(); + long secondPlacement = StructurePlacementGrid.placementRng(placement, 7, -11, 123L, 1).getSeed(); + assertEquals(first, repeated); + assertNotEquals(first, secondPlacement); + } + + private IrisStructurePlacement placement(StructureDistribution distribution) { + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.setDistribution(distribution); + placement.getStructures().add("test:structure"); + placement.setSalt(31415926); + return placement; + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureVerticalBoundsTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureVerticalBoundsTest.java new file mode 100644 index 000000000..d4fc87950 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureVerticalBoundsTest.java @@ -0,0 +1,57 @@ +package art.arcane.iris.engine.framework; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class StructureVerticalBoundsTest { + @Test + public void trialChamberShiftCannotProducePurpurPoiSectionBelowLiveWorld() { + int worldMinY = -64; + int worldMaxYExclusive = 320; + int structureMinY = -48; + int structureMaxY = -16; + + int offset = StructureVerticalBounds.clampOffset( + structureMinY, + structureMaxY, + -64, + worldMinY, + worldMaxYExclusive); + + int placedMinY = structureMinY + offset; + int placedMaxY = structureMaxY + offset; + int placedMinSection = Math.floorDiv(placedMinY, 16); + assertEquals(-16, offset); + assertEquals(-64, placedMinY); + assertEquals(-32, placedMaxY); + assertEquals(-4, placedMinSection); + assertTrue(placedMinSection >= Math.floorDiv(worldMinY, 16)); + assertTrue(placedMinSection <= Math.floorDiv(worldMaxYExclusive - 1, 16)); + } + + @Test + public void offsetIsClampedAtBothLiveWorldBoundaries() { + assertEquals(-20, StructureVerticalBounds.clampOffset(20, 40, -100, 0, 256)); + assertEquals(215, StructureVerticalBounds.clampOffset(20, 40, 300, 0, 256)); + assertEquals(0, StructureVerticalBounds.clampOffset(20, 40, 0, 0, 256)); + } + + @Test + public void structureAlreadyOutsideRangeIsMovedInside() { + assertEquals(48, StructureVerticalBounds.clampOffset(-112, -80, 0, -64, 320)); + assertEquals(-81, StructureVerticalBounds.clampOffset(400, 400, 0, -64, 320)); + } + + @Test + public void oversizedStructureIsRejected() { + try { + StructureVerticalBounds.clampOffset(-100, 400, 0, -64, 320); + fail("Oversized structure must be rejected"); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("exceeds writable world height")); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleMarkerTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleMarkerTest.java new file mode 100644 index 000000000..cedcec4d9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleMarkerTest.java @@ -0,0 +1,91 @@ +package art.arcane.iris.engine.mantle; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.function.Consumer4; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterMarker; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +public class EngineMantleMarkerTest { + @Before + public void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + @SuppressWarnings("unchecked") + public void markerCoordinatesIncludeWorldMinimumHeight() { + EngineMantle engineMantle = mock(EngineMantle.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + EnginePlatformHooks platformHooks = mock(EnginePlatformHooks.class); + Mantle mantle = mock(Mantle.class); + MatterMarker marker = new MatterMarker("test"); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getMantle()).thenReturn(mantle); + when(engine.getPlatformHooks()).thenReturn(platformHooks); + when(engine.getMinHeight()).thenReturn(-64); + doAnswer(invocation -> { + Consumer4 consumer = invocation.getArgument(3); + consumer.accept(2, 7, 3, marker); + return null; + }).when(mantle).iterateChunk(eq(4), eq(-2), eq(MatterMarker.class), any()); + + KList positions = engineMantle.findMarkers(4, -2, marker); + + assertEquals(1, positions.size()); + assertEquals(66, positions.get(0).getX()); + assertEquals(-57, positions.get(0).getY()); + assertEquals(-29, positions.get(0).getZ()); + } + + @Test + @SuppressWarnings("unchecked") + public void platformHookCanSkipUnsafeMarkerRead() { + EngineMantle engineMantle = mock(EngineMantle.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + EnginePlatformHooks platformHooks = mock(EnginePlatformHooks.class); + Mantle mantle = mock(Mantle.class); + MatterMarker marker = new MatterMarker("test"); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getMantle()).thenReturn(mantle); + when(engine.getPlatformHooks()).thenReturn(platformHooks); + when(platformHooks.shouldSkipMantleMarkerRead(engine, 4, -2)).thenReturn(true); + + KList positions = engineMantle.findMarkers(4, -2, marker); + + assertTrue(positions.isEmpty()); + verifyNoInteractions(mantle); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java new file mode 100644 index 000000000..355ee624b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java @@ -0,0 +1,108 @@ +package art.arcane.iris.engine.mantle; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MantleWriterOverlayTest { + private static final int X = 1; + private static final int Y = 2; + private static final int Z = 3; + + private MantleWriter writer; + private Matter matter; + private MatterSlice blockSlice; + private MatterSlice identifierSlice; + private MatterSlice cavernSlice; + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + IrisPlatforms.unbind(); + PlatformBlockState defaultBlock = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + IrisPlatform platform = mock(IrisPlatform.class); + when(registries.block(anyString())).thenReturn(defaultBlock); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + matter = mock(Matter.class); + blockSlice = mock(MatterSlice.class); + identifierSlice = mock(MatterSlice.class); + cavernSlice = mock(MatterSlice.class); + + when(mantle.getWorldHeight()).thenReturn(64); + when(mantle.getChunk(0, 0)).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + when(chunk.getOrCreate(0)).thenReturn(matter); + when(matter.hasSlice(PlatformBlockState.class)).thenReturn(true); + when(matter.hasSlice(Identifier.class)).thenReturn(true); + when(matter.getSlice(PlatformBlockState.class)).thenReturn(blockSlice); + when(matter.getSlice(Identifier.class)).thenReturn(identifierSlice); + doReturn(blockSlice).when(matter).slice(PlatformBlockState.class); + doReturn(cavernSlice).when(matter).slice(MatterCavern.class); + + writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false); + } + + @After + public void tearDown() { + IrisPlatforms.unbind(); + } + + @Test + public void existingCavernStillClearsBlockAndDeferredPlacement() { + MatterCavern existing = new MatterCavern(true, "", (byte) 3); + MatterCavern requested = new MatterCavern(true, "", (byte) 3); + when(cavernSlice.get(X, Y, Z)).thenReturn(existing); + + assertFalse(writer.carveDataIfAbsent(X, Y, Z, requested)); + + verify(blockSlice).set(X, Y, Z, null); + verify(identifierSlice).set(X, Y, Z, null); + verify(cavernSlice, never()).set(X, Y, Z, requested); + } + + @Test + public void absentCavernClearsOverlaysAndWritesMask() { + MatterCavern requested = new MatterCavern(true, "", (byte) 3); + + assertTrue(writer.carveDataIfAbsent(X, Y, Z, requested)); + + verify(blockSlice).set(X, Y, Z, null); + verify(identifierSlice).set(X, Y, Z, null); + verify(cavernSlice).set(X, Y, Z, requested); + } + + @Test + public void normalBlockReplacementClearsDeferredPlacement() { + PlatformBlockState replacement = mock(PlatformBlockState.class); + + writer.setData(X, Y, Z, replacement); + + verify(identifierSlice).set(X, Y, Z, null); + verify(blockSlice).set(X, Y, Z, replacement); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentOverboreTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentOverboreTest.java new file mode 100644 index 000000000..35a1d7bbd --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisStructureComponentOverboreTest.java @@ -0,0 +1,61 @@ +package art.arcane.iris.engine.mantle.components; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisStructureComponentOverboreTest { + @Test + public void exactPieceInteriorAlwaysCarves() { + assertTrue(IrisStructureComponent.shouldCarveOverboreCell(0.0, 0.0)); + assertTrue(IrisStructureComponent.shouldCarveOverboreCell(0.0, 1.0)); + } + + @Test + public void lowNoiseRemovesTheFormerFlatShell() { + double oneBlockAtRadiusSix = 1.0 / 6.0; + assertTrue(IrisStructureComponent.shouldCarveOverboreCell( + oneBlockAtRadiusSix * oneBlockAtRadiusSix, 0.0)); + assertFalse(IrisStructureComponent.shouldCarveOverboreCell(0.25 * 0.25, 0.0)); + } + + @Test + public void maximumNoiseStopsAtConfiguredReach() { + assertTrue(IrisStructureComponent.shouldCarveOverboreCell(1.0, 1.0)); + assertFalse(IrisStructureComponent.shouldCarveOverboreCell(1.000001, 1.0)); + } + + @Test + public void diagonalCornersUseEuclideanFalloff() { + assertTrue(IrisStructureComponent.shouldCarveOverboreCell(1.0, 1.0)); + assertFalse(IrisStructureComponent.shouldCarveOverboreCell(0.8 * 0.8 + 0.8 * 0.8, 1.0)); + } + + @Test + public void boundaryNoiseIsClampedAndDeterministic() { + assertEquals(0.2, IrisStructureComponent.overboreBoundaryLimit(-10.0), 0.0); + assertEquals(1.0, IrisStructureComponent.overboreBoundaryLimit(10.0), 0.0); + assertTrue(IrisStructureComponent.shouldCarveOverboreCell(0.36, 0.5)); + assertFalse(IrisStructureComponent.shouldCarveOverboreCell(0.360001, 0.5)); + } + + @Test + public void candidateExtensionsDoNotScanImpossibleOuterShell() { + assertEquals(6, IrisStructureComponent.overboreSideExtension(6)); + assertEquals(18, IrisStructureComponent.overboreUpExtension(10.0)); + + long oldVolume = (41L + 18L) * (31L + 27L) * (35L + 18L); + long newVolume = (41L + 12L) * (31L + 18L) * (35L + 12L); + assertTrue(newVolume < oldVolume); + } + + @Test + public void ordinaryMarkersAreSeparatedFromStructureMarkers() { + assertTrue(IrisStructureComponent.isOrdinaryObjectMarker("trees/oak@42")); + assertFalse(IrisStructureComponent.isOrdinaryObjectMarker( + "@iris-structure:v1:dHJlZXMvb2Fr:42:bWluZWNyYWZ0Om1hbnNpb24")); + assertFalse(IrisStructureComponent.isOrdinaryObjectMarker("not-a-marker")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveExposureTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveExposureTest.java deleted file mode 100644 index 5399e7758..000000000 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveExposureTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package art.arcane.iris.engine.mantle.components; - -import org.junit.Test; - -import static org.junit.Assert.assertTrue; - -public class MantleObjectComponentCaveExposureTest { - - @Test - public void test_resolveSurfaceObjectExclusionRadius_largeObject_coversFullFootprint() { - int radius = MantleObjectComponent.computeSurfaceExclusionRadius(24, 0, 0); - assertTrue("Expected radius >= 12 for a 24x24 object but got " + radius, radius >= 12); - } - - @Test - public void test_resolveSurfaceObjectExclusionRadius_withTranslateOffset_includesOffset() { - int radius = MantleObjectComponent.computeSurfaceExclusionRadius(16, 5, 3); - int expected = 8 + 5 + 3 + 1; - assertTrue("Expected radius >= " + expected + " for 16x16 object with translate offsets 5+3 but got " + radius, radius >= expected); - } - - @Test - public void test_resolveSurfaceObjectExclusionRadius_smallObject_atLeastOne() { - int radius = MantleObjectComponent.computeSurfaceExclusionRadius(2, 0, 0); - assertTrue("Expected radius >= 1 for a 2x2 object but got " + radius, radius >= 1); - } - - @Test - public void test_resolveSurfaceObjectExclusionRadius_nullLike_returnsOne() { - int radius = MantleObjectComponent.computeSurfaceExclusionRadius(0, 0, 0); - assertTrue("Expected radius >= 1 for zero-dimension object but got " + radius, radius >= 1); - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java new file mode 100644 index 000000000..03324659c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java @@ -0,0 +1,53 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisDimensionReachableBiomesTest { + @Test + @SuppressWarnings("unchecked") + public void includesOnlyBiomesReachableThroughSelectedRegions() { + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("reachable", "missing")); + IrisRegion reachable = new IrisRegion() + .setLandBiomes(new KList<>("parent", "shared")) + .setSeaBiomes(new KList<>("shared")); + IrisBiome parent = biome("parent").setChildren(new KList<>("child", "shared")).setCarvingBiome("carve"); + IrisBiome child = biome("child").setChildren(new KList<>("parent")); + IrisBiome shared = biome("shared"); + IrisBiome carve = biome("carve"); + IrisBiome unused = biome("unused"); + + IrisData data = mock(IrisData.class); + ResourceLoader regionLoader = mock(ResourceLoader.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(regionLoader.load("reachable")).thenReturn(reachable); + when(biomeLoader.load("parent")).thenReturn(parent); + when(biomeLoader.load("child")).thenReturn(child); + when(biomeLoader.load("shared")).thenReturn(shared); + when(biomeLoader.load("carve")).thenReturn(carve); + when(biomeLoader.load("unused")).thenReturn(unused); + + KList biomes = dimension.getReachableBiomes(() -> data); + Set keys = biomes.stream().map(IrisBiome::getLoadKey).collect(Collectors.toSet()); + + assertEquals(Set.of("parent", "child", "shared", "carve"), keys); + assertEquals(keys.size(), biomes.size()); + } + + private IrisBiome biome(String loadKey) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(loadKey); + return biome; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionRuntimeContractTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionRuntimeContractTest.java new file mode 100644 index 000000000..b3bb46e86 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionRuntimeContractTest.java @@ -0,0 +1,89 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class IrisDimensionRuntimeContractTest { + @Test + public void exactTypeAndHeightContractPasses() { + IrisDimensionRuntimeContract expected = expectedContract(); + IrisDimensionRuntimeContract actual = new IrisDimensionRuntimeContract("iris:overworld", -256, 768, 512); + + expected.requireExact("test world", actual); + expected.requireHeight("test world", -256, 768); + } + + @Test + public void tallPackOnVanillaHeightWorldIsRejected() { + IrisDimensionRuntimeContract expected = expectedContract(); + IrisDimensionRuntimeContract actual = new IrisDimensionRuntimeContract("minecraft:overworld", -64, 384, 384); + + try { + expected.requireExact("Bukkit world 'world'", actual); + fail("Vanilla height world must be rejected"); + } catch (IrisDimensionContractException e) { + assertTrue(e.getMessage().contains("Bukkit world 'world'")); + assertTrue(e.getMessage().contains("iris:overworld")); + assertTrue(e.getMessage().contains("-256..512")); + assertTrue(e.getMessage().contains("minecraft:overworld")); + assertTrue(e.getMessage().contains("-64..320")); + assertTrue(e.getMessage().contains("Generation was refused")); + } + } + + @Test + public void wrongIrisTypeKeyIsRejectedEvenWhenHeightMatches() { + IrisDimensionRuntimeContract expected = expectedContract(); + IrisDimensionRuntimeContract actual = new IrisDimensionRuntimeContract("iris:other", -256, 768, 512); + + try { + expected.requireExact("test world", actual); + fail("Wrong Iris dimension type must be rejected"); + } catch (IrisDimensionContractException e) { + assertTrue(e.getMessage().contains("iris:other")); + } + } + + @Test + public void logicalHeightMismatchIsRejected() { + IrisDimensionRuntimeContract expected = expectedContract(); + IrisDimensionRuntimeContract actual = new IrisDimensionRuntimeContract("iris:overworld", -256, 768, 384); + + try { + expected.requireExact("test world", actual); + fail("Wrong logical height must be rejected"); + } catch (IrisDimensionContractException e) { + assertTrue(e.getMessage().contains("logical height 384")); + } + } + + @Test + public void hotloadMismatchReportsReplacementAgainstLiveRuntime() { + IrisDimension active = new IrisDimension(); + active.setLoadKey("overworld"); + active.setDimensionHeight(new IrisRange(-256, 512)); + active.setLogicalHeight(512); + IrisDimension replacement = new IrisDimension(); + replacement.setLoadKey("overworld"); + replacement.setDimensionHeight(new IrisRange(-384, 640)); + replacement.setLogicalHeight(640); + + try { + IrisDimensionRuntimeContract.requireHotloadCompatible("Studio world", active, replacement, "iris"); + fail("A hotload cannot replace the live vertical contract"); + } catch (IrisDimensionContractException e) { + assertTrue(e.getMessage().contains("requires Iris dimension type iris:overworld with range -384..640")); + assertTrue(e.getMessage().contains("loaded runtime uses iris:overworld with range -256..512")); + } + } + + private IrisDimensionRuntimeContract expectedContract() { + IrisDimension dimension = new IrisDimension(); + dimension.setLoadKey("overworld"); + dimension.setDimensionHeight(new IrisRange(-256, 512)); + dimension.setLogicalHeight(512); + return IrisDimensionRuntimeContract.expected(dimension, "iris"); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java index 6913e96b5..9d3c9eb3c 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisImportedStructureControlTest.java @@ -21,7 +21,10 @@ package art.arcane.iris.engine.object; import art.arcane.volmlib.util.collection.KList; import org.junit.Test; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public class IrisImportedStructureControlTest { @@ -87,31 +90,23 @@ public class IrisImportedStructureControlTest { } @Test - public void customModeBehavesLikeWhitelist() { - IrisImportedStructureControl control = new IrisImportedStructureControl() - .setMode(VanillaStructureMode.CUSTOM) - .setEnabled(keys("minecraft:village_plains")); - assertTrue(control.shouldGenerate("minecraft:village_plains")); - assertFalse(control.shouldGenerate("minecraft:stronghold")); - } - - @Test - public void datapackOverridesFalseBlocksDatapackKeysWhileAllOn() { + public void datapackOverridesFalseDoesNotDisableModOrDatapackNamespaces() { IrisImportedStructureControl control = new IrisImportedStructureControl() .setDatapackOverrides(false); - assertFalse(control.shouldGenerate("nova_structures:desert_temple")); - assertFalse(control.shouldGenerate("aquaculture:treasure_vault")); + assertTrue(control.shouldGenerate("nova_structures:desert_temple")); + assertTrue(control.shouldGenerate("aquaculture:treasure_vault")); assertTrue(control.shouldGenerate("minecraft:village_plains")); } @Test - public void datapackOverridesFalseBlocksDatapackKeyEvenWhenWhitelisted() { + public void allOffWhitelistStillControlsThirdPartyStructuresWhenOverridesAreFalse() { IrisImportedStructureControl control = new IrisImportedStructureControl() .setMode(VanillaStructureMode.ALL_OFF) .setEnabled(keys("nova_structures:desert_temple", "minecraft:village_plains")) .setDatapackOverrides(false); - assertFalse(control.shouldGenerate("nova_structures:desert_temple")); + assertTrue(control.shouldGenerate("nova_structures:desert_temple")); assertTrue(control.shouldGenerate("minecraft:village_plains")); + assertFalse(control.shouldGenerate("aquaculture:treasure_vault")); } @Test @@ -155,13 +150,6 @@ public class IrisImportedStructureControlTest { assertTrue(control.active()); } - @Test - public void activeFalseWhenCustomWithoutWhitelist() { - IrisImportedStructureControl control = new IrisImportedStructureControl() - .setMode(VanillaStructureMode.CUSTOM); - assertFalse(control.active()); - } - @Test public void emptyOrNullListEntriesNeverMatchEveryKey() { IrisImportedStructureControl control = new IrisImportedStructureControl() @@ -177,4 +165,101 @@ public class IrisImportedStructureControlTest { .setEnabled(keys("minecraft:village_plains")); assertFalse(control.shouldGenerate(null)); } + + @Test + public void verticalShiftDefaultsToZeroAndAppliesUndergroundBaseOnlyUnderground() { + IrisImportedStructureControl control = new IrisImportedStructureControl().setUndergroundYShift(-32); + + assertEquals(0, control.resolve("minecraft:village_plains", false).yShift()); + assertEquals(-32, control.resolve("minecraft:stronghold", true).yShift()); + } + + @Test + public void matchingVerticalShiftsStackWithoutAffectingOtherStructures() { + IrisVanillaStructureAdjustment broad = new IrisVanillaStructureAdjustment() + .setMatch(keys("minecraft:trial")) + .setYShift(-48); + IrisVanillaStructureAdjustment exact = new IrisVanillaStructureAdjustment() + .setMatch(keys("minecraft:trial_chambers")) + .setYShift(-16); + KList adjustments = new KList<>(); + adjustments.add(broad); + adjustments.add(exact); + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setUndergroundYShift(8) + .setAdjustments(adjustments); + + assertEquals(-56, control.resolve("minecraft:trial_chambers", true).yShift()); + assertEquals(8, control.resolve("minecraft:stronghold", true).yShift()); + assertEquals(0, control.resolve("minecraft:village_plains", false).yShift()); + } + + @Test + public void postprocessingDefaultsAreDisabled() { + IrisImportedStructureControl control = new IrisImportedStructureControl(); + assertFalse(control.resolve("minecraft:woodland_mansion", false).clearVegetation()); + assertNull(control.resolve("minecraft:village_plains", false).stilt()); + assertFalse(control.resolve(null, false).clearVegetation()); + assertNull(control.resolve(null, false).stilt()); + } + + @Test + public void postprocessingUsesPrefixMatches() { + IrisVanillaStructureStiltSettings stilt = new IrisVanillaStructureStiltSettings(); + IrisVanillaStructureAdjustment adjustment = new IrisVanillaStructureAdjustment() + .setMatch(keys("minecraft:village")) + .setClearVegetation(true) + .setStilt(stilt); + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setAdjustments(new KList().qadd(adjustment)); + + assertTrue(control.resolve("minecraft:village_plains", false).clearVegetation()); + assertSame(stilt, control.resolve("minecraft:village_taiga", false).stilt()); + assertFalse(control.resolve("minecraft:woodland_mansion", false).clearVegetation()); + assertNull(control.resolve("minecraft:stronghold", true).stilt()); + } + + @Test + public void multipleMatchesMergeVegetationAndUseLastConfiguredStilt() { + IrisVanillaStructureStiltSettings broadStilt = new IrisVanillaStructureStiltSettings().setMaxDepth(32); + IrisVanillaStructureStiltSettings specificStilt = new IrisVanillaStructureStiltSettings().setMaxDepth(96); + IrisVanillaStructureAdjustment broad = new IrisVanillaStructureAdjustment() + .setMatch(keys("minecraft:village")) + .setClearVegetation(true) + .setStilt(broadStilt); + IrisVanillaStructureAdjustment exactWithoutStilt = new IrisVanillaStructureAdjustment() + .setMatch(keys("minecraft:village_plains")); + IrisVanillaStructureAdjustment exactWithStilt = new IrisVanillaStructureAdjustment() + .setMatch(keys("minecraft:village_plains")) + .setStilt(specificStilt); + KList adjustments = new KList<>(); + adjustments.add(broad); + adjustments.add(exactWithoutStilt); + adjustments.add(exactWithStilt); + IrisImportedStructureControl control = new IrisImportedStructureControl().setAdjustments(adjustments); + + IrisNativeStructureDecision plains = control.resolve("minecraft:village_plains", false); + assertTrue(plains.clearVegetation()); + assertSame(specificStilt, plains.stilt()); + assertEquals(96, plains.stilt().getMaxDepth()); + assertSame(broadStilt, control.resolve("minecraft:village_desert", false).stilt()); + } + + @Test + public void familyPrefixRequiresAResourceBoundary() { + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setDisabled(keys("minecraft:village")); + + assertFalse(control.shouldGenerate("minecraft:village_plains")); + assertTrue(control.shouldGenerate("minecraft:villager_outpost")); + } + + @Test + public void namespacePrefixMatchesEveryKeyInNamespace() { + IrisImportedStructureControl control = new IrisImportedStructureControl() + .setDisabled(keys("example:")); + + assertFalse(control.shouldGenerate("example:castle")); + assertTrue(control.shouldGenerate("other:castle")); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisPlatformIsolationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisPlatformIsolationTest.java new file mode 100644 index 000000000..f9b4627cc --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisPlatformIsolationTest.java @@ -0,0 +1,63 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class IrisPlatformIsolationTest { + @Test + public void irisWorldHasNoBukkitSignatures() { + IrisWorld world = IrisWorld.builder() + .platformIdentity("minecraft:overworld") + .name("overworld") + .seed(1337L) + .minHeight(-64) + .maxHeight(320) + .build(); + + assertEquals("minecraft:overworld", world.identity()); + assertEquals(1337L, world.getRawWorldSeed()); + assertEquals(384, world.getHeight()); + assertNoBukkitSignatures(IrisWorld.class); + assertNoBukkitSignatures(IrisWorld.IrisWorldBuilder.class); + } + + @Test + public void irisDimensionEnvironmentAndHashCodeArePlatformNeutral() { + IrisDimension dimension = new IrisDimension(); + + assertEquals(IrisEnvironment.NORMAL, dimension.getEnvironment()); + dimension.hashCode(); + assertNoBukkitSignatures(IrisDimension.class); + } + + private static void assertNoBukkitSignatures(Class type) { + for (Field field : type.getDeclaredFields()) { + assertPlatformNeutral(field.getType()); + } + for (Constructor constructor : type.getDeclaredConstructors()) { + for (Class parameter : constructor.getParameterTypes()) { + assertPlatformNeutral(parameter); + } + } + for (Method method : type.getDeclaredMethods()) { + assertPlatformNeutral(method.getReturnType()); + for (Class parameter : method.getParameterTypes()) { + assertPlatformNeutral(parameter); + } + } + } + + private static void assertPlatformNeutral(Class type) { + Class component = type; + while (component.isArray()) { + component = component.getComponentType(); + } + assertFalse(component.getName(), component.getName().startsWith("org.bukkit.")); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisSurfaceOpeningTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisSurfaceOpeningTest.java new file mode 100644 index 000000000..36ea0f906 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisSurfaceOpeningTest.java @@ -0,0 +1,188 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.spi.PlatformBlockState; +import org.junit.Test; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisSurfaceOpeningTest { + @Test + public void carvedSurfaceCellRejectsPlacement() { + SurfacePlacer placer = new SurfacePlacer(80); + placer.carve(1, 80, 1); + + assertTrue(IrisSurfaceOpening.isOpen(placer, null, 0, 0, null, null, 0, 0, 0)); + assertEquals(9, placer.heightQueries()); + } + + @Test + public void sealedShallowCaveDoesNotRejectPlacement() { + SurfacePlacer placer = new SurfacePlacer(80); + placer.carve(0, 79, 0); + placer.carve(0, 78, 0); + placer.carve(0, 77, 0); + + assertFalse(IrisSurfaceOpening.isOpen(placer, null, 0, 0, null, null, 0, 0, 0)); + } + + @Test + public void everySupportColumnUsesItsOwnSurfaceHeight() { + SurfacePlacer placer = new SurfacePlacer(80); + for (int dx = -1; dx <= 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + placer.height(dx, dz, 80 + dx + dz); + } + } + placer.carve(1, 82, 1); + + assertTrue(IrisSurfaceOpening.isOpen(placer, null, 0, 0, null, null, 0, 0, 0)); + } + + @Test + public void openingOutsideSupportStencilDoesNotRejectPlacement() { + SurfacePlacer placer = new SurfacePlacer(80); + placer.carve(2, 80, 0); + + assertFalse(IrisSurfaceOpening.isOpen(placer, null, 0, 0, null, null, 0, 0, 0)); + } + + @Test + public void placementTranslationMovesSupportStencil() { + SurfacePlacer placer = new SurfacePlacer(80); + IrisObjectTranslate translate = new IrisObjectTranslate().setX(5).setZ(-3); + placer.carve(5, 80, -3); + + assertTrue(IrisSurfaceOpening.isOpen(placer, null, 0, 0, translate, null, 0, 0, 0)); + } + + @Test + public void placementTranslationUsesTheSameRotationAsObjectBlocks() { + SurfacePlacer placer = new SurfacePlacer(80); + IrisObjectTranslate translate = new IrisObjectTranslate().setX(5).setZ(-3); + IrisObjectRotation rotation = IrisObjectRotation.of(0, 90, 0); + placer.carve(-3, 80, -5); + + assertTrue(IrisSurfaceOpening.isOpen(placer, null, 0, 0, translate, rotation, 0, 0, 0)); + } + + @Test + public void treeBlockClassificationDoesNotMatchUnrelatedObjects() { + PlatformBlockState stone = mock(PlatformBlockState.class); + PlatformBlockState log = mock(PlatformBlockState.class); + when(log.isTreeBlock()).thenReturn(true); + + assertFalse(IrisSurfaceOpening.containsTreeBlocks(List.of(stone))); + assertTrue(IrisSurfaceOpening.containsTreeBlocks(List.of(stone, log))); + } + + private static final class SurfacePlacer implements IObjectPlacer { + private final int defaultHeight; + private final Map heights = new HashMap<>(); + private final Set carved = new HashSet<>(); + private int heightQueries; + + private SurfacePlacer(int defaultHeight) { + this.defaultHeight = defaultHeight; + } + + private void height(int x, int z, int y) { + heights.put(columnKey(x, z), y); + } + + private void carve(int x, int y, int z) { + carved.add(blockKey(x, y, z)); + } + + private int heightQueries() { + return heightQueries; + } + + @Override + public int getHighest(int x, int z, IrisData data) { + return getHighest(x, z, data, true); + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + heightQueries++; + return heights.getOrDefault(columnKey(x, z), defaultHeight); + } + + @Override + public void set(int x, int y, int z, PlatformBlockState state) { + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + return null; + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + return carved.contains(blockKey(x, y, z)); + } + + @Override + public boolean isSolid(int x, int y, int z) { + return false; + } + + @Override + public boolean isUnderwater(int x, int z) { + return false; + } + + @Override + public int getFluidHeight() { + return 0; + } + + @Override + public boolean isDebugSmartBore() { + return false; + } + + @Override + public void setTile(int x, int y, int z, TileData tile) { + } + + @Override + public void setData(int x, int y, int z, T data) { + } + + @Override + public T getData(int x, int y, int z, Class type) { + return null; + } + + @Override + public Engine getEngine() { + return null; + } + + private static String columnKey(int x, int z) { + return x + ":" + z; + } + + private static String blockKey(int x, int y, int z) { + return x + ":" + y + ":" + z; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettingsTest.java new file mode 100644 index 000000000..fd38ee3a8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisVanillaStructureStiltSettingsTest.java @@ -0,0 +1,34 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import org.junit.Test; + +import java.lang.reflect.Field; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +public class IrisVanillaStructureStiltSettingsTest { + @Test + public void defaultsToCobblestoneFoundation() { + IrisVanillaStructureStiltSettings settings = new IrisVanillaStructureStiltSettings(); + + assertEquals(64, settings.getMaxDepth()); + assertNotNull(settings.getPalette()); + assertEquals(1, settings.getPalette().getPalette().size()); + assertEquals("minecraft:cobblestone", settings.getPalette().getPalette().get(0).getBlock()); + } + + @Test + public void maxDepthSchemaIsBoundedToTheMaximumWorldHeight() throws NoSuchFieldException { + Field maxDepth = IrisVanillaStructureStiltSettings.class.getDeclaredField("maxDepth"); + MinNumber minimum = maxDepth.getAnnotation(MinNumber.class); + MaxNumber maximum = maxDepth.getAnnotation(MaxNumber.class); + + assertNotNull(minimum); + assertNotNull(maximum); + assertEquals(1.0, minimum.value(), 0.0); + assertEquals(4064.0, maximum.value(), 0.0); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisWorldIdentityTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisWorldIdentityTest.java index d05475bb2..f98b260c6 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisWorldIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisWorldIdentityTest.java @@ -1,23 +1,21 @@ package art.arcane.iris.engine.object; -import org.bukkit.NamespacedKey; import org.junit.Test; import static org.junit.Assert.assertEquals; public class IrisWorldIdentityTest { @Test - public void usesBukkitKeyBeforePlatformIdentity() { + public void usesPlatformIdentity() { IrisWorld world = IrisWorld.builder() - .key(new NamespacedKey("iris", "bukkit")) - .platformIdentity("iris:modded") + .platformIdentity("iris:bukkit") .build(); assertEquals("iris:bukkit", world.identity()); } @Test - public void usesPlatformIdentityWithoutBukkitKey() { + public void preservesModdedIdentity() { IrisWorld world = IrisWorld.builder() .platformIdentity("minecraft:the_nether") .build(); diff --git a/gradle.properties b/gradle.properties index 701ea18ca..c459058a5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -26,6 +26,6 @@ nmsTools.specialSourceVersion=1.11.4 irisVersion=4.0.0-26.2 minecraftVersion=26.2 fabricLoaderVersion=0.19.3 -forgeVersion=26.2-65.0.0 -neoForgeVersion=26.2.0.6-beta -volmLibCoordinate=com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1 +forgeVersion=26.2-65.0.4 +neoForgeVersion=26.2.0.12-beta +volmLibCoordinate=com.github.VolmitSoftware:VolmLib:4c38988b344792a79f925f57f8a675bc85fc1bed diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 583a24b14..f655d5226 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,58 +4,52 @@ [versions] # Plugins -shadow = "9.0.0-rc1" # https://plugins.gradle.org/plugin/com.gradleup.shadow +shadow = "9.5.1" # https://plugins.gradle.org/plugin/com.gradleup.shadow slimjar = "2.1.9" # https://plugins.gradle.org/plugin/de.crazydev22.slimjar -download = "5.6.0" # https://plugins.gradle.org/plugin/de.undercouch.download -runPaper = "2.3.1" # https://plugins.gradle.org/plugin/xyz.jpenilla.run-paper -sentryPlugin = "5.8.0" # https://github.com/getsentry/sentry-android-gradle-plugin -grgit = "5.3.2" # https://github.com/ajoberstar/grgit +download = "5.7.0" # https://plugins.gradle.org/plugin/de.undercouch.download +sentryPlugin = "6.14.0" # https://github.com/getsentry/sentry-android-gradle-plugin # Core Libraries -lombok = "1.18.44" +lombok = "1.18.46" spigot = "26.2-R0.1-SNAPSHOT" # https://hub.spigotmc.org/nexus/repository/snapshots/org/spigotmc/spigot-api/maven-metadata.xml paper-api = "26.2.build.60-beta" -log4j = "2.19.0" # https://central.sonatype.com/artifact/org.apache.logging.log4j/log4j-api -adventure-api = "5.1.1" # https://github.com/KyoriPowered/adventure +log4j = "2.26.1" # https://central.sonatype.com/artifact/org.apache.logging.log4j/log4j-api +adventure-api = "4.26.1" # https://github.com/KyoriPowered/adventure adventure-platform = "4.4.1" # https://github.com/KyoriPowered/adventure-platform -annotations = "26.0.2" # https://central.sonatype.com/artifact/org.jetbrains/annotations +annotations = "26.1.0" # https://central.sonatype.com/artifact/org.jetbrains/annotations paralithic = "0.8.1" # https://github.com/PolyhedralDev/Paralithic/ paperlib = "1.0.8" # https://github.com/PaperMC/PaperLib/ -bstats = "3.1.0" # https://github.com/Bastian/bstats-metrics/tree/master -sentry = "8.14.0" # https://github.com/getsentry/sentry-java -commons-io = "2.19.0" # https://central.sonatype.com/artifact/commons-io/commons-io -commons-lang = "2.6" # https://central.sonatype.com/artifact/commons-lang/commons-lang -commons-lang3 = "3.17.0" # https://central.sonatype.com/artifact/org.apache.commons/commons-lang3 -commons-math3 = "3.6.1" -oshi = "6.8.2" # https://central.sonatype.com/artifact/com.github.oshi/oshi-core -fastutil = "8.5.16" # https://central.sonatype.com/artifact/it.unimi.dsi/fastutil -lz4 = "1.8.0" # https://central.sonatype.com/artifact/org.lz4/lz4-java +bstats = "3.2.1" # https://github.com/Bastian/bstats-metrics/tree/master +sentry = "8.48.0" # https://github.com/getsentry/sentry-java +commons-io = "2.22.0" # https://central.sonatype.com/artifact/commons-io/commons-io +commons-lang3 = "3.20.0" # https://central.sonatype.com/artifact/org.apache.commons/commons-lang3 +oshi = "6.9.0" # Minecraft 26.2 runtime ceiling +fastutil = "8.5.18" # https://central.sonatype.com/artifact/it.unimi.dsi/fastutil +lz4 = "1.8.1" # https://central.sonatype.com/artifact/org.lz4/lz4-java lru = "1.4.2" # https://central.sonatype.com/artifact/com.googlecode.concurrentlinkedhashmap/concurrentlinkedhashmap-lru -zip = "1.17" # https://central.sonatype.com/artifact/org.zeroturnaround/zt-zip -gson = "2.13.1" # https://central.sonatype.com/artifact/com.google.code.gson/gson -asm = "9.8" # https://central.sonatype.com/artifact/org.ow2.asm/asm -caffeine = "3.2.1" # https://central.sonatype.com/artifact/com.github.ben-manes.caffeine/caffeine -byte-buddy = "1.17.6" # https://central.sonatype.com/artifact/net.bytebuddy/byte-buddy +zip = "1.18.2" # https://central.sonatype.com/artifact/org.zeroturnaround/zt-zip +gson = "2.14.0" # https://central.sonatype.com/artifact/com.google.code.gson/gson +guava = "33.6.0-jre" # https://central.sonatype.com/artifact/com.google.guava/guava +asm = "9.10.1" # https://central.sonatype.com/artifact/org.ow2.asm/asm +caffeine = "3.2.4" # https://central.sonatype.com/artifact/com.github.ben-manes.caffeine/caffeine +byte-buddy = "1.18.11" # https://central.sonatype.com/artifact/net.bytebuddy/byte-buddy dom4j = "2.2.0" # https://central.sonatype.com/artifact/org.dom4j/dom4j -jaxen = "2.0.0" # https://central.sonatype.com/artifact/jaxen/jaxen - -# Kotlin Runtime -kotlin = "2.3.0" -kotlin-coroutines = "1.10.2" +jaxen = "2.0.6" # https://central.sonatype.com/artifact/jaxen/jaxen # Third Party Integrations -nexo = "1.10.0" # https://repo.nexomc.com/#/releases/com/nexomc/nexo +nexo = "1.25.0" # https://repo.nexomc.com/#/releases/com/nexomc/nexo itemsadder = "4.0.10" # https://github.com/LoneDev6/API-ItemsAdder -placeholderApi = "2.11.6" # https://repo.extendedclip.com/#/releases/me/clip/placeholderapi +placeholderApi = "2.12.3" # https://repo.extendedclip.com/#/releases/me/clip/placeholderapi score = "5.25.3.9" # https://github.com/Ssomar-Developement/SCore -mmoitems = "6.9.5-SNAPSHOT" # https://nexus.phoenixdevt.fr/repository/maven-public/net/Indyuce/MMOItems-API/maven-metadata.xml -ecoitems = "5.63.1" # https://github.com/Auxilor/EcoItems/tags -mythic = "5.9.5" -mythic-chrucible = "2.1.0" +mmoitems = "6.10.1-SNAPSHOT" # https://nexus.phoenixdevt.fr/repository/maven-public/net/Indyuce/MMOItems-API/maven-metadata.xml +mythiclib = "1.7.1-SNAPSHOT" # https://nexus.phoenixdevt.fr/repository/maven-public/io/lumine/MythicLib-dist/maven-metadata.xml +eco = "2026.27" # https://repo.auxilor.io/repository/maven-public/com/willfp/eco/maven-metadata.xml +mythic = "5.12.1" +mythic-crucible = "2.2.0" kgenerators = "7.3" # https://repo.codemc.io/repository/maven-public/me/kryniowesegryderiusz/kgenerators-core/maven-metadata.xml -multiverseCore = "5.1.0" -craftengine = "0.0.67" # https://github.com/Xiao-MoMi/craft-engine/releases +multiverseCore = "5.7.2" +craftengine = "26.7.3" # https://github.com/Xiao-MoMi/craft-engine/releases # Fabric API (net.fabricmc.fabric-api) - each module is independently versioned fabricApi-base = "2.0.4+ece063239e" @@ -87,15 +81,14 @@ paperlib = { module = "io.papermc:paperlib", version.ref = "paperlib" } bstats = { module = "org.bstats:bstats-bukkit", version.ref = "bstats" } sentry = { module = "io.sentry:sentry", version.ref = "sentry" } commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } -commons-lang = { module = "commons-lang:commons-lang", version.ref = "commons-lang" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang3" } -commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "commons-math3" } oshi = { module = "com.github.oshi:oshi-core", version.ref = "oshi" } -lz4 = { module = "org.lz4:lz4-java", version.ref = "lz4" } +lz4 = { module = "at.yawk.lz4:lz4-java", version.ref = "lz4" } fastutil = { module = "it.unimi.dsi:fastutil", version.ref = "fastutil" } lru = { module = "com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru", version.ref = "lru" } zip = { module = "org.zeroturnaround:zt-zip", version.ref = "zip" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +guava = { module = "com.google.guava:guava", version.ref = "guava" } asm = { module = "org.ow2.asm:asm", version.ref = "asm" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } byteBuddy-core = { module = "net.bytebuddy:byte-buddy", version.ref = "byte-buddy" } @@ -103,19 +96,16 @@ byteBuddy-agent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "by dom4j = { module = "org.dom4j:dom4j", version.ref = "dom4j" } jaxen = { module = "jaxen:jaxen", version.ref = "jaxen" } -# Kotlin Runtime -kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } -kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } - # Third Party Integrations nexo = { module = "com.nexomc:nexo", version.ref = "nexo" } itemsadder = { module = "dev.lone:api-itemsadder", version.ref = "itemsadder" } placeholderApi = { module = "me.clip:placeholderapi", version.ref = "placeholderApi" } score = { module = "com.github.Ssomar-Developement:SCore", version.ref = "score" } mmoitems = { module = "net.Indyuce:MMOItems-API", version.ref = "mmoitems" } -ecoitems = { module = "com.willfp:EcoItems", version.ref = "ecoitems" } +mythiclib = { module = "io.lumine:MythicLib-dist", version.ref = "mythiclib" } +eco = { module = "com.willfp:eco", version.ref = "eco" } mythic = { module = "io.lumine:Mythic-Dist", version.ref = "mythic" } -mythicChrucible = { module = "io.lumine:MythicCrucible-Dist", version.ref = "mythic-chrucible" } +mythicCrucible = { module = "io.lumine:MythicCrucible-Dist", version.ref = "mythic-crucible" } kgenerators = { module = "me.kryniowesegryderiusz:kgenerators-core", version.ref = "kgenerators" } multiverseCore = { module = "org.mvplugins.multiverse.core:multiverse-core", version.ref = "multiverseCore" } craftengine-core = { module = "net.momirealms:craft-engine-core", version.ref = "craftengine" } @@ -136,8 +126,4 @@ fabricApi-keyMapping = { module = "net.fabricmc.fabric-api:fabric-key-mapping-ap shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } slimjar = { id = "de.crazydev22.slimjar", version.ref = "slimjar" } download = { id = "de.undercouch.download", version.ref = "download" } -runPaper = { id = "xyz.jpenilla.run-paper", version.ref = "runPaper" } sentry = { id = "io.sentry.jvm.gradle", version.ref = "sentryPlugin" } -kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -kotlin-lombok = { id = "org.jetbrains.kotlin.plugin.lombok", version.ref = "kotlin" } -grgit = { id = "org.ajoberstar.grgit", version.ref = "grgit" } diff --git a/probe/build.gradle b/probe/build.gradle index 290b30721..f09b708bd 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -10,7 +10,7 @@ application { } String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate') - .orElse('com.github.VolmitSoftware:VolmLib:cdf7e8bb880199734dacd386a6616bda799fcde1') + .orElse('com.github.VolmitSoftware:VolmLib:4c38988b344792a79f925f57f8a675bc85fc1bed') .get() dependencies { @@ -23,16 +23,13 @@ dependencies { runtimeOnly(libs.paralithic) runtimeOnly(libs.gson) runtimeOnly(libs.lru) - runtimeOnly(libs.kotlin.coroutines) runtimeOnly(libs.commons.io) - runtimeOnly(libs.commons.lang) runtimeOnly(libs.commons.lang3) - runtimeOnly(libs.commons.math3) runtimeOnly(libs.fastutil) runtimeOnly(libs.caffeine) runtimeOnly(libs.lz4) runtimeOnly(libs.zip) - runtimeOnly('com.google.guava:guava:33.4.0-jre') + runtimeOnly(libs.guava) runtimeOnly(libs.sentry) runtimeOnly(libs.oshi) runtimeOnly(libs.byteBuddy.core) diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java index 100700a4a..bf9afd729 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java @@ -21,6 +21,10 @@ package art.arcane.iris.probe; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.IrisEngine; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineAssignedComponent; +import art.arcane.iris.engine.framework.EngineEffects; +import art.arcane.iris.engine.framework.EngineEffectsProvider; +import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.EngineWorldManager; import art.arcane.iris.engine.framework.EngineWorldManagerProvider; @@ -33,10 +37,6 @@ import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.hunk.Hunk; -import org.bukkit.Chunk; -import org.bukkit.event.block.BlockBreakEvent; -import org.bukkit.event.block.BlockPlaceEvent; -import org.bukkit.event.player.PlayerTeleportEvent; import java.io.File; import java.nio.charset.StandardCharsets; @@ -102,26 +102,23 @@ public final class GenerationProbe { @Override public void onSave() { } + } - @Override - public void onBlockBreak(BlockBreakEvent e) { + private static final class InertEffects extends EngineAssignedComponent implements EngineEffects { + private InertEffects(Engine engine) { + super(engine, "FX"); } @Override - public void onBlockPlace(BlockPlaceEvent e) { + public void updatePlayerMap() { } @Override - public void onChunkLoad(Chunk e, boolean generated) { + public void tickRandomPlayer() { } + } - @Override - public void onChunkUnload(Chunk e) { - } - - @Override - public void teleportAsync(PlayerTeleportEvent e) { - } + private static final class InertPlatformHooks implements EnginePlatformHooks { } public static void main(String[] args) throws Exception { @@ -130,6 +127,8 @@ public final class GenerationProbe { StubPlatform.errorSink(REPORTED::add); IrisServices.register(PreservationRegistry.class, new InertPreservation()); IrisServices.register(EngineWorldManagerProvider.class, (EngineWorldManagerProvider) (Engine engine) -> new InertWorldManager()); + IrisServices.register(EngineEffectsProvider.class, (EngineEffectsProvider) InertEffects::new); + IrisServices.register(EnginePlatformHooks.class, new InertPlatformHooks()); File packSource = new File(args[0]); int radius = args.length > 1 ? Integer.parseInt(args[1]) : 2; diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index 46d18bbe2..eec097b0b 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -127,6 +127,20 @@ public final class StubPlatform implements IrisPlatform { return false; } + @Override + public boolean isTreeBlock() { + String blockKey = key; + int properties = blockKey.indexOf('['); + if (properties >= 0) { + blockKey = blockKey.substring(0, properties); + } + return blockKey.endsWith("_log") + || blockKey.endsWith("_wood") + || blockKey.endsWith("_stem") + || blockKey.endsWith("_hyphae") + || blockKey.endsWith("_leaves"); + } + @Override public boolean isFoliagePlantable() { return false; diff --git a/probe/src/main/resources/classload-allowlist.tsv b/probe/src/main/resources/classload-allowlist.tsv index 31c64c3df..d96222181 100644 --- a/probe/src/main/resources/classload-allowlist.tsv +++ b/probe/src/main/resources/classload-allowlist.tsv @@ -10,19 +10,20 @@ art.arcane.iris.core.events.IrisLootEvent$1 BUKKIT_API org.bukkit.loot.LootTable art.arcane.iris.core.events.IrisLootEvent$2 BUKKIT_API org.bukkit.inventory.InventoryHolder art.arcane.iris.core.lifecycle.PaperLibBootstrap PAPERLIB_API io.papermc.lib.environments.Environment art.arcane.iris.core.lifecycle.PaperLibBootstrap$ModernPaperEnvironment PAPERLIB_API io.papermc.lib.environments.PaperEnvironment -art.arcane.iris.core.link.ExternalDataProvider BUKKIT_API org.bukkit.event.Listener -art.arcane.iris.core.link.data.CraftEngineDataProvider BUKKIT_API org.bukkit.event.Listener -art.arcane.iris.core.link.data.EcoItemsDataProvider BUKKIT_API org.bukkit.event.Listener -art.arcane.iris.core.link.data.ExecutableItemsDataProvider BUKKIT_API org.bukkit.event.Listener -art.arcane.iris.core.link.data.HMCLeavesDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.lifecycle.WorldLifecycleService BUKKIT_API org.bukkit.generator.WorldInfo +art.arcane.iris.core.lifecycle.WorldLifecycleSupport BUKKIT_API org.bukkit.generator.WorldInfo +art.arcane.iris.core.link.ExternalDataProvider BUKKIT_API org.bukkit.block.BlockFace +art.arcane.iris.core.link.data.CraftEngineDataProvider BUKKIT_API org.bukkit.block.data.BlockData +art.arcane.iris.core.link.data.EcoItemsDataProvider BUKKIT_API org.bukkit.block.BlockFace +art.arcane.iris.core.link.data.ExecutableItemsDataProvider BUKKIT_API org.bukkit.block.BlockFace art.arcane.iris.core.link.data.ItemAdderDataProvider BUKKIT_API org.bukkit.event.Listener -art.arcane.iris.core.link.data.KGeneratorsDataProvider BUKKIT_API org.bukkit.event.Listener -art.arcane.iris.core.link.data.MMOItemsDataProvider BUKKIT_API org.bukkit.event.Listener -art.arcane.iris.core.link.data.MythicCrucibleDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.KGeneratorsDataProvider BUKKIT_API org.bukkit.block.data.BlockData +art.arcane.iris.core.link.data.MMOItemsDataProvider BUKKIT_API org.bukkit.block.BlockFace +art.arcane.iris.core.link.data.MythicCrucibleDataProvider BUKKIT_API org.bukkit.block.data.BlockData art.arcane.iris.core.link.data.MythicMobsDataProvider BUKKIT_API org.bukkit.event.Listener art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisBiomeCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition art.arcane.iris.core.link.data.MythicMobsDataProvider$IrisRegionCondition MYTHICMOBS_API io.lumine.mythic.api.skills.conditions.ILocationCondition -art.arcane.iris.core.link.data.NexoDataProvider BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.core.link.data.NexoDataProvider BUKKIT_API org.bukkit.block.data.BlockData art.arcane.iris.core.nms.INMS BUKKIT_API org.bukkit.Bukkit art.arcane.iris.core.project.IrisProject BUKKIT_API org.bukkit.entity.Entity art.arcane.iris.core.runtime.WorldRuntimeControlService BUKKIT_API org.bukkit.entity.Entity @@ -41,6 +42,7 @@ art.arcane.iris.core.tools.IrisWorldCreator BUKKIT_API org.bukkit.generator.Chun art.arcane.iris.core.tools.TreePlausibilizer BUKKIT_API org.bukkit.Keyed art.arcane.iris.engine.IrisWorldManager BUKKIT_API org.bukkit.event.Listener art.arcane.iris.engine.framework.EngineAssignedWorldManager BUKKIT_API org.bukkit.event.Listener +art.arcane.iris.engine.framework.EnginePlayer BUKKIT_API org.bukkit.entity.Entity art.arcane.iris.engine.framework.placer.WorldObjectPlacer BUKKIT_API org.bukkit.event.Event art.arcane.iris.engine.object.IrisDirection$11 BUKKIT_API org.bukkit.block.BlockFace art.arcane.iris.engine.object.IrisEffect$BukkitFx BUKKIT_API org.bukkit.entity.Entity @@ -60,12 +62,15 @@ art.arcane.iris.engine.platform.DummyChunkGenerator BUKKIT_API org.bukkit.genera art.arcane.iris.engine.platform.EngineBukkitOps BUKKIT_API org.bukkit.entity.Entity art.arcane.iris.engine.platform.studio.generators.ObjectStudioGenerator BUKKIT_API org.bukkit.Material art.arcane.iris.platform.bukkit.BukkitBlockResolution BUKKIT_API org.bukkit.Material +art.arcane.iris.platform.bukkit.BukkitBlockState BUKKIT_API org.bukkit.Keyed art.arcane.iris.platform.bukkit.BukkitPlatform BUKKIT_API org.bukkit.command.CommandSender +art.arcane.iris.platform.bukkit.BukkitWorldBinding BUKKIT_API org.bukkit.generator.WorldInfo art.arcane.iris.util.common.data.IrisCustomData BUKKIT_API org.bukkit.block.data.BlockData art.arcane.iris.util.common.data.registry.Attributes BUKKIT_API org.bukkit.attribute.Attribute art.arcane.iris.util.common.data.registry.Materials BUKKIT_API org.bukkit.Material art.arcane.iris.util.common.data.registry.Particles BUKKIT_API org.bukkit.Particle art.arcane.iris.util.common.director.handlers.VectorHandler BUKKIT_API org.bukkit.util.Vector +art.arcane.iris.util.common.director.handlers.WorldHandler BUKKIT_API org.bukkit.generator.WorldInfo art.arcane.iris.util.common.format.C$DyeMaps BUKKIT_API org.bukkit.DyeColor art.arcane.iris.util.common.inventorygui.WindowResolution BUKKIT_API org.bukkit.event.inventory.InventoryType art.arcane.iris.util.common.misc.Bindings BUKKIT_API org.bukkit.plugin.Plugin diff --git a/probe/src/test/java/art/arcane/iris/probe/IdentifierPlatformIsolationTest.java b/probe/src/test/java/art/arcane/iris/probe/IdentifierPlatformIsolationTest.java new file mode 100644 index 000000000..798137de7 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/IdentifierPlatformIsolationTest.java @@ -0,0 +1,21 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.core.link.Identifier; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class IdentifierPlatformIsolationTest { + @Test + public void equalityDoesNotResolveBukkitOnTheProbeRuntime() { + ClassLoader classLoader = IdentifierPlatformIsolationTest.class.getClassLoader(); + assertThrows(ClassNotFoundException.class, + () -> Class.forName("org.bukkit.NamespacedKey", false, classLoader)); + + Identifier identifier = new Identifier("minecraft", "stone"); + assertTrue(identifier.equals(new Identifier("minecraft", "stone"))); + assertFalse(identifier.equals("minecraft:stone")); + } +} diff --git a/spi/src/main/java/art/arcane/iris/spi/PlatformBlockState.java b/spi/src/main/java/art/arcane/iris/spi/PlatformBlockState.java index 0fdd0b888..713a0ad92 100644 --- a/spi/src/main/java/art/arcane/iris/spi/PlatformBlockState.java +++ b/spi/src/main/java/art/arcane/iris/spi/PlatformBlockState.java @@ -34,6 +34,14 @@ public interface PlatformBlockState { boolean isCustom(); + default String deferredPlacementKey() { + return null; + } + + default PlatformBlockState placementBaseState() { + return this; + } + boolean isFluid(); boolean isWater(); @@ -46,6 +54,8 @@ public interface PlatformBlockState { boolean isFoliage(); + boolean isTreeBlock(); + boolean isFoliagePlantable(); boolean isDecorant();