Compare commits

...
Author SHA1 Message Date
Brian Neumann-Fopiano cfa274825f Refine terrain and pregeneration
- Add coherent river width variation and shape source and terminal pools.
- Preserve separated hydrology intervals when checking river connectivity.
- Reduce footprint sorting, engine context lookup, and terrain blending work.
- Reuse modded snapshot validation after content and registry checks.
- Explain production pack update staging and restart requirements.
- Retain pregeneration map updates before its desktop window opens.
- Improve pregeneration layout, coordinate hover, keyboard controls, and error details.
- Add the new desktop labels to every supported language catalog.
- Add the buildPsychoLT entry point for verified platform outputs.
- Cover terrain, validation, and desktop behavior with regression tests.
2026-10-07 10:25:41 -04:00
Brian Neumann-Fopiano e72189adc7 Adopt cooperative reload cleanup
- Return cleanup completion from the committed Iris reload callback
2026-10-07 10:24:59 -04:00
Brian Neumann-Fopiano 7f5ce70f49 Improve generation and lifecycle safety
- Reduce terrain, noise, hydrology, and decorator sampling overhead.
- Reduce object transaction allocations while preserving rollback behavior.
- Optimize structure placement and cave anchor sampling.
- Parallelize Studio cache warmup and protect world entry and hotload recovery.
- Validate pack fingerprints and publish pack replacements atomically.
- Notify modded operators about outdated packs without synchronizing pack data.
- Harden world creation, loading, evacuation, and teleport failure recovery.
- Coalesce queued region flushes while preserving chunk save ordering.
- Remove obsolete mantle, pack, and cave planning code.
- Add regression tests and reusable gameplay fixtures.
2026-10-06 20:51:28 -04:00
Brian Neumann-Fopiano a2bcd7a302 V+
- Bump Iris from 4.3.0-26.2 to 4.3.1-26.2.
- Align Fabric, Forge, and NeoForge version defaults with the release.
2026-10-05 16:48:32 -04:00
Brian Neumann-Fopiano 7226fc4400 Refine organic cave generation
- Add seeded cave surface variation and rounded basin undersides.
- Keep dry cave intersections open while preserving fluid seals.
- Apply room biome decorators and preserve their floor substrates.
- Allow ceiling growth above reserved walking clearance.
- Restrict feature placement by region across live and saved generation.
- Cover cave geometry, placement, decoration, and regional filtering with regression tests.
2026-10-05 16:22:55 -04:00
Brian Neumann-Fopiano c479e94c49 Fix biome names and conditions
- Register readable client biome IDs while preserving saved registry identities.
- Audit the modded registry naming mixin.
- Return false from MythicMobs conditions while saved biome data loads.
- Cover biome naming and MythicMobs loading behavior with regression tests.
2026-10-04 20:10:26 -04:00
Brian Neumann-Fopiano ee5aaec87c Fixed Structure Crash
- Don't carash anymore when you disable structures in the server.properties.
2026-10-04 15:32:54 -04:00
Brian Neumann-Fopiano 65ae3f1b45 Cleanup 2026-10-04 15:17:12 -04:00
Brian Neumann-Fopiano a6149b27c9 Adapter patches
(tests)
2026-10-04 13:08:41 -04:00
Brian Neumann-Fopiano 4b4b418c61 Merge Iris generation changes
- Combine authored subterrain features with native CPU generation.
- Verify dimension JSON and schemas preserve subterrain configuration.
2026-10-04 10:11:44 -04:00
Brian Neumann-Fopiano 8d5cf43139 Enable native noise generation
- Enable exact Rust Billow sampling and report the selected backend.
- Add generation parity probes and native comparison tooling.
2026-10-04 09:59:58 -04:00
Brian Neumann-Fopiano 5858eb366d Add subterrain features
- Generate authored underground rooms, materials, fluids, and formations.
- Preserve underground biome ownership across generation and saved terrain.
- Add subterrain searches and regression coverage.
2026-10-04 09:59:57 -04:00
Brian Neumann-Fopiano be0d3b0156 Fix seeded entity spawning
- Derive initial chunk and marker populations from stable location seeds.
- Persist initial spawn completion after region-owned application.
- Seed entity loot and attribute modifier identities.
- Cover spawning, reload completion, and registry fixtures with regression tests.
2026-10-04 06:14:46 -04:00
406 changed files with 19009 additions and 5613 deletions

No files matched your search

+5
View File
@@ -76,3 +76,8 @@ BUILDS/
.compatwt/ .compatwt/
.worktrees/ .worktrees/
/VolmLib/
/docs/
/.perf/
/.codegraph/
@@ -14,6 +14,7 @@ import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap; import net.minecraft.server.Bootstrap;
import net.minecraft.world.level.LevelHeightAccessor; import net.minecraft.world.level.LevelHeightAccessor;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@@ -45,6 +46,11 @@ public final class IrisChunkGeneratorHeightCacheTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception { public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
Fixture fixture = fixture(); Fixture fixture = fixture();
@@ -20,6 +20,7 @@ import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.RotatedPillarBlock; import net.minecraft.world.level.block.RotatedPillarBlock;
import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.levelgen.Heightmap; import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@@ -46,6 +47,11 @@ public final class NativeTransitionColumnTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() { public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
String key = "itemsadder:rocks/ruby_ore"; String key = "itemsadder:rocks/ruby_ore";
@@ -1,21 +1,30 @@
package art.arcane.iris.nativegen.v26_3_R1; package art.arcane.iris.nativegen.v26_3_R1;
import net.minecraft.core.Holder; import net.minecraft.core.Holder;
import net.minecraft.core.HolderSet;
import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import net.minecraft.tags.BlockTags; import net.minecraft.tags.BlockTags;
import net.minecraft.tags.TagLoader; import net.minecraft.tags.TagLoader;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
public final class NativeBlockTags { public final class NativeBlockTags {
private static Map<TagKey<Block>, List<Holder<Block>>> originalTags;
private NativeBlockTags() { private NativeBlockTags() {
} }
public static void bindHeightmapFixtures() { public static void bindHeightmapFixtures() {
originalTags = new HashMap<>();
for (HolderSet.Named<Block> tag : BuiltInRegistries.BLOCK.getTags().toList()) {
originalTags.put(tag.key(), tag.stream().toList());
}
List<Holder<Block>> solid = List.of(Blocks.STONE.builtInRegistryHolder(), List<Holder<Block>> solid = List.of(Blocks.STONE.builtInRegistryHolder(),
Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.builtInRegistryHolder()); Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.builtInRegistryHolder());
List<Holder<Block>> canopy = List.of(Blocks.STONE.builtInRegistryHolder(), List<Holder<Block>> canopy = List.of(Blocks.STONE.builtInRegistryHolder(),
@@ -25,4 +34,12 @@ public final class NativeBlockTags {
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP, canopy, BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP, canopy,
BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, solid))).apply(); BlockTags.BLOCKS_MOTION_IN_HEIGHTMAP_NO_LEAVES, solid))).apply();
} }
public static void restoreTags() {
if (originalTags == null) {
return;
}
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, originalTags)).apply();
originalTags = null;
}
} }
@@ -34,6 +34,7 @@ import net.minecraft.world.level.levelgen.structure.pieces.PiecesContainer;
import net.minecraft.world.level.levelgen.structure.structures.IglooPieces; import net.minecraft.world.level.levelgen.structure.structures.IglooPieces;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece; import net.minecraft.world.level.levelgen.structure.structures.SwampHutPiece;
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure; import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
@@ -68,6 +69,11 @@ public class WorldgenTerrainHeightmapsTest {
NativeBlockTags.bindHeightmapFixtures(); NativeBlockTags.bindHeightmapFixtures();
} }
@AfterClass
public static void restoreBlockTags() {
NativeBlockTags.restoreTags();
}
@Test @Test
public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() { public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() {
ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0)); ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0));
@@ -13,6 +13,7 @@ import com.google.gson.JsonParser;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.File; import java.io.File;
@@ -26,6 +27,9 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class BareMobSpawnHistoryTest { public final class BareMobSpawnHistoryTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1"; private static final String LEGACY_RENDERER_IDENTITY = "bukkit-v26_3_R1-generated-registry-json-v1";
private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns"; private static final String NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED = private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED =
@@ -29,6 +29,7 @@ import net.minecraft.world.level.levelgen.structure.StructureStart;
import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager; import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplateManager;
import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.storage.LevelStorageSource;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.bukkit.craftbukkit.generator.CustomChunkGenerator; import org.bukkit.craftbukkit.generator.CustomChunkGenerator;
@@ -49,6 +50,9 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
public class HeadlessChunkGeneratorTest { public class HeadlessChunkGeneratorTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -31,6 +31,7 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
import net.minecraft.world.level.storage.LevelStorageSource; import net.minecraft.world.level.storage.LevelStorageSource;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
@@ -48,6 +49,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessChunkLightingTest { public final class HeadlessChunkLightingTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -38,6 +38,7 @@ import net.minecraft.world.ticks.ScheduledTick;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
@@ -54,6 +55,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class HeadlessChunkSerializationTest { public class HeadlessChunkSerializationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -9,6 +9,7 @@ import net.minecraft.world.item.Items;
import java.util.Map; import java.util.Map;
import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.Blocks;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
@@ -18,6 +19,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessNativeBootstrapTest { public final class HeadlessNativeBootstrapTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test @Test
public void bindsNativeItemComponentsWithoutStartingServer() throws Exception { public void bindsNativeItemComponentsWithoutStartingServer() throws Exception {
HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get()); HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get());
@@ -7,12 +7,16 @@ import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceKey;
import net.minecraft.world.level.biome.Biome; import net.minecraft.world.level.biome.Biome;
import net.minecraft.world.level.levelgen.structure.Structure; import net.minecraft.world.level.levelgen.structure.Structure;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessNativeRegistriesTest { public final class HeadlessNativeRegistriesTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Test @Test
public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception { public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception {
RegistryAccess registries = HeadlessNativeTestRegistries.get(); RegistryAccess registries = HeadlessNativeTestRegistries.get();
@@ -4,6 +4,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
import net.minecraft.core.registries.Registries; import net.minecraft.core.registries.Registries;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.util.Map; import java.util.Map;
@@ -17,6 +18,9 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class HeadlessNativeRegistryTest { public final class HeadlessNativeRegistryTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
HeadlessNativeBootstrap.initialize(); HeadlessNativeBootstrap.initialize();
@@ -11,6 +11,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.CompoundTag;
import net.minecraft.data.registries.VanillaRegistries; import net.minecraft.data.registries.VanillaRegistries;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
@@ -23,6 +24,9 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
public final class HeadlessNativeTileTest { public final class HeadlessNativeTileTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
HeadlessNativeBootstrap.initialize(); HeadlessNativeBootstrap.initialize();
@@ -17,6 +17,7 @@ import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.spigotmc.SpigotWorldConfig; import org.spigotmc.SpigotWorldConfig;
@@ -36,6 +37,9 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class HeadlessRegionTerrainTest { public final class HeadlessRegionTerrainTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -24,6 +24,7 @@ import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.chunk.storage.RegionFile; import net.minecraft.world.level.chunk.storage.RegionFile;
import net.minecraft.world.level.chunk.storage.RegionStorageInfo; import net.minecraft.world.level.chunk.storage.RegionStorageInfo;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.io.DataInputStream; import java.io.DataInputStream;
@@ -38,6 +39,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class NativeRegionTerrainWriterTest { public final class NativeRegionTerrainWriterTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@BeforeClass @BeforeClass
public static void bootstrap() { public static void bootstrap() {
SharedConstants.tryDetectVersion(); SharedConstants.tryDetectVersion();
@@ -26,6 +26,7 @@ import net.minecraft.world.level.material.Fluids;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
@@ -40,6 +41,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public final class OfflineNativeFeatureFeasibilityTest { public final class OfflineNativeFeatureFeasibilityTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
private static HeadlessNativeRegistries loaded; private static HeadlessNativeRegistries loaded;
private static RegistryAccess registries; private static RegistryAccess registries;
private static DimensionType dimension; private static DimensionType dimension;
@@ -1,6 +1,7 @@
package art.arcane.iris.probe; package art.arcane.iris.probe;
import org.junit.Rule; import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.Test; import org.junit.Test;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
@@ -22,6 +23,9 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
public final class OfflineRegionPublicationTest { public final class OfflineRegionPublicationTest {
@ClassRule
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
@Rule @Rule
public TemporaryFolder temporary = new TemporaryFolder(); public TemporaryFolder temporary = new TemporaryFolder();
@@ -0,0 +1,37 @@
package art.arcane.iris.probe;
import io.papermc.paper.registry.PaperRegistryAccess;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public final class PaperRegistryIsolation implements TestRule {
@Override
public Statement apply(Statement base, Description description) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
Map<Object, Object> registries = registries();
Map<Object, Object> previous = new HashMap<>(registries);
registries.clear();
try {
base.evaluate();
} finally {
registries.clear();
registries.putAll(previous);
}
}
};
}
@SuppressWarnings("unchecked")
private static Map<Object, Object> registries() throws ReflectiveOperationException {
Field field = PaperRegistryAccess.class.getDeclaredField("registries");
field.setAccessible(true);
return (Map<Object, Object>) field.get(PaperRegistryAccess.instance());
}
}
@@ -18,6 +18,7 @@
package art.arcane.iris; package art.arcane.iris;
import java.util.concurrent.CompletionStage;
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump; import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
import art.arcane.iris.generation.runtime.IrisEngineEffects; import art.arcane.iris.generation.runtime.IrisEngineEffects;
import art.arcane.iris.generation.runtime.IrisWorldManager; import art.arcane.iris.generation.runtime.IrisWorldManager;
@@ -1011,20 +1012,21 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
} }
@Override @Override
public void onPreUnload(ReloadAware.PreUnloadReason reason) { public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
teardownPapi(); teardownPapi();
if (IrisToolbelt.isServerStopping()) { if (IrisToolbelt.isServerStopping()) {
quiesceRuntimeForServerShutdown("pre-unload:" + reason); quiesceRuntimeForServerShutdown("pre-unload:" + reason);
startPostStopFinisher(); startPostStopFinisher();
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers."); Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
return; return CompletableFuture.completedFuture(null);
} }
if (alreadyDrained.get()) { if (alreadyDrained.get()) {
Iris.debug("Pre-unload hook skipped; Iris already drained."); Iris.debug("Pre-unload hook skipped; Iris already drained.");
return; return CompletableFuture.completedFuture(null);
} }
Iris.debug("BileTools pre-unload hook fired (" + reason + "). Freezing all Iris worlds."); Iris.debug("BileTools pre-unload hook fired (" + reason + "). Freezing all Iris worlds.");
drainOnce("pre-unload:" + reason, 45L); drainOnce("pre-unload:" + reason, 45L);
return CompletableFuture.completedFuture(null);
} }
/** /**
@@ -25,6 +25,8 @@ import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.pack.datapack.DatapackIngestService; import art.arcane.iris.pack.datapack.DatapackIngestService;
import art.arcane.iris.studio.object.ObjectStudioSaveService; import art.arcane.iris.studio.object.ObjectStudioSaveService;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.locator.SubterrainLocator;
import art.arcane.iris.generation.subterrain.IrisSubterrainFamily;
import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability; import art.arcane.iris.structure.placement.StructureReachability;
@@ -72,7 +74,7 @@ import java.util.Set;
@Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto") @Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto")
public class CommandFind implements DirectorExecutor { public class CommandFind implements DirectorExecutor {
@Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome") @Director(description = "Find a biome", sync = true, descriptionKey = "iris.director.commandfind.director.find_biome")
public void biome( public void biome(
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class) @Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
IrisBiome biome, IrisBiome biome,
@@ -89,6 +91,78 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoBiome(e, biome, player(), teleport); EngineBukkitOps.gotoBiome(e, biome, player(), teleport);
} }
@Director(description = "Find an authored underground feature by id or geometry family", sync = true)
public void subterrain(
@Param(description = "Feature definition id or family: tectonic_fault, cenote, lava_tube, travertine_terraces")
String feature,
@Param(description = "Search radius in blocks", defaultValue = "8192")
int radius,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
) {
String key = feature.trim();
IrisSubterrainFamily family = null;
try {
family = IrisSubterrainFamily.valueOf(key.toUpperCase(Locale.ROOT).replace('-', '_'));
} catch (IllegalArgumentException ignored) {
}
locateSubterrain(new SubterrainLocator.Query(family == null ? key : "", family, ""), radius, teleport);
}
@Director(name = "underground-biome", description = "Find a bounded authored underground biome", sync = true)
public void undergroundBiome(
@Param(description = "The underground biome to look for", customHandler = ReachableBiomeHandler.class)
IrisBiome biome,
@Param(description = "Search radius in blocks", defaultValue = "8192")
int radius,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
) {
locateSubterrain(new SubterrainLocator.Query("", null, biome.getLoadKey()), radius, teleport);
}
private void locateSubterrain(SubterrainLocator.Query query, int radius, boolean teleport) {
Engine activeEngine = engine();
VolmitSender commandSender = sender();
Player target = player();
if (activeEngine == null || commandSender == null || target == null) {
if (commandSender != null) {
commandSender.sendMessage(C.RED + "Run this command from an Iris world.");
}
return;
}
if (radius < 0 || radius > 32768) {
commandSender.sendMessage(C.RED + "Search radius must be between 0 and 32768 blocks.");
return;
}
Location origin = target.getLocation();
World world = origin.getWorld();
commandSender.sendMessage(C.GRAY + "Searching authored underground plans...");
J.a(() -> {
try {
SubterrainLocator.Result found = GenerationSemanticQueries.nearestSubterrain(activeEngine, query,
origin.getBlockX(), origin.getBlockY(), origin.getBlockZ(), radius).orElse(null);
if (found == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + "No matching authored underground volume found within " + radius + " blocks.");
return;
}
String label = found.family().name().toLowerCase(Locale.ROOT) + " " + found.featureId();
if (!teleport) {
sendStructureMessage(target, commandSender, C.GREEN + "Found " + label + " at "
+ found.x() + ", " + found.y() + ", " + found.z() + ".");
return;
}
prepareStructureTeleport(target, world, commandSender, label,
new Location(world, found.x(), found.y(), found.z()), true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate authored underground volume: " + error.getMessage());
Iris.reportError("Could not locate authored underground volume.", error);
}
});
}
@Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region") @Director(description = "Find a region", descriptionKey = "iris.director.commandfind.director.find_region")
public void region( public void region(
@Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class) @Param(description = "The region to look for", descriptionKey = "iris.director.commandfind.param.region_look", customHandler = ReachableRegionHandler.class)
@@ -18,6 +18,8 @@
package art.arcane.iris.command; package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDirectoryResolver;
@@ -174,7 +176,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_CLEANUP_CANDIDATES_FOUND_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return; return;
} }
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath())))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_QUARANTINED_CLEANUP_CANDIDATE_S_UNDER, MessageArgument.untrusted("size", String.valueOf(result.quarantinedPaths().size())), MessageArgument.untrusted("quarantinePath", String.valueOf(result.quarantinePath()))));
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED); reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
return; return;
@@ -230,7 +231,6 @@ public class CommandPack implements DirectorExecutor {
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack)))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NOTHING_RESTORE_PACK, MessageArgument.untrusted("pack", String.valueOf(pack))));
return; return;
} }
PackValidationRegistry.remove(packFolder.getName());
s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath())))); s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_RESTORED_FILE_S_FROM, MessageArgument.untrusted("size", String.valueOf(result.restoredPaths().size())), MessageArgument.untrusted("dumpPath", String.valueOf(result.dumpPath()))));
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED); reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
return; return;
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
return persisted == null || persisted.isBlank() ? "unknown" : persisted; return persisted == null || persisted.isBlank() ? "unknown" : persisted;
} }
private PackValidationResult runValidate(VolmitSender s, File packFolder) { PackValidationResult runValidate(VolmitSender s, File packFolder) {
PackValidationRegistry.ValidationTicket ticket =
PackValidationRegistry.tryBeginValidation(packFolder.toPath());
PackValidationResult result;
String contentFingerprint = "";
String contextFingerprint = "";
try { try {
PackValidationResult result = PackValidator.validate(packFolder); if (ticket == null) {
PackValidationRegistry.publish(result); throw new IllegalStateException("Pack content is currently being changed.");
reportResult(s, result); }
return result; contentFingerprint = PackFingerprints.computePackTreeFingerprint(packFolder);
contextFingerprint = PackValidationCache.contextFingerprint();
result = PackValidator.validate(packFolder);
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packFolder))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
throw new IllegalStateException("Pack content or validation context changed during validation.");
}
} catch (Throwable e) { } catch (Throwable e) {
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e); Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
String detail = e.getMessage() == null || e.getMessage().isBlank() String detail = e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName() ? e.getClass().getSimpleName()
: e.getMessage(); : e.getMessage();
PackValidationResult result = new PackValidationResult( result = new PackValidationResult(
packFolder.getName(), packFolder.getName(),
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail), List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
List.of(), List.of(),
System.currentTimeMillis()); System.currentTimeMillis());
PackValidationRegistry.publish(result); contentFingerprint = "";
reportResult(s, result); contextFingerprint = "";
return result;
} }
if (PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
PackValidationRegistry.publish(result);
} else {
result = new PackValidationResult(packFolder.getName(),
List.of("Pack content changed during validation."), List.of(), System.currentTimeMillis());
}
reportResult(s, result);
return result;
} }
private void persistValidationCache(File packsRoot) { private void persistValidationCache(File packsRoot) {
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); try {
List<PackValidationResult> results = new ArrayList<>(packDirectories.size()); String contextFingerprint = PackValidationCache.contextFingerprint();
for (File packDirectory : packDirectories) { PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName()); List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
if (result == null) { List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
// The boot cache only loads when it covers every pack, so a partial write is for (File packDirectory : packDirectories) {
// pointless - but say so instead of silently skipping the persist forever. PackValidationResult result = PackValidationRegistry.getMatching(
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName() packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
+ "\" has no registered result. Run /iris pack validate all to repopulate it."); if (result == null) {
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
+ "\" has no validation matching its current content. Run /iris pack validate to refresh it.");
return;
}
results.add(result);
}
if (!snapshot.content().equals(PackFingerprints.computePackContentSnapshot(packsRoot).content())
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
Iris.warn("Pack validation cache not written because pack content or validation context changed.");
return; return;
} }
results.add(result); for (int index = 0; index < packDirectories.size(); index++) {
} File packDirectory = packDirectories.get(index);
try { if (PackValidationRegistry.getMatching(packDirectory.toPath(),
snapshot.packContents().get(packDirectory.getName())) != results.get(index)) {
Iris.warn("Pack validation cache not written because validation changed during persistence.");
return;
}
}
PackValidationCache.save( PackValidationCache.save(
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(), Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
PackValidationCache.contentFingerprint(packsRoot), snapshot.content(),
PackValidationCache.contextFingerprint(), contextFingerprint,
results); results);
} catch (IOException | RuntimeException e) { } catch (IOException | RuntimeException e) {
Iris.reportError("Could not persist refreshed pack-validation results", e); Iris.reportError("Could not persist refreshed pack-validation results", e);
@@ -18,8 +18,9 @@
package art.arcane.iris.world; package art.arcane.iris.world;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.datapack.ServerConfigurator;
import art.arcane.iris.Iris; import art.arcane.iris.Iris;
import art.arcane.iris.world.lifecycle.WorldLifecycleStaging; import art.arcane.iris.world.lifecycle.WorldLifecycleStaging;
@@ -184,12 +185,12 @@ public final class IrisWorldGeneratorResolver {
} }
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList(); List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath(); Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
ServerConfigurator.PackContentSnapshot contentSnapshot = PackFingerprints.PackContentSnapshot contentSnapshot =
new ServerConfigurator.PackContentSnapshot("", Map.of()); new PackFingerprints.PackContentSnapshot("", Map.of());
String contextFingerprint = ""; String contextFingerprint = "";
Optional<List<PackValidationResult>> cached = Optional.empty(); Optional<List<PackValidationResult>> cached = Optional.empty();
try { try {
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot); contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint(); contextFingerprint = PackValidationCache.contextFingerprint();
if (!externalContentChanged) { if (!externalContentChanged) {
cached = PackValidationCache.load( cached = PackValidationCache.load(
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
private static FreshValidation validateStablePacks( private static FreshValidation validateStablePacks(
File packsRoot, File packsRoot,
List<File> initialPackDirs, List<File> initialPackDirs,
ServerConfigurator.PackContentSnapshot initialSnapshot PackFingerprints.PackContentSnapshot initialSnapshot
) { ) {
List<File> packDirs = initialPackDirs; List<File> packDirs = initialPackDirs;
ServerConfigurator.PackContentSnapshot before = initialSnapshot; PackFingerprints.PackContentSnapshot before = initialSnapshot;
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) { for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList(); List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
List<PackValidationResult> results = validatePacks(packDirs); List<PackValidationResult> results = validatePacks(packDirs);
ServerConfigurator.PackContentSnapshot after; PackFingerprints.PackContentSnapshot after;
try { try {
after = ServerConfigurator.computePackContentSnapshot(packsRoot); after = PackFingerprints.computePackContentSnapshot(packsRoot);
} catch (RuntimeException exception) { } catch (RuntimeException exception) {
Iris.reportError("Could not verify Iris pack bytes after validation", exception); Iris.reportError("Could not verify Iris pack bytes after validation", exception);
after = new ServerConfigurator.PackContentSnapshot("", Map.of()); after = new PackFingerprints.PackContentSnapshot("", Map.of());
} }
List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot); List<File> afterPackDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList(); List<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
private record FreshValidation( private record FreshValidation(
List<File> packDirs, List<File> packDirs,
ServerConfigurator.PackContentSnapshot contentSnapshot, PackFingerprints.PackContentSnapshot contentSnapshot,
List<PackValidationResult> results, List<PackValidationResult> results,
boolean stable boolean stable
) { ) {
@@ -0,0 +1,47 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 96,
"max": 96
}
],
"decorators": [
{
"palette": [
{
"block": "minecraft:oak_stairs",
"data": {
"half": "bottom",
"facing": "north"
}
}
],
"chance": 1,
"forcePlace": true
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Decorator Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,40 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-decorators',
description: 'Verify authored stair decorations occupy one block and retain their half state.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Decorator generation requires an isolated instance')
await context.step('create a world with authored stair decorations', async () => {
const response = await context.command('/iris create name=decorator_probe type=decorator-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client sees single bottom-half stair decorations', async () => {
let checked = 0
await context.waitUntil(() => {
checked = 0
const center = context.bot.entity.position.floored()
for (let x = -6; x <= 6; x++) {
for (let z = -6; z <= 6; z++) {
for (let y = -4; y <= 1; y++) {
const position = center.offset(x, y, z)
if (context.bot.blockAt(position)?.name !== 'lime_wool') continue
const decoration = context.bot.blockAt(position.offset(0, 1, 0))
const above = context.bot.blockAt(position.offset(0, 2, 0))
if (!decoration || !above) return false
if (decoration.name !== 'oak_stairs' || decoration.getProperties().half !== 'bottom'
|| above.name !== 'air') return false
checked++
}
}
}
return checked >= 100
}, { label: 'At least 100 authored stair columns with empty space above', timeoutMs: 60000, intervalMs: 250 })
context.expect(checked >= 100, 'Client verified generated decorator columns', { checked })
context.report.decoratorColumns = checked
})
}
}
@@ -4,6 +4,13 @@ import art.arcane.volmlib.nativelib.NativeAdapters;
import art.arcane.iris.world.runtime.RuntimeInjection; import art.arcane.iris.world.runtime.RuntimeInjection;
import art.arcane.iris.world.IrisCreator; import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.IrisToolbelt; import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.IrisWorldStorage;
import art.arcane.iris.world.WorldCreatorCompat;
import art.arcane.iris.world.lifecycle.LifecycleOperationCoordinator;
import art.arcane.iris.platform.bootstrap.ServerProperties;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.block.Biome; import org.bukkit.block.Biome;
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess; import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
@@ -19,6 +26,10 @@ import org.bukkit.command.CommandSender;
import org.bukkit.generator.ChunkGenerator; import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.List; import java.util.List;
import java.util.logging.Level; import java.util.logging.Level;
@@ -26,6 +37,7 @@ import java.util.logging.Level;
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution { public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe"); private static final NamespacedKey WORLD_KEY = NamespacedKey.minecraft("native_lifecycle_probe");
private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe"); private static final NamespacedKey TERRAIN_KEY = new NamespacedKey("iris", "native_terrain_probe");
private static final NamespacedKey COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
private static final long WORLD_SEED = 78264193L; private static final long WORLD_SEED = 78264193L;
private NativeWorldRuntime runtime; private NativeWorldRuntime runtime;
@@ -45,7 +57,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) { if (arguments.length != 1) {
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload"); sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
return true; return true;
} }
try { try {
@@ -56,6 +68,8 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
case "terrain-create" -> createTerrain(sender); case "terrain-create" -> createTerrain(sender);
case "terrain-check" -> checkTerrain(sender); case "terrain-check" -> checkTerrain(sender);
case "terrain-unload" -> unloadTerrain(sender); case "terrain-unload" -> unloadTerrain(sender);
case "lifecycle-status" -> lifecycleStatus(sender);
case "lifecycle-entry" -> lifecycleEntry(sender);
case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available() case "status" -> sender.sendMessage("NATIVE_STATUS available=" + runtime.available()
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null)); + " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
default -> throw new IllegalArgumentException("Unknown lifecycle operation"); default -> throw new IllegalArgumentException("Unknown lifecycle operation");
@@ -66,6 +80,61 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
return true; return true;
} }
private void lifecycleStatus(CommandSender sender) throws Exception {
World world = Bukkit.getWorld(COMMAND_KEY);
Path storage = WorldCreatorCompat.persistentLevelRoot(COMMAND_KEY).toPath();
Path levelRoot = IrisWorldStorage.levelRoot().toPath();
Path registry = levelRoot.resolve("iris/worlds.json");
boolean registered = Files.exists(registry)
&& Files.readString(registry).contains("\"" + COMMAND_KEY + "\"");
String startupName = IrisWorldStorage.configuredWorldName(COMMAND_KEY, levelRoot.getFileName().toString());
YamlConfiguration configuration = YamlConfiguration.loadConfiguration(ServerProperties.BUKKIT_YML);
boolean configured = configuration.getConfigurationSection("worlds") != null
&& configuration.getConfigurationSection("worlds").contains(startupName);
File quarantineRoot = levelRoot.resolve("dimensions/iris").toFile();
File[] quarantines = quarantineRoot.listFiles((directory, name) -> name.startsWith(".iris-delete-"));
int quarantineCount = quarantines == null ? 0 : quarantines.length;
Path queueFile = Bukkit.getPluginManager().getPlugin("Iris").getDataFolder().toPath()
.resolve("pending-world-deletes.txt");
List<String> queue = Files.exists(queueFile) ? Files.readAllLines(queueFile) : List.of();
boolean queued = quarantineCount > 0;
if (quarantines != null) {
for (File quarantine : quarantines) {
queued &= queue.contains(quarantine.getName());
}
}
boolean disk = Files.exists(storage, LinkOption.NOFOLLOW_LINKS);
String cleanup = disk ? "present" : quarantineCount == 0 ? "deleted" : queued ? "queued" : "unsafe";
boolean idle = true;
LifecycleOperationCoordinator coordinator = LifecycleOperationCoordinator.get();
for (LifecycleOperationCoordinator.Domain domain : LifecycleOperationCoordinator.Domain.values()) {
idle &= coordinator.active(domain).isEmpty();
}
sender.sendMessage("IRIS_LIFECYCLE loaded=" + (world != null) + " registered=" + registered
+ " configured=" + configured + " disk=" + disk + " quarantines=" + quarantineCount
+ " cleanup=" + cleanup + " idle=" + idle);
}
private void lifecycleEntry(CommandSender sender) {
if (!(sender instanceof Player player)) {
throw new IllegalArgumentException("Lifecycle entry requires a player");
}
World world = Bukkit.getWorld(COMMAND_KEY);
if (world == null || player.getWorld() != world || world.getSeed() != WORLD_SEED
|| !IrisToolbelt.isIrisWorld(world)) {
throw new IllegalStateException("Player did not enter the requested seeded Iris world");
}
Location feet = player.getLocation();
Material floor = world.getBlockAt(feet.getBlockX(), feet.getBlockY() - 1, feet.getBlockZ()).getType();
if (floor != Material.LIME_WOOL || !floor.isSolid()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY(), feet.getBlockZ()).isPassable()
|| !world.getBlockAt(feet.getBlockX(), feet.getBlockY() + 1, feet.getBlockZ()).isPassable()) {
throw new IllegalStateException("Lifecycle player entry lacks a safe fixture floor");
}
sender.sendMessage("IRIS_LIFECYCLE_ENTRY world=" + COMMAND_KEY + " seed=" + world.getSeed()
+ " floor=lime_wool safe=true");
}
private void create(CommandSender sender) throws ReflectiveOperationException { private void create(CommandSender sender) throws ReflectiveOperationException {
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) { if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
throw new IllegalStateException("Native runtime unavailable or probe world already loaded"); throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
@@ -124,6 +193,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
int y = world.getHighestBlockYAt(x, z); int y = world.getHighestBlockYAt(x, z);
if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL if (y < 90 || y > 100 || world.getBlockAt(x, y, z).getType() != Material.LIME_WOOL
|| world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE || world.getBlockAt(x, y - 3, z).getType() != Material.GREEN_CONCRETE
|| world.getBlockAt(x, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|| world.getBiome(x, y, z) != Biome.PLAINS) { || world.getBiome(x, y, z) != Biome.PLAINS) {
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
+ ": " + world.getBlockAt(x, y, z).getType()); + ": " + world.getBlockAt(x, y, z).getType());
@@ -11,6 +11,15 @@
"block": "minecraft:lime_wool" "block": "minecraft:lime_wool"
} }
] ]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
} }
], ],
"generators": [ "generators": [
@@ -21,7 +21,7 @@
"rockPalette": { "rockPalette": {
"palette": [ "palette": [
{ {
"block": "minecraft:green_concrete" "block": "minecraft:red_concrete"
} }
] ]
} }
@@ -6,6 +6,11 @@ import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.world.IrisCreator; import art.arcane.iris.world.IrisCreator;
import art.arcane.iris.world.entity.IrisSpawner; import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.IrisMarker;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.world.entity.IrisEntitySpawn; import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisEntity; import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
@@ -28,6 +33,7 @@ import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType; import org.bukkit.entity.EntityType;
import org.bukkit.entity.LivingEntity; import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
@@ -38,9 +44,13 @@ import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.logging.Level; import java.util.logging.Level;
@@ -83,7 +93,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (arguments.length != 1) { if (arguments.length != 1) {
notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|surface|cave"); notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|seeded|completion|surface|cave");
return true; return true;
} }
try { try {
@@ -93,6 +103,8 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
case "zombies" -> runRegion(sender, () -> spawnZombies(sender)); case "zombies" -> runRegion(sender, () -> spawnZombies(sender));
case "stress" -> runRegion(sender, () -> stress(sender)); case "stress" -> runRegion(sender, () -> stress(sender));
case "check" -> runRegion(sender, () -> check(sender)); case "check" -> runRegion(sender, () -> check(sender));
case "seeded" -> seeded(sender);
case "completion" -> completion(sender);
case "surface" -> view(sender, 67D); case "surface" -> view(sender, 67D);
case "cave" -> view(sender, -46D); case "cave" -> view(sender, -46D);
default -> throw new IllegalArgumentException("Unknown spawning operation"); default -> throw new IllegalArgumentException("Unknown spawning operation");
@@ -147,6 +159,9 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
private void configureWorld(CommandSender sender, World world) { private void configureWorld(CommandSender sender, World world) {
try { try {
world.setDifficulty(Difficulty.NORMAL); world.setDifficulty(Difficulty.NORMAL);
world.setTime(6000L);
world.setStorm(false);
world.setThundering(false);
world.setGameRule(GameRules.SPAWN_MOBS, true); world.setGameRule(GameRules.SPAWN_MOBS, true);
List<CompletableFuture<Void>> chunks = new ArrayList<>(); List<CompletableFuture<Void>> chunks = new ArrayList<>();
for (int chunkX = -1; chunkX <= 1; chunkX++) { for (int chunkX = -1; chunkX <= 1; chunkX++) {
@@ -176,6 +191,11 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> { Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> {
try { try {
chunk.addPluginChunkTicket(this); chunk.addPluginChunkTicket(this);
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
}
}
retained.complete(null); retained.complete(null);
} catch (Throwable error) { } catch (Throwable error) {
retained.completeExceptionally(error); retained.completeExceptionally(error);
@@ -244,12 +264,218 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
} }
} }
private void completion(CommandSender sender) {
World world = requireWorld();
Bukkit.getGlobalRegionScheduler().execute(this, () -> {
world.setGameRule(GameRules.SPAWN_MOBS, false);
runRegion(sender, () -> beginCompletion(sender, world));
});
}
private void beginCompletion(CommandSender sender, World world) {
try {
Engine engine = IrisToolbelt.access(world).getEngine();
Object manager = engine.getWorldManager();
Field field = manager.getClass().getDeclaredField("chunkMaintenance");
field.setAccessible(true);
Object maintenance = field.get(manager);
Method run = maintenance.getClass().getDeclaredMethod("runInitialSpawn", World.class, int.class, int.class, Runnable.class);
run.setAccessible(true);
Field pending = maintenance.getClass().getDeclaredField("markerFlagQueue");
pending.setAccessible(true);
AtomicInteger callbacks = new AtomicInteger();
Runnable callback = () -> {
require(Bukkit.isOwnedByCurrentRegion(world, 0, 0), "Initial callback ran outside its owning region");
callbacks.incrementAndGet();
Chunk chunk = world.getChunkAt(0, 0);
invokeAmbient(runtimeSpawn(chunk, "seeded"), chunk, true);
};
run.invoke(maintenance, world, 0, 0, callback);
awaitSkippedInitial(sender, world, new CompletionRun(engine, maintenance, run, pending, callback, callbacks), 0);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not start initial completion test", failure);
}
}
private void awaitSkippedInitial(CommandSender sender, World world, CompletionRun state, int attempt) {
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> {
try {
require(state.callbacks().get() == 0, "Disabled mob spawning consumed the initial pass");
if (!((Set<?>) state.pending().get(state.maintenance())).isEmpty()) {
require(attempt < 100, "Skipped initial pass did not release its pending state");
awaitSkippedInitial(sender, world, state, attempt + 1);
return;
}
Bukkit.getGlobalRegionScheduler().execute(this, () -> {
world.setGameRule(GameRules.SPAWN_MOBS, true);
runRegion(sender, () -> retryInitialCompletion(sender, world, state));
});
} catch (Throwable failure) {
fail(sender, failure);
}
}, 2L);
}
private void retryInitialCompletion(CommandSender sender, World world, CompletionRun state) {
try {
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
awaitInitialCompletion(sender, world, state, 0);
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not retry initial spawning", failure);
}
}
private void awaitInitialCompletion(CommandSender sender, World world, CompletionRun state, int attempt) {
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0, task -> {
try {
if (state.callbacks().get() == 0 || !((Set<?>) state.pending().get(state.maintenance())).isEmpty()) {
require(attempt < 100, "Initial spawning did not complete after retry");
awaitInitialCompletion(sender, world, state, attempt + 1);
return;
}
require(state.callbacks().get() == 1, "Concurrent initial requests spawned duplicate populations");
state.run().invoke(state.maintenance(), world, 0, 0, state.callback());
Bukkit.getAsyncScheduler().runNow(this, ignored -> {
try {
require(state.engine().getMantle().getMantle().hasFlag(0, 0, MantleFlag.INITIAL_SPAWNED_MARKER), "Initial completion was not persisted");
require(state.callbacks().get() == 1, "Completed initial pass ran again");
evidence(sender, "SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true");
} catch (Throwable failure) {
fail(sender, failure);
}
});
} catch (Throwable failure) {
fail(sender, failure);
}
}, 2L);
}
private void seeded(CommandSender sender) {
requireWorld();
List<List<String>> baseline = new ArrayList<>();
sampleInitial(0, false).thenCompose(first -> {
baseline.add(first);
return sampleInitial(1, false);
}).thenCompose(second -> {
baseline.add(second);
return sampleInitial(1, false);
}).thenCompose(repeated -> {
require(baseline.get(1).equals(repeated), "Second chunk changed after reversed generation order");
return sampleInitial(0, false);
}).thenCompose(repeated -> {
require(baseline.get(0).equals(repeated), "First chunk changed after reversed generation order");
return sampleInitial(0, true);
}).thenCompose(marker -> {
baseline.add(marker);
return sampleInitial(1, true);
}).thenCompose(marker -> {
baseline.add(marker);
return sampleInitial(1, true);
}).thenCompose(repeated -> {
require(baseline.get(3).equals(repeated), "Second marker changed after reversed generation order");
return sampleInitial(0, true);
}).whenComplete((repeated, failure) -> {
if (failure != null) {
fail(sender, failure);
return;
}
try {
require(baseline.get(2).equals(repeated), "First marker changed after reversed generation order");
evidence(sender, "SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true");
} catch (Throwable error) {
fail(sender, error);
}
});
}
private CompletableFuture<List<String>> sampleInitial(int chunkX, boolean marker) {
CompletableFuture<List<String>> result = new CompletableFuture<>();
World world = requireWorld();
Bukkit.getRegionScheduler().execute(this, world, chunkX, 0, () -> {
try {
Chunk chunk = world.getChunkAt(chunkX, 0);
for (Entity entity : chunk.getEntities()) {
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
entity.remove();
}
}
for (int x = 0; x < 16; x++) {
for (int z = 0; z < 16; z++) {
chunk.getBlock(x, 64, z).setType(Material.GRASS_BLOCK, false);
for (int y = 65; y <= 68; y++) {
chunk.getBlock(x, y, z).setType(Material.AIR, false);
}
}
}
RuntimeSpawn spawn = runtimeSpawn(chunk, "seeded");
IrisSpawner definition = spawn.environment().data().getSpawnerLoader().load("qa/seeded");
require(!definition.canSpawn(spawn.engine(), chunkX, 0), "Ongoing weather/time gate unexpectedly allowed spawning");
for (int i = 0; i < 100 + chunkX; i++) {
RNG.r.nextLong();
}
ambientInvocation.set(true);
try {
for (int i = 0; i < 5; i++) {
LivingEntity witness = (LivingEntity) world.spawnEntity(new Location(world, (chunkX << 4) + 8.5, 65, 8.5), EntityType.ZOMBIE);
witness.setCustomName("Population witness");
witness.setAI(false);
witness.setGravity(false);
}
} finally {
ambientInvocation.remove();
}
if (marker) {
invokeInitialMarker(spawn, definition, new IrisPosition((chunkX << 4) + 5, 64, 5));
} else {
invokeAmbient(spawn, chunk, true);
}
List<String> snapshot = new ArrayList<>();
for (Entity entity : chunk.getEntities()) {
String name = entity.getCustomName();
if (!(entity instanceof LivingEntity living) || name == null || !name.startsWith("Seeded ")) {
continue;
}
ItemStack hand = living.getEquipment().getItemInMainHand();
ItemStack helmet = living.getEquipment().getHelmet();
require(hand.hasItemMeta() && hand.getItemMeta().hasAttributeModifiers(), "Spawned gear has no authored attributes");
require(helmet != null && helmet.getType() == Material.IRON_HELMET, "Spawned helmet was not authored equipment");
Location at = entity.getLocation();
snapshot.add(entity.getType() + ":" + at.getX() + ":" + at.getY() + ":" + at.getZ()
+ ":" + HexFormat.of().formatHex(hand.serializeAsBytes())
+ ":" + HexFormat.of().formatHex(helmet.serializeAsBytes()));
}
require(snapshot.size() >= 2 && snapshot.size() <= 4, "Initial population was blocked by runtime gates or cap: " + snapshot.size());
Collections.sort(snapshot);
result.complete(List.copyOf(snapshot));
} catch (Throwable failure) {
result.completeExceptionally(failure);
}
});
return result;
}
private void invokeInitialMarker(RuntimeSpawn spawn, IrisSpawner definition, IrisPosition position) {
ambientInvocation.set(true);
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
Method method = spawn.spawner().getClass().getDeclaredMethod("spawnMarker", IrisPosition.class, IrisSpawner.class, IrisMarker.class, RNG.class, boolean.class);
method.setAccessible(true);
method.invoke(spawn.spawner(), position, definition, null, EntitySpawnSeed.marker(spawn.engine().getSeedManager().getEntity(), position.getX(), position.getY(), position.getZ()), true);
} catch (InvocationTargetException failure) {
throw new IllegalStateException("Initial marker spawning failed", failure.getCause());
} catch (ReflectiveOperationException failure) {
throw new IllegalStateException("Could not invoke initial marker spawning", failure);
} finally {
ambientInvocation.remove();
}
}
private void spawnFrogs(CommandSender sender) { private void spawnFrogs(CommandSender sender) {
Chunk chunk = requireWorld().getChunkAt(0, 0); Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs"); RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs");
getLogger().info(spawnDiagnostics(spawn, chunk)); getLogger().info(spawnDiagnostics(spawn, chunk));
for (int attempt = 0; attempt < 12; attempt++) { for (int attempt = 0; attempt < 12; attempt++) {
invokeAmbient(spawn, chunk); invokeAmbient(spawn, chunk, false);
require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity"); require(count(chunk, EntityType.FROG) <= FROG_CAPACITY, "Frog batch exceeded its category capacity");
} }
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, require(count(chunk, EntityType.FROG) == FROG_CAPACITY,
@@ -266,7 +492,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran"); require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran");
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies"); RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
getLogger().info(spawnDiagnostics(spawn, chunk)); getLogger().info(spawnDiagnostics(spawn, chunk));
invokeAmbient(spawn, chunk); invokeAmbient(spawn, chunk, false);
firstZombieBatch = count(chunk, EntityType.ZOMBIE); firstZombieBatch = count(chunk, EntityType.ZOMBIE);
require(firstZombieBatch == ZOMBIE_CAPACITY, require(firstZombieBatch == ZOMBIE_CAPACITY,
"Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk)); "Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk));
@@ -280,7 +506,7 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
Chunk chunk = requireWorld().getChunkAt(0, 0); Chunk chunk = requireWorld().getChunkAt(0, 0);
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies"); RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
for (int attempt = 0; attempt < 50; attempt++) { for (int attempt = 0; attempt < 50; attempt++) {
invokeAmbient(spawn, chunk); invokeAmbient(spawn, chunk, false);
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity"); require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity");
} }
evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE)); evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE));
@@ -371,10 +597,10 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
return diagnostics.toString(); return diagnostics.toString();
} }
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk) { private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk, boolean initial) {
ambientInvocation.set(true); ambientInvocation.set(true);
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) { try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
spawn.method().invoke(spawn.spawner(), chunk, false, spawn.environment()); spawn.method().invoke(spawn.spawner(), chunk, initial, spawn.environment());
} catch (InvocationTargetException failure) { } catch (InvocationTargetException failure) {
throw new IllegalStateException("Iris ambient spawning failed", failure.getCause()); throw new IllegalStateException("Iris ambient spawning failed", failure.getCause());
} catch (ReflectiveOperationException failure) { } catch (ReflectiveOperationException failure) {
@@ -467,6 +693,9 @@ public final class NaturalSpawningFixture extends JavaPlugin implements Listener
} }
} }
private record CompletionRun(Engine engine, Object maintenance, Method run, Field pending, Runnable callback, AtomicInteger callbacks) {
}
private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) { private record RuntimeSpawn(Engine engine, Object spawner, Method method, BiomeEnvironment environment) {
} }
} }
@@ -0,0 +1,26 @@
{
"name": "Seeded population",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:grass_block"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"entitySpawners": [
"qa/seeded"
]
}
@@ -0,0 +1,29 @@
{
"type": "minecraft:husk",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false,
"customName": "Seeded husk",
"invulnerable": true,
"helmet": {
"type": "IRON_HELMET",
"minDurability": 0.1,
"maxDurability": 0.9
},
"mainHand": {
"type": "IRON_SWORD",
"minDurability": 0.1,
"maxDurability": 0.9,
"attributes": [
{
"name": "seeded_damage",
"attribute": "minecraft:attack_damage",
"minAmount": 1.25,
"maxAmount": 6.75,
"chance": 1
}
]
}
}
@@ -0,0 +1,29 @@
{
"type": "minecraft:zombie",
"reason": "NATURAL",
"surface": "LAND",
"ai": false,
"aware": false,
"gravity": false,
"customName": "Seeded zombie",
"invulnerable": true,
"helmet": {
"type": "IRON_HELMET",
"minDurability": 0.1,
"maxDurability": 0.9
},
"mainHand": {
"type": "IRON_SWORD",
"minDurability": 0.1,
"maxDurability": 0.9,
"attributes": [
{
"name": "seeded_damage",
"attribute": "minecraft:attack_damage",
"minAmount": 1.25,
"maxAmount": 6.75,
"chance": 1
}
]
}
}
@@ -0,0 +1,33 @@
{
"group": "NORMAL",
"maxEntitiesPerChunk": 4,
"allowedLightLevels": {
"min": 0,
"max": 0
},
"weather": "DOWNFALL",
"timeBlock": {
"startHour": 20,
"endHour": 3
},
"maximumRate": {
"amount": 1,
"per": {
"days": 1
}
},
"initialSpawns": [
{
"entity": "qa/seeded-zombie",
"rarity": 1,
"minSpawns": 2,
"maxSpawns": 4
},
{
"entity": "qa/seeded-husk",
"rarity": 2,
"minSpawns": 2,
"maxSpawns": 4
}
]
}
@@ -8,7 +8,8 @@ export default {
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response) context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
return response return response
} }
const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name) const entities = name => Object.values(context.bot.entities).filter(entity => entity.name === name
&& entity.position.x >= 0 && entity.position.x < 16 && entity.position.z >= 0 && entity.position.z < 16)
await context.step('prepare the synthetic Iris world and dark cave', async () => { await context.step('prepare the synthetic Iris world and dark cave', async () => {
await command('setup', 'SPAWN_QA_SETUP world=iris:spawn_qa seed=78264193', 180000) await command('setup', 'SPAWN_QA_SETUP world=iris:spawn_qa seed=78264193', 180000)
await wait(() => Math.abs(context.bot.entity.position.y - 67) < 1, 'Fixture world teleport') await wait(() => Math.abs(context.bot.entity.position.y - 67) < 1, 'Fixture world teleport')
@@ -0,0 +1,32 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:green_concrete"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 96
}
]
}
@@ -0,0 +1,28 @@
{
"name": "Noise Sources",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:red_concrete"
}
]
}
}
@@ -0,0 +1,3 @@
{
"expression": "0.5 + sin(x * 0.15) * 0.25 + cos(y * 0.13) * 0.25"
}
@@ -0,0 +1,22 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"expression": "terrain"
}
},
{
"seed": 311,
"style": {
"style": "CLOVER_HERMITE",
"zoom": 0.1
}
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,48 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
export default {
name: 'iris-noise',
description: 'Verify expression and interpolated noise produce varied, bounded terrain visible to a player.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Noise generation requires an isolated instance')
await context.step('create a world with expression and Clover noise', async () => {
const response = await context.command('/iris create name=noise_probe type=noise-fixture seed=78264193',
/Successfully created your world|Failed to create/, 180000)
context.expect(response.includes('Successfully created your world'), response)
})
await context.step('client observes bounded terrain with varying heights', async () => {
let columns = 0
let minimum = Infinity
let maximum = -Infinity
await context.waitUntil(() => {
columns = 0
minimum = Infinity
maximum = -Infinity
const center = context.bot.entity.position.floored()
for (let x = -8; x <= 8; x++) {
for (let z = -8; z <= 8; z++) {
let surface = null
for (let y = 60; y <= 100; y++) {
const position = center.offset(x, y - center.y, z)
const block = context.bot.blockAt(position)
if (!block) return false
if (block.name === 'lime_wool') {
if (surface !== null || context.bot.blockAt(position.offset(0, 1, 0))?.name !== 'air') return false
surface = y
}
}
if (surface === null || surface < 64 || surface > 96) return false
columns++
minimum = Math.min(minimum, surface)
maximum = Math.max(maximum, surface)
}
}
return columns === 289 && maximum - minimum >= 3
}, { label: '289 noise columns with bounded, varied surfaces', timeoutMs: 60000, intervalMs: 250 })
context.expect(columns === 289 && maximum - minimum >= 3, 'Client verified noise terrain', { columns, minimum, maximum })
context.report.noiseTerrain = { columns, minimum, maximum }
})
}
}
@@ -0,0 +1,24 @@
export default {
name: 'iris-seeded-entities',
description: 'Replay initial chunk and marker populations in reversed order, comparing positions and complete equipment data.',
async run(context) {
const command = async (operation, expected, timeoutMs) => {
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
}
await context.step('prepare a controlled Iris world', async () => {
await command('setup', 'SPAWN_QA_SETUP', 180000)
})
await context.step('replay initial mobs despite unrelated randomness and live spawn restrictions', async () => {
await command('seeded', 'SPAWN_QA_SEEDED chunks=2 markers=2 repeats=2 equipment=true attributes=true', 60000)
await context.waitUntil(() => Object.values(context.bot.entities).some(entity => entity.name === 'zombie' || entity.name === 'husk'), {
label: 'Generated mobs reached the client', timeoutMs: 10000, intervalMs: 100
})
context.report.seededEntities = { chunks: 2, markers: 2, repeats: 2, positions: true, equipmentBytes: true, attributeIds: true }
})
await context.step('retry a skipped initial pass and persist completion once', async () => {
await command('completion', 'SPAWN_QA_COMPLETION retry=true callbacks=1 persisted=true owned=true', 30000)
context.report.seededEntities.completionRetry = true
})
}
}
@@ -0,0 +1,45 @@
{
"name": "Flat",
"derivative": "minecraft:plains",
"vanillaDerivative": "minecraft:plains",
"layers": [
{
"minHeight": 1,
"maxHeight": 1,
"palette": [
{
"block": "minecraft:lime_wool"
}
]
},
{
"minHeight": 384,
"maxHeight": 384,
"palette": [
{
"block": "minecraft:stone"
}
]
}
],
"generators": [
{
"generator": "flat",
"min": 64,
"max": 64
}
],
"objects": [
{
"place": [
"object"
],
"chance": 1,
"density": 4,
"mode": "CENTER_HEIGHT",
"rotation": {
"enabled": false
}
}
]
}
@@ -0,0 +1,66 @@
{
"name": "Structure Fixture",
"version": 1,
"mode": {
"type": "OVERWORLD"
},
"regions": [
"flat"
],
"focusRegion": "flat",
"focus": "flat",
"landChance": 1,
"environment": "NORMAL",
"dimensionHeight": {
"min": -64,
"max": 320
},
"logicalHeight": 384,
"fluidHeight": 0,
"bedrock": false,
"rockPalette": {
"palette": [
{
"block": "minecraft:stone"
}
]
},
"structures": [
{
"structures": [
"surface"
],
"placementId": "surface",
"distribution": "DENSITY",
"density": 1,
"anchor": "SURFACE",
"terrain": {
"mode": "PRESERVE"
},
"stilt": {
"maxDepth": 16,
"palette": {
"palette": [
{
"block": "minecraft:diamond_block"
}
]
}
}
},
{
"structures": [
"buried"
],
"placementId": "buried",
"distribution": "DENSITY",
"density": 1,
"anchor": "HEIGHT_BAND",
"minHeight": 24,
"maxHeight": 24,
"terrain": {
"mode": "BORE"
}
}
]
}
@@ -0,0 +1,15 @@
{
"interpolator": {
"function": "NONE",
"horizontalScale": 1
},
"seed": 310,
"composite": [
{
"seed": 310,
"style": {
"style": "FLAT"
}
}
]
}
@@ -0,0 +1,8 @@
{
"object": "buried",
"connectors": [],
"rotatable": false,
"rules": {
"terminal": true
}
}
@@ -0,0 +1,8 @@
{
"object": "surface",
"connectors": [],
"rotatable": false,
"rules": {
"terminal": true
}
}
@@ -0,0 +1,8 @@
{
"pieces": [
{
"piece": "buried",
"weight": 1
}
]
}
@@ -0,0 +1,8 @@
{
"pieces": [
{
"piece": "surface",
"weight": 1
}
]
}
@@ -0,0 +1,12 @@
{
"name": "Flat",
"landBiomes": [
"flat"
],
"seaBiomes": [
"flat"
],
"shoreBiomes": [
"flat"
]
}
@@ -0,0 +1,6 @@
{
"startPool": "buried",
"maxDepth": 1,
"maxSizeChunks": 1,
"placeMode": "STRUCTURE_PIECE"
}
@@ -0,0 +1,6 @@
{
"startPool": "surface",
"maxDepth": 1,
"maxSizeChunks": 1,
"placeMode": "STRUCTURE_PIECE"
}
@@ -0,0 +1,52 @@
import { readFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
export default {
name: 'iris-structures',
description: 'Verify surface foundations, buried rooms, and waterlogged object blocks in generated chunks.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Structure generation requires an isolated instance')
const world = context.options.command ?? 'structure_probe'
context.expect(/^[a-z0-9_]+$/.test(world), 'Fixture world name must be a simple identifier')
await context.step('create the structure fixture world', async () => {
const response = await context.command(`/iris create name=${world} type=structure-fixture seed=78264193`,
/World ready|Failed to create/, 180000)
context.expect(response.includes('World ready'), response)
})
await context.step('observe structures and objects above and below terrain', async () => {
await context.waitUntil(() => {
if (Math.abs(context.bot.entity.position.x) > 32 || Math.abs(context.bot.entity.position.z) > 32) return false
for (let x = -16; x < 32; x += 16) {
for (let z = -16; z < 32; z += 16) {
for (let y = 16; y <= 80; y += 16) {
if (!context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))) return false
}
}
}
return true
}, { label: 'Complete structure fixture chunks', timeoutMs: 60000, intervalMs: 100 })
const digest = createHash('sha256')
const counts = { emerald_block: 0, diamond_block: 0, blue_concrete: 0, gold_block: 0, oak_slab: 0 }
for (let x = -16; x < 32; x++) {
for (let z = -16; z < 32; z++) {
for (let y = 16; y <= 80; y++) {
const block = context.bot.blockAt(context.bot.entity.position.clone().set(x, y, z))
context.expect(block !== null, 'Every sampled structure block must be loaded')
digest.update(`${block.stateId}\n`)
if (Object.hasOwn(counts, block.name)) counts[block.name]++
if (block.name === 'emerald_block') context.expect(y > 64, 'Surface platform must be above terrain')
if (block.name === 'blue_concrete') context.expect(y < 40, 'Buried room must remain underground')
if (block.name === 'oak_slab') context.expect(block.getProperties().waterlogged === false,
'Object slabs placed on dry ground must clear waterlogging')
}
}
}
for (const [block, count] of Object.entries(counts)) {
context.expect(count > 0, `Generated fixture must contain ${block}`, { count })
}
context.report.structures = { counts, hash: digest.digest('hex') }
})
}
}
@@ -0,0 +1,74 @@
import { readFile, stat } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import path from 'node:path'
export default {
name: 'iris-studio-open',
description: 'Measure repeated Studio entry and verify visible terrain, spectator mode, and world cleanup.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'Studio entry requires an isolated instance')
const match = /^([a-z0-9_-]+):([1-4])$/.exec(context.options.command ?? 'overworld:2')
context.expect(match, 'Expected pack:cycles with one to four cycles')
context.report.studioOpen = []
for (let cycle = 0; cycle < Number(match[2]); cycle++) {
await context.step(`open and close Studio ${cycle + 1}`, async () => {
const before = new Set((await context.observe()).worlds.map(world => world.id))
const start = Date.now()
let world
let center
await context.command(`/iris studio open ${match[1]} seed=1337`)
await context.waitUntil(async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
world = observation.worlds.find(entry => entry.id === player?.world)
if (!world || before.has(world.id) || !/iris-[0-9a-f-]{36}$/.test(world.name)) return false
center = context.bot.entity.position.floored()
return world.loadedChunks > 0 && context.bot.blockAt(center) !== null && context.bot.game.gameMode === 'spectator'
}, { label: 'Studio arrival with terrain and spectator mode', timeoutMs: 180000, intervalMs: 100 })
const arrivalMs = Date.now() - start
context.expect(Number.isInteger(context.bot.game.minY) && Number.isInteger(context.bot.game.height),
'Studio dimension must report its vertical range')
await context.waitUntil(() => {
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y += 16) {
if (context.bot.blockAt(center.offset(x, y - center.y, z)) === null) return false
}
}
}
return true
}, { label: 'Complete neighboring Studio columns', timeoutMs: 60000, intervalMs: 100 })
const digest = createHash('sha256')
let solid = 0
for (let x = -1; x <= 1; x++) {
for (let z = -1; z <= 1; z++) {
for (let y = context.bot.game.minY; y < context.bot.game.minY + context.bot.game.height; y++) {
const block = context.bot.blockAt(center.offset(x, y - center.y, z))
context.expect(block !== null, 'Studio entry column must be available')
digest.update(`${block.name}\n`)
if (!['air', 'cave_air', 'void_air', 'water', 'lava'].includes(block.name)) solid++
}
}
}
context.expect(solid > 0, 'Studio entry must contain solid terrain')
context.report.studioOpen.push({ cycle, arrivalMs, world: world.name, x: center.x, z: center.z,
terrainHash: digest.digest('hex'), solid })
const closed = await context.command('/iris studio close', /^Studio closed\.$|Studio close failed:/, 180000)
context.expect(closed === 'Studio closed.', closed)
await context.waitUntil(async () => !(await context.observe()).worlds.some(entry => entry.id === world.id),
{ label: 'Studio world unload', timeoutMs: 90000, intervalMs: 100 })
const transientName = /iris-[0-9a-f-]{36}$/.exec(world.name)[0]
const properties = await readFile(path.join(context.server.directory, 'server.properties'), 'utf8')
const level = /^level-name=(.+)$/m.exec(properties)?.[1].trim() ?? 'world'
const directory = path.join(context.server.directory, level, 'dimensions', 'iris', transientName)
await context.waitUntil(async () => {
try { await stat(directory); return false } catch (error) {
if (error.code === 'ENOENT') return true
throw error
}
}, { label: 'Studio directory cleanup', timeoutMs: 90000, intervalMs: 100 })
})
}
}
}
@@ -0,0 +1,98 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
const worldName = 'lifecycle_probe'
const worldKey = `iris:${worldName}`
const storageName = `world_iris_${worldName}`
export default {
name: 'iris-world-lifecycle',
description: 'Create, enter, evacuate, reload, and remove an Iris world through the player commands.',
async run(context) {
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
context.expect(/^isolated=true\s*$/m.test(source), 'World lifecycle requires an isolated instance')
const wait = (predicate, label, timeoutMs = 180000) => context.waitUntil(predicate, {
label, timeoutMs, intervalMs: 250
})
const playerWorld = async () => {
const observation = await context.observe()
const player = observation.players.find(entry => entry.username === context.bot.username)
return observation.worlds.find(entry => entry.id === player?.world)?.name
}
const status = async () => {
const response = await context.command('/irislifecycle lifecycle-status', /IRIS_LIFECYCLE |NATIVE_FAILED/, 10000)
context.expect(!response.includes('NATIVE_FAILED'), response)
return response
}
const settledStatus = async expected => {
const response = await status()
return response.includes(expected) && response.includes('idle=true')
}
const assertEvacuated = async label => {
let destination
await wait(async () => {
destination = await playerWorld()
return Boolean(destination && destination !== storageName)
}, label, 30000)
context.expect(destination && destination !== storageName, label, { destination })
}
const assertSafeEntry = async () => {
await wait(async () => (await playerWorld()) === storageName, 'Player entered the lifecycle world')
await wait(() => {
const feet = context.bot.entity.position.floored()
const floor = context.bot.blockAt(feet.offset(0, -1, 0))
const body = context.bot.blockAt(feet)
const head = context.bot.blockAt(feet.offset(0, 1, 0))
return context.bot.entity.onGround && floor?.name === 'lime_wool'
&& body?.boundingBox === 'empty' && head?.boundingBox === 'empty'
}, 'Client sees solid terrain below unobstructed player entry', 30000)
const response = await context.command('/irislifecycle lifecycle-entry', /IRIS_LIFECYCLE_ENTRY|NATIVE_FAILED/, 10000)
context.expect(response.includes(`IRIS_LIFECYCLE_ENTRY world=${worldKey} seed=78264193 floor=lime_wool safe=true`), response)
}
await context.step('confirm disposable world is absent', async () => {
const response = await status()
context.expect(response.includes('loaded=false registered=false configured=false disk=false quarantines=0 cleanup=deleted idle=true'), response)
await context.command('/minecraft:gamemode survival @s')
await wait(() => context.bot.game.gameMode === 'survival', 'Survival mode', 10000)
})
await context.step('create through Iris and automatically enter safe terrain', async () => {
await context.command(`/iris create name=${worldName} type=native-terrain seed=78264193`)
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'Created world registration and storage')
await assertSafeEntry()
})
await context.step('unload while the player is inside and verify evacuation', async () => {
context.expect((await playerWorld()) === storageName, 'Player must be inside the world before unload')
await context.command(`/iris unloadWorld ${worldKey}`)
await wait(async () => await settledStatus('loaded=false registered=true configured=true disk=true'), 'World unloaded with storage preserved')
await assertEvacuated('Unload evacuated the player to a loaded world')
})
await context.step('reload the saved world and enter it again', async () => {
await context.command(`/iris loadWorld ${worldName}`)
await wait(async () => await settledStatus('loaded=true registered=true configured=true disk=true'), 'World reloaded')
await context.command(`/iris tp ${worldKey}`)
await assertSafeEntry()
})
await context.step('remove the occupied world and verify disk cleanup', async () => {
await context.command(`/iris remove world=${worldName} delete=true`)
let finalStatus
await wait(async () => {
finalStatus = await status()
return finalStatus.includes('loaded=false registered=false configured=false disk=false')
&& /cleanup=(deleted|queued)\b/.test(finalStatus) && finalStatus.includes('idle=true')
}, 'Removal detached registrations and deleted or queued quarantine')
await assertEvacuated('Removal evacuated the player to a loaded world')
context.report.worldLifecycle = { world: worldKey, cleanup: /cleanup=(deleted|queued)\b/.exec(finalStatus)[1] }
})
if (process.env.IRIS_QA_PACK_URL) {
await context.step('download and overwrite the fixture pack through Iris', async () => {
const response = await context.command(
`/iris download link=${process.env.IRIS_QA_PACK_URL} overwrite=true`,
/Iris pack 'native-terrain' installed|Iris pack download failed\./,
120000
)
context.expect(response.includes("Iris pack 'native-terrain' installed"), response)
})
}
}
}
@@ -0,0 +1,126 @@
package art.arcane.iris.command;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.Iris;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
import java.lang.reflect.Method;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
public class CommandPackValidationPublicationTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidation() {
PackValidationRegistry.clear();
}
@Test
public void changedCachedPackCannotBePersistedAsFreshValidation() throws Exception {
File packs = temporaryFolder.newFolder("packs");
File root = new File(packs, "terrain");
Files.createDirectories(root.toPath());
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
File cacheFile = new File(temporaryFolder.getRoot(), "validation-cache.json");
Iris previousPlugin = Iris.instance;
Iris plugin = mock(Iris.class);
Iris.instance = plugin;
when(plugin.getDataFile("cache", "pack-validation.json")).thenReturn(cacheFile);
try (MockedStatic<Iris> iris = mockStatic(Iris.class);
MockedStatic<PackValidationCache> cache = mockStatic(PackValidationCache.class, CALLS_REAL_METHODS)) {
cache.when(PackValidationCache::contextFingerprint).thenReturn("context");
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
PackValidationRegistry.publish(valid);
PackValidationRegistry.publish(root.toPath(), valid,
PackFingerprints.computePackTreeFingerprint(root), "context");
Files.writeString(content.toPath(), "changed");
Method persist = CommandPack.class.getDeclaredMethod("persistValidationCache", File.class);
persist.setAccessible(true);
persist.invoke(new CommandPack(), packs);
assertFalse(cacheFile.exists());
Files.writeString(content.toPath(), "original");
persist.invoke(new CommandPack(), packs);
assertTrue(cacheFile.exists());
assertTrue(PackValidationCache.load(cacheFile.toPath(),
PackFingerprints.computePackContentSnapshot(packs).content(), "context",
List.of(root.getName())).orElseThrow().getFirst().isLoadable());
} finally {
Iris.instance = previousPlugin;
}
}
@Test
public void commandPublishesCanonicalRootValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
PackValidationResult valid = new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenReturn(valid);
assertSame(valid, new CommandPack().runValidate(mock(VolmitSender.class), root));
assertSame(valid, PackValidationRegistry.get(root.toPath()));
assertSame(valid, PackValidationRegistry.get(root.getName()));
}
}
@Test
public void mutationDuringValidationCannotRepublishStaleResult() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
}
});
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
assertNull(PackValidationRegistry.get(root.toPath()));
assertNull(PackValidationRegistry.get(root.getName()));
}
}
@Test
public void externalEditDuringValidationPublishesBlockedResult() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<Iris> iris = mockStatic(Iris.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
Files.writeString(content.toPath(), "changed");
return new PackValidationResult(root.getName(), List.of(), List.of(), 1L);
});
assertFalse(new CommandPack().runValidate(mock(VolmitSender.class), root).isLoadable());
assertFalse(PackValidationRegistry.get(root.toPath()).isLoadable());
}
}
}
+1 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.0-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.1-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5')) String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.5'))
Closure<String> irisArtifactName = { String platform, String targetVersion -> Closure<String> irisArtifactName = { String platform, String targetVersion ->
+1 -1
View File
@@ -56,7 +56,7 @@ apply from: file('../../gradle/volmlib-stamp.gradle')
Properties rootProperties = new Properties() Properties rootProperties = new Properties()
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) } file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.0-26.2')) String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.3.1-26.2'))
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3')) String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.3'))
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8')) String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.3-66.0.8'))
Closure<String> loaderDisplayVersion = { String loaderVersion -> Closure<String> loaderDisplayVersion = { String loaderVersion ->
@@ -195,8 +195,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
if (!nativeGenerator.represents(level)) { if (!nativeGenerator.represents(level)) {
throw new IllegalArgumentException("NativeWorld does not use Iris generator '" + dimensionKey + "'"); throw new IllegalArgumentException("NativeWorld does not use Iris generator '" + dimensionKey + "'");
} }
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
Engine activeEngine = engineIfBound(); Engine activeEngine = engineIfBound();
Engine replacement = ModdedWorldEngines.prepareReplacement( Engine replacement = ModdedWorldEngines.prepareReplacement(
level, level,
@@ -462,13 +460,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
// A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the // A recorded failure stands until repoint or unbind resets the binding; re-running the bind would repeat the
// history verification on every chunk call and fail the same way. // history verification on every chunk call and fail the same way.
engineBinding.throwIfFailed(dimensionKey); engineBinding.throwIfFailed(dimensionKey);
try {
requireGlobalStructureGeneration(
NativeModdedServer.forWorld(level).generateStructures(), dimensionKey);
} catch (RuntimeException error) {
recordBindFailure(level, error);
throw error;
}
// Cache the owning level so hot paths never scan the level map to find themselves. // Cache the owning level so hot paths never scan the level map to find themselves.
boundLevel = level; boundLevel = level;
Engine cached = engine; Engine cached = engine;
@@ -575,29 +566,6 @@ public final class IrisModdedChunkGenerator implements NativeGeneratorOwner, Nat
} }
} }
static void requireGlobalStructureGeneration(boolean enabled, String dimensionKey) {
if (enabled) {
return;
}
String remedy = integratedEnvironment()
? "enable 'Generate Structures' for this world; Iris requires it, then deny families through "
+ "importedStructures.disabled or complete keys through importedStructures.disabledExact"
: "set generate-structures=true in server.properties, restart the server, "
+ "then deny families through importedStructures.disabled or complete keys through "
+ "importedStructures.disabledExact";
throw new IllegalStateException("Iris generator '" + dimensionKey
+ "' cannot bind while generate-structures=false; " + remedy);
}
private static boolean integratedEnvironment() {
try {
return ModdedEngineBootstrap.loader().clientEnvironment();
} catch (Throwable e) {
// No loader bound (unit tests, very early boot): assume dedicated wording.
return false;
}
}
/** /**
* Null only while no level uses this generator (world creation screens, codec validation). A bound level whose * Null only while no level uses this generator (world creation screens, codec validation). A bound level whose
* engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator. * engine is refused, unloading or stopping throws, so no stage can mistake it for an unbound generator.
@@ -325,7 +325,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ, private H getNoiseBiome(Engine engine, int quartX, int quartY, int quartZ,
S sampler) { S sampler) {
if (isGuaranteedSurfaceBiome(quartY, engine.getMinHeight())) { if (isGuaranteedSurfaceBiome(engine, quartY)) {
return getSurfaceStructureBiome(engine, quartX, quartZ, sampler); return getSurfaceStructureBiome(engine, quartX, quartZ, sampler);
} }
boolean cacheable = isBiomeCacheable( boolean cacheable = isBiomeCacheable(
@@ -512,7 +512,7 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
} }
boolean monumentQuery = isMonumentSurfaceBiomeQuery( boolean monumentQuery = isMonumentSurfaceBiomeQuery(
y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight()); y, radius, engine.getMinHeight(), engine.getDimension().getFluidHeight());
surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(minQuartY, engine.getMinHeight()); surfaceQuery = monumentQuery || isGuaranteedSurfaceBiome(engine, minQuartY);
} }
if (!surfaceQuery) { if (!surfaceQuery) {
return query.fallback().get(); return query.fallback().get();
@@ -701,6 +701,11 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
int blockZ = quartZ << 2; int blockZ = quartZ << 2;
int internalY = blockY - engine.getMinHeight(); int internalY = blockY - engine.getMinHeight();
int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40); int caveSwitchY = Math.max(-8 - engine.getMinHeight(), 40);
IrisBiome featureBiome = engine.getSubterrainBiome(blockX, internalY, blockZ);
if (featureBiome != null) {
return createBiomeResolution(engine, featureBiome, true, engine.getDimension(), engine.getData(),
blockX, blockY, blockZ);
}
DimensionStackLayout.Layer stackLayer = includeDimensionStack DimensionStackLayout.Layer stackLayer = includeDimensionStack
? stackLayout == null ? stackLayout == null
? resolveDimensionStackLayer(engine, blockX, internalY, blockZ) ? resolveDimensionStackLayer(engine, blockX, internalY, blockZ)
@@ -846,7 +851,13 @@ final class ModdedBiomePolicy<H, S> implements NativeModdedBiomePolicy<H, S> {
return configuredBiomeKeys; return configuredBiomeKeys;
} }
static boolean isGuaranteedSurfaceBiome(int quartY, int minHeight) { static boolean isGuaranteedSurfaceBiome(Engine engine, int quartY) {
IrisDimension dimension = engine.getDimension();
if (dimension != null && dimension.allowsSubterrainFeatures()
&& !dimension.getSubterrainFeatures().isEmpty()) {
return false;
}
int minHeight = engine.getMinHeight();
int internalY = (quartY << 2) - minHeight; int internalY = (quartY << 2) - minHeight;
int caveSwitchY = Math.max(-8 - minHeight, 40); int caveSwitchY = Math.max(-8 - minHeight, 40);
return internalY > caveSwitchY; return internalY > caveSwitchY;
@@ -50,7 +50,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi
int xx = rX + dx; int xx = rX + dx;
int yy = y + dy; int yy = y + dy;
int zz = rZ + dz; int zz = rZ + dz;
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight() return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy >= hunk.getHeight()
? null : hunk.get(xx, yy, zz); ? null : hunk.get(xx, yy, zz);
} }
} }
@@ -70,6 +70,7 @@ import art.arcane.iris.modded.service.ModdedEngineMaintenanceService;
import art.arcane.iris.modded.service.ModdedEntitySpawnService; import art.arcane.iris.modded.service.ModdedEntitySpawnService;
import art.arcane.iris.modded.service.ModdedLogFilterService; import art.arcane.iris.modded.service.ModdedLogFilterService;
import art.arcane.iris.modded.service.ModdedPreservationService; import art.arcane.iris.modded.service.ModdedPreservationService;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.service.ModdedSettingsHotloadService; import art.arcane.iris.modded.service.ModdedSettingsHotloadService;
import art.arcane.iris.modded.service.ModdedStudioHotloadService; import art.arcane.iris.modded.service.ModdedStudioHotloadService;
import art.arcane.iris.modded.service.ModdedTreeFellerService; import art.arcane.iris.modded.service.ModdedTreeFellerService;
@@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap {
createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService()); createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService());
createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService()); createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService());
createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService()); createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService());
createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService());
ModdedStudioHotloadService studioHotloadService = createdServices.register( ModdedStudioHotloadService studioHotloadService = createdServices.register(
ModdedStudioHotloadService.class, new ModdedStudioHotloadService()); ModdedStudioHotloadService.class, new ModdedStudioHotloadService());
createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService()); createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService());
@@ -27,7 +27,6 @@ import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/** /**
* Owns the Iris generation pool and the single decision of whether this loader already runs chunk * Owns the Iris generation pool and the single decision of whether this loader already runs chunk
@@ -43,7 +42,7 @@ public final class ModdedGenPool {
private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L; private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L;
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger(); private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem(); private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool()); private static ExecutorService generationPool = createGenPool();
private ModdedGenPool() { private ModdedGenPool() {
} }
@@ -63,39 +62,32 @@ public final class ModdedGenPool {
return CHUNK_SYSTEM.description(); return CHUNK_SYSTEM.description();
} }
static ExecutorService pool() { static synchronized ExecutorService pool() {
ExecutorService pool = GEN_POOL.get(); if (generationPool.isShutdown()) {
if (pool != null && !pool.isShutdown()) {
return pool;
}
start();
ExecutorService restarted = GEN_POOL.get();
if (restarted == null) {
throw new RejectedExecutionException("Iris gen pool is shut down"); throw new RejectedExecutionException("Iris gen pool is shut down");
} }
return restarted; return generationPool;
} }
static void start() { static synchronized void start() {
while (true) { if (!generationPool.isShutdown()) {
ExecutorService current = GEN_POOL.get(); return;
if (current != null && !current.isShutdown()) {
return;
}
ExecutorService created = createGenPool();
if (GEN_POOL.compareAndSet(current, created)) {
return;
}
created.shutdownNow();
} }
if (!generationPool.isTerminated()) {
throw new IllegalStateException("Iris gen pool cannot restart while prior workers are still active");
}
generationPool = createGenPool();
} }
static void shutdown() { static void shutdown() {
ExecutorService pool = GEN_POOL.getAndSet(null); ExecutorService pool;
if (pool == null) { synchronized (ModdedGenPool.class) {
return; pool = generationPool;
if (pool.isTerminated()) {
return;
}
pool.shutdown();
} }
pool.shutdown();
try { try {
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) { if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
return; return;
@@ -6,10 +6,9 @@ import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackValidationCache; import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.world.history.GenerationEpoch; import art.arcane.iris.world.history.GenerationEpoch;
import art.arcane.iris.world.history.GenerationEpochContractFactory; import art.arcane.iris.world.history.GenerationEpochContractFactory;
import art.arcane.iris.world.history.GenerationHistory; import art.arcane.iris.world.history.GenerationHistory;
@@ -174,7 +173,7 @@ final class ModdedGenerationHistoryStorage {
static ActivePack resolveActive(GenerationHistory history) throws IOException { static ActivePack resolveActive(GenerationHistory history) throws IOException {
GenerationEpoch epoch = history.activeEpoch(); GenerationEpoch epoch = history.activeEpoch();
Path packRoot = normalize(history.activePackRoot()); Path packRoot = normalize(history.activePackRoot());
validatePack(packRoot, epoch.packFingerprint()); validatePack(packRoot);
validateDimension(packRoot, epoch); validateDimension(packRoot, epoch);
return new ActivePack( return new ActivePack(
packRoot, packRoot,
@@ -184,13 +183,16 @@ final class ModdedGenerationHistoryStorage {
); );
} }
private static void validatePack(Path packRoot, String fingerprint) { static PackValidationResult validatePack(Path packRoot) {
String contextFingerprint = PackValidationCache.contextFingerprint(); PackValidationResult validation = PackValidationRegistry.getMatching(
PackValidationResult validation = PackValidator.validate(packRoot.toFile()); packRoot, PackFingerprints.computePackTreeFingerprint(packRoot.toFile()));
PackValidationRegistry.publish(packRoot, validation, fingerprint, contextFingerprint); if (validation == null) {
validation = ModdedStartup.validatePack(packRoot.toFile());
}
if (!validation.isLoadable()) { if (!validation.isLoadable()) {
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors()); throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
} }
return validation;
} }
private static void validateDimension( private static void validateDimension(
@@ -20,13 +20,11 @@ package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.diagnostics.splash.IrisSplashComposer; import art.arcane.iris.diagnostics.splash.IrisSplashComposer;
import art.arcane.iris.diagnostics.splash.IrisSplashRenderer; import art.arcane.iris.diagnostics.splash.IrisSplashRenderer;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import java.io.File;
public final class ModdedIrisSplash { public final class ModdedIrisSplash {
@@ -34,18 +32,11 @@ public final class ModdedIrisSplash {
} }
public static void print(NativeModdedLoader loader) { public static void print(NativeModdedLoader loader) {
printPacks(loader);
if (isLogoEnabled()) { if (isLogoEnabled()) {
printLogo(loader); printLogo(loader);
} }
} }
private static void printPacks(NativeModdedLoader loader) {
File packFolder = IrisPlatforms.get().packsFolderNoCreate();
IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)
.thenAccept(lines -> lines.forEach(IrisLogging::info));
}
private static void printLogo(NativeModdedLoader loader) { private static void printLogo(NativeModdedLoader loader) {
String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion(); String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion();
String[] splash = IrisSplashRenderer.renderPlain(); String[] splash = IrisSplashRenderer.renderPlain();
@@ -45,6 +45,9 @@ public final class ModdedMixinAudit {
private static final AtomicBoolean AUDITED = new AtomicBoolean(false); private static final AtomicBoolean AUDITED = new AtomicBoolean(false);
static final List<ExpectedMixin> EXPECTED = List.of( static final List<ExpectedMixin> EXPECTED = List.of(
new ExpectedMixin(ENTITY_CONFIG, "RegistryClientNamesMixin",
NativeMixinTarget.REGISTRY_PACKET, "volmlib$clientRegistryNames",
false, NativeMixinFlags::registryClientNamesRan),
new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin", new ExpectedMixin(ENTITY_CONFIG, "EntityPersistenceMixin",
NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence", NativeMixinTarget.ENTITY, "iris$applyGeneratedPersistence",
false, NativeMixinFlags::entityPersistenceRan), false, NativeMixinFlags::entityPersistenceRan),
@@ -108,7 +111,7 @@ public final class ModdedMixinAudit {
ModdedIrisLog.error(" missing: {}", entry); ModdedIrisLog.error(" missing: {}", entry);
} }
ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute)."); ModdedIrisLog.error("The mixin config was not registered for this loader (fabric.mod.json mixins, neoforge.mods.toml [[mixins]], forge MixinConfigs manifest attribute).");
ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, or Iris world-type labels are disabled until this is fixed."); ModdedIrisLog.error("Entity persistence, custom mob loot, parallel structure safety, readable biome identifiers, or Iris world-type labels are disabled until this is fixed.");
ModdedIrisLog.error("==============================================================="); ModdedIrisLog.error("===============================================================");
} }
@@ -122,17 +122,6 @@ final class ModdedNativeStructurePolicy implements NativeModdedStructureStage.Po
return ((IrisMaterialPalette) source).get(rng, x, y, z, generator.engine().getData()); return ((IrisMaterialPalette) source).get(rng, x, y, z, generator.engine().getData());
} }
@Override
public RuntimeException structuresDisabled(int chunkX, int chunkZ) {
return new IllegalStateException("Iris cannot generate native structures in chunk "
+ chunkX + "," + chunkZ
+ " because generate-structures=false disables them outside the pack. That flag is fixed when "
+ "the world is created (server.properties generate-structures, or the Generate Structures "
+ "toggle in singleplayer), so it cannot be changed for this world: create a new world with "
+ "structures enabled, then deny families through importedStructures.disabled or complete keys "
+ "through importedStructures.disabledExact");
}
@Override @Override
public String generationLabel(String structureId) { public String generationLabel(String structureId) {
return "Iris native structure " + structureId; return "Iris native structure " + structureId;
@@ -27,16 +27,15 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
public final class ModdedPrimaryWorldRouter { public final class ModdedPrimaryWorldRouter {
private static final int TICK_INTERVAL = 20; private static final int TICK_INTERVAL = 20;
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet(); private static final ConcurrentMap<UUID, CompletableFuture<Boolean>> routes = new ConcurrentHashMap<>();
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
private static int tickCounter = 0; private static int tickCounter = 0;
private static volatile String loginRefusal; private static volatile String loginRefusal;
private static volatile String absentPrimary; private static volatile String absentPrimary;
@@ -45,8 +44,12 @@ public final class ModdedPrimaryWorldRouter {
} }
public static void clear() { public static void clear() {
routed.clear(); routes.forEach((id, route) -> {
inFlight.clear(); if (routes.remove(id, route)) {
route.cancel(false);
}
});
tickCounter = 0;
loginRefusal = null; loginRefusal = null;
absentPrimary = null; absentPrimary = null;
} }
@@ -57,8 +60,10 @@ public final class ModdedPrimaryWorldRouter {
*/ */
public static void forget(UUID player) { public static void forget(UUID player) {
if (player != null) { if (player != null) {
routed.remove(player); CompletableFuture<Boolean> route = routes.remove(player);
inFlight.remove(player); if (route != null) {
route.cancel(false);
}
} }
} }
@@ -110,36 +115,52 @@ public final class ModdedPrimaryWorldRouter {
return; return;
} }
server.forEachPlayer(player -> { server.forEachPlayer(player -> routePlayer(server, player, overworld, target, primary));
UUID id = player.id(); }
if (routed.contains(id) || !inFlight.add(id)) {
return; private static void routePlayer(NativeModdedServer server, NativeProtocolPlayer player,
NativeWorld overworld, NativeWorld target, String primary) {
UUID id = player.id();
if (routes.containsKey(id)) {
return;
}
CompletableFuture<Boolean> route = new CompletableFuture<>();
if (routes.putIfAbsent(id, route) != null) {
return;
}
route.whenComplete((success, failure) -> {
if (!Boolean.TRUE.equals(success)) {
routes.remove(id, route);
} }
if (!player.isInWorld(overworld)) { });
inFlight.remove(id); if (!player.isInWorld(overworld)) {
routed.add(id); route.complete(true);
return; return;
} }
try { try {
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player, CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(), new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
ModdedTeleportDeadline.fromNow())); ModdedTeleportDeadline.fromNow()));
teleport.whenComplete((success, failure) -> { route.whenComplete((success, failure) -> {
inFlight.remove(id); if (!Boolean.TRUE.equals(success) && !teleport.isDone()) {
if (Boolean.TRUE.equals(success) && failure == null) { teleport.cancel(false);
routed.add(id); }
return; });
} teleport.whenComplete((success, failure) -> {
if (failure != null) { if (failure != null) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'",
id, primary, failure); id, primary, failure);
} }
}); } else {
} catch (Throwable e) { route.complete(Boolean.TRUE.equals(success));
inFlight.remove(id); }
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e); });
} catch (Throwable failure) {
if (route.completeExceptionally(failure)) {
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, failure);
} }
}); }
} }
/** /**
@@ -31,6 +31,7 @@ import art.arcane.iris.platform.protocol.IrisVisionRequestService;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.protocol.IrisProtocol; import art.arcane.iris.spi.protocol.IrisProtocol;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
@@ -128,6 +129,10 @@ public final class ModdedProtocolHandler {
} }
String sessionId = sessionId(player); String sessionId = sessionId(player);
ModdedStartup.warnStartupFailuresTo(player); ModdedStartup.warnStartupFailuresTo(player);
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (packUpdates != null) {
packUpdates.notifyPlayer(player);
}
IrisSessionRegistry current = registry; IrisSessionRegistry current = registry;
ModdedProtocolTransport currentTransport = transport; ModdedProtocolTransport currentTransport = transport;
if (current == null || currentTransport == null) { if (current == null || currentTransport == null) {
@@ -18,10 +18,13 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.pack.PackFingerprints;
import art.arcane.iris.pack.BrokenPackException; import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackDirectoryResolver; import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackDownloader; import art.arcane.iris.pack.PackDownloader;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator; import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.command.ModdedPackCommands; import art.arcane.iris.modded.command.ModdedPackCommands;
@@ -31,15 +34,11 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
public final class ModdedStartup { public final class ModdedStartup {
private static final int COMPAT_BOOT_KEY_CAP = 3; private static final int COMPAT_BOOT_KEY_CAP = 3;
@@ -60,36 +59,9 @@ public final class ModdedStartup {
if (!PREPARED.compareAndSet(false, true)) { if (!PREPARED.compareAndSet(false, true)) {
return; return;
} }
reportLegacyPacksDirectory();
validateAllPacks(); validateAllPacks();
} }
/**
* Older builds mkdir'd (and stale guidance sometimes populated) config/iris/packs, but modded
* packs live under config/irisworldgen/packs. Never auto-move user content: warn loudly when
* the legacy directory holds packs, and quietly remove it when it is empty.
*/
private static void reportLegacyPacksDirectory() {
try {
File legacy = ModdedEngineBootstrap.loader().configDir().resolve("iris").resolve("packs").toFile();
if (!legacy.isDirectory()) {
return;
}
if (!PackDirectoryResolver.listVisiblePackDirectories(legacy).isEmpty()) {
File real = art.arcane.iris.spi.IrisPlatforms.get().packsFolderNoCreate();
ModdedIrisLog.warn("Iris found packs under the legacy directory {} - modded packs load from {} only. Move them there.",
legacy.getAbsolutePath(), real.getAbsolutePath());
return;
}
String[] entries = legacy.list();
if (entries == null || entries.length == 0) {
legacy.delete();
}
} catch (Throwable e) {
ModdedIrisLog.debug("Iris legacy packs directory check failed", e);
}
}
/** /**
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler: * Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler:
* ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this * ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
@@ -140,8 +112,7 @@ public final class ModdedStartup {
} }
for (File packDir : packDirs) { for (File packDir : packDirs) {
try { try {
PackValidationResult result = PackValidator.validate(packDir); PackValidationResult result = validatePack(packDir);
PackValidationRegistry.publish(result);
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
String compatSummary = PackValidator.compatSummary(result, minecraftVersion); String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary; String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
@@ -184,16 +155,15 @@ public final class ModdedStartup {
throw new BrokenPackException(pack, List.of( throw new BrokenPackException(pack, List.of(
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + ".")); "Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
} }
PackValidationResult cached = PackValidationRegistry.get(pack); Path packRoot = packDir.toPath();
if (cached != null && cached.getValidatedAtMillis() >= newestModificationMillis(packDir.toPath())) {
// prepareForStartup already validated this pack and nothing in it changed since; re-validating per
// persistent dimension at boot costs a full pack parse each time.
return PackValidationRegistry.requireLoadable(pack);
}
try { try {
PackValidationResult result = PackValidator.validate(packDir); PackValidationResult cached = PackValidationRegistry.getMatching(
PackValidationRegistry.publish(result); packRoot, PackFingerprints.computePackTreeFingerprint(packDir));
return PackValidationRegistry.requireLoadable(pack); if (cached != null) {
return PackValidationRegistry.requireLoadable(packRoot);
}
validatePack(packDir);
return PackValidationRegistry.requireLoadable(packRoot);
} catch (BrokenPackException e) { } catch (BrokenPackException e) {
throw e; throw e;
} catch (Throwable e) { } catch (Throwable e) {
@@ -212,6 +182,42 @@ public final class ModdedStartup {
} }
} }
public static PackValidationResult validatePack(File packDir) {
Path packRoot = packDir.toPath();
PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(packRoot);
if (ticket == null) {
throw new BrokenPackException(packDir.getName(), List.of("Pack content is currently being changed."));
}
String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packDir);
String contextFingerprint = PackValidationCache.contextFingerprint();
PackValidationResult result;
try {
result = PackValidator.validate(packDir);
} catch (Throwable failure) {
ModdedIrisLog.error("Iris pack validation failed for '{}'", packDir.getName(), failure);
String detail = failure.getMessage();
if (detail == null || detail.isBlank()) {
detail = failure.getClass().getSimpleName();
}
result = new PackValidationResult(
packDir.getName(),
List.of("Pack validation failed with " + failure.getClass().getSimpleName() + ": " + detail),
List.of(),
System.currentTimeMillis());
}
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packDir))
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
PackValidationRegistry.remove(packRoot);
PackValidationRegistry.remove(packDir.getName());
throw new BrokenPackException(packDir.getName(), List.of("Pack content or validation context changed during validation."));
}
if (!PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
throw new BrokenPackException(packDir.getName(), List.of("Pack content changed during validation."));
}
PackValidationRegistry.publish(result);
return result;
}
static void reinjectPersistentDimensions(NativeModdedServer server) { static void reinjectPersistentDimensions(NativeModdedServer server) {
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions = List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
ModdedDimensionRegistryStore.loadForStartup(server); ModdedDimensionRegistryStore.loadForStartup(server);
@@ -266,23 +272,6 @@ public final class ModdedStartup {
+ GenerationRefusalNotice.summary(failure)); + GenerationRefusalNotice.summary(failure));
} }
private static long newestModificationMillis(Path root) {
long newest = 0L;
try (Stream<Path> walk = Files.walk(root)) {
for (Path path : (Iterable<Path>) walk::iterator) {
BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);
long modified = attributes.lastModifiedTime().toMillis();
if (modified > newest) {
newest = modified;
}
}
} catch (IOException | RuntimeException unreadable) {
ModdedIrisLog.debug("Iris could not stat {} for validation reuse; revalidating", root, unreadable);
return Long.MAX_VALUE;
}
return newest;
}
/** /**
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime * True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
* dimension that is not loaded yet is still on its way. * dimension that is not loaded yet is still on its way.
@@ -296,7 +285,7 @@ public final class ModdedStartup {
* the console. Tell the operators who can actually act on it when they join. * the console. Tell the operators who can actually act on it when they join.
*/ */
public static void warnStartupFailuresTo(NativeProtocolPlayer player) { public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
if (player == null || !player.isGameMaster()) { if (player == null || (!player.isGameMaster() && !player.isServerOwner())) {
return; return;
} }
for (String failure : DIMENSION_FAILURES) { for (String failure : DIMENSION_FAILURES) {
@@ -44,6 +44,8 @@ import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.pack.value.IrisRange; import art.arcane.iris.pack.value.IrisRange;
import art.arcane.iris.world.entity.IrisSpawnGroup; import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisSpawner; import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.generation.decoration.IrisSurface; import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
@@ -56,11 +58,13 @@ import art.arcane.volmlib.util.matter.MatterMarker;
import art.arcane.volmlib.util.matter.slices.MarkerMatter; import art.arcane.volmlib.util.matter.slices.MarkerMatter;
import java.util.Map; import java.util.Map;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.Comparator;
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue; import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -235,6 +239,10 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
boolean initialSpawnChunk(NativeWorld level, int chunkX, int chunkZ) { boolean initialSpawnChunk(NativeWorld level, int chunkX, int chunkZ) {
if (!isEntitySpawningEnabledForCurrentWorld()
|| !NativeSpawnQueries.ambientAllowed(level, chunkX, chunkZ, true)) {
return false;
}
if (!ModdedEntitySpawner.chunksSafe(entityRuntime(level), chunkX, chunkZ)) { if (!ModdedEntitySpawner.chunksSafe(entityRuntime(level), chunkX, chunkZ)) {
return false; return false;
} }
@@ -249,54 +257,25 @@ public final class ModdedWorldManager implements EngineWorldManager {
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ); MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try { try {
List<PreparedMarkerSpawn> markers = markerSystemEnabled() List<PreparedMarkerSpawn> markers = markerSystemEnabled()
? prepareMarkerSpawns(level, chunkX, chunkZ, chunk) : List.of(); ? prepareMarkerSpawns(level, chunkX, chunkZ, chunk, true) : List.of();
Optional<BiomeEnvironment> environment = ambientSystemEnabled() Optional<BiomeEnvironment> environment = ambientSystemEnabled()
? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty(); ? resolveSurfaceEnvironment(chunkX, chunkZ) : Optional.empty();
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> { if (ambientSystemEnabled() && engine.getComplex() == null) {
spawnPreparedMarkers(level, markers, true); return false;
if (environment.isPresent()) { }
scheduleInitialFollowUp(level, chunkX, chunkZ, environment.get()); spawnPreparedMarkers(level, markers, true);
if (environment.isPresent()) {
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment.get())) {
spawnAmbient(level, chunkX, chunkZ, true, environment.get());
} }
}); }
chunk.raiseFlagUnchecked(INITIAL_SPAWN_COMPLETION_FLAG, () -> {});
} finally { } finally {
chunk.release(); chunk.release();
} }
return true; return true;
} }
private void scheduleInitialFollowUp(NativeWorld level, int chunkX, int chunkZ, BiomeEnvironment environment) {
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(
() -> EngineLifecycleTasks.run(
engine,
"modded_world_manager_initial_spawn_followup",
() -> runInitialFollowUp(level, chunkX, chunkZ, environment)),
RNG.r.i(5, 200));
}
private void runInitialFollowUp(NativeWorld level, int chunkX, int chunkZ, BiomeEnvironment environment) {
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (closed || engine.isClosed() || mantle.isClosed() || !isEntitySpawningEnabledForCurrentWorld()) {
return;
}
if (!level.isChunkLoaded(chunkX, chunkZ) || !mantle.isChunkLoaded(chunkX, chunkZ)) {
return;
}
if (engine.getComplex() == null) {
return;
}
if (ambientSystemEnabled()) {
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
spawnAmbient(level, chunkX, chunkZ, true, environment);
}
}
}
private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) { private void warmupMantleChunkAsync(long key, int chunkX, int chunkZ) {
if (closed || !mantleWarmups.add(key)) { if (closed || !mantleWarmups.add(key)) {
return; return;
@@ -380,11 +359,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (!mantle.isChunkLoaded(chunkX, chunkZ)) { if (!mantle.isChunkLoaded(chunkX, chunkZ)) {
return; return;
} }
if (!mantle.hasFlag(chunkX, chunkZ, INITIAL_SPAWN_COMPLETION_FLAG)) {
return;
}
MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ); MantleChunk<Matter> chunk = mantle.useChunk(chunkX, chunkZ);
try { try {
if (markerSystemEnabled()) { if (markerSystemEnabled()) {
spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk), false); spawnPreparedMarkers(level, prepareMarkerSpawns(level, chunkX, chunkZ, chunk, false), false);
} }
if (ambientSystemEnabled()) { if (ambientSystemEnabled()) {
spawnAmbient(level, chunkX, chunkZ, false); spawnAmbient(level, chunkX, chunkZ, false);
@@ -394,7 +376,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
} }
private List<PreparedMarkerSpawn> prepareMarkerSpawns(NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) { private List<PreparedMarkerSpawn> prepareMarkerSpawns(NativeWorld level, int chunkX, int chunkZ, MantleChunk<Matter> chunk, boolean initial) {
int minHeight = engine.getWorld().minHeight(); int minHeight = engine.getWorld().minHeight();
KList<IrisPosition> obstructed = new KList<>(); KList<IrisPosition> obstructed = new KList<>();
List<PreparedMarkerSpawn> prepared = new ArrayList<>(); List<PreparedMarkerSpawn> prepared = new ArrayList<>();
@@ -429,29 +411,41 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (spawners.isEmpty()) { if (spawners.isEmpty()) {
return; return;
} }
IrisSpawner chosen = spawners.getRandom(); if (initial) {
spawners.sort(Comparator.comparing(IrisSpawner::getLoadKey, Comparator.nullsFirst(Comparator.naturalOrder())));
}
RNG markerRng = initial ? EntitySpawnSeed.marker(engine.getSeedManager().getEntity(), worldX, worldY, worldZ)
: new RNG(RNG.r.nextLong());
IrisSpawner chosen = spawners.getRandom(markerRng);
if (chosen == null) { if (chosen == null) {
return; return;
} }
prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, environment)); prepared.add(new PreparedMarkerSpawn(new IrisPosition(worldX, worldY, worldZ), chosen, resolved, markerRng, environment));
}); });
Mantle<Matter> mantle = engine.getMantle().getMantle(); Mantle<Matter> mantle = engine.getMantle().getMantle();
for (IrisPosition position : obstructed) { for (IrisPosition position : obstructed) {
mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class); mantle.remove(position.getX(), position.getY(), position.getZ(), MatterMarker.class);
} }
if (initial) {
prepared.sort(Comparator.comparingInt((PreparedMarkerSpawn marker) -> marker.position().getX())
.thenComparingInt(marker -> marker.position().getY())
.thenComparingInt(marker -> marker.position().getZ()));
}
return List.copyOf(prepared); return List.copyOf(prepared);
} }
private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) { private KList<IrisSpawner> resolveMarkerSpawners(IrisMarker marker, BiomeEnvironment environment) {
KList<IrisSpawner> spawners = new KList<>(); KList<IrisSpawner> spawners = new KList<>();
Set<IrisSpawner> seen = new HashSet<>();
for (String key : marker.getSpawners()) { for (String key : marker.getSpawners()) {
IrisSpawner spawner = environment.data().getSpawnerLoader().load(key); IrisSpawner spawner = environment.data().getSpawnerLoader().load(key);
if (spawner == null) { if (spawner == null) {
IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName()); IrisLogging.error("Cannot load spawner: " + key + " for marker on " + engine.getName());
continue; continue;
} }
spawner.setReferenceMarker(marker); if (!spawner.isCompatExcluded() && seen.add(spawner)) {
spawners.add(spawner); spawners.add(spawner);
}
} }
return spawners; return spawners;
} }
@@ -459,32 +453,29 @@ public final class ModdedWorldManager implements EngineWorldManager {
private void spawnPreparedMarkers(NativeWorld level, List<PreparedMarkerSpawn> markers, boolean initial) { private void spawnPreparedMarkers(NativeWorld level, List<PreparedMarkerSpawn> markers, boolean initial) {
for (PreparedMarkerSpawn marker : markers) { for (PreparedMarkerSpawn marker : markers) {
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) { try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(marker.environment())) {
spawnFromSpawner(level, marker.position(), marker.spawner(), initial); spawnFromSpawner(level, marker.position(), marker.rng(), new SpawnContext(marker.spawner(), marker.marker(), initial));
} }
} }
} }
private void spawnFromSpawner(NativeWorld level, IrisPosition position, IrisSpawner spawner, boolean initial) { private void spawnFromSpawner(NativeWorld level, IrisPosition position, RNG rng, SpawnContext context) {
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); IrisSpawner spawner = context.spawner();
KList<IrisEntitySpawn> spawns = context.initial() ? spawner.getInitialSpawns() : spawner.getSpawns();
if (spawns.isEmpty()) { if (spawns.isEmpty()) {
return; return;
} }
for (IrisEntitySpawn entry : spawns) { IrisEntitySpawn chosen = rarityPick(spawns, rng);
entry.setReferenceSpawner(spawner);
entry.setReferenceMarker(spawner.getReferenceMarker());
}
IrisEntitySpawn chosen = rarityPick(spawns);
if (chosen == null) { if (chosen == null) {
return; return;
} }
int chunkX = position.getX() >> 4; int chunkX = position.getX() >> 4;
int chunkZ = position.getZ() >> 4; int chunkZ = position.getZ() >> 4;
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, context.initial())) {
return; return;
} }
int spawned = spawnEntryAt(level, chosen, spawner, position); int spawned = spawnEntryAt(level, chosen, position, rng, context);
if (spawned > 0) { if (spawned > 0 && !context.initial()) {
spawner.spawn(engine, chunkX, chunkZ); spawner.spawn(engine, chunkX, chunkZ);
} }
} }
@@ -521,59 +512,168 @@ public final class ModdedWorldManager implements EngineWorldManager {
} }
IrisBiome biome = environment.biome(); IrisBiome biome = environment.biome();
Map<String, Integer> chunkMobs = NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ); Map<String, Integer> chunkMobs = new HashMap<>(initial ? Map.of()
: NativeSpawnQueries.livingEntityCategories(level, chunkX, chunkZ));
RNG rng = initial ? EntitySpawnSeed.chunk(engine.getSeedManager().getEntity(), chunkX, chunkZ)
: new RNG(RNG.r.nextLong());
KList<IrisEntitySpawn> pool = new KList<>(); KList<SpawnCandidate> pool = new KList<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()), biome, chunkX, chunkZ, chunkMobs, initial, false);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial, false);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial); collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(biome.getEntitySpawners()), null, chunkX, chunkZ, chunkMobs, initial, false);
if (environment.dimension().hasUndergroundSpawners(engine)) {
spawnUnderground(level, chunkX, chunkZ, initial, chunkMobs, rng.nextParallelRNG(0x5A17D3B48269C0EFL));
}
if (pool.isEmpty()) { if (pool.isEmpty()) {
return; return;
} }
IrisEntitySpawn chosen = rarityPick(pool); SpawnCandidate chosen = rarityPick(pool, rng);
if (chosen == null || chosen.getReferenceSpawner() == null) { if (chosen == null) {
return; return;
} }
IrisSpawner spawner = chosen.getReferenceSpawner(); IrisSpawner spawner = chosen.spawner();
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, initial)) {
return; return;
} }
int spawned = spawnEntry(level, chosen, spawner, chunkX, chunkZ, int spawned = spawnEntry(level, chosen.entry(), chunkX, chunkZ,
remainingCapacity(chosen, spawner, chunkMobs)); remainingCapacity(chosen.entry(), spawner, chunkMobs), rng, new SpawnContext(spawner, null, initial));
if (spawned > 0) { recordSpawned(chosen.entry(), chunkMobs, spawned);
if (spawned > 0 && !initial) {
spawner.spawn(engine, chunkX, chunkZ); spawner.spawn(engine, chunkX, chunkZ);
} }
} }
private void collectSpawns(KList<IrisEntitySpawn> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map<String, Integer> chunkMobs, boolean initial) { private void spawnUnderground(NativeWorld level, int chunkX, int chunkZ, boolean initial,
Map<String, Integer> chunkMobs, RNG random) {
for (IrisSurface surface : List.of(IrisSurface.LAND, IrisSurface.WATER, IrisSurface.LAVA)) {
IrisPosition candidate = findUndergroundCandidate(level, chunkX, chunkZ,
random.nextParallelRNG(surface.ordinal()), surface);
if (candidate == null) {
continue;
}
BiomeEnvironment environment;
try {
environment = engine.getBiomeOrMantleEnvironment(candidate.getX(),
candidate.getY() - engine.getWorld().minHeight(), candidate.getZ());
} catch (SavedBiomeUnavailableException unavailable) {
continue;
}
try (BiomeEnvironment.Scope ignored = engine.openBiomeEnvironmentScope(environment)) {
KList<SpawnCandidate> pool = new KList<>();
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.dimension().getEntitySpawners()),
environment.biome(), chunkX, chunkZ, chunkMobs, initial, true);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.region().getEntitySpawners()),
null, chunkX, chunkZ, chunkMobs, initial, true);
collectSpawns(pool, environment.data().getSpawnerLoader().loadAll(environment.biome().getEntitySpawners()),
null, chunkX, chunkZ, chunkMobs, initial, true);
pool.removeIf(selection -> !matchesUndergroundSurface(selection.entry(), surface));
SpawnCandidate selected = rarityPick(pool, random);
if (selected == null) {
continue;
}
int capacity = remainingCapacity(selected.entry(), selected.spawner(), chunkMobs);
int spawned = spawnUndergroundEntry(level, candidate, selected, capacity, random, initial);
recordSpawned(selected.entry(), chunkMobs, spawned);
if (spawned > 0 && !initial) {
selected.spawner().spawn(engine, chunkX, chunkZ);
}
return;
}
}
}
private boolean matchesUndergroundSurface(IrisEntitySpawn entry, IrisSurface surface) {
IrisEntity entity = entry.getRealEntity(engine);
return entity != null && entity.getSurface().isFluid() == surface.isFluid()
&& (!surface.isFluid() || entity.getSurface() == surface);
}
static IrisPosition findUndergroundCandidate(NativeWorld level, int chunkX, int chunkZ, RNG random, IrisSurface surface) {
int minimumY = level.minHeight() + 1;
for (int attempt = 0; attempt < 8; attempt++) {
int x = (chunkX << 4) + random.i(1, 15);
int z = (chunkZ << 4) + random.i(1, 15);
int maximumY = Math.min(level.maxHeight() - 3, NativeSpawnQueries.surfaceHeight(level, x, z, true) - 1);
if (maximumY < minimumY) {
continue;
}
int span = maximumY - minimumY + 1;
int startY = LootResolver.inclusive(random, minimumY, maximumY);
for (int sample = 0; sample < Math.min(span, 128); sample++) {
int y = minimumY + Math.floorMod(startY - minimumY - sample, span);
NativeBlockState state = level.getBlock(x, y, z);
boolean acceptable = surface.isFluid() ? matchesSurface(surface, state)
: state != null && state.isAir() && NativeSpawnQueries.solid(level.getBlock(x, y - 1, z))
&& level.getBlock(x, y + 1, z).isAir();
if (acceptable) {
return new IrisPosition(x, y, z);
}
}
}
return null;
}
private int spawnUndergroundEntry(NativeWorld level, IrisPosition candidate, SpawnCandidate selection,
int capacity, RNG random, boolean initial) {
IrisEntity entity = selection.entry().getRealEntity(engine);
if (entity == null) {
return 0;
}
int x = candidate.getX();
int y = candidate.getY();
int z = candidate.getZ();
int count = Math.min(Math.max(0, capacity), LootResolver.inclusive(random,
selection.entry().getMinSpawns(), selection.entry().getMaxSpawns()));
int spawned = 0;
for (int ordinal = 0; ordinal < count; ordinal++) {
if (!initial && !lightAllowed(selection.spawner(), level, x, y, z)
|| !surfaceMatches(entity.getSurface(), level, x, y, z)
|| !ModdedEntitySpawner.isAreaClearForSpawn(entityRuntime(level), entity, x, y, z)) {
continue;
}
if (ModdedEntitySpawner.spawn(engine, entity, entityRuntime(level), x, y, z,
EntitySpawnSeed.entity(random.getSeed(), ordinal)) != null) {
spawned++;
}
}
return spawned;
}
private void collectSpawns(KList<SpawnCandidate> pool, KList<IrisSpawner> spawners, IrisBiome biomeFilter, int chunkX, int chunkZ, Map<String, Integer> chunkMobs, boolean initial, boolean underground) {
for (IrisSpawner spawner : spawners) { for (IrisSpawner spawner : spawners) {
if (spawner == null) { if (spawner == null || (spawner.getGroup() == IrisSpawnGroup.CAVE) != underground) {
continue; continue;
} }
if (biomeFilter != null && !spawner.isValid(biomeFilter)) { if (biomeFilter != null && !spawner.isValid(biomeFilter)) {
continue; continue;
} }
if (!canSpawn(spawner, chunkX, chunkZ)) { if (!canSpawn(spawner, chunkX, chunkZ, initial)) {
continue; continue;
} }
KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns(); KList<IrisEntitySpawn> spawns = initial ? spawner.getInitialSpawns() : spawner.getSpawns();
for (IrisEntitySpawn entry : spawns) { for (IrisEntitySpawn entry : spawns) {
entry.setReferenceSpawner(spawner);
entry.setReferenceMarker(spawner.getReferenceMarker());
if (remainingCapacity(entry, spawner, chunkMobs) > 0) { if (remainingCapacity(entry, spawner, chunkMobs) > 0) {
pool.add(entry); pool.add(new SpawnCandidate(entry, spawner));
} }
} }
} }
} }
private void recordSpawned(IrisEntitySpawn entry, Map<String, Integer> counts, int spawned) {
IrisEntity entity = entry.getRealEntity(engine);
if (spawned > 0 && entity != null) {
counts.merge(entity.spawnCategory(), spawned, Integer::sum);
}
}
private int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Map<String, Integer> counts) { private int remainingCapacity(IrisEntitySpawn entry, IrisSpawner spawner, Map<String, Integer> counts) {
IrisEntity entity = entry.getRealEntity(engine); IrisEntity entity = entry.getRealEntity(engine);
return spawner.remainingCapacity(entity, counts); return spawner.remainingCapacity(entity, counts);
} }
private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, int chunkX, int chunkZ, int remainingCapacity) { private int spawnEntry(NativeWorld level, IrisEntitySpawn entry, int chunkX, int chunkZ,
int remainingCapacity, RNG rng, SpawnContext context) {
IrisEntity irisEntity = entry.getRealEntity(engine); IrisEntity irisEntity = entry.getRealEntity(engine);
if (irisEntity == null) { if (irisEntity == null) {
return 0; return 0;
@@ -581,18 +681,24 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns(); int min = entry.getMinSpawns();
int max = entry.getMaxSpawns(); int max = entry.getMaxSpawns();
int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(RNG.r, min, max)); int count = Math.min(Math.max(0, remainingCapacity), LootResolver.inclusive(rng, min, max));
if (count <= 0) { if (count <= 0) {
return 0; return 0;
} }
RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity())); IrisSpawner spawner = context.spawner();
IrisSpawnGroup group = spawner.getGroup(); IrisSpawnGroup group = spawner.getGroup();
KList<IrisPosition> caveFloors = group == IrisSpawnGroup.CAVE KList<IrisPosition> caveFloors = group == IrisSpawnGroup.CAVE
? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR) ? engine.getMantle().findMarkers(chunkX, chunkZ, MarkerMatter.CAVE_FLOOR)
: new KList<>(); : new KList<>();
if (context.initial()) {
caveFloors.sort(Comparator.comparingInt(IrisPosition::getX)
.thenComparingInt(IrisPosition::getY).thenComparingInt(IrisPosition::getZ));
}
int spawned = 0; int spawned = 0;
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i);
RNG positionRng = entityRng.nextParallelRNG(0x632BE59BD9B4E019L);
int worldX; int worldX;
int worldY; int worldY;
int worldZ; int worldZ;
@@ -600,16 +706,16 @@ public final class ModdedWorldManager implements EngineWorldManager {
if (caveFloors.isEmpty()) { if (caveFloors.isEmpty()) {
continue; continue;
} }
IrisPosition caveFloor = caveFloors.getRandom(RNG.r); IrisPosition caveFloor = caveFloors.getRandom(positionRng);
worldX = caveFloor.getX(); worldX = caveFloor.getX();
worldY = caveFloor.getY(); worldY = caveFloor.getY();
worldZ = caveFloor.getZ(); worldZ = caveFloor.getZ();
} else { } else {
worldX = (chunkX << 4) + RNG.r.i(16); worldX = (chunkX << 4) + positionRng.i(16);
worldZ = (chunkZ << 4) + RNG.r.i(16); worldZ = (chunkZ << 4) + positionRng.i(16);
int surfaceY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, false); int surfaceY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, false);
int solidY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, true); int solidY = NativeSpawnQueries.surfaceHeight(level, worldX, worldZ, true);
Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, RNG.r); Integer selectedY = IrisEntitySpawn.selectSurfaceSpawnY(group, irisEntity.getSurface(), solidY, surfaceY, positionRng);
if (selectedY == null) { if (selectedY == null) {
continue; continue;
} }
@@ -619,7 +725,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
continue; continue;
} }
// Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { if (!context.initial() && !lightAllowed(spawner, level, worldX, worldY, worldZ)) {
continue; continue;
} }
if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) { if (!surfaceMatches(irisEntity.getSurface(), level, worldX, worldY, worldZ)) {
@@ -635,7 +741,7 @@ public final class ModdedWorldManager implements EngineWorldManager {
return spawned; return spawned;
} }
private int spawnEntryAt(NativeWorld level, IrisEntitySpawn entry, IrisSpawner spawner, IrisPosition position) { private int spawnEntryAt(NativeWorld level, IrisEntitySpawn entry, IrisPosition position, RNG rng, SpawnContext context) {
IrisEntity irisEntity = entry.getRealEntity(engine); IrisEntity irisEntity = entry.getRealEntity(engine);
if (irisEntity == null) { if (irisEntity == null) {
return 0; return 0;
@@ -643,21 +749,21 @@ public final class ModdedWorldManager implements EngineWorldManager {
int min = entry.getMinSpawns(); int min = entry.getMinSpawns();
int max = entry.getMaxSpawns(); int max = entry.getMaxSpawns();
int count = LootResolver.inclusive(RNG.r, min, max); int count = LootResolver.inclusive(rng, min, max);
if (count <= 0) { if (count <= 0) {
return 0; return 0;
} }
exhaustMarker(spawner, position); exhaustMarker(context.marker(), position, EntitySpawnSeed.entity(rng.getSeed(), -1));
RNG entityRng = entry.getRng().aquire(() -> new RNG(engine.getSeedManager().getEntity()));
int worldX = position.getX(); int worldX = position.getX();
int worldY = position.getY() + 1; int worldY = position.getY() + 1;
int worldZ = position.getZ(); int worldZ = position.getZ();
int spawned = 0; int spawned = 0;
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
RNG entityRng = EntitySpawnSeed.entity(rng.getSeed(), i);
// Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity). // Rarity is applied exactly once, as pool weighting in rarityPick - never re-rolled per position (Bukkit parity).
if (!lightAllowed(spawner, level, worldX, worldY, worldZ)) { if (!context.initial() && !lightAllowed(context.spawner(), level, worldX, worldY, worldZ)) {
continue; continue;
} }
if (irisEntity.getSurface().isFluid() if (irisEntity.getSurface().isFluid()
@@ -672,16 +778,15 @@ public final class ModdedWorldManager implements EngineWorldManager {
return spawned; return spawned;
} }
private void exhaustMarker(IrisSpawner spawner, IrisPosition position) { private void exhaustMarker(IrisMarker marker, IrisPosition position, RNG rng) {
IrisMarker marker = spawner.getReferenceMarker(); if (marker == null || !marker.shouldExhaust(rng)) {
if (marker == null || !marker.shouldExhaust()) {
return; return;
} }
engine.getMantle().getMantle().remove(position.getX(), position.getY() - engine.getWorld().minHeight(), position.getZ(), MatterMarker.class); engine.getMantle().getMantle().remove(position.getX(), position.getY() - engine.getWorld().minHeight(), position.getZ(), MatterMarker.class);
} }
private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ) { private boolean canSpawn(IrisSpawner spawner, int chunkX, int chunkZ, boolean initial) {
return spawner.canSpawn(engine, chunkX, chunkZ); return !spawner.isCompatExcluded() && (initial || spawner.canSpawn(engine, chunkX, chunkZ));
} }
private boolean lightAllowed(IrisSpawner spawner, NativeWorld level, int worldX, int worldY, int worldZ) { private boolean lightAllowed(IrisSpawner spawner, NativeWorld level, int worldX, int worldY, int worldZ) {
@@ -796,9 +901,9 @@ public final class ModdedWorldManager implements EngineWorldManager {
return !studio || studioSetting; return !studio || studioSetting;
} }
private IrisEntitySpawn rarityPick(KList<IrisEntitySpawn> entries) { private static <T extends Rarity> T rarityPick(KList<T> entries, RNG rng) {
KList<IrisEntitySpawn> weighted = Rarity.expandWeighted(entries); KList<T> weighted = Rarity.expandWeighted(entries);
return weighted.isEmpty() ? entries.getRandom() : weighted.getRandom(); return weighted.isEmpty() ? entries.getRandom(rng) : weighted.getRandom(rng);
} }
private static long pack(int x, int z) { private static long pack(int x, int z) {
@@ -893,7 +998,14 @@ public final class ModdedWorldManager implements EngineWorldManager {
return failure; return failure;
} }
private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, BiomeEnvironment environment) { private record PreparedMarkerSpawn(IrisPosition position, IrisSpawner spawner, IrisMarker marker, RNG rng, BiomeEnvironment environment) {
}
private record SpawnCandidate(IrisEntitySpawn entry, IrisSpawner spawner) implements Rarity {
@Override
public int getRarity() {
return entry.getRarity();
}
} }
private NativeEntityRuntime entityRuntime(NativeWorld level) { private NativeEntityRuntime entityRuntime(NativeWorld level) {
@@ -33,6 +33,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.modded.ModdedDimensionManager; import art.arcane.iris.modded.ModdedDimensionManager;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedEngineBootstrap;
import art.arcane.iris.modded.service.ModdedPackUpdateService;
import art.arcane.iris.modded.ModdedForcedDatapack; import art.arcane.iris.modded.ModdedForcedDatapack;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
import art.arcane.iris.modded.ModdedScheduler; import art.arcane.iris.modded.ModdedScheduler;
@@ -445,6 +446,12 @@ public final class IrisModdedCommands {
); );
String completionMessage = downloadCompletionMessage(result); String completionMessage = downloadCompletionMessage(result);
if (result != null) { if (result != null) {
if (result.changed()) {
ModdedPackUpdateService updates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
if (updates != null) {
dispatchDownloadFeedback(source, updates::refresh);
}
}
if (completionMessage != null) { if (completionMessage != null) {
dispatchDownloadFeedback(source, () -> ok(source, completionMessage)); dispatchDownloadFeedback(source, () -> ok(source, completionMessage));
} }
@@ -31,6 +31,9 @@ import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.structure.placement.IrisStructureLocator; import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.generation.locator.Locator; import art.arcane.iris.generation.locator.Locator;
import art.arcane.iris.generation.locator.BiomeLocator;
import art.arcane.iris.generation.subterrain.SubterrainPosition;
import art.arcane.iris.world.history.SavedBiomeRuntime;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy; import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability; import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.runtime.WrongEngineBroException; import art.arcane.iris.generation.runtime.WrongEngineBroException;
@@ -82,7 +85,7 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key)));
return 0; return 0;
} }
locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey()); locate(source, level, engine, player, BiomeLocator.forBiome(engine, biome.getLoadKey(), player.blockY()), "biome " + biome.getLoadKey());
return 1; return 1;
} }
@@ -545,12 +548,12 @@ final class ModdedLocateCommands {
previous.cancel(true); previous.cancel(true);
} }
search.whenComplete((Position2 at, Throwable error) -> completeLocate( search.whenComplete((Position2 at, Throwable error) -> completeLocate(
source, level, engine, player, label, server, playerId, search, at, error)); source, level, engine, player, label, server, playerId, search, locator, at, error));
} }
private static void completeLocate(NativeCommandSource source, NativeWorld level, Engine engine, private static void completeLocate(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, NativeModdedServer server, UUID playerId, NativeProtocolPlayer player, String label, NativeModdedServer server, UUID playerId,
CompletableFuture<Position2> search, Position2 at, Throwable error) { CompletableFuture<Position2> search, Locator<?> locator, Position2 at, Throwable error) {
if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) { if (ACTIVE_LOCATE_REQUESTS.get(playerId) != search) {
return; return;
} }
@@ -576,15 +579,77 @@ final class ModdedLocateCommands {
}); });
return; return;
} }
if (locator instanceof BiomeLocator caveLocator) {
prepareCaveLocate(new CaveLocateRequest(source, level, engine, player, label, server, playerId,
search, caveLocator, at));
return;
}
server.execute(() -> { server.execute(() -> {
if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) { if (ACTIVE_LOCATE_REQUESTS.remove(playerId, search)) {
teleportToLocateResult(source, level, engine, player, label, at); teleportToLocateResult(source, level, engine, player, label, locator, at, Optional.empty());
} }
}); });
} }
private static void teleportToLocateResult(NativeCommandSource source, NativeWorld level, Engine engine, private static void prepareCaveLocate(CaveLocateRequest request) {
NativeProtocolPlayer player, String label, Position2 at) { CompletableFuture.supplyAsync(() -> resolveCavePosition(request))
.whenComplete((target, error) -> request.server().execute(() -> loadCaveDestination(request, target, error)));
}
private static SubterrainPosition resolveCavePosition(CaveLocateRequest request) {
try (GenerationSessionLease lease = request.engine().acquireGenerationLease("modded_cave_position");
IrisContext.Scope ignored = IrisContext.open(request.engine(), lease.sessionId(), null)) {
return request.locator().position(request.engine(), request.at());
} catch (GenerationSessionException error) {
throw new CompletionException(error);
}
}
private static void loadCaveDestination(CaveLocateRequest request, SubterrainPosition target, Throwable error) {
if (ACTIVE_LOCATE_REQUESTS.get(request.playerId()) != request.search()) {
return;
}
if (error != null) {
failCaveLocate(request, error);
return;
}
if (target == null || !request.player().connected() || !request.player().inWorld(request.level())) {
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
IrisModdedCommands.fail(request.source(), "The cave destination is no longer available.");
}
return;
}
try {
request.level().getBlock(target.x(), target.y(), target.z());
request.locator().prepareRead(request.engine(), new Position2(target.x() >> 4, target.z() >> 4))
.whenComplete((read, failure) -> request.server().execute(() -> finishCaveLocate(request, target, read, failure)));
} catch (RuntimeException failure) {
failCaveLocate(request, failure);
}
}
private static void finishCaveLocate(CaveLocateRequest request, SubterrainPosition target,
Optional<SavedBiomeRuntime.ReadSession> read, Throwable error) {
if (error != null) {
failCaveLocate(request, error);
return;
}
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
teleportToLocateResult(request.source(), request.level(), request.engine(), request.player(),
request.label(), request.locator(), request.at(), Optional.of(new CaveDestination(target, read)));
}
}
private static void failCaveLocate(CaveLocateRequest request, Throwable error) {
ModdedIrisLog.error("Iris cave locate failed for {}", request.label(), error);
if (ACTIVE_LOCATE_REQUESTS.remove(request.playerId(), request.search())) {
IrisModdedCommands.fail(request.source(), "Could not prepare the cave destination. See the server log for details.");
}
}
static void teleportToLocateResult(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, Locator<?> locator, Position2 at,
Optional<CaveDestination> caveDestination) {
// Same liveness guards the structure completion path has: the search can take up to // Same liveness guards the structure completion path has: the search can take up to
// two minutes, and the captured NativeProtocolPlayer may be gone or elsewhere by then. // two minutes, and the captured NativeProtocolPlayer may be gone or elsewhere by then.
if (!player.connected()) { if (!player.connected()) {
@@ -600,6 +665,17 @@ final class ModdedLocateCommands {
try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport"); try (GenerationSessionLease lease = engine.acquireGenerationLease("modded_locator_teleport");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2; int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
if (locator instanceof BiomeLocator caveLocator) {
CaveDestination prepared = caveDestination.orElseThrow();
SubterrainPosition landing = caveLocator.landing(engine, level, prepared.target(), prepared.read());
if (landing == null) {
IrisModdedCommands.fail(source, "No safe cave landing found for " + label + ".");
return;
}
blockX = landing.x();
blockY = landing.y();
blockZ = landing.z();
}
boolean teleported = player.teleport(level, blockX + 0.5D, blockY, blockZ + 0.5D); boolean teleported = player.teleport(level, blockX + 0.5D, blockY, blockZ + 0.5D);
if (!teleported) { if (!teleported) {
IrisModdedCommands.fail(source, IrisLanguage.plain( IrisModdedCommands.fail(source, IrisLanguage.plain(
@@ -611,7 +687,8 @@ final class ModdedLocateCommands {
return; return;
} }
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTED_AT_2, MessageArgument.untrusted("label", label), MessageArgument.untrusted("blockX", blockX), MessageArgument.untrusted("blockY", blockY), MessageArgument.untrusted("blockZ", blockZ))); IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTED_AT_2, MessageArgument.untrusted("label", label), MessageArgument.untrusted("blockX", blockX), MessageArgument.untrusted("blockY", blockY), MessageArgument.untrusted("blockZ", blockZ)));
} catch (GenerationSessionException e) { } catch (GenerationSessionException | RuntimeException e) {
ModdedIrisLog.error("Iris locate teleport failed for {}", label, e);
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_CHANGED_WHILE_LOCATING_TRY_AGAIN, MessageArgument.untrusted("label", label))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_ENGINE_CHANGED_WHILE_LOCATING_TRY_AGAIN, MessageArgument.untrusted("label", label)));
} }
} }
@@ -635,6 +712,14 @@ final class ModdedLocateCommands {
NO_PLACEMENT NO_PLACEMENT
} }
record CaveDestination(SubterrainPosition target, Optional<SavedBiomeRuntime.ReadSession> read) {
}
private record CaveLocateRequest(NativeCommandSource source, NativeWorld level, Engine engine,
NativeProtocolPlayer player, String label, NativeModdedServer server,
UUID playerId, CompletableFuture<Position2> search, BiomeLocator locator, Position2 at) {
}
private record NativeStructureTarget(String key, NativeStructureQueries.Reference holder, private record NativeStructureTarget(String key, NativeStructureQueries.Reference holder,
NativeStructureAvailability availability) { NativeStructureAvailability availability) {
} }
@@ -18,6 +18,8 @@
package art.arcane.iris.modded.command; package art.arcane.iris.modded.command;
import art.arcane.iris.modded.ModdedStartup;
import art.arcane.iris.modded.ModdedIrisLog; import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.pack.validation.PackCompatReport; import art.arcane.iris.pack.validation.PackCompatReport;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
@@ -25,7 +27,6 @@ import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.pack.PackResourceCleanup; import art.arcane.iris.pack.PackResourceCleanup;
import art.arcane.iris.pack.PackValidationRegistry; import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationResult; import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedEngineBootstrap;
import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.LiteralArgumentBuilder; import com.mojang.brigadier.builder.LiteralArgumentBuilder;
@@ -138,8 +139,7 @@ public final class ModdedPackCommands {
int broken = 0; int broken = 0;
for (File target : targets) { for (File target : targets) {
try { try {
PackValidationResult result = PackValidator.validate(target); PackValidationResult result = ModdedStartup.validatePack(target);
PackValidationRegistry.publish(result);
if (!result.isLoadable()) { if (!result.isLoadable()) {
broken++; broken++;
} }
@@ -0,0 +1,138 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
import art.arcane.iris.pack.BuiltInPackUpdates;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Function;
public final class ModdedPackUpdateService implements ModdedTickableService {
private final Function<File, CompletableFuture<PackCheck>> checker;
private final Set<UUID> notified = new HashSet<>();
private CompletableFuture<PackCheck> pending;
private List<String> notices = List.of();
private File packsFolder;
private boolean enabled;
private boolean started;
public ModdedPackUpdateService() {
this(ModdedPackUpdateService::checkPacks);
}
ModdedPackUpdateService(Function<File, CompletableFuture<PackCheck>> checker) {
this.checker = checker;
}
@Override
public void onEnable() {
enabled = true;
}
@Override
public void onDisable() {
enabled = false;
if (pending != null) {
pending.cancel(true);
pending = null;
}
started = false;
notices = List.of();
notified.clear();
packsFolder = null;
}
@Override
public void onServerTick(NativeModdedServer server) {
if (!enabled || server == null || !server.hasPlayerList()) {
return;
}
if (!started) {
started = true;
packsFolder = IrisPlatforms.get().packsFolderNoCreate().getAbsoluteFile();
pending = checker.apply(packsFolder);
}
if (pending == null || !pending.isDone()) {
return;
}
CompletableFuture<PackCheck> completed = pending;
pending = null;
try {
publish(completed.join());
server.forEachPlayer(this::notifyPlayer);
} catch (CompletionException error) {
IrisLogging.reportError("Iris pack update check failed", error);
}
}
public void refresh() {
if (!enabled) {
return;
}
if (pending != null) {
pending.cancel(true);
pending = null;
}
started = false;
notices = List.of();
notified.clear();
}
public void notifyPlayer(NativeProtocolPlayer player) {
if (!enabled || notices.isEmpty() || player == null || !player.connected()
|| (!player.isGameMaster() && !player.isServerOwner()) || !notified.add(player.id())) {
return;
}
for (String notice : notices) {
player.sendMessage(notice);
}
}
private static CompletableFuture<PackCheck> checkPacks(File folder) {
return CompletableFuture.supplyAsync(() -> IrisSplashPackScanner.collect(folder, IrisLogging::reportError))
.thenCompose(packs -> BuiltInPackUpdates.check(packs.stream().map(SplashPackMetadata::name).toList())
.thenApply(updates -> new PackCheck(packs, updates)));
}
private void publish(PackCheck result) {
List<String> updates = new ArrayList<>();
IrisLogging.info("Iris packs: " + packsFolder);
for (SplashPackMetadata pack : result.packs()) {
BuiltInPackUpdates.Update update = result.updates().get(pack.name());
String version = pack.version().matches("[0-9]+") ? "v" + pack.version() : "version unknown";
IrisLogging.info(" " + pack.name() + " " + version
+ (update == null ? "" : update.suffix(pack.version())));
if (update != null && update.newerThan(pack.version())) {
updates.add("Iris " + pack.name() + " release metadata is outdated: installed v" + pack.version()
+ ", latest built-in release v" + update.version() + ".");
updates.add("Back up and review custom edits in " + new File(packsFolder, pack.name())
+ "; close Studio, then update with /iris download pack=" + pack.name() + " overwrite=true.");
updates.add("Existing worlds retain their recorded production pack. Back up the complete world, then stage an update with /iris world update <dimension> "
+ pack.name() + " and restart the server to activate it.");
}
}
notices = List.copyOf(updates);
for (String notice : notices) {
IrisLogging.warn(notice);
}
}
record PackCheck(List<SplashPackMetadata> packs, Map<String, BuiltInPackUpdates.Update> updates) {
PackCheck {
packs = List.copyOf(packs);
updates = Map.copyOf(updates);
}
}
}
@@ -57,6 +57,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks { public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks {
@@ -64,6 +65,8 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
private static final long POLL_MILLIS = 250L; private static final long POLL_MILLIS = 250L;
private static final long CHECK_LATCH_MILLIS = 1_000L; private static final long CHECK_LATCH_MILLIS = 1_000L;
private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L; private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L;
private static final long SHUTDOWN_TIMEOUT_SECONDS = 30L;
private static final long INTERRUPT_DRAIN_TIMEOUT_SECONDS = 5L;
private final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>();
private volatile ExecutorService executor; private volatile ExecutorService executor;
@@ -81,8 +84,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onEnable() { public void onEnable() {
if (executor != null) { ExecutorService current = executor;
return; if (current != null && !current.isTerminated()) {
if (!current.isShutdown()) {
return;
}
throw new IllegalStateException("Iris Studio hotload cannot restart while prior workers are still active");
} }
executor = Executors.newSingleThreadExecutor((Runnable task) -> { executor = Executors.newSingleThreadExecutor((Runnable task) -> {
Thread thread = new Thread(task, "Iris Studio Hotload"); Thread thread = new Thread(task, "Iris Studio Hotload");
@@ -96,10 +103,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onDisable() { public void onDisable() {
ExecutorService active = executor; ExecutorService active = executor;
executor = null; if (!shutdownAndDrain(active)) {
if (active != null) { throw new IllegalStateException("Iris Studio hotload worker remained active during service shutdown");
active.shutdownNow();
} }
executor = null;
for (Watch watch : watches.values()) { for (Watch watch : watches.values()) {
watch.close(); watch.close();
} }
@@ -197,7 +204,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
@Override @Override
public void onServerTick(NativeModdedServer server) { public void onServerTick(NativeModdedServer server) {
ExecutorService active = executor; ExecutorService active = executor;
if (active == null) { if (active == null || active.isShutdown()) {
return; return;
} }
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
@@ -284,6 +291,30 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
&& !isPregeneratorActive(engine); && !isPregeneratorActive(engine);
} }
private static boolean shutdownAndDrain(ExecutorService active) {
if (active == null) {
return true;
}
active.shutdown();
try {
if (active.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
return true;
}
active.shutdownNow();
if (active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
return true;
}
ModdedIrisLog.error("Iris Studio hotload worker did not stop after shutdownNow",
new IllegalStateException("Iris Studio hotload worker is still active"));
return false;
} catch (InterruptedException exception) {
active.shutdownNow();
Thread.currentThread().interrupt();
ModdedIrisLog.error("Interrupted while draining Iris Studio hotload", exception);
return false;
}
}
private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) { private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) {
if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) { if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) {
return true; return true;
@@ -38,12 +38,18 @@ public class IrisModdedStructureParityTest {
Bootstrap.bootStrap(); Bootstrap.bootStrap();
} }
private static Engine surfaceEngine(int minHeight) {
Engine engine = mock(Engine.class);
when(engine.getMinHeight()).thenReturn(minHeight);
return engine;
}
@Test @Test
public void surfaceBiomeFastPathBeginsAboveTheCaveSwitch() { public void surfaceBiomeFastPathBeginsAboveTheCaveSwitch() {
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(-2, -256)); assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(-256), -2));
assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(-1, -256)); assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(-256), -1));
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(10, 0)); assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(0), 10));
assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(11, 0)); assertTrue(ModdedBiomePolicy.isGuaranteedSurfaceBiome(surfaceEngine(0), 11));
} }
@Test @Test
@@ -57,8 +57,11 @@ public class ModdedBindFailureTest {
when(server.generateStructures()).thenReturn(false); when(server.generateStructures()).thenReturn(false);
try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class); try (MockedStatic<NativeModdedServer> servers = mockStatic(NativeModdedServer.class);
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) { MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()))
.thenThrow(new IllegalStateException("broken world binding"));
IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); IllegalStateException recorded = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine); IllegalStateException second = assertThrows(IllegalStateException.class, generator::engine);
@@ -66,9 +69,9 @@ public class ModdedBindFailureTest {
assertSame(recorded, second.getCause()); assertSame(recorded, second.getCause());
assertSame(recorded, third.getCause()); assertSame(recorded, third.getCause());
servers.verify(() -> NativeModdedServer.forWorld(level), times(1)); engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(1));
log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ") log.verify(() -> ModdedIrisLog.error(argThat((String line) -> line != null && line.startsWith("Cause: ")
&& line.contains("generate-structures=false"))), times(1)); && line.contains("broken world binding"))), times(1));
} }
} }
@@ -117,13 +120,15 @@ public class ModdedBindFailureTest {
MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class); MockedStatic<ModdedWorldEngines> engines = mockStatic(ModdedWorldEngines.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) { MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server); servers.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
engines.when(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()))
.thenAnswer(invocation -> { throw new IllegalStateException("broken world binding"); });
IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); IllegalStateException first = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
generator.unbindEngine(level); generator.unbindEngine(level);
IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level)); IllegalStateException second = assertThrows(IllegalStateException.class, () -> generator.bindLevel(level));
assertNotSame(first, second.getCause()); assertNotSame(first, second.getCause());
servers.verify(() -> NativeModdedServer.forWorld(level), times(2)); engines.verify(() -> ModdedWorldEngines.get(eq(level), any(), any(), anyLong(), any()), times(2));
engines.verify(() -> ModdedWorldEngines.evictOrThrow(level)); engines.verify(() -> ModdedWorldEngines.evictOrThrow(level));
} }
} }
@@ -2,6 +2,11 @@ package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.DimensionStackLayout;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.subterrain.IrisSubterrainFeature;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.generation.runtime.GenerationSessionException; import art.arcane.iris.generation.runtime.GenerationSessionException;
import art.arcane.iris.generation.runtime.GenerationSessionLease; import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.runtime.IrisComplex;
@@ -11,20 +16,54 @@ import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Test; import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
import java.io.File;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer; import java.util.function.Consumer;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
public class ModdedBiomePolicyTest { public class ModdedBiomePolicyTest {
@Test
public void authoredBiomeWinsAboveSurfaceGateAndBeforeStackOwnership() throws Exception {
Engine engine = mock(Engine.class);
IrisBiome feature = mock(IrisBiome.class);
IrisDimension host = new IrisDimension().setSubterrainFeatures(new KList<>(
new IrisSubterrainFeature().setId("room")));
IrisData data = mock(IrisData.class);
when(data.getDataFolder()).thenReturn(new File("synthetic-pack"));
when(engine.getData()).thenReturn(data);
when(engine.getDimension()).thenReturn(host);
when(engine.getComplex()).thenReturn(mock(IrisComplex.class));
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getSubterrainBiome(12, 224, -20)).thenReturn(feature);
assertFalse(ModdedBiomePolicy.isGuaranteedSurfaceBiome(engine, 40));
ModdedBiomePolicy<BiomeToken, Object> policy = boundTo(engine);
DimensionStackLayout stack = mock(DimensionStackLayout.class);
Method method = ModdedBiomePolicy.class.getDeclaredMethod("resolveBiomeResolution", Engine.class,
int.class, int.class, int.class, boolean.class, DimensionStackLayout.class);
method.setAccessible(true);
Object resolution = method.invoke(policy, engine, 3, 40, -5, true, stack);
Field biome = resolution.getClass().getDeclaredField("irisBiome");
biome.setAccessible(true);
assertSame(feature, biome.get(resolution));
Field dimension = resolution.getClass().getDeclaredField("dimension");
dimension.setAccessible(true);
assertSame(host, dimension.get(resolution));
verifyNoInteractions(stack);
}
@Test @Test
public void registryOrderAndHolderIdentitySurviveThePolicyBoundaryAndRefreshOnRepoint() { public void registryOrderAndHolderIdentitySurviveThePolicyBoundaryAndRefreshOnRepoint() {
BiomeToken ocean = new BiomeToken("minecraft:ocean"); BiomeToken ocean = new BiomeToken("minecraft:ocean");
@@ -39,6 +39,37 @@ public class ModdedClientPackageIsolationTest {
"art/arcane/iris/nativegen"); "art/arcane/iris/nativegen");
private static final int MINIMUM_SCANNED = 50; private static final int MINIMUM_SCANNED = 50;
@Test
public void clientPresentationCannotAccessPackOrWorldPersistence() throws IOException, URISyntaxException {
Path root = classesRoot();
Path directory = root.resolve(CLIENT_IRIS);
List<String> forbidden = List.of(
"art/arcane/iris/pack/",
"art/arcane/iris/world/storage/",
"art/arcane/iris/world/history/",
"art/arcane/iris/generation/runtime/",
"art/arcane/iris/configuration/",
"java/nio/file/",
"java/nio/channels/FileChannel",
"java/io/File",
"java/io/RandomAccessFile");
List<String> violations = new ArrayList<>();
int scanned = 0;
try (Stream<Path> walk = Files.walk(directory)) {
for (Path classFile : walk.filter(ModdedClientPackageIsolationTest::isClassFile).toList()) {
scanned++;
String bytecode = readAsLatin1(classFile);
for (String dependency : forbidden) {
if (bytecode.contains(dependency)) {
violations.add(root.relativize(classFile) + " -> " + dependency);
}
}
}
}
assertTrue("client presentation classes were not scanned", scanned >= 20);
assertEquals("client presentation can access persistent generation data: " + violations, List.of(), violations);
}
@Test @Test
public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException { public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException {
Path root = classesRoot(); Path root = classesRoot();
@@ -0,0 +1,44 @@
package art.arcane.iris.modded;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockFaces;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
import org.junit.Test;
import org.mockito.MockedStatic;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedDecoratorHooksTest {
@Test
public void faceFixingExcludesTheUpperBoundaryWhileKeepingTheLastRowAccessible() {
NativeBlockState state = mock(NativeBlockState.class);
NativeBlockState lastRow = mock(NativeBlockState.class);
Hunk<NativeBlockState> hunk = mock(Hunk.class);
when(hunk.getHeight()).thenReturn(4);
when(hunk.get(1, 3, 2)).thenReturn(lastRow);
when(hunk.get(1, 4, 2)).thenThrow(new IndexOutOfBoundsException("upper boundary"));
try (MockedStatic<NativeBlockFaces> faces = mockStatic(NativeBlockFaces.class)) {
faces.when(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class)))
.thenAnswer(invocation -> {
NativeBlockFaces.Neighbors neighbors = invocation.getArgument(1);
assertNull(neighbors.secondary(0, 1, 0));
assertSame(lastRow, neighbors.secondary(0, 0, 0));
return state;
});
assertSame(state, new ModdedDecoratorHooks().fixFaces(state, hunk, 1, 2, 99, 3, 108, null));
faces.verify(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class)));
}
verify(hunk, never()).get(1, 4, 2);
verify(hunk).get(1, 3, 2);
}
}
@@ -0,0 +1,78 @@
package art.arcane.iris.modded;
import org.junit.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class ModdedGenPoolTest {
@Test
public void executorLookupDoesNotRestartStoppedPool() throws Exception {
ModdedGenPool.start();
ExecutorService original = ModdedGenPool.pool();
ModdedGenPool.shutdown();
try {
ModdedGenPool.pool();
fail("Stopped generation pool must reject executor lookup");
} catch (RejectedExecutionException expected) {
assertTrue(original.isShutdown());
}
ModdedGenPool.start();
try {
assertNotSame(original, ModdedGenPool.pool());
} finally {
ModdedGenPool.shutdown();
}
}
@Test
public void restartWaitsForPreviousWorkersToTerminate() throws Exception {
ModdedGenPool.start();
ExecutorService original = ModdedGenPool.pool();
CountDownLatch entered = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
original.execute(() -> {
entered.countDown();
boolean released = false;
while (!released) {
try {
release.await();
released = true;
} catch (InterruptedException ignored) {
Thread.interrupted();
}
}
});
assertTrue(entered.await(5L, TimeUnit.SECONDS));
try {
Thread.currentThread().interrupt();
ModdedGenPool.shutdown();
assertTrue(Thread.interrupted());
assertFalse(original.isTerminated());
try {
ModdedGenPool.start();
fail("Generation pool must retain workers until they terminate");
} catch (IllegalStateException expected) {
assertFalse(original.isTerminated());
}
} finally {
Thread.interrupted();
release.countDown();
assertTrue(original.awaitTermination(5L, TimeUnit.SECONDS));
ModdedGenPool.start();
assertNotSame(original, ModdedGenPool.pool());
ExecutorService restarted = ModdedGenPool.pool();
ModdedGenPool.start();
assertSame(restarted, ModdedGenPool.pool());
ModdedGenPool.shutdown();
}
}
}
@@ -0,0 +1,345 @@
package art.arcane.iris.modded;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.loading.ResourceLoader;
import art.arcane.iris.pack.validation.CompatStatus;
import art.arcane.iris.pack.value.IrisRange;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisMarker;
import art.arcane.iris.world.entity.EntitySpawnSeed;
import art.arcane.iris.structure.placement.LootResolver;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnedEntity;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.math.Rarity;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterMarker;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedInitialEntitySpawnTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void initialBatchesIgnoreChunkOrderRuntimeRandomPopulationLightAndCooldowns() {
Map<Long, List<Spawned>> first;
try (Fixture fixture = new Fixture()) {
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
RNG.r.nextLong();
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, 4, -5));
first = fixture.byChunk();
fixture.verifyInitialPolicy();
}
try (Fixture fixture = new Fixture()) {
for (int i = 0; i < 64; i++) {
RNG.r.nextLong();
}
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, 4, -5));
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertEquals(first, fixture.byChunk());
assertEquals(2, fixture.completed.size());
for (List<Spawned> batch : fixture.byChunk().values()) {
assertTrue(batch.size() >= 2 && batch.size() <= 3);
}
fixture.verifyInitialPolicy();
}
}
@Test
public void undergroundAndSurfacePassesShareTheSuccessfulPopulationBudget() {
try (Fixture fixture = new Fixture()) {
BiomeEnvironment environment = fixture.engine.getSurfaceBiomeEnvironment(0, 0);
when(environment.dimension().hasUndergroundSpawners(fixture.engine)).thenReturn(true);
when(fixture.engine.getBiomeOrMantleEnvironment(anyInt(), anyInt(), anyInt())).thenReturn(environment);
NativeBlockState stone = mock(NativeBlockState.class);
NativeBlockState air = mock(NativeBlockState.class);
when(air.isAir()).thenReturn(true);
when(fixture.world.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
int y = invocation.getArgument(1);
return y <= -21 || y == 50 ? stone : air;
});
fixture.queries.when(() -> NativeSpawnQueries.solid(stone)).thenReturn(true);
IrisEntity entity = new IrisEntity().setType("minecraft:cow");
IrisEntitySpawn entry = spy(new IrisEntitySpawn().setMinSpawns(2).setMaxSpawns(2));
doReturn(entity).when(entry).getRealEntity(fixture.engine);
fixture.definitions.add(new IrisSpawner().setGroup(IrisSpawnGroup.CAVE)
.setInitialSpawns(new KList<>(entry)).setMaxEntitiesPerChunk(3));
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertEquals(3, fixture.spawned.size());
assertEquals(2, fixture.spawned.stream().filter(spawn -> spawn.y() == -20).count());
assertEquals(1, fixture.spawned.stream().filter(spawn -> spawn.y() == 51).count());
fixture.queries.verify(() -> NativeSpawnQueries.livingEntityCategories(eq(fixture.world), anyInt(), anyInt()), never());
}
}
@Test
public void initialCompletionFollowsOwnedSpawnPassAndDoesNotRepeatIt() {
try (Fixture fixture = new Fixture()) {
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
int count = fixture.spawned.size();
assertTrue(count > 0);
assertEquals("complete", fixture.trace.getLast());
assertTrue(fixture.trace.subList(0, fixture.trace.size() - 1).stream().allMatch("spawn"::equals));
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertEquals(count, fixture.spawned.size());
assertEquals(1, fixture.completed.size());
}
}
@Test
public void unsafeChunkAndDisabledMobSpawningLeaveCompletionForRetry() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> NativeSpawnQueries.ambientAllowed(fixture.world, -2, 3, true)).thenReturn(false);
assertFalse(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertTrue(fixture.completed.isEmpty());
fixture.queries.when(() -> NativeSpawnQueries.ambientAllowed(fixture.world, -2, 3, true)).thenReturn(true);
fixture.entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(-2), eq(3)))
.thenReturn(false);
assertFalse(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertTrue(fixture.completed.isEmpty());
assertTrue(fixture.spawned.isEmpty());
fixture.entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(-2), eq(3)))
.thenReturn(true);
assertTrue(fixture.manager.initialSpawnChunk(fixture.world, -2, 3));
assertTrue(fixture.completed.contains(key(-2, 3)));
}
}
@Test
@SuppressWarnings("unchecked")
public void initialMarkerSelectionUsesOneStreamAndExcludesDuplicateAndUnsupportedSpawners() throws ReflectiveOperationException {
try (Fixture fixture = new Fixture()) {
IrisSpawner first = fixture.definitions.get(0);
IrisSpawner second = fixture.definitions.get(1);
first.setLoadKey("a");
second.setLoadKey("b");
IrisSpawner excluded = new IrisSpawner();
excluded.setLoadKey("excluded");
excluded.setCompat(CompatStatus.excludedBy(List.of()));
IrisMarker marker = new IrisMarker().setSpawners(new KList<>("b", "excluded", "a", "b"))
.setEmptyAbove(false).setExhaustionChance(0.5);
BiomeEnvironment environment = fixture.engine.getSurfaceBiomeEnvironment(0, 0);
ResourceLoader<IrisSpawner> spawners = environment.data().getSpawnerLoader();
when(spawners.load("a")).thenReturn(first);
when(spawners.load("b")).thenReturn(second);
when(spawners.load("excluded")).thenReturn(excluded);
ResourceLoader<IrisMarker> markers = mock(ResourceLoader.class);
when(environment.data().getMarkerLoader()).thenReturn(markers);
when(markers.load("test")).thenReturn(marker);
when(fixture.engine.getWorld().minHeight()).thenReturn(-64);
when(fixture.engine.getBiomeEnvironment(-25, 15, 57)).thenReturn(environment);
MantleChunk<Matter> chunk = mock(MantleChunk.class);
doAnswer(invocation -> {
Consumer4<Integer, Integer, Integer, MatterMarker> callback = invocation.getArgument(1);
callback.accept(7, 15, 9, new MatterMarker("test"));
return null;
}).when(chunk).iterate(eq(MatterMarker.class), any());
Method resolve = ModdedWorldManager.class.getDeclaredMethod("resolveMarkerSpawners", IrisMarker.class, BiomeEnvironment.class);
resolve.setAccessible(true);
KList<IrisSpawner> resolved = (KList<IrisSpawner>) resolve.invoke(fixture.manager, marker, environment);
assertEquals(List.of(second, first), resolved);
Method prepare = ModdedWorldManager.class.getDeclaredMethod("prepareMarkerSpawns", NativeWorld.class,
int.class, int.class, MantleChunk.class, boolean.class);
prepare.setAccessible(true);
Object prepared = prepare.invoke(fixture.manager, fixture.world, -2, 3, chunk, true);
Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnPreparedMarkers", NativeWorld.class, List.class, boolean.class);
spawn.setAccessible(true);
spawn.invoke(fixture.manager, fixture.world, prepared, true);
RNG rng = EntitySpawnSeed.marker(7721L, -25, -49, 57);
IrisSpawner selected = new KList<>(first, second).getRandom(rng);
IrisEntitySpawn entry = Rarity.expandWeighted(selected.getInitialSpawns()).getRandom(rng);
int count = LootResolver.inclusive(rng, entry.getMinSpawns(), entry.getMaxSpawns());
List<Spawned> expected = new ArrayList<>();
for (int ordinal = 0; ordinal < count; ordinal++) {
expected.add(new Spawned(entry.getRealEntity(fixture.engine).getType(), -25, -48, 57,
EntitySpawnSeed.entity(rng.getSeed(), ordinal).nextLong()));
}
assertEquals(expected, fixture.spawned);
Mantle<Matter> mantle = fixture.engine.getMantle().getMantle();
if (marker.shouldExhaust(EntitySpawnSeed.entity(rng.getSeed(), -1))) {
verify(mantle).remove(-25, 15, 57, MatterMarker.class);
} else {
verify(mantle, never()).remove(anyInt(), anyInt(), anyInt(), eq(MatterMarker.class));
}
}
}
private static long key(int x, int z) {
return (long) x & 0xFFFFFFFFL | (long) z << 32;
}
private record Spawned(String type, int x, int y, int z, long customization) {
}
private static final class Fixture implements AutoCloseable {
private final Engine engine = mock(Engine.class);
private final NativeWorld world = mock(NativeWorld.class);
private final ModdedWorldManager manager = new ModdedWorldManager(engine);
private final MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
private final MockedStatic<NativeSpawnQueries> queries = mockStatic(NativeSpawnQueries.class);
private final MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class);
private final List<Spawned> spawned = new ArrayList<>();
private final List<String> trace = new ArrayList<>();
private final Set<Long> completed = new HashSet<>();
private final List<IrisSpawner> definitions = new ArrayList<>();
@SuppressWarnings("unchecked")
private Fixture() {
IrisSettings settings = new IrisSettings();
settings.getWorld().setAmbientEntitySpawningSystem(true);
settings.getWorld().setMarkerEntitySpawningSystem(false);
configured.when(IrisSettings::get).thenReturn(settings);
EngineMantle engineMantle = mock(EngineMantle.class);
Mantle<Matter> mantle = mock(Mantle.class);
IrisWorld irisWorld = mock(IrisWorld.class);
SeedManager seeds = mock(SeedManager.class);
when(seeds.getEntity()).thenReturn(7721L);
when(engine.getSeedManager()).thenReturn(seeds);
when(engine.getWorld()).thenReturn(irisWorld);
when(engine.getComplex()).thenReturn(mock(IrisComplex.class));
when(engine.getMantle()).thenReturn(engineMantle);
when(engineMantle.getMantle()).thenReturn(mantle);
when(world.nativeHandle()).thenReturn(mock(ServerLevel.class));
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
NativeBlockState support = mock(NativeBlockState.class);
when(world.getBlock(anyInt(), anyInt(), anyInt())).thenReturn(support);
queries.when(() -> NativeSpawnQueries.solid(support)).thenReturn(true);
queries.when(() -> NativeSpawnQueries.ambientAllowed(eq(world), anyInt(), anyInt(), anyBoolean())).thenReturn(true);
queries.when(() -> NativeSpawnQueries.surfaceHeight(eq(world), anyInt(), anyInt(), anyBoolean())).thenReturn(50);
queries.when(() -> NativeSpawnQueries.livingEntityCategories(eq(world), anyInt(), anyInt()))
.thenReturn(Map.of("creature", 1000));
queries.when(() -> NativeSpawnQueries.light(eq(world), anyInt(), anyInt(), anyInt())).thenReturn(15);
entities.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), anyInt(), anyInt())).thenReturn(true);
entities.when(() -> ModdedEntitySpawner.isAreaClearForSpawn(any(NativeEntityRuntime.class),
any(IrisEntity.class), anyInt(), anyInt(), anyInt())).thenReturn(true);
entities.when(() -> ModdedEntitySpawner.spawn(eq(engine), any(IrisEntity.class), any(NativeEntityRuntime.class),
anyInt(), anyInt(), anyInt(), any(RNG.class))).thenAnswer(invocation -> {
IrisEntity entity = invocation.getArgument(1);
RNG rng = invocation.getArgument(6);
spawned.add(new Spawned(entity.getType(), invocation.getArgument(3), invocation.getArgument(4),
invocation.getArgument(5), rng.nextLong()));
trace.add("spawn");
return mock(NativeSpawnedEntity.class);
});
when(mantle.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(mantle.hasFlag(anyInt(), anyInt(), eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG)))
.thenAnswer(invocation -> completed.contains(key(invocation.getArgument(0), invocation.getArgument(1))));
when(mantle.useChunk(anyInt(), anyInt())).thenAnswer(invocation -> {
long key = key(invocation.getArgument(0), invocation.getArgument(1));
MantleChunk<Matter> chunk = mock(MantleChunk.class);
doAnswer(flag -> {
completed.add(key);
trace.add("complete");
return null;
}).when(chunk).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any());
return chunk;
});
IrisData data = mock(IrisData.class);
ResourceLoader<IrisSpawner> loader = mock(ResourceLoader.class);
when(data.getSpawnerLoader()).thenReturn(loader);
IrisBiome biome = mock(IrisBiome.class);
when(biome.getInferredType()).thenReturn(InferredType.LAND);
when(biome.getEntitySpawners()).thenReturn(new KList<>());
IrisRegion region = mock(IrisRegion.class);
when(region.getEntitySpawners()).thenReturn(new KList<>());
IrisDimension dimension = mock(IrisDimension.class);
when(dimension.getEntitySpawners()).thenReturn(new KList<>("land"));
for (String type : List.of("minecraft:cow", "minecraft:pig")) {
IrisEntity entity = new IrisEntity().setType(type);
IrisEntitySpawn entry = spy(new IrisEntitySpawn().setMinSpawns(2).setMaxSpawns(5));
doReturn(entity).when(entry).getRealEntity(engine);
IrisSpawner spawner = spy(new IrisSpawner().setInitialSpawns(new KList<>(entry))
.setMaxEntitiesPerChunk(3).setAllowedLightLevels(new IrisRange(0, 0)));
doReturn(false).when(spawner).canSpawn(eq(engine), anyInt(), anyInt());
definitions.add(spawner);
}
when(loader.loadAll(any(KList.class))).thenAnswer(invocation -> {
KList<String> names = invocation.getArgument(0);
return names.contains("land") ? new KList<>(definitions) : new KList<>();
});
BiomeEnvironment environment = new BiomeEnvironment(1L, biome, region, dimension, data);
when(engine.getSurfaceBiomeEnvironment(anyInt(), anyInt())).thenReturn(environment);
when(engine.openBiomeEnvironmentScope(environment)).thenReturn(mock(BiomeEnvironment.Scope.class));
}
private Map<Long, List<Spawned>> byChunk() {
Map<Long, List<Spawned>> output = new HashMap<>();
for (Spawned spawn : spawned) {
output.computeIfAbsent(key(spawn.x() >> 4, spawn.z() >> 4), ignored -> new ArrayList<>()).add(spawn);
}
return output;
}
private void verifyInitialPolicy() {
queries.verify(() -> NativeSpawnQueries.livingEntityCategories(eq(world), anyInt(), anyInt()), never());
queries.verify(() -> NativeSpawnQueries.light(eq(world), anyInt(), anyInt(), anyInt()), never());
for (IrisSpawner spawner : definitions) {
verify(spawner, never()).canSpawn(eq(engine), anyInt(), anyInt());
verify(spawner, never()).spawn(eq(engine), anyInt(), anyInt());
}
}
@Override
public void close() {
manager.close();
entities.close();
queries.close();
configured.close();
}
}
}
@@ -0,0 +1,195 @@
package art.arcane.iris.modded;
import art.arcane.iris.modded.command.ModdedPackCommands;
import art.arcane.iris.pack.BrokenPackException;
import art.arcane.iris.pack.PackValidationRegistry;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackValidationResult;
import art.arcane.iris.pack.PackValidator;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.attribute.FileTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.times;
public class ModdedPackValidationPublicationTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@After
public void clearValidation() {
PackValidationRegistry.clear();
}
@Test
public void repeatedSnapshotValidationParsesUnchangedPackOnce() throws Exception {
File root = temporaryFolder.newFolder("snapshot");
PackValidationResult result = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenReturn(result);
assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath()));
assertSame(result, ModdedGenerationHistoryStorage.validatePack(root.toPath()));
validator.verify(() -> PackValidator.validate(root), times(1));
}
}
@Test
public void snapshotValidationRejectsChangedBytesWithPreservedTimestamp() throws Exception {
File root = temporaryFolder.newFolder("snapshot");
File content = new File(root, "content.json");
Files.writeString(content.toPath(), "valid");
FileTime timestamp = Files.getLastModifiedTime(content.toPath());
PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(), 1L);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenReturn(loadable(root), broken);
ModdedGenerationHistoryStorage.validatePack(root.toPath());
Files.writeString(content.toPath(), "wrong");
Files.setLastModifiedTime(content.toPath(), timestamp);
assertThrows(BrokenPackException.class,
() -> ModdedGenerationHistoryStorage.validatePack(root.toPath()));
assertThrows(BrokenPackException.class,
() -> ModdedGenerationHistoryStorage.validatePack(root.toPath()));
validator.verify(() -> PackValidator.validate(root), times(2));
}
}
@Test
public void snapshotValidationReparsesChangedRegistryContext() throws Exception {
File root = temporaryFolder.newFolder("snapshot");
AtomicReference<String> context = new AtomicReference<>("first");
PackValidationResult first = loadable(root);
PackValidationResult second = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<PackValidationCache> cache = mockStatic(PackValidationCache.class)) {
cache.when(PackValidationCache::contextFingerprint).thenAnswer(ignored -> context.get());
validator.when(() -> PackValidator.validate(root)).thenReturn(first, second);
assertSame(first, ModdedGenerationHistoryStorage.validatePack(root.toPath()));
context.set("second");
assertSame(second, ModdedGenerationHistoryStorage.validatePack(root.toPath()));
validator.verify(() -> PackValidator.validate(root), times(2));
}
}
@Test
public void validationPublishesTheCanonicalRootAndPackName() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File equivalent = new File(root, ".");
PackValidationResult result = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(equivalent)).thenReturn(result);
assertSame(result, ModdedStartup.validatePack(equivalent));
assertSame(result, PackValidationRegistry.requireLoadable(root.toPath()));
assertSame(result, PackValidationRegistry.requireLoadable(root.getName()));
}
}
@Test
public void rootInvalidationCannotReusePackNameValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
PackValidationRegistry.publish(loadable(root));
PackValidationResult replacement = loadable(root);
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedPackCommands> commands = mockStatic(ModdedPackCommands.class)) {
commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile());
validator.when(() -> PackValidator.validate(root)).thenReturn(replacement);
assertSame(replacement, ModdedStartup.requirePackForWorldCreation(root.getName()));
validator.verify(() -> PackValidator.validate(root));
assertSame(replacement, PackValidationRegistry.requireLoadable(root.toPath()));
}
}
@Test
public void changedContentWithPreservedTimestampCannotReuseValidation() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "valid");
PackValidationResult original = loadable(root);
PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(),
System.currentTimeMillis());
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedPackCommands> commands = mockStatic(ModdedPackCommands.class)) {
commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile());
validator.when(() -> PackValidator.validate(root)).thenReturn(original, broken);
ModdedStartup.validatePack(root);
Files.writeString(content.toPath(), "wrong");
Files.setLastModifiedTime(content.toPath(), FileTime.fromMillis(1L));
Files.setLastModifiedTime(root.toPath(), FileTime.fromMillis(1L));
assertThrows(BrokenPackException.class,
() -> ModdedStartup.requirePackForWorldCreation(root.getName()));
}
}
@Test
public void externalEditDuringValidationCannotPublishMixedSnapshot() throws Exception {
File root = temporaryFolder.newFolder("terrain");
File content = new File(root, "dimension.json");
Files.writeString(content.toPath(), "original");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
Files.writeString(content.toPath(), "changed");
return loadable(root);
});
assertThrows(BrokenPackException.class, () -> ModdedStartup.validatePack(root));
assertNull(PackValidationRegistry.get(root.toPath()));
}
}
@Test
public void mutationDuringValidationPreventsStalePublication() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
return loadable(root);
}
});
try {
ModdedStartup.validatePack(root);
fail("Changed pack must reject stale validation");
} catch (BrokenPackException expected) {
assertNull(PackValidationRegistry.get(root.toPath()));
assertNull(PackValidationRegistry.get(root.getName()));
}
}
}
@Test
public void validationFailurePublishesBlockedRoot() throws Exception {
File root = temporaryFolder.newFolder("terrain");
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
validator.when(() -> PackValidator.validate(root)).thenThrow(new IllegalStateException("Invalid terrain"));
PackValidationResult result = ModdedStartup.validatePack(root);
assertFalse(result.isLoadable());
assertSame(result, PackValidationRegistry.get(root.toPath()));
assertSame(result, PackValidationRegistry.get(root.getName()));
}
}
private static PackValidationResult loadable(File root) {
return new PackValidationResult(root.getName(), List.of(), List.of(), System.currentTimeMillis());
}
}
@@ -4,7 +4,10 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.After; import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock; import org.mockito.invocation.InvocationOnMock;
@@ -38,6 +41,12 @@ public class ModdedPrimaryWorldRouterTest {
private static final int TICK_INTERVAL = 20; private static final int TICK_INTERVAL = 20;
private static final String PRIMARY = "iris:primary"; private static final String PRIMARY = "iris:primary";
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@After @After
public void clearRouter() { public void clearRouter() {
ModdedPrimaryWorldRouter.clear(); ModdedPrimaryWorldRouter.clear();
@@ -233,6 +242,41 @@ public class ModdedPrimaryWorldRouterTest {
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L); return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
} }
@Test
public void disconnectCancelsOldRoutingWithoutAffectingReconnectedPlayer() {
NativeModdedServer server = mock(NativeModdedServer.class);
NativeWorld overworld = mock(NativeWorld.class);
NativeWorld primary = mock(NativeWorld.class);
when(overworld.nativeHandle()).thenReturn(new Object());
when(primary.nativeHandle()).thenReturn(new Object());
when(server.overworld()).thenReturn(overworld);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
UUID id = UUID.randomUUID();
when(player.id()).thenReturn(id);
when(player.isInWorld(overworld)).thenReturn(true);
onlinePlayers(server, player);
CompletableFuture<Boolean> oldRoute = new CompletableFuture<>();
CompletableFuture<Boolean> newRoute = new CompletableFuture<>();
ModdedModConfig config = config(true, PRIMARY);
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
MockedStatic<NativeWorldTeleport> teleport = mockStatic(NativeWorldTeleport.class)) {
configs.when(ModdedModConfig::get).thenReturn(config);
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary);
teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenReturn(oldRoute, newRoute);
evaluate(server);
ModdedPrimaryWorldRouter.forget(id);
assertTrue(oldRoute.isCancelled());
evaluate(server);
oldRoute.complete(true);
evaluate(server);
teleport.verify(() -> NativeWorldTeleport.teleport(any(), any()), times(2));
assertFalse(newRoute.isDone());
ModdedPrimaryWorldRouter.clear();
assertTrue(newRoute.isCancelled());
}
}
private static void evaluate(NativeModdedServer server) { private static void evaluate(NativeModdedServer server) {
for (int tick = 0; tick < TICK_INTERVAL; tick++) { for (int tick = 0; tick < TICK_INTERVAL; tick++) {
ModdedPrimaryWorldRouter.tick(server); ModdedPrimaryWorldRouter.tick(server);
@@ -5,11 +5,17 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld; import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime; import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries;
import art.arcane.iris.configuration.IrisSettings; import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.pack.loading.IrisData; import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.loading.ResourceLoader;
import art.arcane.iris.generation.runtime.BiomeEnvironment; import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine; import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.SeedManager;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.world.history.SavedBiomeUnavailableException; import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiome;
@@ -72,6 +78,15 @@ public class ModdedSavedBiomeConsumersTest {
when(mantle.isChunkLoaded(2, -1)).thenReturn(true); when(mantle.isChunkLoaded(2, -1)).thenReturn(true);
when(mantle.useChunk(2, -1)).thenReturn(chunk); when(mantle.useChunk(2, -1)).thenReturn(chunk);
BiomeEnvironment environment = environment(); BiomeEnvironment environment = environment();
ResourceLoader<IrisSpawner> spawners = mock(ResourceLoader.class);
when(environment.data().getSpawnerLoader()).thenReturn(spawners);
when(spawners.loadAll(any(KList.class))).thenReturn(new KList<>());
when(environment.dimension().getEntitySpawners()).thenReturn(new KList<>());
when(environment.region().getEntitySpawners()).thenReturn(new KList<>());
when(environment.biome().getEntitySpawners()).thenReturn(new KList<>());
when(engine.getSeedManager()).thenReturn(mock(SeedManager.class));
when(engine.getComplex()).thenReturn(mock(IrisComplex.class));
when(engine.openBiomeEnvironmentScope(environment)).thenReturn(mock(BiomeEnvironment.Scope.class));
when(engine.getSurfaceBiomeEnvironment(40, -8)) when(engine.getSurfaceBiomeEnvironment(40, -8))
.thenThrow(new SavedBiomeUnavailableException("Loading", true)) .thenThrow(new SavedBiomeUnavailableException("Loading", true))
.thenReturn(environment); .thenReturn(environment);
@@ -81,8 +96,10 @@ public class ModdedSavedBiomeConsumersTest {
ModdedWorldManager manager = new ModdedWorldManager(engine); ModdedWorldManager manager = new ModdedWorldManager(engine);
try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class); try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
MockedStatic<ModdedEntitySpawner> spawns = mockStatic(ModdedEntitySpawner.class)) { MockedStatic<ModdedEntitySpawner> spawns = mockStatic(ModdedEntitySpawner.class);
MockedStatic<NativeSpawnQueries> queries = mockStatic(NativeSpawnQueries.class)) {
configured.when(IrisSettings::get).thenReturn(settings); configured.when(IrisSettings::get).thenReturn(settings);
queries.when(() -> NativeSpawnQueries.ambientAllowed(any(NativeWorld.class), eq(2), eq(-1), eq(true))).thenReturn(true);
spawns.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(2), eq(-1))).thenReturn(true); spawns.when(() -> ModdedEntitySpawner.chunksSafe(any(NativeEntityRuntime.class), eq(2), eq(-1))).thenReturn(true);
assertThrows(SavedBiomeUnavailableException.class, () -> manager.initialSpawnChunk(new ModdedPlatformWorld(level), 2, -1)); assertThrows(SavedBiomeUnavailableException.class, () -> manager.initialSpawnChunk(new ModdedPlatformWorld(level), 2, -1));
verify(chunk, never()).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any()); verify(chunk, never()).raiseFlagUnchecked(eq(ModdedWorldManager.INITIAL_SPAWN_COMPLETION_FLAG), any());
@@ -57,6 +57,11 @@ public class ModdedStartupReinjectFailureTest {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class); NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
ModdedStartup.warnStartupFailuresTo(operator); ModdedStartup.warnStartupFailuresTo(operator);
ModdedStartup.warnStartupFailuresTo(player); ModdedStartup.warnStartupFailuresTo(player);
NativeProtocolPlayer owner = mock(NativeProtocolPlayer.class);
when(owner.isServerOwner()).thenReturn(true);
ModdedStartup.warnStartupFailuresTo(owner);
verify(owner).sendMessage(argThat((String message) -> message.contains("iris:moon")
&& message.contains("generation history is unusable")));
verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon") verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
&& message.contains("generation history is unusable"))); && message.contains("generation history is unusable")));
@@ -37,7 +37,7 @@ public class ModdedStructureBindingOrderTest {
} }
@Test @Test
public void initialBindingPublishesReadyEngineBeforeStartingStructureSearches() throws Exception { public void disabledStructuresAllowInitialBindingBeforeStartingStructureSearches() throws Exception {
NativeModdedChunkGenerator<?, ?, ?> nativeGenerator = mock(NativeModdedChunkGenerator.class); NativeModdedChunkGenerator<?, ?, ?> nativeGenerator = mock(NativeModdedChunkGenerator.class);
ModdedEngineBinding<Engine> binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS); ModdedEngineBinding<Engine> binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS);
IrisModdedChunkGenerator generator = generator(nativeGenerator, binding); IrisModdedChunkGenerator generator = generator(nativeGenerator, binding);
@@ -45,7 +45,7 @@ public class ModdedStructureBindingOrderTest {
NativeModdedServer server = mock(NativeModdedServer.class); NativeModdedServer server = mock(NativeModdedServer.class);
Engine engine = readyEngine(); Engine engine = readyEngine();
when(nativeGenerator.represents(world)).thenReturn(true); when(nativeGenerator.represents(world)).thenReturn(true);
when(server.generateStructures()).thenReturn(true); when(server.generateStructures()).thenReturn(false);
doAnswer(invocation -> { doAnswer(invocation -> {
assertSame(engine, generator.awaitStructureEngine()); assertSame(engine, generator.awaitStructureEngine());
assertSame(engine, binding.await("overworld")); assertSame(engine, binding.await("overworld"));
@@ -88,7 +88,7 @@ public class ModdedStructureBindingOrderTest {
} }
@Test @Test
public void repointPublishesReplacementBindingBeforeStartingStructureSearches() throws Exception { public void disabledStructuresAllowReplacementBindingBeforeStartingStructureSearches() throws Exception {
NativeModdedChunkGenerator<?, ?, ?> nativeGenerator = mock(NativeModdedChunkGenerator.class); NativeModdedChunkGenerator<?, ?, ?> nativeGenerator = mock(NativeModdedChunkGenerator.class);
ModdedEngineBinding<Engine> binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS); ModdedEngineBinding<Engine> binding = new ModdedEngineBinding<>(1L, TimeUnit.MILLISECONDS);
IrisModdedChunkGenerator generator = generator(nativeGenerator, binding); IrisModdedChunkGenerator generator = generator(nativeGenerator, binding);
@@ -96,7 +96,7 @@ public class ModdedStructureBindingOrderTest {
NativeModdedServer server = mock(NativeModdedServer.class); NativeModdedServer server = mock(NativeModdedServer.class);
Engine replacement = readyEngine(); Engine replacement = readyEngine();
when(nativeGenerator.represents(world)).thenReturn(true); when(nativeGenerator.represents(world)).thenReturn(true);
when(server.generateStructures()).thenReturn(true); when(server.generateStructures()).thenReturn(false);
doAnswer(invocation -> { doAnswer(invocation -> {
assertSame(replacement, generator.awaitStructureEngine()); assertSame(replacement, generator.awaitStructureEngine());
assertSame(replacement, binding.await("overworld")); assertSame(replacement, binding.await("overworld"));
@@ -0,0 +1,114 @@
package art.arcane.iris.modded;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.loading.ResourceLoader;
import art.arcane.iris.pack.value.IrisPosition;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeEntityRuntime;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeSpawnQueries;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.math.RNG;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedUndergroundSpawnOwnershipTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void candidateSamplingAndPoolSelectionUseAbsoluteUndergroundOwner() throws Exception {
NativeWorld world = mock(NativeWorld.class);
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
NativeBlockState stone = mock(NativeBlockState.class);
NativeBlockState air = mock(NativeBlockState.class);
when(air.isAir()).thenReturn(true);
when(world.getBlock(anyInt(), anyInt(), anyInt())).thenAnswer(call ->
(int) call.getArgument(1) <= -21 ? stone : air);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(320).build());
IrisBiome cave = new IrisBiome().setEntitySpawners(new KList<>("residents"));
IrisDimension dimension = new IrisDimension();
IrisRegion region = new IrisRegion();
IrisData data = mock(IrisData.class);
@SuppressWarnings("unchecked")
ResourceLoader<IrisSpawner> loader = mock(ResourceLoader.class);
when(data.getSpawnerLoader()).thenReturn(loader);
when(loader.loadAll(dimension.getEntitySpawners())).thenReturn(new KList<>());
when(loader.loadAll(region.getEntitySpawners())).thenReturn(new KList<>());
IrisEntitySpawn entry = mock(IrisEntitySpawn.class);
IrisEntity entity = mock(IrisEntity.class);
when(entry.getRealEntity(engine)).thenReturn(entity);
when(entry.getRarity()).thenReturn(1);
when(entry.getMinSpawns()).thenReturn(1);
when(entry.getMaxSpawns()).thenReturn(1);
when(entity.getSurface()).thenReturn(IrisSurface.LAND);
when(entity.spawnCategory()).thenReturn("creature");
IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE).setInitialSpawns(new KList<>(entry));
when(loader.loadAll(cave.getEntitySpawners())).thenReturn(new KList<>(spawner));
BiomeEnvironment environment = new BiomeEnvironment(9L, cave, region, dimension, data);
when(engine.getBiomeOrMantleEnvironment(anyInt(), eq(44), anyInt())).thenReturn(environment);
BiomeEnvironment.Scope scope = mock(BiomeEnvironment.Scope.class);
when(engine.openBiomeEnvironmentScope(environment)).thenReturn(scope);
NativeEntityRuntime runtime = mock(NativeEntityRuntime.class);
when(runtime.world()).thenReturn(world);
ModdedWorldManager manager = new ModdedWorldManager(engine);
try (MockedStatic<NativeSpawnQueries> queries = mockStatic(NativeSpawnQueries.class);
MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class)) {
queries.when(() -> NativeSpawnQueries.surfaceHeight(eq(world), anyInt(), anyInt(), eq(true))).thenReturn(64);
queries.when(() -> NativeSpawnQueries.solid(stone)).thenReturn(true);
IrisPosition first = ModdedWorldManager.findUndergroundCandidate(world, 2, -1, new RNG(88771L), IrisSurface.LAND);
IrisPosition second = ModdedWorldManager.findUndergroundCandidate(world, 2, -1, new RNG(88771L), IrisSurface.LAND);
assertNotNull(first);
assertEquals(first, second);
assertEquals(-20, first.getY());
assertTrue(first.getX() >= 33 && first.getX() <= 47 && first.getZ() >= -15 && first.getZ() <= -1);
Field field = ModdedWorldManager.class.getDeclaredField("entityRuntime");
field.setAccessible(true);
field.set(manager, runtime);
entities.when(() -> ModdedEntitySpawner.isAreaClearForSpawn(eq(runtime), eq(entity), anyInt(), eq(-20), anyInt()))
.thenReturn(true);
Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnUnderground", NativeWorld.class,
int.class, int.class, boolean.class, Map.class, RNG.class);
spawn.setAccessible(true);
spawn.invoke(manager, world, 2, -1, true, Map.of(), new RNG(88771L));
entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), eq(runtime), anyInt(), eq(-20), anyInt(), any(RNG.class)));
} finally {
manager.close();
}
verify(scope).close();
}
}
@@ -14,6 +14,7 @@ import art.arcane.iris.world.entity.IrisEntity;
import art.arcane.iris.world.entity.IrisEntitySpawn; import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.entity.IrisSpawnGroup; import art.arcane.iris.world.entity.IrisSpawnGroup;
import art.arcane.iris.world.entity.IrisSpawner; import art.arcane.iris.world.entity.IrisSpawner;
import art.arcane.iris.world.entity.IrisEntitySpawn.SpawnContext;
import art.arcane.iris.generation.decoration.IrisSurface; import art.arcane.iris.generation.decoration.IrisSurface;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
@@ -196,7 +197,7 @@ public class ModdedWaterEntitySpawnTest {
doReturn(entity).when(entry).getRealEntity(engine); doReturn(entity).when(entry).getRealEntity(engine);
IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE); IrisSpawner spawner = new IrisSpawner().setGroup(IrisSpawnGroup.CAVE);
Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnEntry", NativeWorld.class, Method spawn = ModdedWorldManager.class.getDeclaredMethod("spawnEntry", NativeWorld.class,
IrisEntitySpawn.class, IrisSpawner.class, int.class, int.class, int.class); IrisEntitySpawn.class, int.class, int.class, int.class, RNG.class, SpawnContext.class);
spawn.setAccessible(true); spawn.setAccessible(true);
ModdedWorldManager manager = new ModdedWorldManager(engine); ModdedWorldManager manager = new ModdedWorldManager(engine);
try (MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class)) { try (MockedStatic<ModdedEntitySpawner> entities = mockStatic(ModdedEntitySpawner.class)) {
@@ -205,7 +206,7 @@ public class ModdedWaterEntitySpawnTest {
entities.when(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class), entities.when(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class),
eq(-25), eq(-49), eq(57), any(RNG.class))).thenReturn(mock(NativeSpawnedEntity.class)); eq(-25), eq(-49), eq(57), any(RNG.class))).thenReturn(mock(NativeSpawnedEntity.class));
assertEquals(1, spawn.invoke(manager, world, entry, spawner, -2, 3, 1)); assertEquals(1, spawn.invoke(manager, world, entry, -2, 3, 1, new RNG(1L), new SpawnContext(spawner, null, false)));
entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class), entities.verify(() -> ModdedEntitySpawner.spawn(eq(engine), eq(entity), any(NativeEntityRuntime.class),
eq(-25), eq(-49), eq(57), any(RNG.class))); eq(-25), eq(-49), eq(57), any(RNG.class)));
@@ -7,7 +7,6 @@ import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
public class NativeStructureFailureContractTest { public class NativeStructureFailureContractTest {
@@ -17,21 +16,6 @@ public class NativeStructureFailureContractTest {
Bootstrap.bootStrap(); Bootstrap.bootStrap();
} }
@Test
public void globalStructureDisableRefusesGeneratorBinding() {
IrisModdedChunkGenerator.requireGlobalStructureGeneration(true, "overworld:overworld");
IllegalStateException error = assertThrows(
IllegalStateException.class,
() -> IrisModdedChunkGenerator.requireGlobalStructureGeneration(
false, "overworld:overworld"));
assertTrue(error.getMessage().contains("overworld:overworld"));
assertTrue(error.getMessage().contains("generate-structures=false"));
assertTrue(error.getMessage().contains("importedStructures.disabled"));
assertTrue(error.getMessage().contains("importedStructures.disabledExact"));
}
@Test @Test
public void structureFailurePreservesPhaseIdentityChunkAndCause() { public void structureFailurePreservesPhaseIdentityChunkAndCause() {
IllegalArgumentException cause = new IllegalArgumentException("broken placement"); IllegalArgumentException cause = new IllegalArgumentException("broken placement");
@@ -21,6 +21,8 @@ import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.Pose;
import org.junit.BeforeClass; import org.junit.BeforeClass;
import org.junit.Test; import org.junit.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
@@ -98,6 +100,7 @@ public class IrisModdedCommandsTeleportTest {
when(server.getLevel(dimension)).thenReturn(serverLevel); when(server.getLevel(dimension)).thenReturn(serverLevel);
ServerPlayer serverPlayer = mock(ServerPlayer.class); ServerPlayer serverPlayer = mock(ServerPlayer.class);
when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID()); when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID());
when(serverPlayer.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
NativeWorld level = mock(NativeWorld.class); NativeWorld level = mock(NativeWorld.class);
when(level.nativeHandle()).thenReturn(serverLevel); when(level.nativeHandle()).thenReturn(serverLevel);
@@ -0,0 +1,93 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.context.IrisContext;
import art.arcane.iris.generation.locator.BiomeLocator;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.GenerationSessionLease;
import art.arcane.iris.generation.subterrain.SubterrainPosition;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.util.math.Position2;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
public class ModdedCaveLocateTest {
@Test
public void caveTeleportUsesValidatedUndergroundPosition() throws Exception {
Fixture fixture = new Fixture();
when(fixture.player.teleport(fixture.world, 8.5, -42, 8.5)).thenReturn(true);
try (MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class);
MockedStatic<IrisContext> context = mockStatic(IrisContext.class)) {
fixture.context(context);
fixture.teleport();
verify(fixture.player).teleport(fixture.world, 8.5, -42, 8.5);
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()), never());
}
}
@Test
public void failedTeleportNeverReportsSuccess() throws Exception {
Fixture fixture = new Fixture();
try (MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class);
MockedStatic<IrisContext> context = mockStatic(IrisContext.class)) {
fixture.context(context);
fixture.teleport();
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()));
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), never());
}
}
@Test
public void worldChangeCancelsBeforeCaveLookupOrTeleport() throws Exception {
Fixture fixture = new Fixture();
when(fixture.player.inWorld(fixture.world)).thenReturn(false);
try (MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class)) {
fixture.teleport();
verifyNoInteractions(fixture.locator);
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()));
}
}
private static final class Fixture {
private final NativeCommandSource source = mock(NativeCommandSource.class);
private final NativeWorld world = mock(NativeWorld.class);
private final NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
private final Engine engine = mock(Engine.class);
private final BiomeLocator locator = mock(BiomeLocator.class);
private Fixture() throws Exception {
when(player.connected()).thenReturn(true);
when(player.inWorld(world)).thenReturn(true);
when(engine.acquireGenerationLease("modded_locator_teleport")).thenReturn(mock(GenerationSessionLease.class));
SubterrainPosition target = new SubterrainPosition(8, -40, 8);
when(locator.position(engine, new Position2(0, 0))).thenReturn(target);
when(locator.landing(engine, world, target, Optional.empty())).thenReturn(new SubterrainPosition(8, -42, 8));
}
private void context(MockedStatic<IrisContext> context) {
context.when(() -> IrisContext.open(eq(engine), anyLong(), any())).thenReturn(mock(IrisContext.Scope.class));
}
private void teleport() {
ModdedLocateCommands.teleportToLocateResult(source, world, engine, player, "biome deep", locator,
new Position2(0, 0), Optional.of(new ModdedLocateCommands.CaveDestination(
new SubterrainPosition(8, -40, 8), Optional.empty())));
}
}
}
@@ -0,0 +1,212 @@
package art.arcane.iris.modded.service;
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
import art.arcane.iris.pack.BuiltInPackUpdates;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import net.minecraft.SharedConstants;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedPackUpdateServiceTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void pendingCheckNotifiesOnlineAndLaterOperatorsOnceOnServerTick() {
CompletableFuture<ModdedPackUpdateService.PackCheck> pending = new CompletableFuture<>();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> pending);
NativeProtocolPlayer online = player(true, false);
NativeModdedServer server = server(online);
File root = new File("config/irisworldgen/packs").getAbsoluteFile();
try (MockedStatic<IrisPlatforms> platforms = platform(root)) {
service.onEnable();
service.onServerTick(server);
service.notifyPlayer(online);
verify(online, never()).sendMessage(anyString());
pending.complete(outdated());
verify(online, never()).sendMessage(anyString());
service.onServerTick(server);
service.onServerTick(server);
service.notifyPlayer(online);
verify(online, times(1)).sendMessage(contains("installed v1, latest built-in release v2"));
verify(online, times(1)).sendMessage(contains(new File(root, "overworld").toString()));
verify(online, times(1)).sendMessage(contains("/iris download pack=overworld overwrite=true"));
verify(online, times(1)).sendMessage(contains("Back up the complete world"));
verify(online, times(1)).sendMessage(contains("/iris world update <dimension> overworld"));
verify(online, times(1)).sendMessage(contains("restart the server to activate it"));
verify(online, never()).sendMessage(contains("plugins/"));
NativeProtocolPlayer later = player(true, false);
service.notifyPlayer(later);
verify(later).sendMessage(contains("release metadata is outdated"));
service.onDisable();
}
}
@Test
public void integratedOwnerWithoutCheatsReceivesNoticeAndRemoteOrdinaryPlayersDoNot() {
NativeProtocolPlayer owner = player(false, true);
NativeProtocolPlayer remote = player(false, false);
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(outdated()));
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server(owner));
verify(owner).sendMessage(contains("release metadata is outdated"));
service.notifyPlayer(remote);
verify(remote, never()).sendMessage(anyString());
service.onDisable();
}
}
@Test
public void disableCancelsCheckAndRestartDoesNotPublishOldNotices() {
CompletableFuture<ModdedPackUpdateService.PackCheck> first = new CompletableFuture<>();
CompletableFuture<ModdedPackUpdateService.PackCheck> second = new CompletableFuture<>();
AtomicInteger checks = new AtomicInteger();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0 ? first : second);
NativeProtocolPlayer operator = player(true, false);
NativeModdedServer server = server(operator);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server);
service.onDisable();
assertTrue(first.isCancelled());
assertFalse(first.complete(outdated()));
service.onEnable();
service.onServerTick(server);
second.complete(new ModdedPackUpdateService.PackCheck(List.of(), Map.of()));
service.onServerTick(server);
verify(operator, never()).sendMessage(anyString());
assertEquals(2, checks.get());
service.onDisable();
}
}
@Test
public void currentCustomUnknownAndUnavailableVersionsNeverBecomeOutdated() {
ModdedPackUpdateService.PackCheck result = new ModdedPackUpdateService.PackCheck(List.of(
new SplashPackMetadata("overworld", "unknown"),
new SplashPackMetadata("underworld", "3"),
new SplashPackMetadata("custom", "1")), Map.of(
"overworld", new BuiltInPackUpdates.Update("2"),
"underworld", new BuiltInPackUpdates.Update("2")));
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(result));
NativeProtocolPlayer operator = player(true, false);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server(operator));
verify(operator, never()).sendMessage(anyString());
service.onDisable();
}
}
@Test
public void successfulAuthoringUpdateClearsOldNoticesAndRescansInstalledVersion() {
CompletableFuture<ModdedPackUpdateService.PackCheck> current = new CompletableFuture<>();
AtomicInteger checks = new AtomicInteger();
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0
? CompletableFuture.completedFuture(outdated()) : current);
NativeProtocolPlayer operator = player(true, false);
NativeModdedServer server = server(operator);
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
service.onEnable();
service.onServerTick(server);
verify(operator).sendMessage(contains("release metadata is outdated"));
service.refresh();
NativeProtocolPlayer later = player(true, false);
service.notifyPlayer(later);
verify(later, never()).sendMessage(anyString());
service.onServerTick(server);
current.complete(new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "2")),
Map.of("overworld", new BuiltInPackUpdates.Update("2"))));
service.onServerTick(server);
service.notifyPlayer(later);
verify(later, never()).sendMessage(anyString());
assertEquals(2, checks.get());
service.onDisable();
}
}
@Test
public void nativeOwnerCheckDoesNotGrantGameMasterPermissions() {
ServerPlayer handle = mock(ServerPlayer.class);
ServerLevel level = mock(ServerLevel.class);
MinecraftServer server = mock(MinecraftServer.class);
when(handle.level()).thenReturn(level);
when(level.getServer()).thenReturn(server);
when(handle.permissions()).thenReturn(LevelBasedPermissionSet.ALL);
NativeProtocolPlayer player = NativeProtocolPlayer.fromHandle(handle);
when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(true);
assertTrue(player.isServerOwner());
assertFalse(player.isGameMaster());
when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(false);
assertFalse(player.isServerOwner());
assertFalse(player.isGameMaster());
}
private static ModdedPackUpdateService.PackCheck outdated() {
return new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "1")),
Map.of("overworld", new BuiltInPackUpdates.Update("2")));
}
private static MockedStatic<IrisPlatforms> platform(File root) {
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.packsFolderNoCreate()).thenReturn(root);
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
platforms.when(IrisPlatforms::get).thenReturn(platform);
return platforms;
}
private static NativeProtocolPlayer player(boolean operator, boolean owner) {
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
when(player.id()).thenReturn(UUID.randomUUID());
when(player.connected()).thenReturn(true);
when(player.isGameMaster()).thenReturn(operator);
when(player.isServerOwner()).thenReturn(owner);
return player;
}
@SuppressWarnings("unchecked")
private static NativeModdedServer server(NativeProtocolPlayer player) {
NativeModdedServer server = mock(NativeModdedServer.class);
when(server.hasPlayerList()).thenReturn(true);
doAnswer(invocation -> {
invocation.getArgument(0, Consumer.class).accept(player);
return null;
}).when(server).forEachPlayer(any());
return server;
}
}
@@ -4,14 +4,65 @@ import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.modded.ModdedScheduler; import art.arcane.iris.modded.ModdedScheduler;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import org.junit.Test; import org.junit.Test;
import org.mockito.InOrder;
import java.lang.reflect.Field;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ModdedStudioHotloadServiceTest { public class ModdedStudioHotloadServiceTest {
@Test
public void shutdownDrainsWorkerBeforeAllowingRestart() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
ExecutorService executor = mock(ExecutorService.class);
setExecutor(service, executor);
when(executor.awaitTermination(30L, TimeUnit.SECONDS)).thenReturn(false);
when(executor.awaitTermination(5L, TimeUnit.SECONDS)).thenReturn(true);
service.onDisable();
InOrder order = inOrder(executor);
order.verify(executor).shutdown();
order.verify(executor).awaitTermination(30L, TimeUnit.SECONDS);
order.verify(executor).shutdownNow();
order.verify(executor).awaitTermination(5L, TimeUnit.SECONDS);
service.onEnable();
service.onDisable();
}
@Test
public void undrainedWorkerPreventsRestart() throws Exception {
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
ExecutorService executor = mock(ExecutorService.class);
setExecutor(service, executor);
when(executor.isShutdown()).thenReturn(true);
try {
service.onDisable();
fail("Hotload shutdown must report a worker that did not terminate");
} catch (IllegalStateException expected) {
verify(executor).shutdownNow();
}
try {
service.onEnable();
fail("Hotload must not start another worker before the previous worker terminates");
} catch (IllegalStateException expected) {
assertFalse(executor.isTerminated());
}
when(executor.isTerminated()).thenReturn(true);
service.onEnable();
service.onDisable();
}
@Test @Test
public void detectsDatapackImportsAcrossLoadedDimensions() { public void detectsDatapackImportsAcrossLoadedDimensions() {
IrisDimension empty = new IrisDimension(); IrisDimension empty = new IrisDimension();
@@ -45,4 +96,10 @@ public class ModdedStudioHotloadServiceTest {
scheduler.shutdown(); scheduler.shutdown();
} }
} }
private static void setExecutor(ModdedStudioHotloadService service, ExecutorService executor) throws Exception {
Field field = ModdedStudioHotloadService.class.getDeclaredField("executor");
field.setAccessible(true);
field.set(service, executor);
}
} }
@@ -0,0 +1,142 @@
package art.arcane.volmlib.nativelib.minecraft26_2.modded;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerChunkCache;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.entity.EntityDimensions;
import net.minecraft.world.entity.Pose;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import org.junit.BeforeClass;
import org.junit.Test;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class NativeTeleportSafetyTest {
@BeforeClass
public static void bootstrap() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
}
@Test
public void rejectsTallAndHazardousSupportsAndAcceptsSolidFloor() {
ServerPlayer player = player();
ServerLevel level = level();
for (BlockState support : new BlockState[]{Blocks.OAK_FENCE.defaultBlockState(),
Blocks.MAGMA_BLOCK.defaultBlockState(), Blocks.CACTUS.defaultBlockState()}) {
setSupport(level, support);
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
}
setSupport(level, Blocks.STONE.defaultBlockState());
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
}
@Test
public void fractionalPositionUsesWholePlayerBodyAndRequiresNeighborChunk() {
ServerPlayer player = player();
ServerLevel level = level();
setSupport(level, Blocks.STONE.defaultBlockState());
when(level.noCollision(any(), any(AABB.class))).thenAnswer(invocation -> {
AABB body = invocation.getArgument(1);
return body.maxX <= 16D;
});
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.5D, 4, 8.5D));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D));
when(level.noCollision(any(), any(AABB.class))).thenReturn(true);
when(level.getChunkSource().hasChunk(1, 0)).thenReturn(false);
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D));
EntityDimensions dimensions = player.getDimensions(Pose.STANDING);
assertEquals(0, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.5D, 4D, 8.5D), new ChunkPos(0, 0)));
assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.9D, 4D, 8.5D), new ChunkPos(0, 0)));
assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(-16.1D, 4D, -8.5D), new ChunkPos(-2, -1)));
}
@Test
public void scaledStandingBodyStaysInsideActualWorldHeight() {
ServerLevel level = level();
setSupport(level, Blocks.STONE.defaultBlockState());
ServerPlayer player = player();
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 13F));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 12F));
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 0, 8.5D));
}
@Test
public void reconnectDuringChunkWarmupDoesNotTeleportReplacementSession() {
MinecraftServer server = mock(MinecraftServer.class, RETURNS_DEEP_STUBS);
when(server.isSameThread()).thenReturn(true);
ServerLevel level = level();
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, Identifier.parse("iris:test"));
when(level.getServer()).thenReturn(server);
when(level.dimension()).thenReturn(key);
when(server.getLevel(key)).thenReturn(level);
ServerChunkCache chunks = level.getChunkSource();
when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(false);
CompletableFuture<Void> loaded = new CompletableFuture<>();
doReturn(loaded).when(chunks).addTicketAndLoadWithRadius(any(), any(), anyInt());
ServerPlayer original = player();
UUID id = UUID.randomUUID();
when(original.getUUID()).thenReturn(id);
when(server.getPlayerList().getPlayer(id)).thenReturn(original);
NativeWorld world = mock(NativeWorld.class);
when(world.nativeHandle()).thenReturn(level);
CompletableFuture<Boolean> result = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(original),
new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), world, 8.5D, 4D, 8.5D, 0L));
assertFalse(result.isDone());
ServerPlayer replacement = player();
when(server.getPlayerList().getPlayer(id)).thenReturn(replacement);
loaded.complete(null);
assertFalse(result.join());
verify(chunks).removeTicketWithRadius(any(), any(), anyInt());
}
private ServerPlayer player() {
ServerPlayer player = mock(ServerPlayer.class);
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
return player;
}
private ServerLevel level() {
ServerLevel level = mock(ServerLevel.class);
ServerChunkCache chunks = mock(ServerChunkCache.class);
when(level.getChunkSource()).thenReturn(chunks);
when(level.getMinY()).thenReturn(0);
when(level.getMaxY()).thenReturn(16);
when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(true);
when(level.noCollision(any(), any(AABB.class))).thenReturn(true);
return level;
}
private void setSupport(ServerLevel level, BlockState support) {
when(level.getBlockState(any(BlockPos.class))).thenAnswer(invocation -> {
BlockPos position = invocation.getArgument(0);
return position.getY() == 3 ? support : Blocks.AIR.defaultBlockState();
});
}
}
Loaded 100 of 406 files, more files were not shown because too many files have changed in this diff. Show more