mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-15 04:54:02 +00:00
Compare commits
115
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3246e88f88 | ||
|
|
09d36ec990 | ||
|
|
718b13df46 | ||
|
|
f17a9e0977 | ||
|
|
9dbb6b6e85 | ||
|
|
ee3af26f11 | ||
|
|
13171edafc | ||
|
|
ba2ecb7c0f | ||
|
|
57882a3f1c | ||
|
|
56178b0e0a | ||
|
|
aa3420b4fa | ||
|
|
cec9568c9a | ||
|
|
5b5f3dae89 | ||
|
|
cb7eea604e | ||
|
|
a207cdd828 | ||
|
|
0d34fae316 | ||
|
|
2889a2dbc9 | ||
|
|
baf64fb9d1 | ||
|
|
0e90d38acb | ||
|
|
acc2619bed | ||
|
|
750ee87c6b | ||
|
|
d34dd48f74 | ||
|
|
99c7559f5d | ||
|
|
65894df632 | ||
|
|
6ff13d11c3 | ||
|
|
7d8968c45a | ||
|
|
4df536a6ca | ||
|
|
54feb80295 | ||
|
|
4f32c78982 | ||
|
|
7dfcd6a989 | ||
|
|
8c0b37c0b8 | ||
|
|
4b44bcb12b | ||
|
|
2e3a41f262 | ||
|
|
45745abaa1 | ||
|
|
ac026343c1 | ||
|
|
78ddaaaf53 | ||
|
|
de90d0ec5e | ||
|
|
fac3b4e106 | ||
|
|
8757355174 | ||
|
|
2af594de61 | ||
|
|
6e77755279 | ||
|
|
12591820e8 | ||
|
|
a521109a6a | ||
|
|
a9f5145c0f | ||
|
|
e3673afb1e | ||
|
|
2145e90b1c | ||
|
|
d480abad50 | ||
|
|
be0cd567ed | ||
|
|
ce7872f667 | ||
|
|
cfa274825f | ||
|
|
e72189adc7 | ||
|
|
7f5ce70f49 | ||
|
|
a2bcd7a302 | ||
|
|
7226fc4400 | ||
|
|
c479e94c49 | ||
|
|
ee5aaec87c | ||
|
|
65ae3f1b45 | ||
|
|
a6149b27c9 | ||
|
|
4b4b418c61 | ||
|
|
8d5cf43139 | ||
|
|
5858eb366d | ||
|
|
be0d3b0156 | ||
|
|
12e8bb5a09 | ||
|
|
a765e76aa3 | ||
|
|
4ef6fc7cc6 | ||
|
|
6b82ea99e2 | ||
|
|
813f3c7bcf | ||
|
|
318c1e100b | ||
|
|
357f14e900 | ||
|
|
99cc2383d6 | ||
|
|
76d71f8344 | ||
|
|
1544c34f44 | ||
|
|
00601678d9 | ||
|
|
700dc53559 | ||
|
|
0faae93049 | ||
|
|
92310ef839 | ||
|
|
5fa6d82b79 | ||
|
|
2078bece40 | ||
|
|
077b48f47f | ||
|
|
7981fa3ce5 | ||
|
|
5c4f7bb521 | ||
|
|
0d16f8e57f | ||
|
|
cf7ebcece1 | ||
|
|
8f8a3b2355 | ||
|
|
ffc409a4ac | ||
|
|
5b5c1975c6 | ||
|
|
25c04c0157 | ||
|
|
1e25c07eac | ||
|
|
ff74a6f811 | ||
|
|
9e07009f1d | ||
|
|
53172c38ad | ||
|
|
658c6b0fde | ||
|
|
b998ff535a | ||
|
|
ba4708f2bb | ||
|
|
bda770650b | ||
|
|
8bccb48c09 | ||
|
|
a9df19cae2 | ||
|
|
4ac49baaff | ||
|
|
38a7e0fdb5 | ||
|
|
7e73e8f599 | ||
|
|
788ac06713 | ||
|
|
622b40f786 | ||
|
|
72eff9b775 | ||
|
|
789644b93f | ||
|
|
f07dfb7a85 | ||
|
|
5bffa6eb04 | ||
|
|
0b6ac19fa7 | ||
|
|
b7e6259a05 | ||
|
|
a0aa62b8c6 | ||
|
|
d80fcedd91 | ||
|
|
41664fc605 | ||
|
|
74df63f717 | ||
|
|
f46041690b | ||
|
|
b8433340c9 | ||
|
|
593c3f732a |
@@ -76,3 +76,8 @@ BUILDS/
|
||||
.compatwt/
|
||||
|
||||
.worktrees/
|
||||
|
||||
/VolmLib/
|
||||
/docs/
|
||||
/.perf/
|
||||
/.codegraph/
|
||||
|
||||
+6
@@ -14,6 +14,7 @@ import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.world.level.LevelHeightAccessor;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
@@ -45,6 +46,11 @@ public final class IrisChunkGeneratorHeightCacheTest {
|
||||
NativeBlockTags.bindHeightmapFixtures();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void restoreBlockTags() {
|
||||
NativeBlockTags.restoreTags();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedNativeHeightUsesLiveHeightQueriesAcrossRuntimeChanges() throws Exception {
|
||||
Fixture fixture = fixture();
|
||||
|
||||
+6
@@ -20,6 +20,7 @@ import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.RotatedPillarBlock;
|
||||
import net.minecraft.world.level.block.LiquidBlock;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
@@ -46,6 +47,11 @@ public final class NativeTransitionColumnTest {
|
||||
NativeBlockTags.bindHeightmapFixtures();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void restoreBlockTags() {
|
||||
NativeBlockTags.restoreTags();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
|
||||
String key = "itemsadder:rocks/ruby_ore";
|
||||
|
||||
+17
@@ -1,21 +1,30 @@
|
||||
package art.arcane.iris.nativegen.v26_3_R1;
|
||||
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.HolderSet;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.tags.BlockTags;
|
||||
import net.minecraft.tags.TagLoader;
|
||||
import net.minecraft.tags.TagKey;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class NativeBlockTags {
|
||||
private static Map<TagKey<Block>, List<Holder<Block>>> originalTags;
|
||||
|
||||
private NativeBlockTags() {
|
||||
}
|
||||
|
||||
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(),
|
||||
Blocks.OAK_LOG.builtInRegistryHolder(), Blocks.SPRUCE_LOG.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_NO_LEAVES, solid))).apply();
|
||||
}
|
||||
|
||||
public static void restoreTags() {
|
||||
if (originalTags == null) {
|
||||
return;
|
||||
}
|
||||
BuiltInRegistries.BLOCK.prepareTagReload(new TagLoader.LoadResult<>(Registries.BLOCK, originalTags)).apply();
|
||||
originalTags = null;
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -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.SwampHutPiece;
|
||||
import net.minecraft.world.level.levelgen.structure.structures.SwampHutStructure;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -68,6 +69,11 @@ public class WorldgenTerrainHeightmapsTest {
|
||||
NativeBlockTags.bindHeightmapFixtures();
|
||||
}
|
||||
|
||||
@AfterClass
|
||||
public static void restoreBlockTags() {
|
||||
NativeBlockTags.restoreTags();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authoringHeightmapsReadPlacedObjectsAndFluidsWithoutTerrainResolvers() {
|
||||
ProtoChunk chunk = terrainChunk(new ChunkPos(0, 0));
|
||||
|
||||
+4
@@ -13,6 +13,7 @@ import com.google.gson.JsonParser;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.File;
|
||||
@@ -26,6 +27,9 @@ import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
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 NATURAL_MOB_SPAWNS = "minecraft:gameplay/natural_mob_spawns";
|
||||
private static final GenerationRegistryContract.PhysicalResourceKey INSTALLED =
|
||||
|
||||
+4
@@ -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.storage.LevelStorageSource;
|
||||
import org.junit.Rule;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.bukkit.craftbukkit.generator.CustomChunkGenerator;
|
||||
@@ -49,6 +50,9 @@ import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class HeadlessChunkGeneratorTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
|
||||
+4
@@ -31,6 +31,7 @@ import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemp
|
||||
import net.minecraft.world.level.storage.LevelStorageSource;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.Rule;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
@@ -48,6 +49,9 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class HeadlessChunkLightingTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
|
||||
+4
@@ -38,6 +38,7 @@ import net.minecraft.world.ticks.ScheduledTick;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.Rule;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
@@ -54,6 +55,9 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HeadlessChunkSerializationTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
|
||||
+4
@@ -9,6 +9,7 @@ import net.minecraft.world.item.Items;
|
||||
|
||||
import java.util.Map;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.bukkit.Bukkit;
|
||||
|
||||
@@ -18,6 +19,9 @@ import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class HeadlessNativeBootstrapTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Test
|
||||
public void bindsNativeItemComponentsWithoutStartingServer() throws Exception {
|
||||
HeadlessNativeBootstrap.bindComponents(HeadlessNativeTestRegistries.get());
|
||||
|
||||
+4
@@ -7,12 +7,16 @@ import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.world.level.biome.Biome;
|
||||
import net.minecraft.world.level.levelgen.structure.Structure;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class HeadlessNativeRegistriesTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Test
|
||||
public void nativeStructureBiomeTagsUseTheLoadedBiomeHolders() throws Exception {
|
||||
RegistryAccess registries = HeadlessNativeTestRegistries.get();
|
||||
|
||||
+4
@@ -4,6 +4,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeRegistryAccess;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -17,6 +18,9 @@ import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public final class HeadlessNativeRegistryTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
HeadlessNativeBootstrap.initialize();
|
||||
|
||||
+4
@@ -11,6 +11,7 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeTileReader;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.data.registries.VanillaRegistries;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
@@ -23,6 +24,9 @@ import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
public final class HeadlessNativeTileTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
HeadlessNativeBootstrap.initialize();
|
||||
|
||||
+4
@@ -17,6 +17,7 @@ import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.Rule;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.spigotmc.SpigotWorldConfig;
|
||||
@@ -36,6 +37,9 @@ import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class HeadlessRegionTerrainTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
|
||||
+4
@@ -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.RegionStorageInfo;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.DataInputStream;
|
||||
@@ -38,6 +39,9 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class NativeRegionTerrainWriterTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
|
||||
+4
@@ -26,6 +26,7 @@ import net.minecraft.world.level.material.Fluids;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.junit.AfterClass;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -40,6 +41,9 @@ import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class OfflineNativeFeatureFeasibilityTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
private static HeadlessNativeRegistries loaded;
|
||||
private static RegistryAccess registries;
|
||||
private static DimensionType dimension;
|
||||
|
||||
+4
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.probe;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.ClassRule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
@@ -22,6 +23,9 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public final class OfflineRegionPublicationTest {
|
||||
@ClassRule
|
||||
public static final PaperRegistryIsolation registryIsolation = new PaperRegistryIsolation();
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporary = new TemporaryFolder();
|
||||
|
||||
|
||||
+37
@@ -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;
|
||||
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import art.arcane.volmlib.util.diagnostics.BukkitDebugDump;
|
||||
import art.arcane.iris.generation.runtime.IrisEngineEffects;
|
||||
import art.arcane.iris.generation.runtime.IrisWorldManager;
|
||||
@@ -36,6 +37,7 @@ import art.arcane.iris.world.PendingWorldDeleteQueue;
|
||||
import art.arcane.iris.world.PendingWorldReplacementManager;
|
||||
import art.arcane.iris.world.WorldRefusalReporter;
|
||||
import art.arcane.iris.configuration.SettingsHotloadWatch;
|
||||
import art.arcane.iris.update.IrisUpdateNotifier;
|
||||
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.pack.datapack.DatapackIngestService.StartupValidationOutcome;
|
||||
@@ -131,8 +133,11 @@ import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.server.PluginDisableEvent;
|
||||
import org.bukkit.event.server.PluginEnableEvent;
|
||||
import org.bukkit.generator.BiomeProvider;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
import org.bukkit.plugin.IllegalPluginAccessException;
|
||||
@@ -204,6 +209,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
private volatile PlaceholderRegistration papiRegistration;
|
||||
private volatile IrisPapiListener papiListener;
|
||||
private volatile IrisPapiState papiState;
|
||||
private volatile boolean papiLifecycleActive;
|
||||
private KMap<Class<? extends IrisService>, IrisService> services;
|
||||
// Copy-on-write: mutated on the main thread during enable() and iterated by the JVM
|
||||
// shutdown-hook thread during teardown; a plain list would CME and abort the teardown.
|
||||
@@ -214,9 +220,9 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
private final PendingWorldDeleteQueue pendingWorldDeletes = new PendingWorldDeleteQueue(this);
|
||||
private final PendingWorldReplacementManager pendingWorldReplacements = new PendingWorldReplacementManager(this);
|
||||
private BukkitLanguageSwitcher languageSwitcher;
|
||||
private IrisUpdateNotifier updateNotifier;
|
||||
private BukkitDebugDump debugDump;
|
||||
private volatile SettingsHotloadWatch settingsHotloadWatch;
|
||||
private volatile Thread serverLifecycleThread;
|
||||
private volatile ServerShutdownBoundary serverShutdownBoundary;
|
||||
|
||||
public static VolmitSender getSender() {
|
||||
@@ -636,6 +642,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
languageSwitcher = BukkitLanguageSwitcher.register(this, IrisLanguage.selections(),
|
||||
new BukkitLanguageSwitcher.Options("iris", "iris.all",
|
||||
DirectorMiniMenu.Theme.irisGreen(), IrisLanguage.directorResolver(), IrisLanguage.editorOptions()));
|
||||
updateNotifier = new IrisUpdateNotifier(this);
|
||||
PaperLibBootstrap.install();
|
||||
SimdSupport.install();
|
||||
timings.mark("bootstrap");
|
||||
@@ -824,7 +831,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
|
||||
public void addShutdownHook() {
|
||||
removeShutdownHook();
|
||||
serverLifecycleThread = Thread.currentThread();
|
||||
serverShutdownBoundary = INMS.isBound() ? INMS.get().createServerShutdownBoundary() : null;
|
||||
shutdownHook = new Thread(this::runShutdownHook, "Iris-ShutdownHook");
|
||||
try {
|
||||
@@ -863,7 +869,14 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
*/
|
||||
private CompletableFuture<ManagedWorldLoader.ManagedWorldLoad> loadManagedWorld(String configuredWorldName) {
|
||||
return worldReconciler.loadWorld(ServerProperties.BUKKIT_YML, configuredWorldName)
|
||||
.thenApply(result -> new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message()));
|
||||
.thenApply(this::managedWorldLoadResult);
|
||||
}
|
||||
|
||||
private ManagedWorldLoader.ManagedWorldLoad managedWorldLoadResult(BukkitWorldReconciler.LoadResult result) {
|
||||
if (!result.succeeded() && result.failure() != null) {
|
||||
reportError("Could not explicitly load Iris world " + result.worldKey() + ": " + result.message(), result.failure());
|
||||
}
|
||||
return new ManagedWorldLoader.ManagedWorldLoad(result.succeeded(), result.message());
|
||||
}
|
||||
|
||||
public PendingWorldReplacementManager pendingWorldReplacements() {
|
||||
@@ -927,6 +940,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
ServerConfigurator.restartAtStartupBoundary(restartReason);
|
||||
return;
|
||||
}
|
||||
papiLifecycleActive = true;
|
||||
setupPapi();
|
||||
reportLockedRuntime();
|
||||
}
|
||||
|
||||
@@ -973,6 +988,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
public void onDisable() {
|
||||
preservePendingBlockDrops();
|
||||
papiLifecycleActive = false;
|
||||
if (updateNotifier != null) {
|
||||
updateNotifier.close();
|
||||
updateNotifier = null;
|
||||
}
|
||||
if (debugDump != null) {
|
||||
debugDump.close();
|
||||
debugDump = null;
|
||||
@@ -1011,20 +1032,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPreUnload(ReloadAware.PreUnloadReason reason) {
|
||||
public CompletionStage<Void> commitReload(ReloadAware.PreUnloadReason reason) {
|
||||
preservePendingBlockDrops();
|
||||
papiLifecycleActive = false;
|
||||
teardownPapi();
|
||||
if (IrisToolbelt.isServerStopping()) {
|
||||
quiesceRuntimeForServerShutdown("pre-unload:" + reason);
|
||||
startPostStopFinisher();
|
||||
Iris.debug("Pre-unload hook deferred generator teardown until Paper closes its chunk schedulers.");
|
||||
return;
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
if (alreadyDrained.get()) {
|
||||
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.");
|
||||
drainOnce("pre-unload:" + reason, 45L);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1138,7 +1162,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
if (generator == null) {
|
||||
continue;
|
||||
}
|
||||
IrisToolbelt.beginWorldMaintenance(world, reason, true);
|
||||
IrisToolbelt.beginWorldMaintenance(world, reason);
|
||||
if (!deferredShutdownGenerators.contains(generator)) {
|
||||
deferredShutdownGenerators.add(generator);
|
||||
}
|
||||
@@ -1150,52 +1174,32 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
if (!postStopFinisherStarted.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
Thread activeServerThread = serverLifecycleThread;
|
||||
if (activeServerThread == null) {
|
||||
Iris.warn("Iris could not start its post-stop runtime finisher because the server lifecycle thread is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
Thread finisher = new Thread(() -> {
|
||||
if (!awaitServerThreadTermination(activeServerThread)) {
|
||||
return;
|
||||
}
|
||||
finishDeferredRuntimeTeardown("post-server-stop", 30L);
|
||||
}, "Iris-PostStop-Finisher");
|
||||
Thread finisher = new Thread(() -> finishRuntimeAfterServerShutdown("post-server-stop"),
|
||||
"Iris-PostStop-Finisher");
|
||||
finisher.setDaemon(false);
|
||||
finisher.start();
|
||||
}
|
||||
|
||||
static boolean awaitServerThreadTermination(Thread serverThread) {
|
||||
if (serverThread == null || serverThread == Thread.currentThread()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
serverThread.join();
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
Iris.reportError("Iris post-stop runtime finisher was interrupted.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void runShutdownHook() {
|
||||
if (startupBoundaryRestart.get()) {
|
||||
finishDeferredRuntimeTeardown("startup-boundary-restart-hook", 30L);
|
||||
return;
|
||||
}
|
||||
finishRuntimeAfterServerShutdown("shutdown-hook");
|
||||
}
|
||||
|
||||
private void finishRuntimeAfterServerShutdown(String reason) {
|
||||
if (!awaitServerShutdownBoundary()) {
|
||||
SHUTDOWN_ERRORS.println("[Iris] Paper did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit.");
|
||||
SHUTDOWN_ERRORS.println("[Iris] The server did not reach its post-world-close boundary; retaining the runtime and plugin class loader until JVM exit.");
|
||||
return;
|
||||
}
|
||||
finishDeferredRuntimeTeardown("shutdown-hook", 30L);
|
||||
finishDeferredRuntimeTeardown(reason, 30L);
|
||||
}
|
||||
|
||||
private boolean awaitServerShutdownBoundary() {
|
||||
ServerShutdownBoundary boundary = serverShutdownBoundary;
|
||||
if (boundary == null) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return boundary.await(
|
||||
@@ -1259,6 +1263,20 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
&& preservation.hasActiveResources();
|
||||
}
|
||||
|
||||
private void preservePendingBlockDrops() {
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
PlatformChunkGenerator generator = IrisToolbelt.access(world);
|
||||
if (generator != null && generator.getEngine() != null
|
||||
&& generator.getEngine().getWorldManager() instanceof IrisWorldManager manager) {
|
||||
manager.flushPendingBlockDropsAsync().whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
Iris.reportError("Failed to preserve pending block loot before Iris teardown.", failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void drainWorldGenerators(String reason, long timeoutSeconds) {
|
||||
List<World> irisWorlds = new ArrayList<>();
|
||||
List<PlatformChunkGenerator> generators = new ArrayList<>();
|
||||
@@ -1313,8 +1331,23 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
}
|
||||
|
||||
private void setupPapi() {
|
||||
if (!PlaceholderRegistration.isPlaceholderApiEnabled()) {
|
||||
@EventHandler
|
||||
public void onPlaceholderApiEnable(PluginEnableEvent event) {
|
||||
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
|
||||
setupPapi();
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlaceholderApiDisable(PluginDisableEvent event) {
|
||||
if ("PlaceholderAPI".equals(event.getPlugin().getName())) {
|
||||
teardownPapi();
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void setupPapi() {
|
||||
if (!papiLifecycleActive || papiRegistration != null
|
||||
|| !PlaceholderRegistration.isPlaceholderApiEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1341,7 +1374,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
papiRegistration = registration;
|
||||
}
|
||||
|
||||
private void teardownPapi() {
|
||||
private synchronized void teardownPapi() {
|
||||
IrisPapiListener listener = papiListener;
|
||||
papiListener = null;
|
||||
|
||||
@@ -1453,12 +1486,15 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
public int getIrisVersion() {
|
||||
String input = getDescription().getVersion();
|
||||
int hyphenIndex = input.indexOf('-');
|
||||
if (hyphenIndex != -1) {
|
||||
String result = input.substring(0, hyphenIndex);
|
||||
result = result.replaceAll("\\.", "");
|
||||
return Integer.parseInt(result);
|
||||
String release = hyphenIndex < 0 ? input : input.substring(0, hyphenIndex);
|
||||
if (!release.matches("[0-9]+(?:\\.[0-9]+)*")) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(release.replace(".", ""));
|
||||
} catch (NumberFormatException invalid) {
|
||||
return -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
public int getMCVersion() {
|
||||
|
||||
@@ -25,6 +25,8 @@ import art.arcane.iris.localization.IrisLanguage;
|
||||
import art.arcane.iris.pack.datapack.DatapackIngestService;
|
||||
import art.arcane.iris.studio.object.ObjectStudioSaveService;
|
||||
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.nativegen.NativeStructureGenerationPolicy;
|
||||
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")
|
||||
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(
|
||||
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
|
||||
IrisBiome biome,
|
||||
@@ -89,6 +91,78 @@ public class CommandFind implements DirectorExecutor {
|
||||
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")
|
||||
public void region(
|
||||
@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;
|
||||
|
||||
import art.arcane.iris.pack.PackFingerprints;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.pack.validation.PackCompatReport;
|
||||
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))));
|
||||
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()))));
|
||||
reportPaths(s, result.quarantinedPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_QUARANTINED);
|
||||
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))));
|
||||
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()))));
|
||||
reportPaths(s, result.restoredPaths(), BukkitRuntimeMessages.COMMAND_PACK_PATH_RESTORED);
|
||||
return;
|
||||
@@ -363,47 +363,79 @@ public class CommandPack implements DirectorExecutor {
|
||||
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 {
|
||||
PackValidationResult result = PackValidator.validate(packFolder);
|
||||
PackValidationRegistry.publish(result);
|
||||
reportResult(s, result);
|
||||
return result;
|
||||
if (ticket == null) {
|
||||
throw new IllegalStateException("Pack content is currently being changed.");
|
||||
}
|
||||
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) {
|
||||
Iris.reportError("Pack validation failed for '" + packFolder.getName() + "'", e);
|
||||
String detail = e.getMessage() == null || e.getMessage().isBlank()
|
||||
? e.getClass().getSimpleName()
|
||||
: e.getMessage();
|
||||
PackValidationResult result = new PackValidationResult(
|
||||
result = new PackValidationResult(
|
||||
packFolder.getName(),
|
||||
List.of("Pack validation failed with " + e.getClass().getSimpleName() + ": " + detail),
|
||||
List.of(),
|
||||
System.currentTimeMillis());
|
||||
PackValidationRegistry.publish(result);
|
||||
reportResult(s, result);
|
||||
return result;
|
||||
contentFingerprint = "";
|
||||
contextFingerprint = "";
|
||||
}
|
||||
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) {
|
||||
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
|
||||
for (File packDirectory : packDirectories) {
|
||||
PackValidationResult result = PackValidationRegistry.get(packDirectory.getName());
|
||||
if (result == null) {
|
||||
// The boot cache only loads when it covers every pack, so a partial write is
|
||||
// pointless - but say so instead of silently skipping the persist forever.
|
||||
Iris.warn("Pack validation cache not written: \"" + packDirectory.getName()
|
||||
+ "\" has no registered result. Run /iris pack validate all to repopulate it.");
|
||||
try {
|
||||
String contextFingerprint = PackValidationCache.contextFingerprint();
|
||||
PackFingerprints.PackContentSnapshot snapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||
List<File> packDirectories = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
|
||||
List<PackValidationResult> results = new ArrayList<>(packDirectories.size());
|
||||
for (File packDirectory : packDirectories) {
|
||||
PackValidationResult result = PackValidationRegistry.getMatching(
|
||||
packDirectory.toPath(), snapshot.packContents().get(packDirectory.getName()));
|
||||
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;
|
||||
}
|
||||
results.add(result);
|
||||
}
|
||||
try {
|
||||
for (int index = 0; index < packDirectories.size(); index++) {
|
||||
File packDirectory = packDirectories.get(index);
|
||||
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(
|
||||
Iris.instance.getDataFile("cache", "pack-validation.json").toPath(),
|
||||
PackValidationCache.contentFingerprint(packsRoot),
|
||||
PackValidationCache.contextFingerprint(),
|
||||
snapshot.content(),
|
||||
contextFingerprint,
|
||||
results);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
Iris.reportError("Could not persist refreshed pack-validation results", e);
|
||||
|
||||
@@ -81,7 +81,6 @@ import org.bukkit.FluidCollisionMode;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
@@ -309,23 +308,33 @@ public class CommandStudio implements DirectorExecutor {
|
||||
O<Integer> ta = new O<>();
|
||||
ta.set(-1);
|
||||
|
||||
var sender = sender();
|
||||
var player = player();
|
||||
var engine = engine();
|
||||
VolmitSender sender = sender();
|
||||
Player player = player();
|
||||
Engine engine = engine();
|
||||
|
||||
ta.set(J.sr(() ->
|
||||
{
|
||||
if (!player.getOpenInventory().getType().equals(InventoryType.CHEST)) {
|
||||
ta.set(J.sr(() -> {
|
||||
if (!J.runEntity(player, () -> {
|
||||
if (player.getOpenInventory().getTopInventory() != inv) {
|
||||
J.csr(ta.get());
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY));
|
||||
return;
|
||||
}
|
||||
if (!add) {
|
||||
inv.clear();
|
||||
}
|
||||
Location location = player.getLocation();
|
||||
try {
|
||||
EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE,
|
||||
location.getWorld(), location.getBlockX(), location.getBlockY(), location.getBlockZ());
|
||||
} catch (RuntimeException | Error failure) {
|
||||
J.csr(ta.get());
|
||||
Iris.reportError(failure);
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_CANNOT_ADD_ITEMS_VIRTUAL_INVENTORY_BECAUSE,
|
||||
MessageArgument.untrusted("value", String.valueOf(failure.getMessage()))));
|
||||
}
|
||||
}, 0, () -> J.csr(ta.get()))) {
|
||||
J.csr(ta.get());
|
||||
sender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_STUDIO_OPENED_INVENTORY));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!add) {
|
||||
inv.clear();
|
||||
}
|
||||
|
||||
EngineBukkitOps.addItems(engine, true, inv, tables, InventorySlotType.STORAGE, player.getWorld(), player.getLocation().getBlockX(), player.getLocation().getBlockY(), player.getLocation().getBlockZ());
|
||||
}, fast ? 5 : 35));
|
||||
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_INVENTORY_NOW));
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
final class GenerationRateWindow {
|
||||
static final long TIMING_IDLE_MILLIS = 10_000L;
|
||||
|
||||
private long lastSampleAtMs;
|
||||
private int lastGeneratedCount;
|
||||
private long lastGenerationAtMs;
|
||||
|
||||
double sampleChunksPerSecond(int generatedCount, long sampledAtMs) {
|
||||
int safeGeneratedCount = Math.max(0, generatedCount);
|
||||
long previousAt = lastSampleAtMs;
|
||||
int previousCount = lastGeneratedCount;
|
||||
lastSampleAtMs = sampledAtMs;
|
||||
lastGeneratedCount = safeGeneratedCount;
|
||||
if (previousAt <= 0L) {
|
||||
if (safeGeneratedCount > 0) {
|
||||
lastGenerationAtMs = sampledAtMs;
|
||||
}
|
||||
return 0D;
|
||||
}
|
||||
if (sampledAtMs <= previousAt) {
|
||||
return 0D;
|
||||
}
|
||||
int generatedDelta = Math.max(0, safeGeneratedCount - previousCount);
|
||||
if (generatedDelta > 0) {
|
||||
lastGenerationAtMs = sampledAtMs;
|
||||
}
|
||||
return generatedDelta * 1000D / (sampledAtMs - previousAt);
|
||||
}
|
||||
|
||||
boolean generationTimingsFresh(long sampledAtMs) {
|
||||
return lastGenerationAtMs > 0L && sampledAtMs - lastGenerationAtMs <= TIMING_IDLE_MILLIS;
|
||||
}
|
||||
}
|
||||
+8
-17
@@ -460,8 +460,12 @@ public final class IrisEngineSVC implements IrisService {
|
||||
continue;
|
||||
}
|
||||
|
||||
double chunksPerSecond = registered.sampleChunksPerSecond(engine.getGenerated(), now);
|
||||
worldSnapshots.add(EngineTelemetrySnapshot.capture(engine, chunksPerSecond, now));
|
||||
double chunksPerSecond = registered.generationRate.sampleChunksPerSecond(engine.getGenerated(), now);
|
||||
worldSnapshots.add(EngineTelemetrySnapshot.capture(
|
||||
engine,
|
||||
chunksPerSecond,
|
||||
registered.generationRate.generationTimingsFresh(now),
|
||||
now));
|
||||
registered.clearMetricsFailure();
|
||||
} catch (Throwable exception) {
|
||||
if (EngineMaintenance.isMantleClosed(exception)) {
|
||||
@@ -552,6 +556,7 @@ public final class IrisEngineSVC implements IrisService {
|
||||
progress.generated(),
|
||||
progress.totalChunks(),
|
||||
progress.chunksRemaining(),
|
||||
progress.chunksInFlight(),
|
||||
progress.chunksPerSecond(),
|
||||
progress.eta(),
|
||||
progress.elapsed(),
|
||||
@@ -612,8 +617,7 @@ public final class IrisEngineSVC implements IrisService {
|
||||
private final AtomicReference<CompletableFuture<Void>> activeMaintenance = new AtomicReference<>();
|
||||
private volatile ScheduledFuture<?> maintenance;
|
||||
private volatile boolean closed;
|
||||
private long lastGeneratedSampleAtMs;
|
||||
private int lastGeneratedCount;
|
||||
private final GenerationRateWindow generationRate = new GenerationRateWindow();
|
||||
|
||||
private Registered(Registration registration, ScheduledThreadPoolExecutor executor) {
|
||||
name = registration.name();
|
||||
@@ -696,19 +700,6 @@ public final class IrisEngineSVC implements IrisService {
|
||||
return activeMaintenance.get() != null;
|
||||
}
|
||||
|
||||
private double sampleChunksPerSecond(int generatedCount, long sampledAtMs) {
|
||||
int safeGeneratedCount = Math.max(0, generatedCount);
|
||||
long previousAt = lastGeneratedSampleAtMs;
|
||||
int previousCount = lastGeneratedCount;
|
||||
lastGeneratedSampleAtMs = sampledAtMs;
|
||||
lastGeneratedCount = safeGeneratedCount;
|
||||
if (previousAt <= 0L || sampledAtMs <= previousAt) {
|
||||
return 0D;
|
||||
}
|
||||
int generatedDelta = Math.max(0, safeGeneratedCount - previousCount);
|
||||
return generatedDelta * 1000D / (sampledAtMs - previousAt);
|
||||
}
|
||||
|
||||
private void close() {
|
||||
if (closed) {
|
||||
return;
|
||||
|
||||
+3
@@ -101,6 +101,7 @@ public record IrisTelemetrySnapshot(
|
||||
long generated,
|
||||
long total,
|
||||
long remaining,
|
||||
long inFlight,
|
||||
double chunksPerSecond,
|
||||
long etaMs,
|
||||
long elapsedMs,
|
||||
@@ -115,6 +116,7 @@ public record IrisTelemetrySnapshot(
|
||||
0L,
|
||||
0L,
|
||||
0L,
|
||||
0L,
|
||||
0D,
|
||||
0L,
|
||||
0L,
|
||||
@@ -130,6 +132,7 @@ public record IrisTelemetrySnapshot(
|
||||
generated = Math.max(0L, generated);
|
||||
total = Math.max(0L, total);
|
||||
remaining = Math.max(0L, remaining);
|
||||
inFlight = Math.max(0L, inFlight);
|
||||
chunksPerSecond = Double.isFinite(chunksPerSecond) ? Math.max(0D, chunksPerSecond) : 0D;
|
||||
etaMs = Math.max(0L, etaMs);
|
||||
elapsedMs = Math.max(0L, elapsedMs);
|
||||
|
||||
+32
-2
@@ -6,6 +6,7 @@ import art.arcane.iris.generation.runtime.IrisTelemetrySnapshot;
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.iris.generation.runtime.EngineTelemetrySnapshot;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.iris.platform.bukkit.plugin.IrisService;
|
||||
import art.arcane.volmlib.integration.IntegrationHandshakeRequest;
|
||||
import art.arcane.volmlib.integration.IntegrationHandshakeResponse;
|
||||
@@ -13,6 +14,9 @@ import art.arcane.volmlib.integration.IntegrationHeartbeat;
|
||||
import art.arcane.volmlib.integration.IntegrationMetricDescriptor;
|
||||
import art.arcane.volmlib.integration.IntegrationMetricGroup;
|
||||
import art.arcane.volmlib.integration.IntegrationMetricSample;
|
||||
import art.arcane.volmlib.integration.IntegrationMetricPublisher;
|
||||
import art.arcane.volmlib.integration.IntegrationMetricSnapshot;
|
||||
import art.arcane.volmlib.integration.IntegrationSnapshotProvider;
|
||||
import art.arcane.volmlib.integration.IntegrationMetricSchema;
|
||||
import art.arcane.volmlib.integration.IntegrationProtocolNegotiator;
|
||||
import art.arcane.volmlib.integration.IntegrationProtocolVersion;
|
||||
@@ -30,7 +34,7 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class IrisIntegrationService implements IrisService, IntegrationServiceContract {
|
||||
public class IrisIntegrationService implements IrisService, IntegrationSnapshotProvider {
|
||||
private static final IntegrationProtocolVersion CURRENT_PROTOCOL = new IntegrationProtocolVersion(1, 2);
|
||||
private static final Set<IntegrationProtocolVersion> SUPPORTED_PROTOCOLS = Set.of(
|
||||
new IntegrationProtocolVersion(1, 0),
|
||||
@@ -41,6 +45,7 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
||||
"handshake",
|
||||
"heartbeat",
|
||||
"metrics",
|
||||
IntegrationSnapshotProvider.CAPABILITY,
|
||||
"metric-groups",
|
||||
"iris-engine-metrics",
|
||||
"iris-world-metrics"
|
||||
@@ -63,6 +68,9 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
||||
);
|
||||
|
||||
private final Supplier<IrisTelemetrySnapshot> telemetrySupplier;
|
||||
private final IntegrationMetricPublisher snapshots = new IntegrationMetricPublisher(
|
||||
IntegrationMetricSchema.irisKeys().size(), 30_000L);
|
||||
private int snapshotTaskId = -1;
|
||||
|
||||
public IrisIntegrationService() {
|
||||
this(IrisIntegrationService::currentTelemetry);
|
||||
@@ -77,11 +85,17 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
||||
@Override
|
||||
public void onEnable() {
|
||||
Bukkit.getServicesManager().register(IntegrationServiceContract.class, this, Iris.instance, ServicePriority.Normal);
|
||||
snapshotTaskId = J.ar(this::publishSnapshots, 20);
|
||||
Iris.verbose("Integration provider registered for Iris");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (snapshotTaskId != -1) {
|
||||
J.car(snapshotTaskId);
|
||||
snapshotTaskId = -1;
|
||||
}
|
||||
snapshots.clear();
|
||||
Bukkit.getServicesManager().unregister(IntegrationServiceContract.class, this);
|
||||
}
|
||||
|
||||
@@ -188,6 +202,20 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
||||
return Map.copyOf(selected);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IntegrationMetricSnapshot snapshotMetrics(Set<String> metricKeys) {
|
||||
return snapshots.snapshotMetrics(metricKeys, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
void publishSnapshots() {
|
||||
IntegrationMetricPublisher.Demand demand = snapshots.demandedKeys(System.currentTimeMillis());
|
||||
if (demand.keys().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<String, IntegrationMetricSample> samples = sampleMetrics(demand.keys());
|
||||
snapshots.publish(demand, System.currentTimeMillis(), samples);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IntegrationMetricGroup> metricGroups() {
|
||||
IrisTelemetrySnapshot snapshot = safeSnapshot();
|
||||
@@ -319,6 +347,7 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
||||
IntegrationMetricSchema.IRIS_PREGEN_GENERATED,
|
||||
IntegrationMetricSchema.IRIS_PREGEN_TOTAL,
|
||||
IntegrationMetricSchema.IRIS_PREGEN_QUEUE,
|
||||
IntegrationMetricSchema.IRIS_PREGEN_REMAINING,
|
||||
IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT,
|
||||
IntegrationMetricSchema.IRIS_PREGEN_ETA_MS,
|
||||
IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS,
|
||||
@@ -332,7 +361,8 @@ public class IrisIntegrationService implements IrisService, IntegrationServiceCo
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_PROGRESS, pregenerator.progressPercent(), sampledAtMs);
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_GENERATED, pregenerator.generated(), sampledAtMs);
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_TOTAL, pregenerator.total(), sampledAtMs);
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_QUEUE, pregenerator.remaining(), sampledAtMs);
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_QUEUE, pregenerator.inFlight(), sampledAtMs);
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_REMAINING, pregenerator.remaining(), sampledAtMs);
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_THROUGHPUT, pregenerator.chunksPerSecond(), sampledAtMs);
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ETA_MS, pregenerator.etaMs(), sampledAtMs);
|
||||
put(samples, IntegrationMetricSchema.IRIS_PREGEN_ELAPSED_MS, pregenerator.elapsedMs(), sampledAtMs);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package art.arcane.iris.update;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.volmlib.util.update.BukkitUpdateService;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class IrisUpdateNotifier implements AutoCloseable {
|
||||
private final Consumer<IrisSettings> settingsListener = this::reconfigure;
|
||||
private final BukkitUpdateService updates;
|
||||
private volatile boolean enabled;
|
||||
private boolean closed;
|
||||
|
||||
public IrisUpdateNotifier(Plugin plugin) {
|
||||
enabled = IrisSettings.get().getGeneral().isUpdateNotifications();
|
||||
updates = BukkitUpdateService.register(plugin, new BukkitUpdateService.Options(
|
||||
"VolmitSoftware", "Iris", "iris.update", () -> enabled));
|
||||
IrisSettings.addSettingsListener(settingsListener);
|
||||
reconfigure(IrisSettings.get());
|
||||
}
|
||||
|
||||
private synchronized void reconfigure(IrisSettings settings) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
enabled = settings.getGeneral().isUpdateNotifications();
|
||||
updates.reconfigure();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
closed = true;
|
||||
IrisSettings.removeSettingsListener(settingsListener);
|
||||
updates.close();
|
||||
}
|
||||
}
|
||||
+11
-10
@@ -18,8 +18,9 @@
|
||||
|
||||
package art.arcane.iris.world;
|
||||
|
||||
import art.arcane.iris.pack.PackFingerprints;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.pack.datapack.ServerConfigurator;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
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();
|
||||
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
|
||||
ServerConfigurator.PackContentSnapshot contentSnapshot =
|
||||
new ServerConfigurator.PackContentSnapshot("", Map.of());
|
||||
PackFingerprints.PackContentSnapshot contentSnapshot =
|
||||
new PackFingerprints.PackContentSnapshot("", Map.of());
|
||||
String contextFingerprint = "";
|
||||
Optional<List<PackValidationResult>> cached = Optional.empty();
|
||||
try {
|
||||
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
|
||||
contentSnapshot = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||
contextFingerprint = PackValidationCache.contextFingerprint();
|
||||
if (!externalContentChanged) {
|
||||
cached = PackValidationCache.load(
|
||||
@@ -274,19 +275,19 @@ public final class IrisWorldGeneratorResolver {
|
||||
private static FreshValidation validateStablePacks(
|
||||
File packsRoot,
|
||||
List<File> initialPackDirs,
|
||||
ServerConfigurator.PackContentSnapshot initialSnapshot
|
||||
PackFingerprints.PackContentSnapshot initialSnapshot
|
||||
) {
|
||||
List<File> packDirs = initialPackDirs;
|
||||
ServerConfigurator.PackContentSnapshot before = initialSnapshot;
|
||||
PackFingerprints.PackContentSnapshot before = initialSnapshot;
|
||||
for (int attempt = 0; attempt < VALIDATION_STABILITY_ATTEMPTS; attempt++) {
|
||||
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
|
||||
List<PackValidationResult> results = validatePacks(packDirs);
|
||||
ServerConfigurator.PackContentSnapshot after;
|
||||
PackFingerprints.PackContentSnapshot after;
|
||||
try {
|
||||
after = ServerConfigurator.computePackContentSnapshot(packsRoot);
|
||||
after = PackFingerprints.computePackContentSnapshot(packsRoot);
|
||||
} catch (RuntimeException 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<String> afterPackNames = afterPackDirs.stream().map(File::getName).sorted().toList();
|
||||
@@ -923,7 +924,7 @@ public final class IrisWorldGeneratorResolver {
|
||||
|
||||
private record FreshValidation(
|
||||
List<File> packDirs,
|
||||
ServerConfigurator.PackContentSnapshot contentSnapshot,
|
||||
PackFingerprints.PackContentSnapshot contentSnapshot,
|
||||
List<PackValidationResult> results,
|
||||
boolean stable
|
||||
) {
|
||||
|
||||
+18
-4
@@ -3,19 +3,33 @@ package art.arcane.iris.world.tree;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Objects;
|
||||
|
||||
final class RoutedBlockBreakEvent extends BlockBreakEvent implements BlockDropRouter {
|
||||
private final TreeFellerSVC service;
|
||||
private final FellingRun run;
|
||||
private final ItemStack tool;
|
||||
|
||||
RoutedBlockBreakEvent(Block block, Player player, FellingRun run, TreeFellerSVC service) {
|
||||
super(block, player);
|
||||
this.run = run;
|
||||
this.service = service;
|
||||
RoutedBlockBreakEvent(Options options) {
|
||||
super(options.block(), options.player());
|
||||
run = options.run();
|
||||
service = options.service();
|
||||
tool = Objects.requireNonNull(options.tool(), "tool").clone();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<ItemStack> captureNativeDrops() {
|
||||
return getBlock().getDrops(tool);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean routeDrop(Object drop) {
|
||||
return service.isServiceEnabled() && run.presentation.routeDrop(drop);
|
||||
}
|
||||
|
||||
record Options(Block block, Player player, FellingRun run, TreeFellerSVC service, ItemStack tool) {
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -387,7 +387,8 @@ final class TreeFellingRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
BlockBreakEvent probe = new RoutedBlockBreakEvent(block, run.candidate.player(), run, service);
|
||||
BlockBreakEvent probe = new RoutedBlockBreakEvent(new RoutedBlockBreakEvent.Options(
|
||||
block, run.candidate.player(), run, service, reservation.toolForDrops()));
|
||||
service.managedEvents.add(probe);
|
||||
try {
|
||||
Bukkit.getPluginManager().callEvent(probe);
|
||||
|
||||
@@ -10,6 +10,9 @@ authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
|
||||
website: VolmitSoftware.com
|
||||
description: More than a Dimension!
|
||||
permissions:
|
||||
iris.update:
|
||||
description: Allows viewing Iris updates in VolmLib's combined update reports.
|
||||
default: op
|
||||
iris.debugdump:
|
||||
description: Allows creating and optionally uploading diagnostic reports.
|
||||
default: op
|
||||
@@ -25,34 +28,26 @@ permissions:
|
||||
dependencies:
|
||||
server:
|
||||
PlaceholderAPI:
|
||||
load: BEFORE
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
CraftEngine:
|
||||
load: BEFORE
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
Nexo:
|
||||
load: BEFORE
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
Oraxen:
|
||||
load: BEFORE
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
ItemsAdder:
|
||||
load: BEFORE
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
SCore:
|
||||
load: BEFORE
|
||||
required: false
|
||||
join-classpath: true
|
||||
ExecutableItems:
|
||||
load: BEFORE
|
||||
required: false
|
||||
join-classpath: false
|
||||
MythicLib:
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
@@ -61,11 +56,7 @@ dependencies:
|
||||
required: false
|
||||
join-classpath: true
|
||||
eco:
|
||||
load: BEFORE
|
||||
required: false
|
||||
join-classpath: true
|
||||
EcoItems:
|
||||
load: BEFORE
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
MythicMobs:
|
||||
@@ -77,7 +68,7 @@ dependencies:
|
||||
required: false
|
||||
join-classpath: true
|
||||
KGenerators:
|
||||
load: BEFORE
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
Multiverse-Core:
|
||||
@@ -85,6 +76,6 @@ dependencies:
|
||||
required: false
|
||||
join-classpath: true
|
||||
WorldEdit:
|
||||
load: BEFORE
|
||||
load: OMIT
|
||||
required: false
|
||||
join-classpath: true
|
||||
|
||||
@@ -3,18 +3,6 @@ version: ${version}
|
||||
main: ${main}
|
||||
folia-supported: true
|
||||
load: STARTUP
|
||||
softdepend:
|
||||
- PlaceholderAPI
|
||||
- CraftEngine
|
||||
- Nexo
|
||||
- Oraxen
|
||||
- ItemsAdder
|
||||
- SCore
|
||||
- ExecutableItems
|
||||
- eco
|
||||
- EcoItems
|
||||
- KGenerators
|
||||
- WorldEdit
|
||||
loadbefore: [ Multiverse-Core ]
|
||||
authors: [ cyberpwn, NextdoorPsycho, Vatuu ]
|
||||
website: VolmitSoftware.com
|
||||
@@ -23,6 +11,9 @@ commands:
|
||||
iris:
|
||||
aliases: [ ir, irs ]
|
||||
permissions:
|
||||
iris.update:
|
||||
description: Allows viewing Iris updates in VolmLib's combined update reports.
|
||||
default: op
|
||||
iris.debugdump:
|
||||
description: Allows creating and optionally uploading diagnostic reports.
|
||||
default: op
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ name: IrisNativeLifecycleFixture
|
||||
version: 1.0.0
|
||||
main: art.arcane.iris.fixture.NativeLifecycleFixture
|
||||
api-version: '26.1.2'
|
||||
folia-supported: true
|
||||
depend: [Iris]
|
||||
commands:
|
||||
irislifecycle:
|
||||
|
||||
+284
-7
@@ -1,11 +1,31 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.Iris;
|
||||
import art.arcane.volmlib.nativelib.NativeAdapters;
|
||||
import art.arcane.iris.world.runtime.RuntimeInjection;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.structure.placement.IrisStructureLocator;
|
||||
import art.arcane.iris.world.IrisCreator;
|
||||
import art.arcane.iris.world.task.J;
|
||||
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.block.Biome;
|
||||
import org.bukkit.block.Container;
|
||||
import org.bukkit.loot.Lootable;
|
||||
import org.bukkit.loot.LootTable;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.MapMeta;
|
||||
import org.bukkit.map.MapView;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
import org.bukkit.util.StructureSearchResult;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeTerrainAccess;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeStructureReader;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorldRuntime;
|
||||
@@ -19,6 +39,10 @@ import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
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.List;
|
||||
import java.util.logging.Level;
|
||||
@@ -26,12 +50,16 @@ import java.util.logging.Level;
|
||||
public final class NativeLifecycleFixture extends JavaPlugin implements WorldRuntimeExecution {
|
||||
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 COMMAND_KEY = new NamespacedKey("iris", "lifecycle_probe");
|
||||
private static final NamespacedKey TREASURE_KEY = new NamespacedKey("iris", "treasure_map_probe");
|
||||
private static final long WORLD_SEED = 78264193L;
|
||||
private NativeWorldRuntime runtime;
|
||||
private TreasureLootChecks treasureLootChecks;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
runtime = NativeAdapters.require(NativeWorldRuntime.class);
|
||||
treasureLootChecks = new TreasureLootChecks(this);
|
||||
NativeTerrainAccess terrain = NativeAdapters.require(NativeTerrainAccess.class);
|
||||
getLogger().info("Native lifecycle ready: " + runtime.description()
|
||||
+ "; terrain provider=" + terrain.getClass().getName());
|
||||
@@ -44,18 +72,40 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload");
|
||||
if (arguments.length == 0) {
|
||||
sender.sendMessage("Use /irislifecycle create|unload|status|structures|terrain-create|terrain-check|terrain-unload|lifecycle-status|lifecycle-entry");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
switch (arguments[0]) {
|
||||
case "treasure-map-create" -> createTreasureMapWorld(sender);
|
||||
case "treasure-map-limit" -> runTreasureRegion(sender, () -> checkTreasureMapLimit(sender));
|
||||
case "treasure-map-chest" -> {
|
||||
if (arguments.length != 3) {
|
||||
throw new IllegalArgumentException("Use treasure-map-chest <destination> <CHEST|COPPER_CHEST>");
|
||||
}
|
||||
runTreasureRegion(sender, () -> prepareTreasureMapChest(sender, arguments[1], arguments[2]));
|
||||
}
|
||||
case "treasure-map-inspect" -> {
|
||||
if (arguments.length != 2) {
|
||||
throw new IllegalArgumentException("Use treasure-map-inspect <destination>");
|
||||
}
|
||||
runTreasureRegion(sender, () -> inspectTreasureMapChest(sender, arguments[1]));
|
||||
}
|
||||
case "treasure-map-check" -> runTreasureRegion(sender, () -> verifyTreasureMapLoot(sender));
|
||||
case "treasure-extra-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verify(sender));
|
||||
case "treasure-region-check" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyRegion(sender));
|
||||
case "treasure-reload-before" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, false));
|
||||
case "treasure-reload-after" -> runTreasureRegion(sender, () -> treasureLootChecks.verifyReload(sender, true));
|
||||
case "treasure-resources" -> treasureLootChecks.reportResourceReloads(sender);
|
||||
case "create" -> create(sender);
|
||||
case "unload" -> unload(sender);
|
||||
case "structures" -> structures(sender);
|
||||
case "terrain-create" -> createTerrain(sender);
|
||||
case "terrain-check" -> checkTerrain(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()
|
||||
+ " loaded=" + (Bukkit.getWorld(WORLD_KEY) != null));
|
||||
default -> throw new IllegalArgumentException("Unknown lifecycle operation");
|
||||
@@ -66,6 +116,232 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
return true;
|
||||
}
|
||||
|
||||
private void createTreasureMapWorld(CommandSender sender) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
throw new IllegalStateException("Treasure map fixture requires a player");
|
||||
}
|
||||
runTreasureRegion(sender, () -> prepareTreasureMapWorld(player));
|
||||
}
|
||||
|
||||
private void runTreasureRegion(CommandSender sender, Runnable task) {
|
||||
ensureTreasureMapWorld()
|
||||
.thenCompose(ignored -> J.runRegionFuture(Bukkit.getWorld(TREASURE_KEY), 0, 0, task))
|
||||
.whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<?> ensureTreasureMapWorld() {
|
||||
if (Bukkit.getWorld(TREASURE_KEY) != null) {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
if (WorldCreatorCompat.persistentLevelRoot(TREASURE_KEY).exists()) {
|
||||
return Iris.instance.worldReconciler().loadWorld(ServerProperties.BUKKIT_YML, TREASURE_KEY.toString());
|
||||
}
|
||||
return runAsync(() -> {
|
||||
try {
|
||||
new IrisCreator().name(TREASURE_KEY.toString()).dimension("treasure-map-fixture")
|
||||
.seed(WORLD_SEED).create();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Treasure map fixture creation failed", failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void prepareTreasureMapWorld(Player player) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
if (world == null || !IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED
|
||||
|| !"treasure-map-fixture".equals(IrisToolbelt.access(world).getEngine().getDimension().getLoadKey())) {
|
||||
throw new IllegalStateException("Treasure map fixture world identity is invalid");
|
||||
}
|
||||
for (int x = 0; x <= 8; x++) {
|
||||
for (int z = 0; z <= 8; z++) {
|
||||
world.getBlockAt(x, 160, z).setType(Material.STONE, false);
|
||||
for (int y = 161; y <= 164; y++) {
|
||||
world.getBlockAt(x, y, z).setType(Material.AIR, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!J.runEntity(player, () -> player.teleportAsync(new Location(world, 0.5, 161, 0.5)).whenComplete((teleported, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(player, failure);
|
||||
} else if (!Boolean.TRUE.equals(teleported)) {
|
||||
fail(player, new IllegalStateException("Treasure map fixture teleport failed"));
|
||||
} else {
|
||||
player.sendMessage("TREASURE_MAP_READY world=" + TREASURE_KEY
|
||||
+ " version=" + Bukkit.getMinecraftVersion() + " folia=" + J.isFolia());
|
||||
}
|
||||
}))) {
|
||||
throw new IllegalStateException("Could not schedule treasure map fixture teleport");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkTreasureMapLimit(CommandSender sender) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Treasure map fixture world is absent");
|
||||
}
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
IrisStructureLocator.LocateResult located = IrisStructureLocator.locateInPlacementRings(
|
||||
engine, "minecraft:buried_treasure", 2, 2, 50, (x, z) -> true);
|
||||
if (located.status() != IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED) {
|
||||
throw new IllegalStateException("Blocked treasure placement returned " + located.status());
|
||||
}
|
||||
sender.sendMessage("TREASURE_MAP_LIMIT status=" + located.status());
|
||||
}
|
||||
|
||||
private void prepareTreasureMapChest(CommandSender sender, String destination, String material) {
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
treasureMapChest(world, destination, material);
|
||||
sender.sendMessage("TREASURE_MAP_CHEST destination=" + destination + " material=" + material);
|
||||
}
|
||||
|
||||
private Container treasureMapChest(World world, String destination, String material) {
|
||||
requireTreasureDestination(destination);
|
||||
if (!List.of("CHEST", "COPPER_CHEST").contains(material)) {
|
||||
throw new IllegalArgumentException("Unknown treasure map container: " + material);
|
||||
}
|
||||
LootTable loot = Bukkit.getLootTable(new NamespacedKey("iris_qa", destination));
|
||||
if (loot == null) {
|
||||
throw new IllegalStateException("Treasure map loot table is absent: " + destination);
|
||||
}
|
||||
world.getBlockAt(2, 161, 2).setType(Material.AIR, false);
|
||||
world.getBlockAt(2, 161, 2).setType(Material.valueOf(material), false);
|
||||
Container chest = (Container) world.getBlockAt(2, 161, 2).getState();
|
||||
((Lootable) chest).setLootTable(loot);
|
||||
((Lootable) chest).setSeed(WORLD_SEED);
|
||||
if (!chest.update(true, false)) {
|
||||
throw new IllegalStateException("Treasure map loot chest was not installed");
|
||||
}
|
||||
return (Container) world.getBlockAt(2, 161, 2).getState();
|
||||
}
|
||||
|
||||
private void verifyTreasureMapLoot(CommandSender sender) {
|
||||
checkTreasureMapLimit(sender);
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
for (String destination : List.of("blocked", "disabled", "found", "mixed")) {
|
||||
Inventory inventory = treasureMapChest(world, destination, "CHEST").getInventory();
|
||||
inspectTreasureMapChest(sender, destination);
|
||||
inventory.remove(Material.DIAMOND);
|
||||
Inventory reopened = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory();
|
||||
if (reopened.contains(Material.DIAMOND)
|
||||
|| !(reopened.contains(Material.FILLED_MAP) || reopened.contains(Material.MAP))) {
|
||||
throw new IllegalStateException("Reopening changed " + destination + " chest loot");
|
||||
}
|
||||
}
|
||||
sender.sendMessage("TREASURE_MAP_CHECK destinations=4 retained=true");
|
||||
}
|
||||
|
||||
private void inspectTreasureMapChest(CommandSender sender, String destination) {
|
||||
requireTreasureDestination(destination);
|
||||
World world = Bukkit.getWorld(TREASURE_KEY);
|
||||
Inventory inventory = ((Container) world.getBlockAt(2, 161, 2).getState()).getInventory();
|
||||
ItemStack map = null;
|
||||
int diamonds = 0;
|
||||
for (ItemStack item : inventory.getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if ((item.getType() == Material.MAP || item.getType() == Material.FILLED_MAP)
|
||||
&& item.getAmount() == 1 && map == null) {
|
||||
map = item;
|
||||
} else if (item.getType() == Material.DIAMOND) {
|
||||
diamonds += item.getAmount();
|
||||
} else {
|
||||
throw new IllegalStateException("Unexpected chest loot: " + item);
|
||||
}
|
||||
}
|
||||
if (map == null || diamonds != 1) {
|
||||
throw new IllegalStateException("Invalid " + destination + " chest: map=" + map + " diamonds=" + diamonds);
|
||||
}
|
||||
boolean expectedLocated = destination.equals("found") || destination.equals("mixed");
|
||||
MapView view = map.getItemMeta() instanceof MapMeta meta && meta.hasMapView() ? meta.getMapView() : null;
|
||||
if ((view != null) != expectedLocated) {
|
||||
throw new IllegalStateException("Unexpected map data for " + destination + ": " + map);
|
||||
}
|
||||
String result = "TREASURE_MAP_INSPECT destination=" + destination + " diamonds=1 located=" + expectedLocated;
|
||||
if (view != null) {
|
||||
StructureSearchResult target = world.locateNearestStructure(
|
||||
new Location(world, 2, 161, 2), Structure.DESERT_PYRAMID, 50, false);
|
||||
if (target == null || target.getStructure() != Structure.DESERT_PYRAMID) {
|
||||
throw new IllegalStateException("Map has no verified pyramid target");
|
||||
}
|
||||
int targetX = target.getLocation().getBlockX();
|
||||
int targetZ = target.getLocation().getBlockZ();
|
||||
if (view.getWorld() != world || view.getScale() != MapView.Scale.CLOSEST
|
||||
|| view.getCenterX() != Math.floorDiv(targetX + 64, 128) * 128
|
||||
|| view.getCenterZ() != Math.floorDiv(targetZ + 64, 128) * 128) {
|
||||
throw new IllegalStateException("Map data does not cover the verified pyramid at " + target.getLocation());
|
||||
}
|
||||
result += " mapId=" + view.getId() + " targetX=" + targetX + " targetZ=" + targetZ
|
||||
+ " centerX=" + view.getCenterX() + " centerZ=" + view.getCenterZ();
|
||||
}
|
||||
sender.sendMessage(result);
|
||||
}
|
||||
|
||||
private void requireTreasureDestination(String destination) {
|
||||
if (!List.of("blocked", "disabled", "found", "mixed").contains(destination)) {
|
||||
throw new IllegalArgumentException("Unknown treasure map destination: " + destination);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!runtime.available() || Bukkit.getWorld(WORLD_KEY) != null) {
|
||||
throw new IllegalStateException("Native runtime unavailable or probe world already loaded");
|
||||
@@ -124,6 +400,7 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
int y = world.getHighestBlockYAt(x, z);
|
||||
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, world.getMinHeight(), z).getType() != Material.GREEN_CONCRETE
|
||||
|| world.getBiome(x, y, z) != Biome.PLAINS) {
|
||||
throw new IllegalStateException("Terrain column differs at " + x + "," + y + "," + z
|
||||
+ ": " + world.getBlockAt(x, y, z).getType());
|
||||
@@ -212,11 +489,11 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
}
|
||||
|
||||
public boolean regionized() {
|
||||
return false;
|
||||
return J.isFolia();
|
||||
}
|
||||
|
||||
public boolean primaryThread() {
|
||||
return Bukkit.isPrimaryThread();
|
||||
return J.isFolia() ? Bukkit.isGlobalTickThread() : Bukkit.isPrimaryThread();
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> runGlobal(Runnable task) {
|
||||
@@ -229,10 +506,10 @@ public final class NativeLifecycleFixture extends JavaPlugin implements WorldRun
|
||||
result.completeExceptionally(failure);
|
||||
}
|
||||
};
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
if (primaryThread()) {
|
||||
wrapped.run();
|
||||
} else {
|
||||
Bukkit.getScheduler().runTask(this, wrapped);
|
||||
} else if (!J.runGlobal(wrapped)) {
|
||||
result.completeExceptionally(new IllegalStateException("Could not schedule fixture task"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.platform.bukkit.BukkitBlockResolution;
|
||||
import art.arcane.iris.platform.generation.EngineBukkitOps;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.loot.InventorySlotType;
|
||||
import art.arcane.iris.world.loot.IrisVanillaLootTable;
|
||||
import art.arcane.iris.world.loot.IrisLootTable;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.volmlib.util.matter.Matter;
|
||||
import io.papermc.paper.event.world.StructuresLocateEvent;
|
||||
import io.papermc.paper.event.server.ServerResourcesReloadedEvent;
|
||||
import io.papermc.paper.math.Position;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.Container;
|
||||
import org.bukkit.block.Chest;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.world.LootGenerateEvent;
|
||||
import org.bukkit.generator.structure.Structure;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.util.StructureSearchResult;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public final class TreasureLootChecks implements Listener {
|
||||
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "treasure_map_probe");
|
||||
private static final List<String> CHESTS = List.of(
|
||||
"CHEST", "COPPER_CHEST", "EXPOSED_COPPER_CHEST", "WEATHERED_COPPER_CHEST",
|
||||
"OXIDIZED_COPPER_CHEST", "WAXED_COPPER_CHEST", "WAXED_EXPOSED_COPPER_CHEST",
|
||||
"WAXED_WEATHERED_COPPER_CHEST", "WAXED_OXIDIZED_COPPER_CHEST");
|
||||
private final IrisVanillaLootTable reloadTable = new IrisVanillaLootTable("iris_qa:reload");
|
||||
private final AtomicInteger resourceReloads = new AtomicInteger();
|
||||
private LocateAction locateAction = LocateAction.PASS;
|
||||
private int locateEvents;
|
||||
private int lootEvents;
|
||||
private boolean lootEventsOwned = true;
|
||||
|
||||
public TreasureLootChecks(JavaPlugin plugin) {
|
||||
Bukkit.getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
public void verify(CommandSender sender) {
|
||||
World world = requireWorld();
|
||||
verifyCopperLoot(world);
|
||||
sender.sendMessage("TREASURE_COPPER_LOOT variants=8 normal=true diamonds=1");
|
||||
verifyInventoryRollback(world);
|
||||
sender.sendMessage("TREASURE_LOOT_ROLLBACK contents=true nativeTable=true seed=true retry=true");
|
||||
verifyLocateEvents(world);
|
||||
sender.sendMessage("TREASURE_LOCATE_EVENTS cancelled=true replaced=true calls=2");
|
||||
sender.sendMessage("TREASURE_EXTRA_CHECK variants=9 rollback=true locateEvents=true");
|
||||
}
|
||||
|
||||
public void verifyRegion(CommandSender sender) {
|
||||
World world = requireWorld();
|
||||
if (!J.isOwnedByCurrentRegion(world, 0, 0)) {
|
||||
throw new IllegalStateException("Fixture does not own its loot region");
|
||||
}
|
||||
lootEvents = 0;
|
||||
lootEventsOwned = true;
|
||||
verifyCopperLoot(world);
|
||||
if (lootEvents != CHESTS.size() || !lootEventsOwned) {
|
||||
throw new IllegalStateException("Loot event ownership failed: count=" + lootEvents
|
||||
+ " owned=" + lootEventsOwned);
|
||||
}
|
||||
sender.sendMessage("TREASURE_REGION_LOOT copper=8 normal=1 events=" + lootEvents
|
||||
+ " owned=true folia=" + J.isFolia());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onLoot(LootGenerateEvent event) {
|
||||
if (event.getWorld().getKey().equals(WORLD_KEY) && event.isPlugin()) {
|
||||
Location origin = event.getLootContext().getLocation();
|
||||
lootEvents++;
|
||||
lootEventsOwned &= J.isOwnedByCurrentRegion(event.getWorld(),
|
||||
origin.getBlockX() >> 4, origin.getBlockZ() >> 4);
|
||||
}
|
||||
}
|
||||
|
||||
public void verifyReload(CommandSender sender, boolean reloaded) {
|
||||
World world = requireWorld();
|
||||
Material expected = reloaded ? Material.EMERALD : Material.DIAMOND;
|
||||
Collection<ItemStack> items = reloadTable.getLoot(
|
||||
false, 783L, InventorySlotType.STORAGE, world, 2, 161, 2);
|
||||
if (items.size() != 1 || items.iterator().next().getType() != expected) {
|
||||
throw new IllegalStateException("Reloaded loot did not contain " + expected + ": " + items);
|
||||
}
|
||||
sender.sendMessage("TREASURE_RELOAD material=" + expected);
|
||||
}
|
||||
|
||||
public void reportResourceReloads(CommandSender sender) {
|
||||
sender.sendMessage("TREASURE_RESOURCES revision=" + resourceReloads.get());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onResourcesReloaded(ServerResourcesReloadedEvent event) {
|
||||
resourceReloads.incrementAndGet();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onLocate(StructuresLocateEvent event) {
|
||||
if (!event.getWorld().getKey().equals(WORLD_KEY) || locateAction == LocateAction.PASS) {
|
||||
return;
|
||||
}
|
||||
locateEvents++;
|
||||
if (locateAction == LocateAction.CANCEL) {
|
||||
event.setCancelled(true);
|
||||
} else {
|
||||
event.setResult(new StructuresLocateEvent.Result(Position.block(123, 100, 456), Structure.DESERT_PYRAMID));
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyCopperLoot(World world) {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
Block block = world.getBlockAt(4, 161, 4);
|
||||
MantleChunk<Matter> mantle = engine.getMantle().getMantle().useChunk(block.getChunk());
|
||||
try {
|
||||
for (String name : CHESTS) {
|
||||
block.setType(Material.AIR, false);
|
||||
block.setType(Material.valueOf(name), false);
|
||||
if (!BukkitBlockResolution.isStorageChest(block.getBlockData())) {
|
||||
throw new IllegalStateException("Chest is excluded from Iris loot: " + name);
|
||||
}
|
||||
EngineBukkitOps.update(engine, 4, 161, 4, block.getChunk(), mantle);
|
||||
Container chest = (Container) block.getState();
|
||||
int diamonds = 0;
|
||||
for (ItemStack item : chest.getInventory().getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getType() != Material.DIAMOND) {
|
||||
throw new IllegalStateException("Unexpected " + name + " loot: " + item);
|
||||
}
|
||||
diamonds += item.getAmount();
|
||||
}
|
||||
if (diamonds != 1) {
|
||||
throw new IllegalStateException(name + " contains " + diamonds + " diamonds");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
mantle.release();
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyInventoryRollback(World world) {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
Block block = world.getBlockAt(6, 161, 4);
|
||||
block.setType(Material.AIR, false);
|
||||
block.setType(Material.CHEST, false);
|
||||
Chest chest = (Chest) block.getState();
|
||||
chest.getBlockInventory().setItem(0, new ItemStack(Material.EMERALD, 3));
|
||||
chest = (Chest) block.getState();
|
||||
NamespacedKey key = new NamespacedKey("iris_qa", "reload");
|
||||
chest.setLootTable(Bukkit.getLootTable(key));
|
||||
chest.setSeed(913L);
|
||||
if (!chest.update(true, false)) {
|
||||
throw new IllegalStateException("Could not set native loot for rollback check");
|
||||
}
|
||||
Inventory inventory = chest.getBlockInventory();
|
||||
IllegalStateException injected = new IllegalStateException("Fixture insertion interruption");
|
||||
Inventory interrupted = (Inventory) Proxy.newProxyInstance(Inventory.class.getClassLoader(),
|
||||
new Class<?>[]{Inventory.class}, (proxy, method, arguments) -> {
|
||||
Object result;
|
||||
try {
|
||||
result = method.invoke(inventory, arguments);
|
||||
} catch (InvocationTargetException failure) {
|
||||
throw failure.getCause();
|
||||
}
|
||||
if (method.getName().equals("addItem")) {
|
||||
throw injected;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
KList<IrisLootTable> tables = new KList<>();
|
||||
tables.add(new IrisVanillaLootTable("iris_qa:reload"));
|
||||
try {
|
||||
EngineBukkitOps.addItems(engine, false, interrupted, tables, InventorySlotType.STORAGE,
|
||||
world, 6, 161, 4);
|
||||
throw new IllegalStateException("Interrupted insertion did not fail");
|
||||
} catch (IllegalStateException failure) {
|
||||
if (failure != injected) {
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
Chest restored = (Chest) block.getState();
|
||||
if (restored.getLootTable() == null || !restored.getLootTable().getKey().equals(key)
|
||||
|| restored.getSeed() != 913L) {
|
||||
throw new IllegalStateException("Rollback lost the native loot table or seed");
|
||||
}
|
||||
ItemStack existing = restored.getSnapshotInventory().getItem(0);
|
||||
if (existing == null || existing.getType() != Material.EMERALD || existing.getAmount() != 3) {
|
||||
throw new IllegalStateException("Rollback lost the original contents: " + existing);
|
||||
}
|
||||
EngineBukkitOps.addItems(engine, false, restored.getBlockInventory(), tables, InventorySlotType.STORAGE,
|
||||
world, 6, 161, 4);
|
||||
int diamonds = 0;
|
||||
int emeralds = 0;
|
||||
for (ItemStack item : restored.getBlockInventory().getContents()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (item.getType() == Material.DIAMOND) {
|
||||
diamonds += item.getAmount();
|
||||
} else if (item.getType() == Material.EMERALD) {
|
||||
emeralds += item.getAmount();
|
||||
}
|
||||
}
|
||||
if (diamonds != 2 || emeralds != 3 || ((Chest) block.getState()).getLootTable() != null) {
|
||||
throw new IllegalStateException("Loot retry failed: diamonds=" + diamonds + " emeralds=" + emeralds);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyLocateEvents(World world) {
|
||||
Location origin = new Location(world, 2, 161, 2);
|
||||
locateEvents = 0;
|
||||
try {
|
||||
locateAction = LocateAction.CANCEL;
|
||||
if (world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false) != null) {
|
||||
throw new IllegalStateException("Cancelled structure search returned a target");
|
||||
}
|
||||
locateAction = LocateAction.REPLACE;
|
||||
StructureSearchResult result = world.locateNearestStructure(origin, Structure.DESERT_PYRAMID, 50, false);
|
||||
if (result == null || result.getStructure() != Structure.DESERT_PYRAMID
|
||||
|| result.getLocation().getBlockX() != 123 || result.getLocation().getBlockY() != 100
|
||||
|| result.getLocation().getBlockZ() != 456 || locateEvents != 2) {
|
||||
throw new IllegalStateException("Structure search ignored event result: " + result);
|
||||
}
|
||||
} finally {
|
||||
locateAction = LocateAction.PASS;
|
||||
}
|
||||
}
|
||||
|
||||
private World requireWorld() {
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
if (world == null || !IrisToolbelt.isIrisWorld(world)) {
|
||||
throw new IllegalStateException("Treasure map fixture world is absent");
|
||||
}
|
||||
return world;
|
||||
}
|
||||
|
||||
private enum LocateAction {
|
||||
PASS, CANCEL, REPLACE
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,15 @@
|
||||
"block": "minecraft:lime_wool"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"minHeight": 384,
|
||||
"maxHeight": 384,
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"generators": [
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:green_concrete"
|
||||
"block": "minecraft:red_concrete"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
version = '1.0.0'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven { url = 'https://repo.papermc.io/repository/maven-public/' }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly files(providers.gradleProperty('irisJar').get())
|
||||
compileOnly files(providers.gradleProperty('irisGsonJar').get())
|
||||
compileOnly 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain.languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java.srcDirs = ['src']
|
||||
resources.srcDirs = ['resources']
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.compilerArgs += '-parameters'
|
||||
}
|
||||
|
||||
tasks.named('jar') {
|
||||
archiveBaseName = 'IrisNaturalSpawningFixture'
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
name: IrisNaturalSpawningFixture
|
||||
version: 1.0.0
|
||||
main: art.arcane.iris.fixture.NaturalSpawningFixture
|
||||
api-version: '26.1.2'
|
||||
folia-supported: true
|
||||
depend: [Iris]
|
||||
commands:
|
||||
irisspawnqa:
|
||||
permission: irisspawnqa.run
|
||||
permissions:
|
||||
irisspawnqa.run:
|
||||
default: op
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'iris-natural-spawning-fixture'
|
||||
+701
@@ -0,0 +1,701 @@
|
||||
package art.arcane.iris.fixture;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.runtime.BiomeEnvironment;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.world.IrisCreator;
|
||||
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.IrisEntity;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.platform.bukkit.BukkitEntityType;
|
||||
import art.arcane.iris.world.IrisToolbelt;
|
||||
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Difficulty;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.HeightMap;
|
||||
import org.bukkit.GameRules;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.CreatureSpawnEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public final class NaturalSpawningFixture extends JavaPlugin implements Listener {
|
||||
private static final NamespacedKey WORLD_KEY = new NamespacedKey("iris", "spawn_qa");
|
||||
private static final long WORLD_SEED = 78264193L;
|
||||
private static final int FROG_CAPACITY = 16;
|
||||
private static final int ZOMBIE_CAPACITY = 2;
|
||||
private static final int BIOME_READY_ATTEMPTS = 60;
|
||||
private static final long BIOME_RETRY_TICKS = 10L;
|
||||
|
||||
private boolean previousAmbient;
|
||||
private boolean previousMarkers;
|
||||
private boolean previousForcedPersistence;
|
||||
private volatile boolean ready;
|
||||
private int firstZombieBatch;
|
||||
private int ambientEvents;
|
||||
private int cancelledAmbientEvents;
|
||||
private final ThreadLocal<Boolean> ambientInvocation = ThreadLocal.withInitial(() -> false);
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
Bukkit.getPluginManager().registerEvents(this, this);
|
||||
previousAmbient = IrisSettings.get().getWorld().isAmbientEntitySpawningSystem();
|
||||
previousMarkers = IrisSettings.get().getWorld().isMarkerEntitySpawningSystem();
|
||||
previousForcedPersistence = IrisSettings.get().getWorld().isForcePersistEntities();
|
||||
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(false);
|
||||
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(false);
|
||||
IrisSettings.get().getWorld().setForcePersistEntities(false);
|
||||
getLogger().info("Natural spawning fixture ready; commands use the Iris ambient path on the owning region.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
IrisSettings.get().getWorld().setAmbientEntitySpawningSystem(previousAmbient);
|
||||
IrisSettings.get().getWorld().setMarkerEntitySpawningSystem(previousMarkers);
|
||||
IrisSettings.get().getWorld().setForcePersistEntities(previousForcedPersistence);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
|
||||
if (arguments.length != 1) {
|
||||
notifySender(sender, "Use /irisspawnqa setup|frogs|zombies|stress|check|seeded|completion|surface|cave");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
switch (arguments[0]) {
|
||||
case "setup" -> setup(sender);
|
||||
case "frogs" -> runRegion(sender, () -> spawnFrogs(sender));
|
||||
case "zombies" -> runRegion(sender, () -> spawnZombies(sender));
|
||||
case "stress" -> runRegion(sender, () -> stress(sender));
|
||||
case "check" -> runRegion(sender, () -> check(sender));
|
||||
case "seeded" -> seeded(sender);
|
||||
case "completion" -> completion(sender);
|
||||
case "surface" -> view(sender, 67D);
|
||||
case "cave" -> view(sender, -46D);
|
||||
default -> throw new IllegalArgumentException("Unknown spawning operation");
|
||||
}
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGHEST)
|
||||
public void isolateSpawns(CreatureSpawnEvent event) {
|
||||
if (!WORLD_KEY.equals(event.getLocation().getWorld().getKey())) {
|
||||
return;
|
||||
}
|
||||
if (!ambientInvocation.get()) {
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
ambientEvents++;
|
||||
if (event.isCancelled()) {
|
||||
cancelledAmbientEvents++;
|
||||
}
|
||||
}
|
||||
|
||||
private void setup(CommandSender sender) {
|
||||
if (!(sender instanceof Player)) {
|
||||
throw new IllegalArgumentException("Setup requires a player");
|
||||
}
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
if (world != null) {
|
||||
require(IrisToolbelt.isIrisWorld(world) && world.getSeed() == WORLD_SEED,
|
||||
"Existing fixture key is not the owned Iris world with the expected seed");
|
||||
ready = false;
|
||||
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
|
||||
return;
|
||||
}
|
||||
ready = false;
|
||||
Bukkit.getAsyncScheduler().runNow(this, task -> createWorld(sender));
|
||||
}
|
||||
|
||||
private void createWorld(CommandSender sender) {
|
||||
try {
|
||||
World world = new IrisCreator().name(WORLD_KEY.toString()).dimension("spawn-qa")
|
||||
.seed(WORLD_SEED).create();
|
||||
Bukkit.getGlobalRegionScheduler().execute(this, () -> configureWorld(sender, world));
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void configureWorld(CommandSender sender, World world) {
|
||||
try {
|
||||
world.setDifficulty(Difficulty.NORMAL);
|
||||
world.setTime(6000L);
|
||||
world.setStorm(false);
|
||||
world.setThundering(false);
|
||||
world.setGameRule(GameRules.SPAWN_MOBS, true);
|
||||
List<CompletableFuture<Void>> chunks = new ArrayList<>();
|
||||
for (int chunkX = -1; chunkX <= 1; chunkX++) {
|
||||
for (int chunkZ = -1; chunkZ <= 1; chunkZ++) {
|
||||
chunks.add(retainChunk(world, chunkX, chunkZ));
|
||||
}
|
||||
}
|
||||
CompletableFuture.allOf(chunks.toArray(CompletableFuture[]::new)).whenComplete((ignored, failure) -> {
|
||||
if (failure != null) {
|
||||
fail(sender, failure);
|
||||
} else {
|
||||
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> prepareChunk(sender, world));
|
||||
}
|
||||
});
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<Void> retainChunk(World world, int chunkX, int chunkZ) {
|
||||
CompletableFuture<Void> retained = new CompletableFuture<>();
|
||||
world.getChunkAtAsync(chunkX, chunkZ, true).whenComplete((chunk, failure) -> {
|
||||
if (failure != null) {
|
||||
retained.completeExceptionally(failure);
|
||||
return;
|
||||
}
|
||||
Bukkit.getRegionScheduler().execute(this, world, chunkX, chunkZ, () -> {
|
||||
try {
|
||||
chunk.addPluginChunkTicket(this);
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||
entity.remove();
|
||||
}
|
||||
}
|
||||
retained.complete(null);
|
||||
} catch (Throwable error) {
|
||||
retained.completeExceptionally(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
return retained;
|
||||
}
|
||||
|
||||
private void prepareChunk(CommandSender sender, World world) {
|
||||
try {
|
||||
require(WORLD_KEY.equals(world.getKey()) && IrisToolbelt.isIrisWorld(world)
|
||||
&& world.getSeed() == WORLD_SEED, "Fixture world ownership changed");
|
||||
Chunk chunk = world.getChunkAt(0, 0);
|
||||
int removed = 0;
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||
entity.remove();
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
require(!(entity instanceof LivingEntity) || entity instanceof Player || !entity.isValid(),
|
||||
"Fixture target chunk retained a living mob after cleanup: " + entity.getType());
|
||||
}
|
||||
getLogger().info("SPAWN_QA_CLEANUP removed=" + removed + " chunk=0,0");
|
||||
firstZombieBatch = 0;
|
||||
ambientEvents = 0;
|
||||
cancelledAmbientEvents = 0;
|
||||
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);
|
||||
}
|
||||
for (int y = -48; y <= -42; y++) {
|
||||
boolean shell = y == -48 || y == -42 || x == 0 || x == 15 || z == 0 || z == 15;
|
||||
chunk.getBlock(x, y, z).setType(shell ? Material.STONE : Material.CAVE_AIR, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!IrisToolbelt.isIrisWorld(world) || world.getSeed() != WORLD_SEED) {
|
||||
throw new IllegalStateException("Fixture is not the requested Iris world");
|
||||
}
|
||||
awaitBiomeEnvironment(sender, world, 0);
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
}
|
||||
|
||||
private void awaitBiomeEnvironment(CommandSender sender, World world, int attempt) {
|
||||
try {
|
||||
Engine engine = IrisToolbelt.access(world).getEngine();
|
||||
engine.getSurfaceBiomeEnvironment(8, 8);
|
||||
ready = true;
|
||||
teleport(sender, world, 67D, "SPAWN_QA_SETUP world=" + WORLD_KEY + " seed=" + WORLD_SEED);
|
||||
} catch (SavedBiomeUnavailableException unavailable) {
|
||||
if (!unavailable.isLoading() || attempt >= BIOME_READY_ATTEMPTS) {
|
||||
fail(sender, unavailable);
|
||||
return;
|
||||
}
|
||||
Bukkit.getRegionScheduler().runDelayed(this, world, 0, 0,
|
||||
task -> awaitBiomeEnvironment(sender, world, attempt + 1), BIOME_RETRY_TICKS);
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||
RuntimeSpawn spawn = runtimeSpawn(chunk, "frogs");
|
||||
getLogger().info(spawnDiagnostics(spawn, chunk));
|
||||
for (int attempt = 0; attempt < 12; attempt++) {
|
||||
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 ambient category did not fill: " + spawnDiagnostics(spawn, chunk));
|
||||
require(count(chunk, EntityType.ZOMBIE) == 0, "Unexpected zombie before cave phase");
|
||||
verifyPersistence(chunk);
|
||||
evidence(sender, "SPAWN_QA_FROGS frogs=" + count(chunk, EntityType.FROG)
|
||||
+ " persistent=true removable=true ambient=true");
|
||||
}
|
||||
|
||||
private void spawnZombies(CommandSender sender) {
|
||||
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Frogs must fill their category before cave spawning");
|
||||
require(count(chunk, EntityType.ZOMBIE) == 0, "Cave phase already ran");
|
||||
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
|
||||
getLogger().info(spawnDiagnostics(spawn, chunk));
|
||||
invokeAmbient(spawn, chunk, false);
|
||||
firstZombieBatch = count(chunk, EntityType.ZOMBIE);
|
||||
require(firstZombieBatch == ZOMBIE_CAPACITY,
|
||||
"Authored seven-zombie batch was not clamped to capacity two: " + spawnDiagnostics(spawn, chunk));
|
||||
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Cave spawning changed the passive population");
|
||||
verifyPersistence(chunk);
|
||||
evidence(sender, "SPAWN_QA_ZOMBIES frogs=" + count(chunk, EntityType.FROG)
|
||||
+ " zombies=" + firstZombieBatch + " firstBatch=" + firstZombieBatch + " ambient=true");
|
||||
}
|
||||
|
||||
private void stress(CommandSender sender) {
|
||||
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||
RuntimeSpawn spawn = runtimeSpawn(chunk, "cave-zombies");
|
||||
for (int attempt = 0; attempt < 50; attempt++) {
|
||||
invokeAmbient(spawn, chunk, false);
|
||||
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Saturated zombie category exceeded capacity");
|
||||
}
|
||||
evidence(sender, "SPAWN_QA_STRESS attempts=50 zombies=" + count(chunk, EntityType.ZOMBIE));
|
||||
}
|
||||
|
||||
private void check(CommandSender sender) {
|
||||
Chunk chunk = requireWorld().getChunkAt(0, 0);
|
||||
require(count(chunk, EntityType.FROG) == FROG_CAPACITY, "Passive population differs");
|
||||
require(count(chunk, EntityType.ZOMBIE) == ZOMBIE_CAPACITY, "Hostile population differs");
|
||||
require(firstZombieBatch == ZOMBIE_CAPACITY, "First hostile batch was not pinned");
|
||||
require(chunk.getBlock(8, -47, 8).getType() == Material.CAVE_AIR, "Cave is not real CAVE_AIR");
|
||||
require(chunk.getBlock(8, -47, 8).getLightLevel() == 0, "Cave is not dark");
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity.getType() == EntityType.ZOMBIE) {
|
||||
require(entity.getLocation().getY() < -42, "Hostile spawned outside the cave");
|
||||
}
|
||||
}
|
||||
verifyPersistence(chunk);
|
||||
evidence(sender, "SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0");
|
||||
}
|
||||
|
||||
private RuntimeSpawn runtimeSpawn(Chunk chunk, String biomeKey) {
|
||||
try {
|
||||
Engine engine = IrisToolbelt.access(chunk.getWorld()).getEngine();
|
||||
BiomeEnvironment surface = engine.getSurfaceBiomeEnvironment(8, 8);
|
||||
IrisBiome biome = surface.data().getBiomeLoader().load(biomeKey);
|
||||
require(biome != null, "Synthetic biome was not loaded");
|
||||
BiomeEnvironment selected = new BiomeEnvironment(surface.activationId(), biome, surface.region(),
|
||||
surface.dimension(), surface.data());
|
||||
Object manager = engine.getWorldManager();
|
||||
Field field = manager.getClass().getDeclaredField("entitySpawner");
|
||||
field.setAccessible(true);
|
||||
Object spawner = field.get(manager);
|
||||
Method method = spawner.getClass().getDeclaredMethod("spawnAmbient", Chunk.class, boolean.class, BiomeEnvironment.class);
|
||||
method.setAccessible(true);
|
||||
return new RuntimeSpawn(engine, spawner, method, selected);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Could not access Iris ambient spawning", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private String spawnDiagnostics(RuntimeSpawn spawn, Chunk chunk) {
|
||||
World world = chunk.getWorld();
|
||||
int floor = world.getHighestBlockYAt(8, 8, HeightMap.OCEAN_FLOOR);
|
||||
int top = world.getHighestBlockYAt(8, 8, HeightMap.WORLD_SURFACE);
|
||||
Map<String, Integer> categories = new TreeMap<>();
|
||||
Map<String, Integer> types = new TreeMap<>();
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity instanceof LivingEntity && !(entity instanceof Player)) {
|
||||
categories.merge(BukkitEntityType.of(entity.getType()).spawnCategory(), 1, Integer::sum);
|
||||
types.merge(entity.getType().name(), 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
StringBuilder diagnostics = new StringBuilder("SPAWN_QA_DIAGNOSTICS frogs=")
|
||||
.append(count(chunk, EntityType.FROG)).append(" zombies=").append(count(chunk, EntityType.ZOMBIE))
|
||||
.append(" categories=").append(categories).append(" types=").append(types)
|
||||
.append(" loadLevel=").append(chunk.getLoadLevel())
|
||||
.append(" spawnMobs=").append(world.getGameRuleValue(GameRules.SPAWN_MOBS))
|
||||
.append(" animals=").append(world.getAllowAnimals()).append(" monsters=").append(world.getAllowMonsters())
|
||||
.append(" floor=").append(floor).append(" top=").append(top)
|
||||
.append(" support=").append(world.getBlockAt(8, top, 8).getType())
|
||||
.append(" above=").append(world.getBlockAt(8, top + 1, 8).getType())
|
||||
.append(" light=").append(world.getBlockAt(8, top + 1, 8).getLightLevel())
|
||||
.append(" events=").append(ambientEvents).append(" cancelled=").append(cancelledAmbientEvents);
|
||||
for (String key : spawn.environment().biome().getEntitySpawners()) {
|
||||
IrisSpawner spawner = spawn.environment().data().getSpawnerLoader().load(key);
|
||||
if (spawner == null) {
|
||||
diagnostics.append(" spawner=").append(key).append(":missing");
|
||||
continue;
|
||||
}
|
||||
diagnostics.append(" spawner=").append(key).append(" canSpawn=")
|
||||
.append(spawner.canSpawn(spawn.engine(), chunk.getX(), chunk.getZ()))
|
||||
.append(" excluded=").append(spawner.isCompatExcluded())
|
||||
.append(" group=").append(spawner.getGroup()).append(" capacity=").append(spawner.getMaxEntitiesPerChunk());
|
||||
for (IrisEntitySpawn entry : spawner.getSpawns()) {
|
||||
IrisEntity entity = entry.getRealEntity(spawn.engine());
|
||||
diagnostics.append(" entity=").append(entry.getEntity());
|
||||
if (entity == null) {
|
||||
diagnostics.append(":missing");
|
||||
continue;
|
||||
}
|
||||
diagnostics.append(" type=").append(entity.getBukkitType()).append(" category=").append(entity.spawnCategory())
|
||||
.append(" surface=").append(entity.getSurface())
|
||||
.append(" supportMatches=").append(entity.getSurface().matches(world.getBlockAt(8, top, 8)))
|
||||
.append(" bounds=").append(BukkitPlatform.entityBoundingBox(entity.getBukkitType()));
|
||||
}
|
||||
}
|
||||
return diagnostics.toString();
|
||||
}
|
||||
|
||||
private void invokeAmbient(RuntimeSpawn spawn, Chunk chunk, boolean initial) {
|
||||
ambientInvocation.set(true);
|
||||
try (BiomeEnvironment.Scope ignored = spawn.engine().openBiomeEnvironmentScope(spawn.environment())) {
|
||||
spawn.method().invoke(spawn.spawner(), chunk, initial, spawn.environment());
|
||||
} catch (InvocationTargetException failure) {
|
||||
throw new IllegalStateException("Iris ambient spawning failed", failure.getCause());
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new IllegalStateException("Could not invoke Iris ambient spawning", failure);
|
||||
} finally {
|
||||
ambientInvocation.remove();
|
||||
}
|
||||
}
|
||||
|
||||
private int count(Chunk chunk, EntityType type) {
|
||||
int count = 0;
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity.getType() == type) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void verifyPersistence(Chunk chunk) {
|
||||
for (Entity entity : chunk.getEntities()) {
|
||||
if (entity.getType() == EntityType.FROG || entity.getType() == EntityType.ZOMBIE) {
|
||||
require(entity.isPersistent(), "Ambient entity stopped participating in chunk saves");
|
||||
require(entity instanceof LivingEntity living && living.getRemoveWhenFarAway(),
|
||||
"Ambient entity has distance despawning disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void view(CommandSender sender, double y) {
|
||||
teleport(sender, requireWorld(), y, "SPAWN_QA_VIEW y=" + (int) y);
|
||||
}
|
||||
|
||||
private void teleport(CommandSender sender, World world, double y, String message) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
throw new IllegalArgumentException("Viewing requires a player");
|
||||
}
|
||||
player.getScheduler().run(this, task -> {
|
||||
player.setGameMode(GameMode.CREATIVE);
|
||||
player.setAllowFlight(true);
|
||||
player.setFlying(true);
|
||||
player.teleportAsync(new Location(world, 40.5D, y, 8.5D)).whenComplete((success, failure) -> {
|
||||
if (failure != null || !Boolean.TRUE.equals(success)) {
|
||||
fail(sender, failure == null ? new IllegalStateException("Fixture teleport failed") : failure);
|
||||
} else {
|
||||
notifySender(sender, message);
|
||||
}
|
||||
});
|
||||
}, null);
|
||||
}
|
||||
|
||||
private World requireWorld() {
|
||||
World world = Bukkit.getWorld(WORLD_KEY);
|
||||
require(ready && world != null, "Run setup before a spawning phase");
|
||||
return world;
|
||||
}
|
||||
|
||||
private void runRegion(CommandSender sender, Runnable operation) {
|
||||
World world = requireWorld();
|
||||
Bukkit.getRegionScheduler().execute(this, world, 0, 0, () -> {
|
||||
try {
|
||||
operation.run();
|
||||
} catch (Throwable failure) {
|
||||
fail(sender, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void evidence(CommandSender sender, String message) {
|
||||
getLogger().info(message);
|
||||
notifySender(sender, message);
|
||||
}
|
||||
|
||||
private void notifySender(CommandSender sender, String message) {
|
||||
if (sender instanceof Player player) {
|
||||
player.getScheduler().run(this, task -> player.sendMessage(message), null);
|
||||
} else {
|
||||
sender.sendMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void fail(CommandSender sender, Throwable failure) {
|
||||
getLogger().log(Level.SEVERE, "Spawning acceptance failed", failure);
|
||||
notifySender(sender, "SPAWN_QA_FAILED " + failure.getMessage());
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) {
|
||||
throw new IllegalStateException(message);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Cave Zombies",
|
||||
"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/cave-zombies"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Frogs",
|
||||
"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/frogs"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Spawn QA",
|
||||
"version": 1,
|
||||
"mode": {
|
||||
"type": "OVERWORLD"
|
||||
},
|
||||
"regions": [
|
||||
"flat"
|
||||
],
|
||||
"focusRegion": "flat",
|
||||
"focus": "frogs",
|
||||
"landChance": 1,
|
||||
"environment": "NORMAL",
|
||||
"dimensionHeight": {
|
||||
"min": -64,
|
||||
"max": 320
|
||||
},
|
||||
"logicalHeight": 384,
|
||||
"fluidHeight": 0,
|
||||
"bedrock": false,
|
||||
"rockPalette": {
|
||||
"palette": [
|
||||
{
|
||||
"block": "minecraft:stone"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"type": "minecraft:frog",
|
||||
"reason": "NATURAL",
|
||||
"surface": "LAND",
|
||||
"ai": false,
|
||||
"aware": false,
|
||||
"gravity": false
|
||||
}
|
||||
+29
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+29
@@ -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,8 @@
|
||||
{
|
||||
"type": "minecraft:zombie",
|
||||
"reason": "NATURAL",
|
||||
"surface": "LAND",
|
||||
"ai": false,
|
||||
"aware": false,
|
||||
"gravity": false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"interpolator": {
|
||||
"function": "NONE",
|
||||
"horizontalScale": 1
|
||||
},
|
||||
"seed": 310,
|
||||
"composite": [
|
||||
{
|
||||
"seed": 310,
|
||||
"style": {
|
||||
"style": "FLAT"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "Flat",
|
||||
"landBiomes": [
|
||||
"frogs"
|
||||
],
|
||||
"seaBiomes": [
|
||||
"frogs"
|
||||
],
|
||||
"shoreBiomes": [
|
||||
"frogs"
|
||||
]
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"group": "CAVE",
|
||||
"maxEntitiesPerChunk": 2,
|
||||
"allowedLightLevels": {
|
||||
"min": 0,
|
||||
"max": 0
|
||||
},
|
||||
"spawns": [
|
||||
{
|
||||
"entity": "qa/zombie",
|
||||
"rarity": 1,
|
||||
"minSpawns": 7,
|
||||
"maxSpawns": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"group": "NORMAL",
|
||||
"maxEntitiesPerChunk": 16,
|
||||
"spawns": [
|
||||
{
|
||||
"entity": "qa/frog",
|
||||
"rarity": 1,
|
||||
"minSpawns": 7,
|
||||
"maxSpawns": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export default {
|
||||
name: 'iris-natural-spawning',
|
||||
description: 'Observe category-limited Iris ambient frogs and cave zombies on Folia.',
|
||||
async run(context) {
|
||||
const wait = (predicate, label, timeoutMs = 30000) => context.waitUntil(predicate, { label, timeoutMs, intervalMs: 100 })
|
||||
const command = async (operation, expected, timeoutMs = 30000) => {
|
||||
const response = await context.command(`/irisspawnqa ${operation}`, /SPAWN_QA_[A-Z]+(?:\s|$)/, timeoutMs)
|
||||
context.expect(response.includes(expected) && !response.includes('SPAWN_QA_FAILED'), response)
|
||||
return response
|
||||
}
|
||||
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 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 context.step('fill the passive category through real Iris ambient spawning', async () => {
|
||||
await command('frogs', 'SPAWN_QA_FROGS frogs=16 persistent=true removable=true ambient=true', 60000)
|
||||
await wait(() => entities('frog').length === 16, 'All sixteen ambient frogs became visible')
|
||||
context.report.irisNaturalSpawning = { visibleFrogs: entities('frog').length }
|
||||
})
|
||||
await context.step('spawn an authored seven-zombie batch with capacity two despite existing frogs', async () => {
|
||||
await command('zombies', 'SPAWN_QA_ZOMBIES frogs=16 zombies=2 firstBatch=2 ambient=true', 60000)
|
||||
await command('cave', 'SPAWN_QA_VIEW y=-46')
|
||||
await wait(() => Math.abs(context.bot.entity.position.y + 46) < 1, 'Underground fixture teleport')
|
||||
await wait(() => context.bot.blockAt(context.bot.entity.position.clone().set(8, -47, 8))?.name === 'cave_air', 'Live cave_air block reached the client')
|
||||
await wait(() => entities('zombie').length === 2, 'Both ambient cave zombies became visible')
|
||||
context.expect(entities('zombie').every(entity => entity.position.y < -42), 'Zombies were not inside the underground chamber')
|
||||
context.report.irisNaturalSpawning.visibleZombies = entities('zombie').length
|
||||
context.report.irisNaturalSpawning.caveAir = true
|
||||
})
|
||||
await context.step('repeat fifty ambient attempts without exceeding the hostile capacity', async () => {
|
||||
await command('stress', 'SPAWN_QA_STRESS attempts=50 zombies=2', 60000)
|
||||
await command('check', 'SPAWN_QA_RESULT frogs=16 zombies=2 firstBatch=2 persistent=true removable=true caveAir=true light=0', 30000)
|
||||
context.expect(entities('zombie').length === 2, 'Client observed a hostile capacity overflow')
|
||||
context.report.irisNaturalSpawning.saturatedAttempts = 50
|
||||
context.report.irisNaturalSpawning.firstHostileBatch = 2
|
||||
context.report.irisNaturalSpawning.removeWhenFarAwayAllowed = true
|
||||
context.report.irisNaturalSpawning.chunkSaving = true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,102 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
const chunks = [[-64, -64], [64, -64], [-64, 64], [64, 64]]
|
||||
|
||||
export default {
|
||||
name: 'iris-seed-cohesion',
|
||||
description: 'Compare client-visible decorated chunks in two same-seed worlds explored in opposite orders.',
|
||||
async run(context) {
|
||||
const source = await readFile(path.join(context.server.directory, '.server-source'), 'utf8')
|
||||
context.expect(/^isolated=true\s*$/m.test(source), 'Seed cohesion requires an isolated instance')
|
||||
const wait = (predicate, label) => context.waitUntil(predicate, { label, timeoutMs: 60000, intervalMs: 100 })
|
||||
const currentWorld = 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)
|
||||
}
|
||||
const enter = async name => {
|
||||
await context.command(`/iris tp ${name}`)
|
||||
await wait(async () => (await currentWorld())?.key === `iris:${name}`
|
||||
|| (await currentWorld())?.name.endsWith(name), `Enter ${name}`)
|
||||
await context.command('/minecraft:gamemode spectator @s')
|
||||
await wait(() => context.bot.game.gameMode === 'spectator', 'Spectator mode')
|
||||
await context.command('/minecraft:seed', /Seed:.*1337/)
|
||||
}
|
||||
const capture = async coordinates => {
|
||||
const hashes = {}
|
||||
const decorators = {}
|
||||
let decorationCount = 0
|
||||
for (const [cx, cz] of coordinates) {
|
||||
const x = cx * 16
|
||||
const z = cz * 16
|
||||
await context.command(`/minecraft:tp @s ${x + 8.5} 48 ${z + 8.5}`, /Teleported/i, 30000)
|
||||
await wait(() => Math.abs(context.bot.entity.position.x - x - 8.5) < 1
|
||||
&& Math.abs(context.bot.entity.position.z - z - 8.5) < 1, 'Chunk sample teleport')
|
||||
const position = context.bot.entity.position.clone()
|
||||
await wait(() => context.bot.blockAt(position.clone().set(x, 1, z))
|
||||
&& context.bot.blockAt(position.clone().set(x + 15, 63, z + 15)), 'Complete sample chunk')
|
||||
const digest = createHash('sha256')
|
||||
let decoration = 0
|
||||
let terrain = 0
|
||||
for (let dx = 0; dx < 16; dx++) {
|
||||
for (let dz = 0; dz < 16; dz++) {
|
||||
for (let y = 0; y < 64; y++) {
|
||||
const block = context.bot.blockAt(position.set(x + dx, y, z + dz))
|
||||
context.expect(block !== null, 'Missing sampled block', { x: x + dx, y, z: z + dz })
|
||||
digest.update(`${block.stateId},`)
|
||||
if (block.name === 'granite' || block.name === 'andesite') decoration++
|
||||
if (block.name === 'stone') terrain++
|
||||
}
|
||||
}
|
||||
}
|
||||
context.expect(terrain > 1000, 'Fixture terrain was not generated', { cx, cz, terrain })
|
||||
decorationCount += decoration
|
||||
decorators[`${cx},${cz}`] = decoration
|
||||
hashes[`${cx},${cz}`] = digest.digest('hex')
|
||||
}
|
||||
context.expect(decorationCount > 0, 'Fixture decorators were not generated in sampled chunks', { decorators })
|
||||
context.report.decoratorCoverage ??= []
|
||||
context.report.decoratorCoverage.push(decorators)
|
||||
return hashes
|
||||
}
|
||||
const baseline = 'seed-cohesion-forward'
|
||||
const reverse = 'seed-cohesion-reverse'
|
||||
const stateFile = path.join(context.server.directory, '.seed-cohesion.json')
|
||||
const jarHash = createHash('sha256').update(await readFile(path.join(context.server.directory, 'plugins', 'Iris.jar'))).digest('hex')
|
||||
const restart = context.options.command === 'restart-check'
|
||||
const saved = restart ? JSON.parse(await readFile(stateFile, 'utf8')) : undefined
|
||||
if (restart) {
|
||||
context.expect(jarHash === saved.jarHash, 'Restart-check requires the same Iris jar', { before: saved.jarHash, after: jarHash })
|
||||
context.expect((await context.observe()).processId !== saved.processId, 'Restart-check requires a cold server restart')
|
||||
}
|
||||
const outputs = []
|
||||
for (const [name, coordinates] of [[baseline, chunks], [reverse, [...chunks].reverse()]]) {
|
||||
await context.step(`${restart ? 'Reload' : 'Create'} and sample ${name}`, async () => {
|
||||
if (!restart) {
|
||||
await context.command(`/iris create name=${name} type=cohesion seed=1337`, /Successfully created your world/i, 180000)
|
||||
}
|
||||
await enter(name)
|
||||
const output = await capture(coordinates)
|
||||
if (restart) {
|
||||
for (const [coordinate, hash] of Object.entries(output)) {
|
||||
context.expect(saved.hashes[coordinate] === hash, 'Persisted chunk changed after restart', { name, coordinate, before: saved.hashes[coordinate], after: hash })
|
||||
}
|
||||
Object.assign(output, await capture(coordinates.map(([cx, cz]) => [cx * 3, cz * 3])))
|
||||
}
|
||||
outputs.push(output)
|
||||
})
|
||||
}
|
||||
await context.step('Compare all sampled blocks', async () => {
|
||||
for (const [coordinate, hash] of Object.entries(outputs[0])) {
|
||||
context.expect(outputs[1][coordinate] === hash, 'Same-seed worlds differ',
|
||||
{ coordinate, forward: hash, reverse: outputs[1][coordinate] })
|
||||
}
|
||||
context.report.seedCohesion = { seed: 1337, jarHash, blocksPerChunk: 16384, restart, hashes: outputs[0] }
|
||||
if (!restart) {
|
||||
await writeFile(stateFile, JSON.stringify({ processId: (await context.observe()).processId, jarHash, hashes: outputs[0] }))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+66
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,71 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { setup, openStudio, closeStudio, sampleTerrain, readJson, writeJson,
|
||||
logPosition, logSince } from './studio-support.mjs'
|
||||
|
||||
export default {
|
||||
name: 'iris-studio-hotload-lifecycle',
|
||||
description: 'Generate cold chunks during Studio edits and close after saving an edit.',
|
||||
async run(context) {
|
||||
const fixture = await setup(context)
|
||||
const biomeFile = path.join(fixture.pack, 'biomes', 'flat.json')
|
||||
const original = await readFile(biomeFile, 'utf8')
|
||||
const logOffset = await logPosition(fixture)
|
||||
let active
|
||||
let x = 16384
|
||||
try {
|
||||
active = await openStudio(context, fixture)
|
||||
await sampleTerrain(context, fixture, 0, fixture.state.material)
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
await context.step(`Load cold chunks during hotload ${cycle + 1}`, async () => {
|
||||
const manifestFile = fixture.manifestPath(active.key)
|
||||
const before = await readJson(manifestFile)
|
||||
const biome = await readJson(biomeFile)
|
||||
const material = cycle % 2 === 0 ? 'gold_block' : 'diamond_block'
|
||||
biome.name = `Lifecycle terrain ${cycle + 1}`
|
||||
biome.layers[0].palette[0].block = `minecraft:${material}`
|
||||
const startedAt = Date.now()
|
||||
await writeJson(biomeFile, biome)
|
||||
x += 512
|
||||
await context.command(`/minecraft:tp @s ${x + 0.5} 101 0.5`, /Teleported/i, 15000)
|
||||
const after = await fixture.wait(async () => {
|
||||
const manifest = await readJson(manifestFile)
|
||||
return manifest.activeActivationId !== before.activeActivationId ? manifest : false
|
||||
}, 'Hotload did not complete while the player requested fresh chunks', 90000)
|
||||
const activationDurationMs = Date.now() - startedAt
|
||||
await fixture.wait(() => Math.abs(context.bot.entity.position.x - x - 0.5) < 1,
|
||||
'Cold chunk teleport did not arrive after hotload', 30000)
|
||||
context.report.iris.activations.push({ before: before.activeActivationId,
|
||||
after: after.activeActivationId, material, coldChunkRequests: 1, activationDurationMs })
|
||||
x += 2048
|
||||
await sampleTerrain(context, fixture, x, material)
|
||||
})
|
||||
}
|
||||
await context.step('Close Studio while a saved edit is activating', async () => {
|
||||
const biome = await readJson(biomeFile)
|
||||
biome.name = 'Lifecycle close terrain'
|
||||
await writeJson(biomeFile, biome)
|
||||
const pending = await fixture.wait(async () => {
|
||||
const manifest = await readJson(fixture.manifestPath(active.key))
|
||||
return manifest.pendingActivationId !== null ? manifest : false
|
||||
}, 'The saved close edit did not enter activation', 90000)
|
||||
context.report.iris.closePendingActivationId = pending.pendingActivationId
|
||||
await closeStudio(context, fixture, active)
|
||||
active = undefined
|
||||
await context.command('/minecraft:seed', /Seed:/i, 10000)
|
||||
})
|
||||
await context.step('Reopen the edited pack and retain usable generation', async () => {
|
||||
active = await openStudio(context, fixture)
|
||||
await sampleTerrain(context, fixture, 0, 'gold_block')
|
||||
await closeStudio(context, fixture, active)
|
||||
active = undefined
|
||||
})
|
||||
const log = await logSince(fixture, logOffset)
|
||||
context.expect(!/Studio generation update failed|Unable to (capture terrain|save the terrain boundary)|ClosedChannelException|Chunk system error|server has stopped responding|could not acquire its engine runtime|Failed to list files/i.test(log),
|
||||
'Studio lifecycle emitted a generation, snapshot, storage, or watchdog failure')
|
||||
} finally {
|
||||
if (active) await closeStudio(context, fixture, active)
|
||||
await writeFile(biomeFile, original)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:blocked",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:disabled",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:found",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:map",
|
||||
"functions": [
|
||||
{
|
||||
"destination": "iris_qa:mixed",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"function": "minecraft:exploration_map"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:buried_treasure"
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:mansion"
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:desert_pyramid"
|
||||
]
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:buried_treasure",
|
||||
"minecraft:desert_pyramid"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"pack": {
|
||||
"description": "Treasure map gameplay fixtures",
|
||||
"min_format": [
|
||||
107,
|
||||
1
|
||||
],
|
||||
"max_format": [
|
||||
107,
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:filled_map",
|
||||
"modifier": {
|
||||
"destination": "#iris_qa:blocked",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"type": "minecraft:exploration_map"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:filled_map",
|
||||
"modifier": {
|
||||
"destination": "#iris_qa:disabled",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"type": "minecraft:exploration_map"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:filled_map",
|
||||
"modifier": {
|
||||
"destination": "#iris_qa:found",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"type": "minecraft:exploration_map"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:filled_map",
|
||||
"modifier": {
|
||||
"destination": "#iris_qa:mixed",
|
||||
"decoration": "minecraft:red_x",
|
||||
"zoom": 0,
|
||||
"search_radius": 50,
|
||||
"skip_existing_chunks": false,
|
||||
"type": "minecraft:exploration_map"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"type": "minecraft:chest",
|
||||
"pools": [
|
||||
{
|
||||
"rolls": 1,
|
||||
"entries": [
|
||||
{
|
||||
"type": "minecraft:item",
|
||||
"name": "minecraft:diamond"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"values": [
|
||||
"minecraft:buried_treasure"
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user