- Thsi fixes an integration between Itemsadder and Oraxen.
- Waiting on oraxen folks
This commit is contained in:
Brian Neumann-Fopiano
2026-09-07 19:22:59 -04:00
parent 894cef73a6
commit d3245aa1c2
61 changed files with 3497 additions and 160 deletions
@@ -2,6 +2,10 @@ package art.arcane.iris.nativegen;
import art.arcane.iris.engine.history.BoundaryColumnGeometry;
import art.arcane.iris.engine.history.TerrainBoundarySignature;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import net.minecraft.SharedConstants;
import net.minecraft.core.Direction;
import net.minecraft.server.Bootstrap;
@@ -13,6 +17,7 @@ import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.levelgen.Heightmap;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.OptionalInt;
@@ -20,6 +25,9 @@ import java.util.OptionalInt;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public final class NativeTransitionColumnTest {
@BeforeClass
@@ -28,6 +36,30 @@ public final class NativeTransitionColumnTest {
Bootstrap.bootStrap();
}
@Test
public void customKeysUseCarrierForNativeColumnsAndHeightmaps() {
String key = "itemsadder:rocks/ruby_ore";
PlatformBlockState custom = mock(PlatformBlockState.class);
PlatformBlockState carrier = mock(PlatformBlockState.class);
when(custom.placementBaseState()).thenReturn(carrier);
when(carrier.key()).thenReturn("minecraft:oak_log[axis=z]");
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(platform.registries()).thenReturn(registries);
when(registries.blockOrNull(key)).thenReturn(custom);
LevelHeightAccessor height = LevelHeightAccessor.create(-16, 32);
TerrainBoundarySignature signature = signature(key);
try (MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class)) {
platforms.when(IrisPlatforms::isBound).thenReturn(true);
platforms.when(IrisPlatforms::get).thenReturn(platform);
NoiseColumn column = NativeTransitionColumn.column(signature, height);
assertEquals(Direction.Axis.Z, column.getBlock(-15).getValue(RotatedPillarBlock.AXIS));
assertEquals(-14, NativeTransitionColumn.height(signature, Heightmap.Types.OCEAN_FLOOR_WG, height));
}
}
@Test
public void preservesNativePropertiesCavesAndFluids() {
NoiseColumn column = NativeTransitionColumn.column(signature("minecraft:oak_log[axis=x]"),
@@ -671,9 +671,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
IrisServices.register(IrisCompat.class, compat);
ServerConfigurator.configure();
StartupValidationOutcome datapackValidation = DatapackIngestService.validateOnStartup();
if (datapackValidation == StartupValidationOutcome.READY) {
generatorResolver.validateAllPacks();
}
IrisSafeguard.execute();
getSender().setTag(getTag());
// A cosmetic banner must never abort the bootstrap.
@@ -727,12 +724,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
Iris.reportError("Failed to register listener for " + service.getClass().getSimpleName() + ".", e);
}
}
if (datapackValidation == StartupValidationOutcome.READY) {
IrisServices.get(ExternalDataSVC.class).setContentChangeListener(generatorResolver::requestExternalContentRefresh);
generatorResolver.validateAllPacks();
}
addShutdownHook();
pendingWorldReplacements.processPendingStartupReplacements();
pendingWorldDeletes.processPendingStartupWorldDeletes();
if (J.isFolia() && IrisStartupValidation.isReady()) {
J.s(() -> worldReconciler.checkForBukkitWorlds(s -> true), 1);
J.s(this::reconcileStartupWorlds, 1);
}
J.s(() -> {
@@ -747,7 +748,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
autoStartStudio();
}
if (!J.isFolia() && IrisStartupValidation.isReady()) {
worldReconciler.checkForBukkitWorlds(s -> true);
reconcileStartupWorlds();
}
IrisToolbelt.retainMantleDataForSlice(String.class.getCanonicalName());
// The mantle stores block values as PlatformBlockState, so a BlockData retention can never
@@ -757,6 +758,16 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
return true;
}
private void reconcileStartupWorlds() {
generatorResolver.startupWorldsReady().whenComplete((ignored, failure) -> {
if (failure != null) {
Iris.reportError("Could not resume Iris startup world reconciliation.", failure);
return;
}
J.s(() -> worldReconciler.checkForBukkitWorlds(s -> true));
});
}
public void addShutdownHook() {
removeShutdownHook();
serverLifecycleThread = Thread.currentThread();
@@ -20,6 +20,7 @@ package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.lifecycle.WorldLifecycleStaging;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.lifecycle.WorldReplacementSeed;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.BrokenPackException;
@@ -29,8 +30,11 @@ import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.core.service.ExternalDataSVC;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.history.GenerationEpoch;
@@ -42,6 +46,7 @@ import art.arcane.iris.engine.history.GenerationRegistryContract;
import art.arcane.iris.engine.history.GenerationRegistryContractFactory;
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import lombok.NonNull;
import org.bukkit.Bukkit;
@@ -60,6 +65,10 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
/**
@@ -72,17 +81,102 @@ public final class IrisWorldGeneratorResolver {
private static final Object SNAPSHOT_VALIDATION_LOCK = new Object();
private static final String IRIS_DIMENSION_NAMESPACE = "iris";
private static final String PLOT_SQUARED_DISCOVERY_WORLD = "CheckingPlotSquaredGenerator";
private static final long EXTERNAL_CONTENT_STARTUP_TIMEOUT_SECONDS = 120L;
private final VolmitPlugin plugin;
private final AtomicBoolean externalContentRefreshQueued = new AtomicBoolean();
private final AtomicBoolean externalContentRefreshRequested = new AtomicBoolean();
private final Map<Path, PendingSnapshot> pendingSnapshots = new ConcurrentHashMap<>();
public IrisWorldGeneratorResolver(VolmitPlugin plugin) {
this.plugin = plugin;
}
public void validateAllPacks() {
validateAllPacks(false);
}
public void requestExternalContentRefresh() {
externalContentRefreshRequested.set(true);
if (!externalContentRefreshQueued.compareAndSet(false, true)) {
return;
}
try {
J.a(this::runExternalContentRefresh);
} catch (RuntimeException | LinkageError error) {
externalContentRefreshQueued.set(false);
throw error;
}
}
public CompletableFuture<Void> startupWorldsReady() {
return CompletableFuture.allOf(pendingSnapshots.values().stream()
.map(PendingSnapshot::ready)
.toArray(CompletableFuture<?>[]::new));
}
private void runExternalContentRefresh() {
externalContentRefreshRequested.set(false);
try {
retryPendingSnapshots();
refreshExternalContent();
} catch (RuntimeException | LinkageError error) {
Iris.reportError("Failed to revalidate Iris packs after external content changed.", error);
} finally {
externalContentRefreshQueued.set(false);
if (externalContentRefreshRequested.get()) {
requestExternalContentRefresh();
}
}
}
private void retryPendingSnapshots() {
for (Map.Entry<Path, PendingSnapshot> entry : pendingSnapshots.entrySet()) {
PendingSnapshot pending = entry.getValue();
if (pending.contentReady().isDone()) {
continue;
}
try {
ExternalDataSVC external = IrisServices.getOrNull(ExternalDataSVC.class);
long revision = external == null ? 0L : external.contentRevision();
boolean wasPending = hasPendingExternalContent(PackValidationRegistry.get(entry.getKey()), external);
PackValidationRegistry.remove(entry.getKey());
IrisData.invalidateLoadedContentRegistries(entry.getKey().toFile());
PackValidationResult validation = validateSnapshot(entry.getKey().toFile());
if (external != null && revision != external.contentRevision()) {
requestExternalContentRefresh();
continue;
}
boolean stillPending = hasPendingExternalContent(validation, external);
if (wasPending && !stillPending) {
requestExternalContentRefresh();
continue;
}
if (stillPending) {
continue;
}
PackValidationRegistry.requireLoadable(entry.getKey());
requireHistoricalDimension(pending.history(), entry.getKey().toFile(), pending.dimensionKey());
pending.contentReady().complete(null);
} catch (RuntimeException | LinkageError failure) {
pending.contentReady().completeExceptionally(failure);
}
}
}
synchronized void refreshExternalContent() {
for (File pack : PackDirectoryResolver.listVisiblePackDirectories(plugin.getDataFolder("packs"))) {
IrisData.invalidateLoadedContentRegistries(pack);
}
validateAllPacks(true);
}
private synchronized void validateAllPacks(boolean externalContentChanged) {
File packsRoot = plugin.getDataFolder("packs");
List<File> packDirs = PackDirectoryResolver.listVisiblePackDirectories(packsRoot);
if (!externalContentChanged) {
PackValidationRegistry.clear();
}
List<String> packNames = packDirs.stream().map(File::getName).sorted().toList();
Path cacheFile = IrisPlatforms.get().dataFile("cache", "pack-validation.json").toPath();
ServerConfigurator.PackContentSnapshot contentSnapshot =
@@ -92,11 +186,13 @@ public final class IrisWorldGeneratorResolver {
try {
contentSnapshot = ServerConfigurator.computePackContentSnapshot(packsRoot);
contextFingerprint = PackValidationCache.contextFingerprint();
if (!externalContentChanged) {
cached = PackValidationCache.load(
cacheFile,
contentSnapshot.content(),
contextFingerprint,
packNames);
}
} catch (RuntimeException exception) {
Iris.reportError("Could not evaluate the persisted pack-validation cache", exception);
}
@@ -136,6 +232,12 @@ public final class IrisWorldGeneratorResolver {
String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
if (!result.isLoadable()) {
if (hasPendingExternalContent(result, IrisServices.getOrNull(ExternalDataSVC.class))
&& packDirectory != null
&& PackValidator.validateForDatapackBootstrap(packDirectory).isLoadable()) {
Iris.info("Pack '" + result.getPackName() + "' is waiting for external block providers to initialize.");
continue;
}
Iris.error("Pack '" + result.getPackName()
+ "' FAILED validation - world and Studio creation with this pack will be refused. Reasons:");
for (String reason : result.getBlockingErrors()) {
@@ -226,6 +328,11 @@ public final class IrisWorldGeneratorResolver {
}
static PackValidationResult requireSnapshotLoadable(File packRoot) {
validateSnapshot(packRoot);
return PackValidationRegistry.requireLoadable(packRoot.toPath());
}
private static PackValidationResult validateSnapshot(File packRoot) {
Path normalizedRoot = packRoot.toPath().toAbsolutePath().normalize();
PackValidationResult result = PackValidationRegistry.get(normalizedRoot);
if (result == null) {
@@ -255,7 +362,7 @@ public final class IrisWorldGeneratorResolver {
}
}
}
return PackValidationRegistry.requireLoadable(normalizedRoot);
return result;
}
@Nullable
@@ -493,6 +600,10 @@ public final class IrisWorldGeneratorResolver {
long worldSeed = requireStoredWorldSeed(worldName, dimensionRoot);
GenerationHistory history = requireGenerationHistory(dimensionRoot, id, worldSeed);
File snapshotRoot = requireActivePack(history);
PackValidationResult validation = validateSnapshot(snapshotRoot);
if (hasPendingExternalContent(validation, IrisServices.getOrNull(ExternalDataSVC.class))) {
return deferFrozenWorldGenerator(worldName, worldKey, dimensionRoot, snapshotRoot, id, history);
}
try {
requireSnapshotLoadable(snapshotRoot);
} catch (BrokenPackException exception) {
@@ -522,6 +633,61 @@ public final class IrisWorldGeneratorResolver {
return new BukkitChunkGenerator(world, false, snapshotRoot, dimension.getLoadKey(), history);
}
static boolean hasPendingExternalContent(PackValidationResult validation, ExternalDataSVC external) {
return validation != null && external != null && validation.getCompatFindings().stream()
.anyMatch(finding -> finding.registry() == CompatRegistry.BLOCK
&& external.hasPendingBlockProvider(finding.key()));
}
private BukkitChunkGenerator deferFrozenWorldGenerator(
String worldName,
NamespacedKey worldKey,
File dimensionRoot,
File snapshotRoot,
String dimensionKey,
GenerationHistory history
) {
PackValidationResult structural = PackValidator.validateForDatapackBootstrap(snapshotRoot);
if (!structural.isLoadable()) {
throw new BrokenPackException(snapshotRoot.toString(), structural.getBlockingErrors());
}
IrisData data = IrisData.openDatapackCompiler(snapshotRoot);
try {
IrisDimension dimension = requireHistoricalDimension(history, data, dimensionKey);
GenerationEpoch.DimensionContract contract = history.activeEpoch().dimensionContract();
IrisWorld world = IrisWorld.builder()
.platformIdentity(worldKey.toString())
.name(worldName)
.seed(history.activeEpoch().worldSeed())
.worldFolder(dimensionRoot)
.minHeight(contract.minHeight())
.maxHeight(Math.addExact(contract.minHeight(), contract.height()))
.build();
Path snapshotPath = snapshotRoot.toPath().toAbsolutePath().normalize();
CompletableFuture<Void> contentReady = new CompletableFuture<>();
BukkitChunkGenerator generator = new BukkitChunkGenerator(
world, false, snapshotRoot, dimensionKey, history);
generator.deferStartup(new EngineTarget(world, dimension, data), contentReady);
PendingSnapshot pending = new PendingSnapshot(history, dimensionKey, contentReady, generator.getStartupReady());
if (pendingSnapshots.putIfAbsent(snapshotPath, pending) != null) {
generator.closeAsync();
throw new IllegalStateException("Iris snapshot already has a deferred startup: " + snapshotPath);
}
contentReady.orTimeout(EXTERNAL_CONTENT_STARTUP_TIMEOUT_SECONDS, TimeUnit.SECONDS);
pending.ready().whenComplete((ignored, failure) -> {
if (failure == null) {
pendingSnapshots.remove(snapshotPath, pending);
}
});
Iris.info("Waiting for external block providers before initializing Iris world '" + worldName + "'.");
requestExternalContentRefresh();
return generator;
} catch (RuntimeException | LinkageError failure) {
data.close();
throw failure;
}
}
private static GenerationHistory requireGenerationHistory(
File dimensionRoot,
String dimensionKey,
@@ -587,6 +753,14 @@ public final class IrisWorldGeneratorResolver {
GenerationHistory history,
File packRoot,
String dimensionKey
) {
return requireHistoricalDimension(history, IrisData.get(packRoot), dimensionKey);
}
private static IrisDimension requireHistoricalDimension(
GenerationHistory history,
IrisData data,
String dimensionKey
) {
GenerationEpoch epoch = history.activeEpoch();
if (!epoch.dimensionContract().dimensionKey().equals(dimensionKey)) {
@@ -594,21 +768,24 @@ public final class IrisWorldGeneratorResolver {
+ " does not match active generation history dimension "
+ epoch.dimensionContract().dimensionKey() + ".");
}
IrisData data = IrisData.get(packRoot);
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
if (dimension == null) {
throw new IllegalStateException("Immutable Iris generation pack at " + packRoot
throw new IllegalStateException("Immutable Iris generation pack at " + data.getDataFolder()
+ " does not contain dimension " + dimensionKey + ".");
}
GenerationEpoch.DimensionContract actualContract = GenerationEpochContractFactory.createForEpoch(
dimension, dimensionTypeKey(data, dimension), epoch);
if (!epoch.dimensionContract().equals(actualContract)) {
throw new IllegalStateException("Immutable Iris generation pack dimension contract changed at "
+ packRoot + ".");
+ data.getDataFolder() + ".");
}
return dimension;
}
private record PendingSnapshot(GenerationHistory history, String dimensionKey,
CompletableFuture<Void> contentReady, CompletableFuture<Void> ready) {
}
private static GenerationEpoch.DimensionContract captureDimensionContract(
IrisData data,
IrisDimension dimension
@@ -36,6 +36,10 @@ dependencies:
load: BEFORE
required: false
join-classpath: true
Oraxen:
load: BEFORE
required: false
join-classpath: true
ItemsAdder:
load: BEFORE
required: false
@@ -7,6 +7,7 @@ softdepend:
- PlaceholderAPI
- CraftEngine
- Nexo
- Oraxen
- ItemsAdder
- SCore
- ExecutableItems
@@ -24,6 +24,7 @@ public class PaperPluginMetadataTest {
"PlaceholderAPI",
"CraftEngine",
"Nexo",
"Oraxen",
"ItemsAdder",
"SCore",
"ExecutableItems",
@@ -29,6 +29,17 @@ public class IrisStartupOrderingTest {
"generatorResolver.validateAllPacks();");
}
@Test
public void externalProvidersEnableBeforePackValidation() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
String enable = section(source, "private boolean enable()", "public void addShutdownHook()");
assertOrdered(enable,
"service.onEnable();",
"setContentChangeListener(generatorResolver::requestExternalContentRefresh)",
"generatorResolver.validateAllPacks();");
}
@Test
public void restartRequiredStartupTerminatesAfterPluginInitialization() throws Exception {
String source = Files.readString(Path.of(System.getProperty("iris.startupSource")));
@@ -2,9 +2,15 @@ package art.arcane.iris.core;
import art.arcane.iris.Iris;
import art.arcane.iris.core.lifecycle.WorldLifecycleStaging;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.core.service.ExternalDataSVC;
import art.arcane.iris.core.pack.BrokenPackException;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.iris.util.common.scheduling.J;
import org.junit.After;
import org.junit.Rule;
import org.junit.Test;
@@ -22,7 +28,9 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.ArrayList;
import java.util.Random;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.junit.Assert.assertEquals;
@@ -36,12 +44,31 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
public class IrisWorldGeneratorResolverTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void snapshotDeferralRequiresAnUnresolvedBlockWithAPendingProvider() {
ExternalDataSVC external = mock(ExternalDataSVC.class);
when(external.hasPendingBlockProvider("itemsadder:example/stone")).thenReturn(true);
PackValidationResult unavailable = new PackValidationResult("pack", List.of("unavailable"), List.of(), 1L,
List.of(new CompatFinding(CompatRegistry.BLOCK, "itemsadder:example/stone",
CompatAction.EXCLUDED, "biome", "main", "layers[0]")), "26.2");
assertTrue(IrisWorldGeneratorResolver.hasPendingExternalContent(unavailable, external));
when(external.hasPendingBlockProvider("itemsadder:example/stone")).thenReturn(false);
assertFalse(IrisWorldGeneratorResolver.hasPendingExternalContent(unavailable, external));
assertFalse(IrisWorldGeneratorResolver.hasPendingExternalContent(unavailable, null));
assertFalse(IrisWorldGeneratorResolver.hasPendingExternalContent(
new PackValidationResult("vanilla", List.of(), List.of(), 1L), external));
}
@After
public void clearValidationState() {
PackValidationRegistry.clear();
@@ -83,6 +110,36 @@ public class IrisWorldGeneratorResolverTest {
assertFalse(invalid.getBlockingErrors().toString(), invalid.isLoadable());
}
@Test
public void providerEventsCoalesceAndRefreshAgainAfterAnInFlightChange() {
IrisWorldGeneratorResolver resolver = spy(new IrisWorldGeneratorResolver(mock(VolmitPlugin.class)));
List<Runnable> tasks = new ArrayList<>();
AtomicInteger passes = new AtomicInteger();
doAnswer(invocation -> {
if (passes.incrementAndGet() == 1) {
resolver.requestExternalContentRefresh();
assertTrue(tasks.isEmpty());
}
return null;
}).when(resolver).refreshExternalContent();
try (MockedStatic<J> scheduler = mockStatic(J.class)) {
scheduler.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> {
tasks.add(invocation.getArgument(0, Runnable.class));
return null;
});
resolver.requestExternalContentRefresh();
resolver.requestExternalContentRefresh();
assertEquals(1, tasks.size());
tasks.removeFirst().run();
assertEquals(1, passes.get());
assertEquals(1, tasks.size());
tasks.removeFirst().run();
assertEquals(2, passes.get());
assertTrue(tasks.isEmpty());
}
}
@Test
public void startupValidationPublishesFingerprintBoundExactRootResults() throws Exception {
String source = Files.readString(Path.of(
@@ -1,6 +1,8 @@
package art.arcane.iris.nativegen;
import art.arcane.iris.engine.history.TerrainBoundarySignature;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import com.mojang.brigadier.StringReader;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import net.minecraft.commands.arguments.blocks.BlockStateParser;
@@ -39,7 +41,15 @@ public final class NativeTransitionColumn {
}
private static BlockState parse(String key) {
StringReader reader = new StringReader(key);
String nativeKey = key;
if (!key.startsWith("minecraft:") && IrisPlatforms.isBound()) {
PlatformBlockState logicalState = IrisPlatforms.get().registries().blockOrNull(key);
PlatformBlockState baseState = logicalState == null ? null : logicalState.placementBaseState();
if (baseState != null) {
nativeKey = baseState.key();
}
}
StringReader reader = new StringReader(nativeKey);
try {
BlockState state = BlockStateParser.parseForBlock(BuiltInRegistries.BLOCK, reader, false).blockState();
if (reader.canRead()) {
+1
View File
@@ -703,6 +703,7 @@ allprojects {
maven { url = uri('https://jitpack.io') } // score
maven { url = uri('https://repo.auxilor.io/repository/maven-releases/') } // eco
maven { url = uri('https://repo.nexomc.com/releases/') } // nexo
maven { url = uri('https://repo.oraxen.com/releases/') }
maven { url = uri('https://maven.devs.beer/') } // itemsadder
maven { url = uri('https://repo.extendedclip.com/releases/') } // placeholderapi
maven { url = uri('https://mvn.lumine.io/repository/maven-public/') } // mythic
+26
View File
@@ -81,6 +81,9 @@ dependencies {
// Third Party Integrations
compileOnly(libs.nexo)
compileOnly(libs.oraxen) {
transitive = false
}
compileOnly(libs.itemsadder)
compileOnly(libs.score)
compileOnly(libs.mmoitems)
@@ -481,6 +484,29 @@ sourceSets {
srcDir(generateTemplates.map { it.outputs })
}
}
externalProviderTest {
compileClasspath += sourceSets.test.compileClasspath + sourceSets.main.output
runtimeClasspath += sourceSets.test.runtimeClasspath + sourceSets.main.output
}
}
dependencies {
externalProviderTestImplementation(libs.oraxen) {
transitive = false
}
externalProviderTestImplementation(libs.itemsadder) {
transitive = false
}
}
tasks.register('externalProviderTest', Test) {
testClassesDirs = sourceSets.externalProviderTest.output.classesDirs
classpath = sourceSets.externalProviderTest.runtimeClasspath
jvmArgs('--add-modules', 'jdk.incubator.vector')
}
tasks.named('check').configure {
dependsOn('externalProviderTest')
}
tasks.register('bukkitPurityRatchet') {
+1
View File
@@ -35,6 +35,7 @@ art/arcane/iris/core/link/data/MMOItemsDataProvider.java
art/arcane/iris/core/link/data/MythicCrucibleDataProvider.java
art/arcane/iris/core/link/data/MythicMobsDataProvider.java
art/arcane/iris/core/link/data/NexoDataProvider.java
art/arcane/iris/core/link/data/OraxenDataProvider.java
art/arcane/iris/core/nms/INMS.java
art/arcane/iris/core/nms/INMSBinding.java
art/arcane/iris/core/nms/MinecraftVersion.java
@@ -0,0 +1,190 @@
package art.arcane.iris.core.link.data;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.volmlib.util.collection.KMap;
import dev.lone.itemsadder.api.CustomBlock;
import dev.lone.itemsadder.api.CustomStack;
import dev.lone.itemsadder.api.Events.ItemsAdderLoadDataEvent;
import dev.lone.itemsadder.api.ItemsAdder;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.plugin.Plugin;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.MissingResourceException;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;
public class ItemAdderDataProviderTest {
@Test
public void loadedEmptyRegistryIsReadyAndDisabledPluginIsNot() {
ItemAdderDataProvider provider = spy(new ItemAdderDataProvider());
Plugin plugin = mock(Plugin.class);
doReturn(plugin).when(provider).getPlugin();
when(plugin.isEnabled()).thenReturn(true);
try (MockedStatic<ItemsAdder> api = mockStatic(ItemsAdder.class);
MockedStatic<CustomStack> items = mockStatic(CustomStack.class);
MockedStatic<CustomBlock> blocks = mockStatic(CustomBlock.class)) {
api.when(ItemsAdder::areItemsLoaded).thenReturn(false);
provider.init();
assertFalse(provider.isReady());
items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of());
blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of());
provider.onLoadData(mock(ItemsAdderLoadDataEvent.class));
assertTrue(provider.isReady());
assertTrue(provider.getTypes(DataType.BLOCK).isEmpty());
when(plugin.isEnabled()).thenReturn(false);
assertFalse(provider.isReady());
}
}
@Test
public void startupWaitsForLoadEventBeforeReadingRegistries() {
ItemAdderDataProvider provider = new ItemAdderDataProvider();
try (MockedStatic<ItemsAdder> api = mockStatic(ItemsAdder.class);
MockedStatic<CustomStack> items = mockStatic(CustomStack.class);
MockedStatic<CustomBlock> blocks = mockStatic(CustomBlock.class)) {
api.when(ItemsAdder::areItemsLoaded).thenReturn(false);
provider.init();
assertTrue(provider.getTypes(DataType.ITEM).isEmpty());
assertTrue(provider.getTypes(DataType.BLOCK).isEmpty());
assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.BLOCK));
items.verifyNoInteractions();
blocks.verifyNoInteractions();
items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore"));
blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore"));
provider.onLoadData(mock(ItemsAdderLoadDataEvent.class));
assertEquals(Set.of(new Identifier("rocks", "ruby_ore")), provider.getTypes(DataType.ITEM));
assertEquals(Set.of(new Identifier("rocks", "ruby_ore")), provider.getTypes(DataType.BLOCK));
assertTrue(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.BLOCK));
}
}
@Test
public void blockClaimsUseExactRegistryIds() {
ItemAdderDataProvider provider = new ItemAdderDataProvider();
try (MockedStatic<ItemsAdder> api = mockStatic(ItemsAdder.class);
MockedStatic<CustomStack> items = mockStatic(CustomStack.class);
MockedStatic<CustomBlock> blocks = mockStatic(CustomBlock.class)) {
api.when(ItemsAdder::areItemsLoaded).thenReturn(true);
items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore", "rocks:ruby_sword"));
blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore"));
provider.init();
assertTrue(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.BLOCK));
assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_sword"), DataType.BLOCK));
assertFalse(provider.isValidProvider(new Identifier("rocks", "unknown"), DataType.BLOCK));
assertFalse(provider.isValidProvider(new Identifier("other", "ruby_ore"), DataType.BLOCK));
assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.ENTITY));
}
}
@Test
public void loadEventReplacesBlockClaimsAfterRegistryChanges() {
ItemAdderDataProvider provider = new ItemAdderDataProvider();
try (MockedStatic<ItemsAdder> api = mockStatic(ItemsAdder.class);
MockedStatic<CustomStack> items = mockStatic(CustomStack.class);
MockedStatic<CustomBlock> blocks = mockStatic(CustomBlock.class)) {
api.when(ItemsAdder::areItemsLoaded).thenReturn(true);
items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore"));
blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:ruby_ore"));
provider.init();
items.when(CustomStack::getNamespacedIdsInRegistry).thenReturn(Set.of("gems:sapphire_ore"));
blocks.when(CustomBlock::getNamespacedIdsInRegistry).thenReturn(Set.of("rocks:sapphire_ore"));
assertEquals(Set.of(new Identifier("rocks", "ruby_ore")), provider.getTypes(DataType.ITEM));
assertEquals(Set.of(new Identifier("rocks", "ruby_ore")), provider.getTypes(DataType.BLOCK));
items.verify(CustomStack::getNamespacedIdsInRegistry);
blocks.verify(CustomBlock::getNamespacedIdsInRegistry);
provider.onLoadData(mock(ItemsAdderLoadDataEvent.class));
assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.BLOCK));
assertTrue(provider.isValidProvider(new Identifier("rocks", "sapphire_ore"), DataType.BLOCK));
assertFalse(provider.isValidProvider(new Identifier("rocks", "ruby_ore"), DataType.ITEM));
assertTrue(provider.isValidProvider(new Identifier("gems", "sapphire_ore"), DataType.ITEM));
assertEquals(Set.of(new Identifier("gems", "sapphire_ore")), provider.getTypes(DataType.ITEM));
assertEquals(Set.of(new Identifier("rocks", "sapphire_ore")), provider.getTypes(DataType.BLOCK));
}
}
@Test
public void resolutionRetainsCarrierAndNativeIdentifier() {
Identifier identifier = new Identifier("rocks", "ruby_ore");
CustomBlock customBlock = mock(CustomBlock.class);
BlockData carrier = mock(BlockData.class);
when(customBlock.getBaseBlockData()).thenReturn(carrier);
try (MockedStatic<CustomBlock> blocks = mockStatic(CustomBlock.class)) {
blocks.when(() -> CustomBlock.getInstance("rocks:ruby_ore")).thenReturn(customBlock);
BlockData result = new ItemAdderDataProvider().getBlockData(identifier, new KMap<>());
assertTrue(result instanceof IrisCustomData);
IrisCustomData custom = (IrisCustomData) result;
assertSame(carrier, custom.getBase());
assertEquals(identifier, custom.getCustom());
}
}
@Test
public void placementDoesNotOverwriteProviderInitializedBlock() {
Block block = mock(Block.class);
Location location = new Location(null, 7, 81, -6);
CustomBlock placed = mock(CustomBlock.class);
when(block.getLocation()).thenReturn(location);
try (MockedStatic<CustomBlock> blocks = mockStatic(CustomBlock.class)) {
blocks.when(() -> CustomBlock.place("rocks:ruby_ore", location)).thenReturn(placed);
new ItemAdderDataProvider().processUpdate(mock(Engine.class), block, new Identifier("rocks", "ruby_ore"));
blocks.verify(() -> CustomBlock.place("rocks:ruby_ore", location));
verify(block).getLocation();
verifyNoMoreInteractions(block, placed);
}
}
@Test
public void missingPlacementReportsFailure() {
Block block = mock(Block.class);
when(block.getLocation()).thenReturn(new Location(null, 7, 81, -6));
try (MockedStatic<CustomBlock> blocks = mockStatic(CustomBlock.class)) {
assertThrows(MissingResourceException.class, () -> new ItemAdderDataProvider().processUpdate(
mock(Engine.class), block, new Identifier("rocks", "unknown")));
}
}
@Test
public void unsupportedStateDoesNotResolveDefaultBlockSilently() {
KMap<String, String> state = new KMap<>();
state.put("facing", "north");
try (MockedStatic<CustomBlock> blocks = mockStatic(CustomBlock.class)) {
assertThrows(MissingResourceException.class, () -> new ItemAdderDataProvider().getBlockData(
new Identifier("rocks", "ruby_ore"), state));
blocks.verifyNoInteractions();
}
}
}
@@ -0,0 +1,91 @@
package art.arcane.iris.core.link.data;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.volmlib.util.collection.KMap;
import io.th0rgal.oraxen.api.OraxenBlocks;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.MissingResourceException;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class OraxenDataProviderTest {
@Test
public void blockResolutionRetainsProviderIdentityForDeferredPlacement() {
Identifier identifier = new Identifier("oraxen", "ruby_ore");
BlockData carrier = mock(BlockData.class);
OraxenDataProvider provider = new OraxenDataProvider();
try (MockedStatic<OraxenBlocks> blocks = mockStatic(OraxenBlocks.class)) {
blocks.when(() -> OraxenBlocks.getOraxenBlockData("ruby_ore")).thenReturn(carrier);
BlockData result = provider.getBlockData(identifier, new KMap<>());
assertTrue(result instanceof IrisCustomData);
IrisCustomData custom = (IrisCustomData) result;
assertSame(carrier, custom.getBase());
assertEquals(identifier, custom.getCustom());
}
}
@Test
public void missingBlockDoesNotProduceCarrierData() {
OraxenDataProvider provider = new OraxenDataProvider();
try (MockedStatic<OraxenBlocks> blocks = mockStatic(OraxenBlocks.class)) {
assertThrows(MissingResourceException.class,
() -> provider.getBlockData(new Identifier("oraxen", "unknown"), new KMap<>()));
}
}
@Test
public void unsupportedStateDoesNotResolveDefaultBlockSilently() {
OraxenDataProvider provider = new OraxenDataProvider();
KMap<String, String> state = new KMap<>();
state.put("facing", "north");
try (MockedStatic<OraxenBlocks> blocks = mockStatic(OraxenBlocks.class)) {
assertThrows(MissingResourceException.class,
() -> provider.getBlockData(new Identifier("oraxen", "ruby_ore"), state));
blocks.verifyNoInteractions();
}
}
@Test
public void placementUsesProviderApiAtTargetLocation() {
OraxenDataProvider provider = new OraxenDataProvider();
Block block = mock(Block.class);
Location location = new Location(null, 31, 80, -18);
when(block.getLocation()).thenReturn(location);
try (MockedStatic<OraxenBlocks> blocks = mockStatic(OraxenBlocks.class)) {
provider.processUpdate(mock(Engine.class), block, new Identifier("oraxen", "ruby_ore"));
blocks.verify(() -> OraxenBlocks.place("ruby_ore", location));
}
}
@Test
public void blockClaimsRequireExactRegisteredMechanic() {
OraxenDataProvider provider = new OraxenDataProvider();
try (MockedStatic<OraxenBlocks> blocks = mockStatic(OraxenBlocks.class)) {
blocks.when(() -> OraxenBlocks.isOraxenBlock("ruby_ore")).thenReturn(true);
assertTrue(provider.isValidProvider(new Identifier("oraxen", "ruby_ore"), DataType.BLOCK));
assertFalse(provider.isValidProvider(new Identifier("oraxen", "ruby_sword"), DataType.BLOCK));
assertFalse(provider.isValidProvider(new Identifier("nexo", "ruby_ore"), DataType.BLOCK));
assertFalse(provider.isValidProvider(new Identifier("oraxen", "ruby_ore"), DataType.ENTITY));
assertTrue(provider.getTypes(DataType.ENTITY).isEmpty());
}
}
}
@@ -382,21 +382,24 @@ public final class ContentGate {
return merged.isEmpty() ? Map.of() : Collections.unmodifiableMap(merged);
}
/** {@code namespace:name[props]} lowercased with the default namespace applied; null for blank input. */
/** Normalizes block names and vanilla properties, preserving external property case; null for blank input. */
public static String normalizeState(String state) {
if (state == null) {
return null;
}
String trimmed = state.trim().toLowerCase(Locale.ROOT);
String trimmed = state.trim();
if (trimmed.isEmpty()) {
return null;
}
int props = trimmed.indexOf('[');
String base = props < 0 ? trimmed : trimmed.substring(0, props);
String base = (props < 0 ? trimmed : trimmed.substring(0, props)).toLowerCase(Locale.ROOT);
String rest = props < 0 ? "" : trimmed.substring(props);
if (base.indexOf(':') < 0) {
base = DEFAULT_NAMESPACE + ':' + base;
}
if (base.startsWith(DEFAULT_NAMESPACE + ':')) {
rest = rest.toLowerCase(Locale.ROOT);
}
return base + rest;
}
@@ -3,12 +3,15 @@ package art.arcane.iris.core.link.data;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.core.link.ExternalDataProvider;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.service.ExternalDataSVC;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.spi.IrisServices;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.iris.util.common.data.IrisCustomData;
import dev.lone.itemsadder.api.CustomBlock;
import dev.lone.itemsadder.api.CustomStack;
import dev.lone.itemsadder.api.Events.ItemsAdderLoadDataEvent;
import dev.lone.itemsadder.api.ItemsAdder;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.event.EventHandler;
@@ -24,8 +27,7 @@ import java.util.stream.Collectors;
public class ItemAdderDataProvider extends ExternalDataProvider implements Listener {
private volatile Set<String> itemNamespaces = Set.of();
private volatile Set<String> blockNamespaces = Set.of();
private volatile ContentRegistry content = new ContentRegistry(Set.of(), Set.of(), Set.of(), false);
public ItemAdderDataProvider() {
super("ItemsAdder");
@@ -33,22 +35,42 @@ public class ItemAdderDataProvider extends ExternalDataProvider implements Liste
@Override
public void init() {
updateNamespaces();
if (ItemsAdder.areItemsLoaded()) {
updateContent();
}
}
@Override
public boolean isReady() {
return super.isReady() && content.ready();
}
@EventHandler
public void onLoadData(ItemsAdderLoadDataEvent event) {
updateNamespaces();
if (!updateContent()) {
return;
}
ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class);
if (service != null) {
service.notifyContentChanged();
}
}
@NotNull
@Override
public BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap<String, String> state) throws MissingResourceException {
if (!state.isEmpty()) {
throw new MissingResourceException("ItemsAdder blocks do not expose Iris block properties.", blockId.namespace(), blockId.key());
}
CustomBlock block = CustomBlock.getInstance(blockId.toString());
if (block == null) {
throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key());
}
return IrisCustomData.of(block.getBaseBlockData(), blockId);
BlockData base = block.getBaseBlockData();
if (base == null) {
throw new MissingResourceException("Failed to find ItemsAdder block data.", blockId.namespace(), blockId.key());
}
return IrisCustomData.of(base, blockId);
}
@NotNull
@@ -63,46 +85,42 @@ public class ItemAdderDataProvider extends ExternalDataProvider implements Liste
@Override
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
CustomBlock custom;
if ((custom = CustomBlock.place(blockId.toString(), block.getLocation())) == null)
return;
block.setBlockData(custom.getBaseBlockData(), false);
if (CustomBlock.place(blockId.toString(), block.getLocation()) == null) {
throw new MissingResourceException("Failed to place ItemsAdder block.", blockId.namespace(), blockId.key());
}
}
@Override
public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) {
return switch (dataType) {
case ENTITY -> List.of();
case ITEM -> CustomStack.getNamespacedIdsInRegistry()
.stream()
.map(Identifier::fromString)
.toList();
case BLOCK -> CustomBlock.getNamespacedIdsInRegistry()
.stream()
.map(Identifier::fromString)
.toList();
case ITEM -> content.items();
case BLOCK -> content.blocks();
};
}
private void updateNamespaces() {
try {
updateNamespaces(DataType.ITEM);
updateNamespaces(DataType.BLOCK);
} catch (Throwable e) {
IrisLogging.warn("Failed to update ItemAdder namespaces: " + e.getMessage());
}
}
private void updateNamespaces(DataType dataType) {
Set<String> namespaces = getTypes(dataType).stream().map(Identifier::namespace).collect(Collectors.toSet());
if (dataType == DataType.ITEM) itemNamespaces = namespaces;
else blockNamespaces = namespaces;
IrisLogging.debug("Updated ItemAdder namespaces: " + dataType + " - " + namespaces);
}
@Override
public boolean isValidProvider(@NotNull Identifier id, DataType dataType) {
if (dataType == DataType.ENTITY) return false;
return dataType == DataType.ITEM ? itemNamespaces.contains(id.namespace()) : blockNamespaces.contains(id.namespace());
return dataType == DataType.ITEM ? content.itemNamespaces().contains(id.namespace()) : content.blocks().contains(id);
}
private boolean updateContent() {
try {
Set<Identifier> items = CustomStack.getNamespacedIdsInRegistry().stream()
.map(Identifier::fromString).collect(Collectors.toUnmodifiableSet());
Set<Identifier> blocks = CustomBlock.getNamespacedIdsInRegistry().stream()
.map(Identifier::fromString).collect(Collectors.toUnmodifiableSet());
Set<String> itemNamespaces = items.stream().map(Identifier::namespace).collect(Collectors.toUnmodifiableSet());
content = new ContentRegistry(items, blocks, itemNamespaces, true);
IrisLogging.debug("Updated ItemsAdder content registry: " + items.size() + " items, " + blocks.size() + " blocks");
return true;
} catch (RuntimeException | LinkageError e) {
IrisLogging.reportError("Failed to update ItemsAdder content registry.", e);
return false;
}
}
private record ContentRegistry(Set<Identifier> items, Set<Identifier> blocks, Set<String> itemNamespaces, boolean ready) {
}
}
@@ -71,7 +71,9 @@ public class KGeneratorsDataProvider extends ExternalDataProvider {
@Override
public boolean isValidProvider(@NotNull Identifier id, DataType dataType) {
if (dataType == DataType.ENTITY) return false;
return "kgenerators".equalsIgnoreCase(id.namespace());
if (dataType == DataType.ENTITY || !"kgenerators".equalsIgnoreCase(id.namespace())) {
return false;
}
return dataType == DataType.ITEM || Main.getGenerators().get(id.key()) != null;
}
}
@@ -116,8 +116,20 @@ public class MMOItemsDataProvider extends ExternalDataProvider {
@Override
public boolean isValidProvider(@NotNull Identifier id, DataType dataType) {
if (dataType == DataType.ENTITY) return false;
return dataType == DataType.ITEM ? id.namespace().split("_", 2).length == 2 : id.namespace().equals("mmoitems");
if (dataType == DataType.ENTITY) {
return false;
}
if (dataType == DataType.ITEM) {
return id.namespace().split("_", 2).length == 2;
}
if (!id.namespace().equals("mmoitems")) {
return false;
}
try {
return api().getCustomBlocks().getBlock(Integer.parseInt(id.key())) != null;
} catch (NumberFormatException ignored) {
return false;
}
}
private MMOItems api() {
@@ -138,7 +138,13 @@ public class MythicCrucibleDataProvider extends ExternalDataProvider {
@Override
public boolean isValidProvider(@NotNull Identifier key, DataType dataType) {
if (dataType == DataType.ENTITY) return false;
return key.namespace().equalsIgnoreCase("crucible");
if (dataType == DataType.ENTITY || !key.namespace().equalsIgnoreCase("crucible")) {
return false;
}
if (dataType == DataType.ITEM) {
return true;
}
CrucibleItem item = itemManager.getItem(key.key()).orElse(null);
return item != null && (item.getBlockData() != null || item.getFurnitureData() != null);
}
}
@@ -140,7 +140,13 @@ public class NexoDataProvider extends ExternalDataProvider {
@Override
public boolean isValidProvider(@NotNull Identifier id, DataType dataType) {
if (dataType == DataType.ENTITY) return false;
return "nexo".equalsIgnoreCase(id.namespace());
if (!"nexo".equalsIgnoreCase(id.namespace())) {
return false;
}
return switch (dataType) {
case BLOCK -> NexoBlocks.isCustomBlock(id.key()) || NexoFurniture.isFurniture(id.key());
case ITEM -> NexoItems.exists(id.key());
case ENTITY -> false;
};
}
}
@@ -0,0 +1,90 @@
package art.arcane.iris.core.link.data;
import art.arcane.iris.core.link.ExternalDataProvider;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.service.ExternalDataSVC;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.volmlib.util.collection.KMap;
import io.th0rgal.oraxen.api.OraxenBlocks;
import io.th0rgal.oraxen.api.OraxenItems;
import io.th0rgal.oraxen.api.events.OraxenItemsLoadedEvent;
import io.th0rgal.oraxen.items.ItemBuilder;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.inventory.ItemStack;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.Collection;
import java.util.List;
import java.util.MissingResourceException;
public final class OraxenDataProvider extends ExternalDataProvider implements Listener {
public OraxenDataProvider() {
super("Oraxen");
}
@Override
public void init() {
}
@EventHandler
public void onItemsLoaded(OraxenItemsLoadedEvent event) {
ExternalDataSVC service = IrisServices.getOrNull(ExternalDataSVC.class);
if (service != null) {
service.notifyContentChanged();
}
}
@Override
public @NotNull BlockData getBlockData(@NotNull Identifier blockId, @NotNull KMap<String, String> state) {
if (!state.isEmpty()) {
throw new MissingResourceException("Oraxen blocks do not expose Iris block properties.", blockId.namespace(), blockId.key());
}
BlockData data = OraxenBlocks.getOraxenBlockData(blockId.key());
if (data == null) {
throw new MissingResourceException("Failed to find BlockData!", blockId.namespace(), blockId.key());
}
return IrisCustomData.of(data, blockId);
}
@Override
public @NotNull ItemStack getItemStack(@NotNull Identifier itemId, @NotNull KMap<String, Object> customNbt) {
ItemBuilder builder = OraxenItems.getItemById(itemId.key());
if (builder == null) {
throw new MissingResourceException("Failed to find ItemData!", itemId.namespace(), itemId.key());
}
return builder.build();
}
@Override
public void processUpdate(@NotNull Engine engine, @NotNull Block block, @NotNull Identifier blockId) {
OraxenBlocks.place(blockId.key(), block.getLocation());
}
@Override
public @NotNull Collection<@NotNull Identifier> getTypes(@NotNull DataType dataType) {
if (dataType == DataType.ENTITY) {
return List.of();
}
return OraxenItems.getNames().stream()
.filter(id -> dataType == DataType.ITEM || OraxenBlocks.isOraxenBlock(id))
.map(id -> new Identifier("oraxen", id))
.toList();
}
@Override
public boolean isValidProvider(@NotNull Identifier id, DataType dataType) {
if (!"oraxen".equalsIgnoreCase(id.namespace())) {
return false;
}
return switch (dataType) {
case BLOCK -> OraxenBlocks.isOraxenBlock(id.key());
case ITEM -> OraxenItems.exists(id.key());
case ENTITY -> false;
};
}
}
@@ -198,6 +198,21 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
}
public static void invalidateLoadedContentRegistries(File source) {
Path requested = dataFolderIdentity(Objects.requireNonNull(source, "authoring pack source"));
for (Map.Entry<File, IrisData> entry : dataLoaders.entrySet()) {
IrisData data = entry.getValue();
if (dataFolderIdentity(entry.getKey()).equals(requested)) {
synchronized (data) {
if (data.generationRegistryContract == null && data.getEngines().isEmpty()) {
data.invalidateAuthoringResources();
data.compatReport.clear();
}
}
}
}
}
private synchronized void invalidateAuthoringResources() {
if (generationRegistryContract != null) {
throw new IllegalStateException("Cannot invalidate an immutable generation pack as an authoring source.");
@@ -88,7 +88,7 @@ public final class ContentKeyValidator {
return List.of();
}
List<String> blockKeys = normalizeKeyList(registries.blockKeys());
List<String> blockKeys = normalizeKeyList(registries.blockTypeKeys());
List<String> itemKeys = normalizeKeyList(registries.itemKeys());
List<String> entityKeys = normalizeKeyList(registries.entityKeys());
@@ -432,7 +432,7 @@ public final class ContentKeyValidator {
if (registries == null) {
return ContentKeyIssues.none();
}
List<String> blockKeys = registries.blockKeys();
List<String> blockKeys = registries.blockTypeKeys();
List<String> itemKeys = registries.itemKeys();
List<String> entityKeys = registries.entityKeys();
if (blockKeys == null || blockKeys.isEmpty() || itemKeys == null || itemKeys.isEmpty() || entityKeys == null || entityKeys.isEmpty()) {
@@ -57,6 +57,7 @@ public final class PackValidationCache {
PlatformRegistries registries = Objects.requireNonNull(
platform.registries(), "Pack validation platform registries");
updateSorted(digest, registries.blockKeys());
updateSorted(digest, registries.blockTypeKeys());
updateSorted(digest, registries.biomeKeys());
updateSorted(digest, registries.itemKeys());
updateSorted(digest, registries.entityKeys());
@@ -314,6 +314,10 @@ public class SchemaBuilder {
return a;
}
private static String definitionReference(String key) {
return "#/definitions/" + key.replace("~", "~0").replace("/", "~1");
}
private static String stripNamespace(String key) {
int idx = key.indexOf(':');
return idx < 0 ? key : key.substring(idx + 1);
@@ -566,12 +570,12 @@ public class SchemaBuilder {
.put("properties", new JSONObject()
.put(blockType, new JSONObject()
.put("type", "string")
.put("$ref", "#/definitions/" + enumKey))))
.put("$ref", definitionReference(enumKey)))))
.put("then", new JSONObject()
.put("properties", new JSONObject()
.put(k.getName(), new JSONObject()
.put("type", "object")
.put("$ref", "#/definitions/" + propertiesKey))))
.put("$ref", definitionReference(propertiesKey)))))
.put("else", false));
if (!definitions.containsKey(enumKey)) {
@@ -26,7 +26,7 @@ import art.arcane.iris.core.link.data.DataType;
import art.arcane.iris.core.nms.container.BlockProperty;
import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.volmlib.util.collection.KMap;
import art.arcane.iris.util.common.plugin.IrisService;
import org.bukkit.Bukkit;
@@ -41,10 +41,16 @@ import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import java.util.Collection;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
public class ExternalDataSVC implements IrisService {
@@ -52,6 +58,7 @@ public class ExternalDataSVC implements IrisService {
private static final List<ProviderDefinition> BUILT_IN_PROVIDERS = List.of(
new ProviderDefinition("CraftEngine", PROVIDER_PACKAGE + "CraftEngineDataProvider"),
new ProviderDefinition("Nexo", PROVIDER_PACKAGE + "NexoDataProvider"),
new ProviderDefinition("Oraxen", PROVIDER_PACKAGE + "OraxenDataProvider"),
new ProviderDefinition("ItemsAdder", PROVIDER_PACKAGE + "ItemAdderDataProvider"),
new ProviderDefinition("ExecutableItems", PROVIDER_PACKAGE + "ExecutableItemsDataProvider"),
new ProviderDefinition("MMOItems", PROVIDER_PACKAGE + "MMOItemsDataProvider"),
@@ -60,9 +67,13 @@ public class ExternalDataSVC implements IrisService {
new ProviderDefinition("MythicCrucible", PROVIDER_PACKAGE + "MythicCrucibleDataProvider"),
new ProviderDefinition("KGenerators", PROVIDER_PACKAGE + "KGeneratorsDataProvider")
);
private static final Set<String> BLOCK_PROVIDER_PLUGINS = Set.of(
"craftengine", "nexo", "oraxen", "itemsadder", "mmoitems", "mythiccrucible", "kgenerators");
private final KList<ExternalDataProvider> providers = new KList<>();
private final KList<ExternalDataProvider> activeProviders = new KList<>();
private final List<ExternalDataProvider> providers = new CopyOnWriteArrayList<>();
private final List<ExternalDataProvider> activeProviders = new CopyOnWriteArrayList<>();
private final AtomicLong contentRevision = new AtomicLong();
private volatile Runnable contentChangeListener;
@Override
public void onEnable() {
@@ -77,10 +88,67 @@ public class ExternalDataSVC implements IrisService {
@Override
public void onDisable() {
contentChangeListener = null;
activeProviders.clear();
providers.clear();
}
public void setContentChangeListener(Runnable listener) {
contentChangeListener = Objects.requireNonNull(listener);
}
public void notifyContentChanged() {
contentRevision.incrementAndGet();
Runnable listener = contentChangeListener;
if (listener != null) {
listener.run();
}
}
public long contentRevision() {
return contentRevision.get();
}
public boolean hasPendingBlockProvider(String blockKey) {
Identifier identifier = Identifier.fromString(blockKey);
if ("minecraft".equals(identifier.namespace())) {
return false;
}
String namespace = identifier.namespace().toLowerCase(Locale.ROOT);
Optional<ExternalDataProvider> qualified = findProvider(namespace);
if (qualified.isPresent()) {
return !qualified.get().isReady();
}
if (BLOCK_PROVIDER_PLUGINS.contains(namespace)) {
return isInstalledProviderPending(namespace);
}
for (String pluginId : BLOCK_PROVIDER_PLUGINS) {
if (isInstalledProviderPending(pluginId)) {
return true;
}
}
for (ExternalDataProvider provider : providers) {
if (!provider.isReady()) {
return true;
}
}
return false;
}
private boolean isInstalledProviderPending(String pluginId) {
String installedName = BUILT_IN_PROVIDERS.stream()
.map(ProviderDefinition::pluginId)
.filter(name -> name.equalsIgnoreCase(pluginId))
.findFirst()
.orElse(pluginId);
Plugin plugin = Bukkit.getPluginManager().getPlugin(installedName);
if (plugin == null) {
return false;
}
Optional<ExternalDataProvider> provider = findProvider(pluginId);
return !plugin.isEnabled() || provider.isEmpty() || !provider.get().isReady();
}
@EventHandler
public void onPluginEnable(PluginEnableEvent event) {
String pluginId = event.getPlugin().getName();
@@ -106,7 +174,8 @@ public class ExternalDataSVC implements IrisService {
}
providers.add(registeredProvider);
if (registeredProvider.isReady()) {
Plugin plugin = registeredProvider.getPlugin();
if (plugin != null && plugin.isEnabled()) {
activateProvider(registeredProvider);
}
}
@@ -119,28 +188,35 @@ public class ExternalDataSVC implements IrisService {
IrisLogging.error(e.getMessage());
return Optional.empty();
}
Identifier mod = pair.getA();
Optional<ExternalDataProvider> provider = activeProviders.stream().filter(p -> p.isValidProvider(mod, DataType.BLOCK)).findFirst();
if (provider.isEmpty())
return Optional.empty();
try {
return Optional.of(provider.get().getBlockData(mod, pair.getB()));
BlockProvider match = findBlockProvider(pair.getA());
if (match == null) {
return Optional.empty();
}
BlockData resolved = match.provider().getBlockData(match.identifier(), pair.getB());
if (resolved instanceof IrisCustomData custom) {
resolved = IrisCustomData.of(custom.getBase(), qualifyBlockId(match.provider(), custom.getCustom()));
}
return Optional.of(resolved);
} catch (MissingResourceException e) {
IrisLogging.error(e.getMessage() + " - [" + e.getClassName() + ":" + e.getKey() + "]");
return Optional.empty();
} catch (RuntimeException | LinkageError e) {
IrisLogging.reportError("Failed to resolve external block " + key + ".", e);
return Optional.empty();
}
}
public Optional<List<BlockProperty>> getBlockProperties(final Identifier key) {
Optional<ExternalDataProvider> provider = activeProviders.stream().filter(p -> p.isValidProvider(key, DataType.BLOCK)).findFirst();
if (provider.isEmpty())
return Optional.empty();
try {
return Optional.of(provider.get().getBlockProperties(key));
BlockProvider match = findBlockProvider(parseState(key).getA());
return match == null ? Optional.empty() : Optional.of(match.provider().getBlockProperties(match.identifier()));
} catch (MissingResourceException e) {
IrisLogging.error(e.getMessage() + " - [" + e.getClassName() + ":" + e.getKey() + "]");
return Optional.empty();
} catch (RuntimeException | LinkageError e) {
IrisLogging.reportError("Failed to resolve external block properties for " + key + ".", e);
return Optional.empty();
}
}
@@ -160,13 +236,12 @@ public class ExternalDataSVC implements IrisService {
}
public void processUpdate(Engine engine, Block block, Identifier blockId) {
Optional<ExternalDataProvider> provider = activeProviders.stream().filter(p -> p.isValidProvider(blockId, DataType.BLOCK)).findFirst();
if (provider.isEmpty()) {
IrisLogging.warnOnce("external-provider:block:" + blockId,
"No matching Provider found for modded material \"%s\"!", blockId);
return;
Pair<Identifier, KMap<String, String>> state = parseState(blockId);
BlockProvider match = findBlockProvider(state.getA());
if (match == null) {
throw new MissingResourceException("No matching provider found for external block placement.", blockId.namespace(), blockId.key());
}
provider.get().processUpdate(engine, block, blockId);
match.provider().processUpdate(engine, block, buildState(match.identifier(), state.getB()));
}
public Entity spawnMob(Location location, Identifier mobId) {
@@ -185,36 +260,61 @@ public class ExternalDataSVC implements IrisService {
}
public Collection<Identifier> getAllIdentifiers(DataType dataType) {
if (dataType == DataType.BLOCK) {
Set<Identifier> identifiers = new LinkedHashSet<>();
for (ExternalDataProvider provider : activeProviders) {
for (Identifier identifier : providerBlockTypes(provider)) {
identifiers.add(identifier);
identifiers.add(qualifyBlockId(provider, identifier));
}
}
return List.copyOf(identifiers);
}
return activeProviders.stream()
.flatMap(p -> p.getTypes(dataType).stream())
.toList();
}
private Collection<Identifier> providerBlockTypes(ExternalDataProvider provider) {
try {
return provider.getTypes(DataType.BLOCK);
} catch (RuntimeException | LinkageError error) {
IrisLogging.reportError("Failed to enumerate blocks from " + provider.getPluginId() + ".", error);
return List.of();
}
}
public Collection<Pair<Identifier, List<BlockProperty>>> getAllBlockProperties() {
return activeProviders.stream()
.flatMap(p -> p.getTypes(DataType.BLOCK)
.stream()
.map(id -> new Pair<>(id, p.getBlockProperties(id))))
.toList();
List<Pair<Identifier, List<BlockProperty>>> properties = new ArrayList<>();
for (Identifier identifier : getAllIdentifiers(DataType.BLOCK)) {
getBlockProperties(identifier).ifPresent(values -> properties.add(new Pair<>(identifier, values)));
}
return properties;
}
public static Pair<Identifier, KMap<String, String>> parseState(Identifier key) {
String raw = key.key();
int open = raw.indexOf('[');
int close = raw.lastIndexOf(']');
if (open < 0 || close < open) {
if (open < 0 && close < 0) {
return new Pair<>(key, new KMap<>());
}
if (open <= 0 || close != raw.length() - 1 || close < open
|| raw.indexOf('[', open + 1) >= 0 || raw.indexOf(']') != close) {
throw new IllegalArgumentException("Malformed block state \"" + key + "\" (expected block[key=value])");
}
String state = raw.substring(open + 1, close);
KMap<String, String> stateMap = new KMap<>();
if (!state.isEmpty()) {
for (String entry : state.split(",")) {
for (String entry : state.split(",", -1)) {
String[] pair = entry.split("=", 2);
if (pair.length != 2 || pair[0].isBlank() || pair[1].isBlank()) {
throw new IllegalArgumentException("Malformed block state \"" + entry + "\" in \"" + key + "\" (expected key=value)");
}
stateMap.put(pair[0], pair[1]);
if (stateMap.put(pair[0].trim(), pair[1].trim()) != null) {
throw new IllegalArgumentException("Duplicate block property \"" + pair[0] + "\" in \"" + key + "\"");
}
}
}
return new Pair<>(new Identifier(key.namespace(), raw.substring(0, open)), stateMap);
@@ -237,6 +337,45 @@ public class ExternalDataSVC implements IrisService {
.findFirst();
}
private BlockProvider findBlockProvider(Identifier identifier) {
int separator = identifier.key().indexOf('/');
if (separator > 0) {
for (ExternalDataProvider provider : activeProviders) {
if (!provider.getPluginId().equalsIgnoreCase(identifier.namespace())) {
continue;
}
Identifier nativeId = new Identifier(identifier.key().substring(0, separator), identifier.key().substring(separator + 1));
if (provider.isValidProvider(nativeId, DataType.BLOCK)) {
return new BlockProvider(provider, nativeId);
}
return provider.isValidProvider(identifier, DataType.BLOCK) ? new BlockProvider(provider, identifier) : null;
}
if (BUILT_IN_PROVIDERS.stream().anyMatch(definition -> definition.pluginId().equalsIgnoreCase(identifier.namespace()))
|| findProvider(identifier.namespace()).isPresent()) {
return null;
}
}
BlockProvider match = null;
for (ExternalDataProvider provider : activeProviders) {
if (!provider.isValidProvider(identifier, DataType.BLOCK)) {
continue;
}
if (match != null) {
IrisLogging.warnOnce("external-provider:ambiguous-block:" + identifier,
"Multiple providers own block %s. Use %s or %s to select one.", identifier,
qualifyBlockId(match.provider(), identifier), qualifyBlockId(provider, identifier));
return null;
}
match = new BlockProvider(provider, identifier);
}
return match;
}
private static Identifier qualifyBlockId(ExternalDataProvider provider, Identifier identifier) {
return new Identifier(provider.getPluginId().toLowerCase(Locale.ROOT), identifier.namespace() + "/" + identifier.key());
}
private void activateConfiguredProvider(ProviderDefinition definition) {
if (!isPluginEnabled(definition.pluginId()) || findProvider(definition.pluginId()).isPresent()) {
return;
@@ -267,6 +406,7 @@ public class ExternalDataSVC implements IrisService {
}
activeProviders.add(provider);
IrisLogging.debug("Enabled ExternalDataProvider for %s.", provider.getPluginId());
notifyContentChanged();
} catch (Throwable error) {
IrisLogging.reportError("Failed to enable Iris external data provider " + provider.getPluginId() + ".", error);
}
@@ -279,4 +419,7 @@ public class ExternalDataSVC implements IrisService {
private record ProviderDefinition(String pluginId, String className) {
}
private record BlockProvider(ExternalDataProvider provider, Identifier identifier) {
}
}
@@ -56,6 +56,7 @@ final class EngineShutdownSequence {
private final Set<EngineTarget> incompleteTargets = Collections.synchronizedSet(
Collections.newSetFromMap(new IdentityHashMap<>()));
private boolean runtimeReleased;
private boolean worldManagerStopped;
private boolean targetReleased;
private boolean engineDataReleased;
private boolean preservationReleased;
@@ -72,7 +73,10 @@ final class EngineShutdownSequence {
if (!engine.beginShutdown()) {
return;
}
Throwable routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter);
Throwable routerFailure = stopWorldManager();
if (routerFailure == null) {
routerFailure = runCleanup(null, engine::closeAttachedGenerationHistoryRuntimeRouter);
}
if (routerFailure != null) {
synchronized (engine.lifecycleLock) {
engine.lifecycleState = LifecycleState.FAILED;
@@ -111,20 +115,16 @@ final class EngineShutdownSequence {
drainFailure = e;
}
if (drainFailure != null) {
// A drain timeout must not abandon teardown: the world manager is the lease
// producer, so stop it first, then re-drain briefly.
// A drain timeout must not abandon teardown: give remaining admitted work
// one final bounded drain before deciding whether resources can be released.
IrisLogging.warn("Iris generation did not drain for close on " + engine.getWorld().name()
+ "; stopping the world manager and retrying.");
Throwable managerFailure = engine.runtime == null
? null
: runCleanup(null, engine.runtime.worldManager()::close);
+ "; waiting briefly for remaining work.");
try {
engine.getGenerationSessions().sealAndAwait("close-retry", CLOSE_RETRY_DRAIN_TIMEOUT_MILLIS, true);
drainFailure = null;
} catch (GenerationSessionException e) {
drainFailure = appendFailure(drainFailure, e);
}
drainFailure = appendFailure(drainFailure, managerFailure);
}
if (drainFailure != null) {
// A live lease may be mid-write, so the mantle must not be closed at it — but
@@ -322,7 +322,9 @@ final class EngineShutdownSequence {
if (engineRuntime == null) {
return failure;
}
Throwable serviceFailure = runCleanup(null, engineRuntime.worldManager()::close);
Throwable serviceFailure = engineRuntime == engine.runtime
? stopWorldManager()
: runCleanup(null, engineRuntime.worldManager()::close);
serviceFailure = runCleanup(serviceFailure, engineRuntime.effects()::close);
if (serviceFailure != null) {
return appendFailure(failure, serviceFailure);
@@ -414,6 +416,17 @@ final class EngineShutdownSequence {
return failure;
}
private Throwable stopWorldManager() {
if (worldManagerStopped || engine.runtime == null) {
return null;
}
Throwable failure = runCleanup(null, engine.runtime.worldManager()::close);
if (failure == null) {
worldManagerStopped = true;
}
return failure;
}
private Throwable closeAssemblyResources(RuntimeAssembly assembly) {
Throwable failure = runCleanup(null, () -> {
if (assembly.worldManager != null) {
@@ -275,7 +275,7 @@ public class IrisEngineMantle implements EngineMantle {
return true;
}
private static Mantle<Matter> createMantle(
public static Mantle<Matter> createMantle(
Engine engine,
Path storageDirectory,
Supplier<IrisData> dataSupplier
@@ -213,6 +213,9 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
return;
}
if (generated) {
chunkMaintenance.invalidateMaterialization(e.getX(), e.getZ());
}
INMS.get().reconcileNativeStructurePois(e);
if (cleanupServiceStopped || cleanupService == null || cleanupService.isShutdown()) {
@@ -274,10 +277,15 @@ public class IrisWorldManager extends EngineAssignedWorldManager {
public synchronized void close() {
Throwable failure = null;
try {
super.close();
chunkMaintenance.close();
} catch (Throwable e) {
failure = e;
}
try {
super.close();
} catch (Throwable e) {
failure = appendCloseFailure(failure, e);
}
if (!looperStopped) {
try {
if (looper != null) {
@@ -20,6 +20,7 @@ package art.arcane.iris.engine;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.service.tree.BlockDropRouter;
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBlockDrops;
import art.arcane.iris.engine.object.IrisMarker;
@@ -52,21 +53,26 @@ final class WorldBlockDropRouter {
}
void onBlockBreak(BlockBreakEvent e) {
if (e.isCancelled()) {
return;
}
if (e.getBlock().getWorld().equals(BukkitWorldBinding.world(manager.getTarget().getWorld()))) {
int blockX = e.getBlock().getX();
int mantleY = toMantleY(e.getBlock().getY(), manager.getEngine().getWorld().minHeight());
int blockZ = e.getBlock().getZ();
KList<ItemStack> d = new KList<>();
IrisBiome b = EngineBukkitOps.getBiome(manager.getEngine(), e.getBlock().getLocation());
List<IrisBlockDrops> dropProviders = filterDrops(b.getBlockDrops(), e, manager.getData());
if (dropProviders.stream().noneMatch(IrisBlockDrops::isSkipParents)) {
IrisRegion r = EngineBukkitOps.getRegion(manager.getEngine(), e.getBlock().getLocation());
dropProviders.addAll(filterDrops(r.getBlockDrops(), e, manager.getData()));
dropProviders.addAll(filterDrops(manager.getEngine().getDimension().getBlockDrops(), e, manager.getData()));
List<IrisBlockDrops> dropProviders;
try {
dropProviders = resolveDropProviders(e);
} catch (SavedBiomeUnavailableException unavailable) {
e.setCancelled(true);
if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) {
throw unavailable;
}
return;
}
KList<ItemStack> d = new KList<>();
dropProviders.forEach(provider -> provider.fillDrops(false, d));
if (dropProviders.stream().anyMatch(IrisBlockDrops::isReplaceVanillaDrops)) {
@@ -131,4 +137,15 @@ final class WorldBlockDropRouter {
private List<IrisBlockDrops> filterDrops(KList<IrisBlockDrops> drops, BlockBreakEvent e, IrisData data) {
return new KList<>(drops.stream().filter(d -> d.shouldDropFor(e.getBlock().getBlockData(), data)).toList());
}
private List<IrisBlockDrops> resolveDropProviders(BlockBreakEvent event) {
IrisBiome biome = EngineBukkitOps.getBiome(manager.getEngine(), event.getBlock().getLocation());
List<IrisBlockDrops> providers = filterDrops(biome.getBlockDrops(), event, manager.getData());
if (providers.stream().noneMatch(IrisBlockDrops::isSkipParents)) {
IrisRegion region = EngineBukkitOps.getRegion(manager.getEngine(), event.getBlock().getLocation());
providers.addAll(filterDrops(region.getBlockDrops(), event, manager.getData()));
providers.addAll(filterDrops(manager.getEngine().getDimension().getBlockDrops(), event, manager.getData()));
}
return providers;
}
}
@@ -20,6 +20,8 @@ package art.arcane.iris.engine;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter.SavedChunkMantle;
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
import art.arcane.iris.engine.platform.EngineBukkitOps;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
@@ -28,6 +30,7 @@ import art.arcane.iris.util.common.plugin.Chunks;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.math.PowerOfTwoCoordinates;
import art.arcane.volmlib.util.math.Position2;
import art.arcane.volmlib.util.matter.Matter;
@@ -36,10 +39,14 @@ import org.bukkit.World;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.atomic.AtomicBoolean;
/**
@@ -50,21 +57,47 @@ import java.util.concurrent.atomic.AtomicBoolean;
*/
final class WorldChunkMaintenance {
private static final int MAX_FORCED_CHUNK_UPDATES = 128;
private static final int MAX_PENDING_MATERIALIZATIONS = 128;
private static final int MAX_COMPLETED_MATERIALIZATIONS = 8192;
private final IrisWorldManager manager;
private final Set<Long> mantleWarmupQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> markerFlagQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> discoveredFlagQueue = ConcurrentHashMap.newKeySet();
private final Set<Long> chunkUpdateQueue = ConcurrentHashMap.newKeySet();
private final Map<Long, RoutedMaterialization> materializations = new ConcurrentHashMap<>();
private final Set<Long> completedMaterializations = Collections.synchronizedSet(new LinkedHashSet<>());
private final AtomicBoolean chunkUpdateScanScheduled = new AtomicBoolean();
private final AtomicBoolean chunkDiscoveryScanScheduled = new AtomicBoolean();
private volatile Position2[] loadedChunkPositions = new Position2[0];
private int forcedChunkUpdateCursor = 0;
private volatile boolean closed;
WorldChunkMaintenance(IrisWorldManager manager) {
this.manager = manager;
}
void close() {
synchronized (materializations) {
closed = true;
for (RoutedMaterialization materialization : materializations.values()) {
materialization.cancel();
}
}
completedMaterializations.clear();
}
void invalidateMaterialization(int chunkX, int chunkZ) {
long key = Cache.key(chunkX, chunkZ);
synchronized (materializations) {
RoutedMaterialization materialization = materializations.get(key);
if (materialization != null) {
materialization.cancel();
}
completedMaterializations.remove(key);
}
}
void discoverChunks() {
World world = BukkitWorldBinding.world(manager.getEngine().getWorld());
if (world == null) {
@@ -264,32 +297,84 @@ final class WorldChunkMaintenance {
}
void updateChunkRegion(World world, int chunkX, int chunkZ) {
if (world == null || !world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) {
if (closed || world == null || !world.isChunkLoaded(chunkX, chunkZ) || !Chunks.isSafe(world, chunkX, chunkZ)) {
return;
}
Chunk chunk = world.getChunkAt(chunkX, chunkZ);
if (IrisSettings.get().getWorld().isPostLoadBlockUpdates()) {
if (!manager.getMantle().isChunkLoaded(chunkX, chunkZ)) {
warmupMantleChunkAsync(chunkX, chunkZ);
GenerationHistoryRuntimeRouter router = manager.getEngine() instanceof IrisEngine engine
? engine.getGenerationHistoryRuntimeRouter().orElse(null) : null;
if (router != null && !completedMaterializations.contains(Cache.key(chunkX, chunkZ))) {
scheduleMaterialization(new MaterializationTarget(world, chunkX, chunkZ), router);
return;
}
try {
EngineBukkitOps.updateChunk(manager.getEngine(), chunk);
} catch (SavedBiomeUnavailableException unavailable) {
if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) {
throw unavailable;
}
if (router == null && !updateCurrentChunk(chunk, chunkX, chunkZ)) {
return;
}
}
spawnInitially(chunk);
}
private boolean updateCurrentChunk(Chunk chunk, int chunkX, int chunkZ) {
if (!manager.getMantle().isChunkLoaded(chunkX, chunkZ)) {
warmupMantleChunkAsync(chunkX, chunkZ);
return false;
}
return materializeChunk(chunk, manager.getMantle());
}
private boolean materializeChunk(Chunk chunk, Mantle<Matter> mantle) {
try {
EngineBukkitOps.updateChunk(manager.getEngine(), chunk, mantle);
} catch (SavedBiomeUnavailableException unavailable) {
if (!unavailable.isLoading() || unavailable.getSuppressed().length != 0) {
throw unavailable;
}
return false;
}
return true;
}
private void spawnInitially(Chunk chunk) {
if (manager.entitySpawner.isEntitySpawningEnabledForCurrentWorld()) {
manager.entitySpawner.spawnInitially(chunk);
}
}
private void scheduleMaterialization(MaterializationTarget target, GenerationHistoryRuntimeRouter router) {
RoutedMaterialization materialization;
synchronized (materializations) {
if (closed || materializations.size() >= MAX_PENDING_MATERIALIZATIONS
|| materializations.containsKey(target.key())) {
return;
}
materialization = new RoutedMaterialization(target, router);
materializations.put(target.key(), materialization);
}
try {
J.a(manager.managedTask("bukkit_world_manager_materialization_load",
materialization::prepare, materialization::cancel));
} catch (Throwable failure) {
materialization.cancel();
IrisLogging.reportError(failure);
}
}
private void recordMaterialized(long key) {
synchronized (completedMaterializations) {
if (closed) {
return;
}
if (completedMaterializations.size() >= MAX_COMPLETED_MATERIALIZATIONS) {
completedMaterializations.remove(completedMaterializations.iterator().next());
}
completedMaterializations.add(key);
}
}
void raiseInitialSpawnMarkerFlag(World world, int chunkX, int chunkZ, Runnable onFirstRaise) {
if (world == null || onFirstRaise == null) {
return;
@@ -355,4 +440,156 @@ final class WorldChunkMaintenance {
}
return loadedChunkPositions;
}
private record MaterializationTarget(World world, int x, int z) {
private long key() {
return Cache.key(x, z);
}
}
private final class RoutedMaterialization {
private final MaterializationTarget target;
private final GenerationHistoryRuntimeRouter router;
private SavedChunkMantle savedMantle;
private MantleChunk<Matter> mantleChunk;
private boolean cancelled;
private boolean preparing;
private boolean applying;
private boolean releasing;
private RoutedMaterialization(MaterializationTarget target, GenerationHistoryRuntimeRouter router) {
this.target = target;
this.router = router;
}
private void prepare() {
SavedChunkMantle opened = null;
MantleChunk<Matter> loaded = null;
try {
synchronized (this) {
if (cancelled) {
return;
}
preparing = true;
}
opened = router.openSavedChunkMantle(target.x(), target.z());
loaded = opened.mantle().getChunk(target.x(), target.z()).use();
opened.detachThread();
synchronized (this) {
if (cancelled) {
return;
}
savedMantle = opened;
mantleChunk = loaded;
opened = null;
loaded = null;
}
boolean scheduled = J.runRegion(target.world(), target.x(), target.z(),
manager.managedTask("bukkit_world_manager_materialization_apply", this::apply, this::cancel));
if (!scheduled) {
cancel();
}
} catch (Throwable failure) {
cancel();
IrisLogging.reportError("Failed to load saved chunk materialization at " + target.x() + "," + target.z() + ".", failure);
} finally {
try {
try {
if (loaded != null) {
loaded.release();
}
} finally {
if (opened != null) {
opened.close();
}
}
} finally {
boolean cleanup;
synchronized (this) {
preparing = false;
cleanup = cancelled;
}
if (cleanup) {
cancel();
}
}
}
}
private void apply() {
SavedChunkMantle active;
synchronized (this) {
if (cancelled || savedMantle == null) {
return;
}
applying = true;
active = savedMantle;
}
try {
World world = target.world();
if (closed || !world.isChunkLoaded(target.x(), target.z())
|| !Chunks.isSafe(world, target.x(), target.z())) {
return;
}
Chunk chunk = world.getChunkAt(target.x(), target.z());
boolean completed;
try (SavedChunkMantle.Scope ignored = active.openScope()) {
Mantle<Matter> mantle = active.mantle();
if (!materializeChunk(chunk, mantle)) {
return;
}
completed = mantle.hasFlag(target.x(), target.z(), MantleFlag.ETCHED);
}
synchronized (this) {
if (completed && !cancelled) {
recordMaterialized(target.key());
}
}
spawnInitially(chunk);
} catch (Throwable failure) {
IrisLogging.reportError("Failed to materialize saved chunk at " + target.x() + "," + target.z() + ".", failure);
} finally {
synchronized (this) {
applying = false;
}
cancel();
}
}
private void cancel() {
SavedChunkMantle retained;
MantleChunk<Matter> loaded;
synchronized (this) {
cancelled = true;
if (preparing || applying || releasing) {
return;
}
retained = savedMantle;
loaded = mantleChunk;
savedMantle = null;
mantleChunk = null;
releasing = retained != null;
}
if (retained != null) {
ForkJoinPool.commonPool().execute(() -> release(retained, loaded));
} else {
materializations.remove(target.key(), this);
}
}
private void release(SavedChunkMantle retained, MantleChunk<Matter> loaded) {
try {
try {
loaded.release();
} finally {
retained.close();
}
} catch (Throwable failure) {
completedMaterializations.remove(target.key());
IrisLogging.reportError("Failed to release saved chunk materialization at " + target.x() + "," + target.z() + ".", failure);
} finally {
materializations.remove(target.key(), this);
}
}
}
}
@@ -138,6 +138,7 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen
}
private static BoundaryColumnGeometry.Voxel encode(PlatformBlockState state) {
String stateKey = state.key();
boolean custom = state.isCustom();
PlatformBlockState nativeState = state.placementBaseState();
if (nativeState != null) {
@@ -149,7 +150,7 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen
: state.isWaterLogged() ? "minecraft:water[level=0]" : "";
boolean protectedContent = custom || state.isDecorant() || state.isFoliage() || state.isTreeBlock()
|| state.hasTileEntity();
return new BoundaryColumnGeometry.Voxel(state.key(), phase, fluidKey, protectedContent);
return new BoundaryColumnGeometry.Voxel(stateKey, phase, fluidKey, protectedContent);
}
}
}
@@ -574,11 +574,19 @@ public final class GenerationHistory {
}
public GenerationStage openStage(int chunkX, int chunkZ) throws IOException {
return openStage(chunkX, chunkZ, false);
}
GenerationStage openSavedChunkStage(int chunkX, int chunkZ) throws IOException {
return openStage(chunkX, chunkZ, true);
}
private GenerationStage openStage(int chunkX, int chunkZ, boolean savedChunk) throws IOException {
GenerationAdmission.StageLease lease = admission.enterStage();
try {
GenerationManifest manifest = store.manifest();
GenerationActivation activation = manifest.activeActivation();
GenerationEpoch epoch = manifest.activeEpoch();
GenerationActivation activation = savedChunk ? resolveActivation(chunkX, chunkZ) : manifest.activeActivation();
GenerationEpoch epoch = savedChunk ? requireEpoch(activation.epochId()) : manifest.activeEpoch();
return new GenerationStage(
this,
chunkX,
@@ -2,9 +2,12 @@ package art.arcane.iris.engine.history;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.IrisEngineMantle;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.framework.GenerationTransitionGate;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import java.io.IOException;
import java.nio.file.Path;
@@ -28,6 +31,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
private final ActivationRuntimeFactory runtimeFactory;
private final LinkedHashMap<Long, RuntimeCacheEntry> bindings;
private final Map<Long, RuntimeRetirement> retiringBindings;
private final Map<Long, SavedMantleEntry> savedMantles = new LinkedHashMap<>();
private final ReentrantLock stateLock;
private final Condition inactive;
private final ThreadLocal<Integer> operationDepth;
@@ -265,6 +269,29 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
}
}
public SavedChunkMantle openSavedChunkMantle(int chunkX, int chunkZ) throws IOException {
GenerationTransitionGate.Participation participation = engine.getGenerationSessions().transitionGate().enter();
boolean operation = false;
try {
enterRouteOperation();
operation = true;
GenerationHistory.GenerationStage stage = history.openSavedChunkStage(chunkX, chunkZ);
try {
SavedMantleAccess access = acquireSavedMantle(stage.activation(), stage.epoch());
return new SavedChunkMantle(this, new SavedChunkResources(stage, participation, access));
} catch (Throwable failure) {
stage.close();
throw failure;
}
} catch (Throwable failure) {
if (operation) {
leaveRouteOperation();
}
participation.close();
throw propagate(failure, "Unable to open saved Iris chunk mantle.");
}
}
public RuntimeStage openStage(int chunkX, int chunkZ) throws IOException {
RuntimeRoute route = openRoute(chunkX, chunkZ);
try {
@@ -369,6 +396,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
throw new IllegalStateException("Cannot close the generation-history router from an active route.");
}
List<RuntimeRetirement> retired = null;
List<SavedMantleEntry> saved = null;
stateLock.lock();
try {
closed = true;
@@ -389,6 +417,7 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
}
}
bindings.clear();
saved = new ArrayList<>(savedMantles.values());
break;
}
} finally {
@@ -411,6 +440,13 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
} catch (Throwable retirementFailure) {
failure = appendFailure(failure, retirementFailure);
}
for (SavedMantleEntry entry : saved) {
try {
closeSavedMantle(entry);
} catch (Throwable closeFailure) {
failure = appendFailure(failure, closeFailure);
}
}
stateLock.lock();
try {
@@ -431,6 +467,164 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
return signatureSampler.open(engine);
}
private SavedMantleAccess acquireSavedMantle(GenerationActivation activation, GenerationEpoch epoch) throws IOException {
SavedMantleEntry saved = null;
RuntimeCacheEntry runtime;
boolean loader = false;
stateLock.lock();
try {
while (true) {
awaitRetirementLocked(activation.activationId());
saved = savedMantles.get(activation.activationId());
if (saved == null || !saved.retiring) {
break;
}
awaitSavedMantleChange(saved);
}
runtime = bindings.get(activation.activationId());
if (runtime != null) {
runtime.leases++;
} else {
if (saved == null) {
saved = new SavedMantleEntry(activation.activationId());
saved.loading = true;
savedMantles.put(activation.activationId(), saved);
activeLoads++;
loader = true;
}
saved.leases++;
}
} finally {
stateLock.unlock();
}
if (runtime != null) {
RuntimeLease lease = awaitRuntime(runtime, activation.activationId());
try (IrisEngine.GenerationRuntimeScope ignored = engine.openGenerationRuntimeScope(lease.binding())) {
return new SavedMantleAccess(engine.getMantle().getMantle(), lease::close);
} catch (Throwable failure) {
lease.close();
throw failure;
}
}
SavedMantleEntry entry = saved;
if (loader) {
loadSavedMantle(entry, activation, epoch);
}
stateLock.lock();
try {
while (entry.loading) {
try {
inactive.await();
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
entry.leases--;
inactive.signalAll();
throw new IOException("Interrupted while loading saved mantle for activation " + entry.activationId + ".", failure);
}
}
if (entry.failure != null) {
entry.leases--;
throw propagate(entry.failure, "Unable to load saved mantle for activation " + entry.activationId + ".");
}
return new SavedMantleAccess(entry.mantle, () -> releaseSavedMantle(entry));
} finally {
stateLock.unlock();
}
}
private void loadSavedMantle(SavedMantleEntry entry, GenerationActivation activation, GenerationEpoch epoch) {
IrisData data = null;
Mantle<Matter> mantle = null;
Throwable failure = null;
try {
data = IrisData.openRuntime(history.packRoot(activation.activationId()).toFile());
data.bindGenerationRegistryContract(epoch.registryContract());
data.registerEngine(engine);
IrisData savedData = data;
mantle = IrisEngineMantle.createMantle(engine, history.paths().activationMantleRoot(activation.activationId()), () -> savedData);
} catch (Throwable loadFailure) {
failure = loadFailure;
if (data != null) {
try {
data.unregisterEngine(engine);
data.close();
} catch (Throwable closeFailure) {
failure = appendFailure(failure, closeFailure);
}
}
}
stateLock.lock();
try {
entry.data = data;
entry.mantle = mantle;
entry.failure = failure;
entry.loading = false;
activeLoads--;
if (failure != null) {
savedMantles.remove(entry.activationId, entry);
}
inactive.signalAll();
} finally {
stateLock.unlock();
}
}
private void releaseSavedMantle(SavedMantleEntry entry) {
stateLock.lock();
try {
if (entry.leases <= 0) {
throw new IllegalStateException("Saved mantle has no active lease.");
}
entry.leases--;
if (entry.leases > 0) {
return;
}
entry.retiring = true;
} finally {
stateLock.unlock();
}
closeSavedMantle(entry);
}
private void closeSavedMantle(SavedMantleEntry entry) {
Throwable failure = null;
try {
entry.mantle.close();
entry.data.unregisterEngine(engine);
entry.data.close();
} catch (Throwable closeFailure) {
failure = closeFailure;
}
stateLock.lock();
try {
entry.failure = failure;
if (failure == null) {
savedMantles.remove(entry.activationId, entry);
}
inactive.signalAll();
} finally {
stateLock.unlock();
}
if (failure != null) {
throw new IllegalStateException("Failed to close saved mantle for activation " + entry.activationId + ".", failure);
}
}
private void awaitSavedMantleChange(SavedMantleEntry entry) throws IOException {
if (entry.failure != null) {
throw propagate(entry.failure, "Saved mantle for activation " + entry.activationId + " is unavailable.");
}
if (closed) {
throw new IllegalStateException("Generation-history runtime router is closed.");
}
try {
inactive.await();
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while awaiting saved mantle for activation " + entry.activationId + ".", failure);
}
}
private RuntimeLease acquireRuntime(
GenerationActivation activation,
GenerationEpoch epoch
@@ -439,7 +633,14 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
boolean loader = false;
stateLock.lock();
try {
while (true) {
awaitRetirementLocked(activation.activationId());
SavedMantleEntry saved = savedMantles.get(activation.activationId());
if (saved == null) {
break;
}
awaitSavedMantleChange(saved);
}
entry = bindings.get(activation.activationId());
if (entry == null) {
entry = new RuntimeCacheEntry(activation.activationId());
@@ -823,6 +1024,139 @@ public final class GenerationHistoryRuntimeRouter implements AutoCloseable {
throw new IllegalStateException("Failed to close the generation-history runtime router.", failure);
}
private static final class SavedMantleEntry {
private final long activationId;
private IrisData data;
private Mantle<Matter> mantle;
private Throwable failure;
private int leases;
private boolean loading;
private boolean retiring;
private SavedMantleEntry(long activationId) {
this.activationId = activationId;
}
}
private record SavedMantleAccess(Mantle<Matter> mantle, Runnable release) {
}
private record SavedChunkResources(GenerationHistory.GenerationStage stage,
GenerationTransitionGate.Participation participation,
SavedMantleAccess access) {
}
public static final class SavedChunkMantle implements AutoCloseable {
private final GenerationHistoryRuntimeRouter router;
private final SavedChunkResources resources;
private int activeScopes;
private boolean closed;
private SavedChunkMantle(GenerationHistoryRuntimeRouter router, SavedChunkResources resources) {
this.router = router;
this.resources = resources;
}
public Mantle<Matter> mantle() {
return resources.access().mantle();
}
public void detachThread() {
resources.participation().detachThread();
}
public Scope openScope() {
synchronized (this) {
if (closed) {
throw new IllegalStateException("Saved chunk mantle is closed.");
}
activeScopes++;
}
try {
GenerationTransitionGate.Participation participation = resources.participation().attach();
router.enterRuntimeScope();
return new Scope(this, participation);
} catch (Throwable failure) {
releaseScope();
throw failure;
}
}
@Override
public synchronized void close() {
if (closed) {
return;
}
if (activeScopes > 0) {
throw new IllegalStateException("Saved chunk mantle still has active scopes.");
}
closed = true;
Throwable failure = null;
try {
resources.access().release().run();
} catch (Throwable closeFailure) {
failure = closeFailure;
}
try {
resources.stage().close();
} catch (Throwable closeFailure) {
failure = appendFailure(failure, closeFailure);
}
try {
router.leaveRouteOperation();
} catch (Throwable closeFailure) {
failure = appendFailure(failure, closeFailure);
}
try {
resources.participation().close();
} catch (Throwable closeFailure) {
failure = appendFailure(failure, closeFailure);
}
if (failure != null) {
throw new IllegalStateException("Failed to close saved chunk mantle.", failure);
}
}
private synchronized void releaseScope() {
if (activeScopes <= 0) {
throw new IllegalStateException("Saved chunk mantle has no active scope.");
}
activeScopes--;
}
public static final class Scope implements AutoCloseable {
private final SavedChunkMantle handle;
private final GenerationTransitionGate.Participation participation;
private final Thread owner = Thread.currentThread();
private boolean closed;
private Scope(SavedChunkMantle handle, GenerationTransitionGate.Participation participation) {
this.handle = handle;
this.participation = participation;
}
@Override
public void close() {
if (closed) {
return;
}
if (Thread.currentThread() != owner) {
throw new IllegalStateException("Saved chunk mantle scope closed from a different thread.");
}
closed = true;
try {
handle.router.leaveRuntimeScope();
} finally {
try {
handle.releaseScope();
} finally {
participation.close();
}
}
}
}
}
private static final class RuntimeCacheEntry {
private final long activationId;
private IrisEngine.GenerationRuntimeBinding binding;
@@ -41,6 +41,7 @@ import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.Locale;
import java.util.Map;
@Accessors(chain = true)
@@ -76,12 +77,17 @@ public class IrisBlockData extends IrisRegistrant {
public static IrisBlockData from(String j) {
IrisBlockData b = new IrisBlockData();
String v = j.toLowerCase().trim();
String v = j.trim();
int bracket = v.indexOf('[');
String block = (bracket < 0 ? v : v.substring(0, bracket)).toLowerCase(Locale.ROOT);
b.setBlock(block);
if (v.contains("[")) {
if (bracket >= 0) {
KList<String> props = new KList<>();
String rp = v.split("\\Q[\\E")[1].replaceAll("\\Q]\\E", "");
b.setBlock(v.split("\\Q[\\E")[0]);
String rp = v.substring(bracket + 1).replace("]", "");
if (!block.contains(":") || block.startsWith("minecraft:")) {
rp = rp.toLowerCase(Locale.ROOT);
}
if (rp.contains(",")) {
props.add(rp.split("\\Q,\\E"));
@@ -93,8 +99,6 @@ public class IrisBlockData extends IrisRegistrant {
Object kg = filter(i.split("\\Q=\\E")[1]);
b.data.put(i.split("\\Q=\\E")[0], kg);
}
} else {
b.setBlock(v);
}
return b;
@@ -116,7 +120,11 @@ public class IrisBlockData extends IrisRegistrant {
}
try {
return Double.valueOf(string).intValue();
double value = Double.parseDouble(string);
if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE && value == Math.rint(value)) {
return (int) value;
}
return value;
} catch (Throwable ignored) {
// Checks
}
@@ -156,19 +164,13 @@ public class IrisBlockData extends IrisRegistrant {
PlatformBlockState customState = customData.getBlockData(data);
if (customState != null) {
String st = customState.key();
if (st.contains("[")) {
st = st.split("\\Q[\\E")[0];
if (getData().isEmpty()) {
return customState;
}
KMap<String, Object> cdata = customData.getData().copy();
for (String i : getData().keySet()) {
cdata.put(i, getData().get(i));
}
String sx = keyify(st) + computeProperties(cdata);
IrisBlockData merged = IrisBlockData.from(customState.key());
merged.getData().putAll(getData());
String sx = merged.stateKey();
if (debug) {
IrisLogging.debug("Block Data used " + sx + " (CUSTOM)");
@@ -146,6 +146,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
private volatile Throwable initializationFailure;
private volatile IrisDimension validatedDimension;
private volatile IrisDimensionRuntimeContract validatedWorldContract;
private volatile EngineTarget startupTarget;
private volatile CompletableFuture<Void> startupContentReady = CompletableFuture.completedFuture(null);
private volatile CompletableFuture<Void> startupReady = CompletableFuture.completedFuture(null);
private volatile boolean closing;
@Setter
private volatile StudioGenerator studioGenerator;
@@ -195,6 +198,11 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
}
BukkitPlatform.volmitPlugin().unregisterListener(this);
world.setRawWorldSeed(event.getWorld().getSeed());
if (startupTarget != null) {
startupContentReady.whenComplete((ignored, failure) ->
J.s(() -> resumeDeferredStartup(event.getWorld(), failure)));
return;
}
if (initialize(event.getWorld())) return;
IrisLogging.warn("Failed to get Engine for " + event.getWorld().getName() + " re-trying...");
@@ -205,6 +213,73 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
}, 10);
}
public void deferStartup(EngineTarget target, CompletableFuture<Void> contentReady) {
if (setup.get() || startupTarget != null) {
throw new IllegalStateException("Iris generator startup has already begun.");
}
startupTarget = Objects.requireNonNull(target);
startupContentReady = Objects.requireNonNull(contentReady);
startupReady = new CompletableFuture<>();
}
private void resumeDeferredStartup(World runtimeWorld, Throwable failure) {
lock.lock();
try {
closeStartupTarget();
if (closing) {
startupReady.completeExceptionally(new IllegalStateException("Iris generator closed during startup."));
return;
}
if (failure != null) {
initializationFailure = failure;
spawnChunks.completeExceptionally(failure);
initialSpawnReady.completeExceptionally(failure);
startupReady.completeExceptionally(failure);
IrisLogging.reportError("External block providers did not become available for Iris world '"
+ world.name() + "'; generation remains locked.", failure);
Bukkit.shutdown();
return;
}
if (!initialize(runtimeWorld)) {
throw new IllegalStateException("Iris engine initialization did not complete for '" + world.name() + "'.");
}
startupReady.complete(null);
} catch (RuntimeException | Error initializationError) {
initializationFailure = initializationError;
spawnChunks.completeExceptionally(initializationError);
initialSpawnReady.completeExceptionally(initializationError);
startupReady.completeExceptionally(initializationError);
Bukkit.shutdown();
throw initializationError;
} finally {
lock.unlock();
}
}
private void closeStartupTarget() {
EngineTarget target = startupTarget;
startupTarget = null;
if (target != null) {
target.getData().close();
}
}
private void requireStartupContentReady() {
if (startupReady.isCompletedExceptionally()) {
startupReady.join();
}
boolean initializationPending = startupTarget != null
|| !startupReady.isDone() && !lock.isHeldByCurrentThread();
requireStartupContentReady(startupContentReady, initializationPending);
}
static void requireStartupContentReady(CompletableFuture<Void> ready, boolean initializationPending) {
if (!ready.isDone() || initializationPending) {
throw new IllegalStateException("Iris generation is waiting for external block providers to initialize.");
}
ready.join();
}
private boolean initialize(World world) {
try {
Engine engine = getEngine(world);
@@ -579,6 +654,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
@Override
public EngineTarget getTarget() {
if (engine != null) return engine.getTarget();
EngineTarget pendingTarget = startupTarget;
if (pendingTarget != null) {
return pendingTarget;
}
return targetCache.aquireOrThrow(() -> {
if (generationHistory != null) {
@@ -621,6 +700,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
private Engine getEngine(WorldInfo world) {
throwIfInitializationFailed();
requireStartupContentReady();
validateAndBindWorld(world);
if (setup.get()) {
return getEngine();
@@ -763,6 +843,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
}
folder.clear();
populators.clear();
closeStartupTarget();
startupReady.completeExceptionally(new IllegalStateException("Iris generator closed during startup."));
});
} catch (Throwable throwable) {
if (!alreadyClosing) {
@@ -1079,6 +1161,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
}
public void touch(World world) {
if (!startupReady.isDone() || startupReady.isCompletedExceptionally()) {
return;
}
getEngine(world);
}
@@ -1274,7 +1359,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
return shouldGenerateNativeStructures(
isAuthoringStudio(),
studioEntryBootstrapActive.get(),
initializationFailure != null);
initializationFailure != null || startupTarget != null);
}
static boolean shouldGenerateNativeStructures(
@@ -101,6 +101,10 @@ public final class EngineBukkitOps {
}
public static void updateChunk(Engine engine, Chunk c) {
updateChunk(engine, c, engine.getMantle().getMantle());
}
public static void updateChunk(Engine engine, Chunk c, Mantle<Matter> mantle) {
for (int x = -1; x <= 1; x++) {
for (int z = -1; z <= 1; z++) {
if (c.getWorld().isChunkLoaded(c.getX() + x, c.getZ() + z))
@@ -110,7 +114,6 @@ public final class EngineBukkitOps {
return;
}
}
Mantle<Matter> mantle = engine.getMantle().getMantle();
if (!mantle.isLoaded(c)) {
String msg = "Mantle Chunk " + c.getX() + "," + c.getZ() + " is not loaded";
IrisLogging.debug(msg);
@@ -118,7 +121,7 @@ public final class EngineBukkitOps {
}
if (!J.isFolia() && !J.isPrimaryThread()) {
CompletableFuture<?> scheduled = J.sfut(() -> updateChunk(engine, c));
CompletableFuture<?> scheduled = J.sfut(() -> updateChunk(engine, c, mantle));
if (scheduled != null) {
try {
scheduled.join();
@@ -70,7 +70,7 @@ public final class BukkitBlockState implements PlatformBlockState {
public static BukkitBlockState of(BlockData data) {
if (data instanceof IrisCustomData custom) {
return new BukkitBlockState(data, custom.getAsString());
return new BukkitBlockState(data, custom.getCustom().toString());
}
BukkitBlockState fast = DATA_CACHE.get(data);
if (fast != null) {
@@ -369,15 +369,14 @@ public final class BukkitBlockState implements PlatformBlockState {
@Override
public PlatformBlockState withProperty(String name, String value) {
String merged = mergeProperty(key, name, value);
BlockData resolved = Bukkit.createBlockData(merged);
// Re-attach the custom identity (as the proxy's own merge/clone cases do): the key of
// a custom state is the BASE block's string, so rebuilding from it alone silently
// downgraded custom blocks to vanilla on e.g. auto-waterlogging.
// Re-attach the custom identity (as the proxy's own merge/clone cases do) after
// editing the base block, so auto-waterlogging cannot turn custom blocks into vanilla.
if (data instanceof IrisCustomData custom) {
String merged = mergeProperty(custom.getBase().getAsString(), name, value);
BlockData resolved = Bukkit.createBlockData(merged);
return of(IrisCustomData.of(resolved, custom.getCustom()));
}
return of(resolved);
return of(Bukkit.createBlockData(mergeProperty(key, name, value)));
}
@Override
@@ -94,17 +94,17 @@ final class CompatFixtures {
@Override
public PlatformBlockState blockOrNull(String key, boolean warn) {
blockLookups++;
String normalized = key.trim().toLowerCase(Locale.ROOT);
String normalized = key.trim();
int props = normalized.indexOf('[');
String base = props < 0 ? normalized : normalized.substring(0, props);
String base = (props < 0 ? normalized : normalized.substring(0, props)).toLowerCase(Locale.ROOT);
String properties = props < 0 ? "" : normalized.substring(props);
if (base.indexOf(':') < 0) {
base = "minecraft:" + base;
normalized = base + (props < 0 ? "" : normalized.substring(props));
}
if (missingBlocks.contains(base)) {
return null;
}
return new FakeBlockState(normalized);
return new FakeBlockState(base + (base.startsWith("minecraft:") ? properties.toLowerCase(Locale.ROOT) : properties));
}
@Override
@@ -165,6 +165,8 @@ public class ContentGateTest {
assertEquals("minecraft:stone", ContentGate.normalizeState(" STONE "));
assertEquals("minecraft:oak_log[axis=y]", ContentGate.normalizeState("Oak_Log[AXIS=Y]"));
assertEquals("create:cogwheel", ContentGate.normalizeState("create:cogwheel"));
assertEquals("craftengine:forest/chair[randomYaw=true,variant=OakLarge]",
ContentGate.normalizeState("CraftEngine:Forest/Chair[randomYaw=true,variant=OakLarge]"));
assertNull(ContentGate.normalizeState(null));
assertNull(ContentGate.normalizeState(" "));
assertEquals("minecraft:oak_log", ContentGate.baseKey("minecraft:oak_log[axis=y]"));
@@ -1,7 +1,9 @@
package art.arcane.iris.core.compat;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.spi.PlatformBlockState;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
@@ -11,7 +13,10 @@ import java.nio.file.Files;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
public class IrisBlockDataResolutionTest {
private static CompatFixtures.FakeRegistries registries;
@@ -93,4 +98,119 @@ public class IrisBlockDataResolutionTest {
assertEquals(CompatAction.SUBSTITUTED, data.getCompatReport().findings().getFirst().action());
assertEquals("block", data.getCompatReport().findings().getFirst().subjectType());
}
@Test
public void nestedPackBlocksPreserveInheritedProviderProperties() throws Exception {
IrisData data = pack(null, "{\"block\":\"base\",\"data\":{\"variant\":\"large\"}}");
File blocks = new File(data.getDataFolder(), "blocks");
Files.writeString(new File(blocks, "base.json").toPath(),
"{\"block\":\"craftengine:forest/amber_log\",\"data\":{\"axis\":\"y\",\"yaw\":22.5}}");
IrisBlockData entry = new IrisBlockData("fancy");
entry.getData().put("axis", "x");
PlatformBlockState resolved = entry.getBlockData(data);
IrisBlockData parsed = IrisBlockData.from(resolved.key());
assertEquals("craftengine:forest/amber_log", parsed.getBlock());
assertEquals("x", parsed.getData().get("axis"));
assertEquals("large", parsed.getData().get("variant"));
assertEquals(22.5, (Double) parsed.getData().get("yaw"), 0.0);
}
@Test
public void providerPropertiesKeepTheirCaseThroughDirectResolution() throws Exception {
IrisData data = pack(null, null);
IrisBlockData entry = new IrisBlockData("craftengine:forest/chair");
entry.getData().put("randomYaw", true);
entry.getData().put("variant", "OakLarge");
PlatformBlockState resolved = entry.getBlockData(data);
IrisBlockData parsed = IrisBlockData.from(resolved.key());
assertEquals(true, parsed.getData().get("randomYaw"));
assertEquals("OakLarge", parsed.getData().get("variant"));
assertTrue(resolved.key().contains("randomYaw=true"));
assertTrue(resolved.key().contains("variant=OakLarge"));
}
@Test
public void nestedPackBlocksKeepInheritedProviderPropertyCase() throws Exception {
IrisData data = pack(null, "{\"block\":\"base\",\"data\":{\"yaw\":22.5}}");
File blocks = new File(data.getDataFolder(), "blocks");
Files.writeString(new File(blocks, "base.json").toPath(),
"{\"block\":\"craftengine:forest/chair\",\"data\":{\"randomYaw\":true,\"variant\":\"OakLarge\"}}");
IrisBlockData entry = new IrisBlockData("fancy");
entry.getData().put("pitch", 12.75);
IrisBlockData parsed = IrisBlockData.from(entry.getBlockData(data).key());
assertEquals(true, parsed.getData().get("randomYaw"));
assertEquals("OakLarge", parsed.getData().get("variant"));
assertEquals(22.5, (Double) parsed.getData().get("yaw"), 0.0);
assertEquals(12.75, (Double) parsed.getData().get("pitch"), 0.0);
}
@Test
public void parsedVanillaPropertiesStillNormalizeCase() {
IrisBlockData entry = IrisBlockData.from("OAK_LOG[AXIS=Y]");
assertEquals("minecraft:oak_log[axis=y]", entry.stateKey());
}
@Test
public void decimalProviderPropertiesRetainPrecision() {
IrisBlockData entry = IrisBlockData.from("craftengine:forest/lantern[yaw=22.5,pitch=-12.75]");
assertEquals(22.5, (Double) entry.getData().get("yaw"), 0.0);
assertEquals(-12.75, (Double) entry.getData().get("pitch"), 0.0);
assertTrue(entry.stateKey().contains("yaw=22.5"));
assertTrue(entry.stateKey().contains("pitch=-12.75"));
}
@Test
public void wholeNumberPropertiesKeepIntegerSyntax() {
IrisBlockData entry = new IrisBlockData("minecraft:wheat");
entry.getData().put("age", 4.0);
assertEquals("minecraft:wheat[age=4]", entry.stateKey());
}
@Test
public void providerReadinessClearsAuthoringBlockAndGateMisses() throws Exception {
String blockKey = "lateblocks:example/stone";
registries.missingBlocks.add(blockKey);
IrisData data = pack(null, "{\"block\":\"" + blockKey + "\"}");
try {
IrisBlockData missing = data.getBlockLoader().load("fancy", false);
assertTrue(missing.getBlockData(data).isAir());
ContentGate originalGate = data.getContentGate();
registries.missingBlocks.remove(blockKey);
IrisData.invalidateLoadedContentRegistries(data.getDataFolder());
IrisBlockData resolved = data.getBlockLoader().load("fancy", false);
assertNotSame(missing, resolved);
assertNotSame(originalGate, data.getContentGate());
assertEquals(blockKey, resolved.getBlockData(data).key());
} finally {
registries.missingBlocks.remove(blockKey);
}
}
@Test
public void providerReadinessLeavesEngineAttachedCachesIntact() throws Exception {
IrisData data = pack(null, "{\"block\":\"minecraft:stone\"}");
IrisBlockData block = data.getBlockLoader().load("fancy", false);
ContentGate gate = data.getContentGate();
Engine engine = mock(Engine.class);
data.registerEngine(engine);
try {
IrisData.invalidateLoadedContentRegistries(data.getDataFolder());
assertSame(block, data.getBlockLoader().load("fancy", false));
assertSame(gate, data.getContentGate());
} finally {
data.unregisterEngine(engine);
}
}
}
@@ -18,6 +18,8 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ContentKeyValidatorTest {
private static PlatformRegistries registries() {
@@ -87,6 +89,37 @@ public class ContentKeyValidatorTest {
assertTrue(error.namespaceLoaded());
}
@Test
public void validateAcceptsNativeAndQualifiedExternalBlockTypes() {
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.blockKeys()).thenReturn(List.of("minecraft:stone"));
when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:stone", "oraxen:caveblock",
"oraxen:oraxen/caveblock", "custom:marble", "itemsadder:custom/marble"));
List<ContentKeyError> errors = ContentKeyValidator.validate(registries,
List.of("stone", "oraxen:caveblock", "oraxen:oraxen/caveblock",
"custom:marble", "itemsadder:custom/marble"), List.of(), List.of());
assertTrue(errors.toString(), errors.isEmpty());
}
@Test
public void validateStillReportsMissingExternalBlockTypes() {
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.blockKeys()).thenReturn(List.of("minecraft:stone"));
when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:stone", "oraxen:oraxen/caveblock"));
List<ContentKeyError> errors = ContentKeyValidator.validate(registries,
List.of("oraxen:oraxen/caveblok", "itemsadder:custom/marble"), List.of(), List.of());
assertEquals(2, errors.size());
assertEquals("oraxen:oraxen/caveblok", errors.get(0).key());
assertTrue(errors.get(0).namespaceLoaded());
assertEquals("oraxen:oraxen/caveblock", errors.get(0).suggestion());
assertEquals("itemsadder:custom/marble", errors.get(1).key());
assertFalse(errors.get(1).namespaceLoaded());
}
@Test
public void validateFlagsUnloadedNamespaceWhenNamespaceMissing() {
List<ContentKeyError> errors = ContentKeyValidator.validate(registries(),
@@ -275,7 +308,7 @@ public class ContentKeyValidatorTest {
@Override
public List<String> blockTypeKeys() {
return List.of();
return blocks;
}
@Override
@@ -3,6 +3,10 @@ package art.arcane.iris.core.pack;
import art.arcane.iris.core.compat.CompatAction;
import art.arcane.iris.core.compat.CompatFinding;
import art.arcane.iris.core.compat.CompatRegistry;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.spi.PlatformStructureHooks;
import org.junit.Rule;
import org.junit.Test;
import org.junit.Assume;
@@ -22,6 +26,8 @@ import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class PackValidationCacheTest {
@Rule
@@ -214,4 +220,28 @@ public class PackValidationCacheTest {
PackValidationRegistry.clear();
}
}
@Test
public void externalBlockAvailabilityChangesValidationContext() {
IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatforms.unbind();
PlatformRegistries registries = mock(PlatformRegistries.class);
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.registries()).thenReturn(registries);
when(platform.structureHooks()).thenReturn(mock(PlatformStructureHooks.class));
when(registries.blockKeys()).thenReturn(List.of("minecraft:stone"));
when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:stone"));
IrisPlatforms.bind(platform);
try {
String before = PackValidationCache.contextFingerprint();
when(registries.blockTypeKeys()).thenReturn(List.of("minecraft:stone", "oraxen:oraxen/caveblock"));
assertNotEquals(before, PackValidationCache.contextFingerprint());
} finally {
IrisPlatforms.unbind();
if (previous != null) {
IrisPlatforms.bind(previous);
}
}
}
}
@@ -0,0 +1,77 @@
package art.arcane.iris.core.project;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.loader.ResourceLoader;
import art.arcane.iris.engine.object.IrisBlockData;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockProperty;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.volmlib.util.json.JSONObject;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ExternalBlockSchemaTest {
private static final String QUALIFIED_BLOCK = "craftengine:example/ores/copper";
private IrisPlatform previous;
@Before
public void bindPlatform() {
previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatforms.unbind();
PlatformRegistries registries = mock(PlatformRegistries.class);
when(registries.blockTypeKeys()).thenReturn(List.of(QUALIFIED_BLOCK));
when(registries.blockStateProperties()).thenReturn(Map.of(QUALIFIED_BLOCK,
List.of(new PlatformBlockProperty("axis", "string", "y", List.of("x", "y", "z"), null))));
IrisPlatform platform = mock(IrisPlatform.class);
when(platform.registries()).thenReturn(registries);
IrisPlatforms.bind(platform);
}
@After
public void restorePlatform() {
IrisPlatforms.unbind();
if (previous != null) {
IrisPlatforms.bind(previous);
}
}
@Test
public void qualifiedBlockReferencesResolveToItsEnumAndPropertySchema() {
IrisData data = mock(IrisData.class);
ResourceLoader<IrisBlockData> blocks = mock(ResourceLoader.class);
when(blocks.getPossibleKeys()).thenReturn(new String[0]);
when(data.getBlockLoader()).thenReturn(blocks);
JSONObject schema = new SchemaBuilder(IrisBlockData.class, data).construct();
JSONObject branch = schema.getJSONArray("allOf").getJSONObject(0)
.getJSONArray("anyOf").getJSONObject(0);
String enumReference = branch.getJSONObject("if").getJSONObject("properties")
.getJSONObject("block").getString("$ref");
String propertyReference = branch.getJSONObject("then").getJSONObject("properties")
.getJSONObject("data").getString("$ref");
assertEquals(QUALIFIED_BLOCK, resolve(schema, enumReference).getJSONArray("enum").getString(0));
JSONObject axis = resolve(schema, propertyReference).getJSONObject("properties").getJSONObject("axis");
assertEquals("string", axis.getString("type"));
assertEquals("x", axis.getJSONArray("enum").getString(0));
assertEquals("z", axis.getJSONArray("enum").getString(2));
}
private static JSONObject resolve(JSONObject schema, String reference) {
JSONObject resolved = schema;
for (String token : reference.substring(2).split("/")) {
resolved = resolved.getJSONObject(token.replace("~1", "/").replace("~0", "~"));
}
return resolved;
}
}
@@ -0,0 +1,179 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.link.ExternalDataProvider;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.link.data.DataType;
import art.arcane.iris.core.nms.container.BlockProperty;
import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.volmlib.util.collection.KMap;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.Test;
import java.lang.reflect.Field;
import java.util.List;
import java.util.MissingResourceException;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ExternalDataSVCBlockRoutingTest {
@Test
public void nativeLookupPinsProviderAndRetainsPropertiesUntilPlacement() throws Exception {
Identifier nativeId = Identifier.fromString("example:wood");
ExternalDataProvider provider = provider("CraftEngine", nativeId);
BlockData base = mock(BlockData.class);
KMap<String, String> properties = new KMap<>();
properties.put("facing", "east");
when(provider.getBlockData(nativeId, properties)).thenReturn(IrisCustomData.of(base,
Identifier.fromString("example:wood[facing=east]")));
ExternalDataSVC service = service(provider);
IrisCustomData resolved = (IrisCustomData) service.getBlockData(
Identifier.fromString("example:wood[facing=east]")).orElseThrow();
assertSame(base, resolved.getBase());
assertEquals("craftengine:example/wood[facing=east]", resolved.getCustom().toString());
ExternalDataProvider competing = provider("ItemsAdder", nativeId);
addProvider(service, competing);
Engine engine = mock(Engine.class);
Block block = mock(Block.class);
service.processUpdate(engine, block, resolved.getCustom());
verify(provider).processUpdate(engine, block, Identifier.fromString("example:wood[facing=east]"));
verify(competing, never()).processUpdate(any(), any(), any());
}
@Test
public void qualifiedLookupSelectsProviderDespiteNativeCollision() throws Exception {
Identifier nativeId = Identifier.fromString("example:wood");
ExternalDataProvider first = provider("CraftEngine", nativeId);
ExternalDataProvider second = provider("ItemsAdder", nativeId);
BlockData base = mock(BlockData.class);
when(second.getBlockData(eq(nativeId), any())).thenReturn(IrisCustomData.of(base, nativeId));
ExternalDataSVC service = service(first, second);
assertFalse(service.getBlockData(nativeId).isPresent());
IrisCustomData resolved = (IrisCustomData) service.getBlockData(
Identifier.fromString("itemsadder:example/wood")).orElseThrow();
assertEquals("itemsadder:example/wood", resolved.getCustom().toString());
verify(first, never()).getBlockData(any(), any());
assertFalse(service.getBlockProperties(nativeId).isPresent());
assertEquals(List.of(), service.getBlockProperties(Identifier.fromString("itemsadder:example/wood")).orElseThrow());
}
@Test
public void missingQualifiedProviderDoesNotLeakIntoAnotherNamespaceOwner() throws Exception {
Identifier id = Identifier.fromString("itemsadder:example/wood");
ExternalDataProvider unrelated = provider("OtherBlocks", id);
ExternalDataSVC service = service(unrelated);
assertFalse(service.getBlockData(id).isPresent());
verify(unrelated, never()).getBlockData(any(), any());
}
@Test
public void qualifiedNestedKeysAndPropertiesAppearInSchemas() throws Exception {
Identifier nativeId = Identifier.fromString("example:ores/copper");
ExternalDataProvider provider = provider("ItemsAdder", nativeId);
List<BlockProperty> properties = List.of(BlockProperty.ofBoolean("lit", false));
when(provider.getBlockProperties(nativeId)).thenReturn(properties);
BlockData base = mock(BlockData.class);
when(provider.getBlockData(eq(nativeId), any())).thenReturn(base);
ExternalDataSVC service = service(provider);
Identifier qualified = Identifier.fromString("itemsadder:example/ores/copper");
assertSame(base, service.getBlockData(qualified).orElseThrow());
assertEquals(Set.of(nativeId, qualified), Set.copyOf(service.getAllIdentifiers(DataType.BLOCK)));
assertEquals(properties, service.getBlockProperties(qualified).orElseThrow());
assertEquals(2, service.getAllBlockProperties().size());
}
@Test
public void nativeProviderKeysCanContainSlashes() throws Exception {
Identifier nativeId = Identifier.fromString("oraxen:ores/copper");
ExternalDataProvider provider = provider("Oraxen", nativeId);
BlockData base = mock(BlockData.class);
when(provider.getBlockData(eq(nativeId), any())).thenReturn(base);
ExternalDataSVC service = service(provider);
assertSame(base, service.getBlockData(nativeId).orElseThrow());
assertSame(base, service.getBlockData(Identifier.fromString("oraxen:oraxen/ores/copper")).orElseThrow());
}
@Test
public void providerFailureDoesNotEscapeIntoVanillaFallbackParsing() throws Exception {
Identifier id = Identifier.fromString("example:stone");
ExternalDataProvider provider = provider("ItemsAdder", id);
when(provider.getBlockData(eq(id), any())).thenThrow(new IllegalStateException("Registry is unavailable"));
ExternalDataSVC service = service(provider);
assertFalse(service.getBlockData(id).isPresent());
}
@Test
public void unavailableProviderDoesNotReportPlacementSuccess() throws Exception {
ExternalDataSVC service = service();
assertThrows(MissingResourceException.class, () -> service.processUpdate(
mock(Engine.class), mock(Block.class), Identifier.fromString("itemsadder:example/wood")));
}
@Test
public void malformedStateSuffixesFailBeforeProviderLookup() throws Exception {
ExternalDataProvider provider = provider("ItemsAdder", Identifier.fromString("example:wood"));
ExternalDataSVC service = service(provider);
for (String state : List.of("wood[", "wood]", "wood[a=b]tail", "wood[a=b,]", "wood[a=b,a=c]", "wood[[a=b]]", "wood[a]")) {
Identifier id = new Identifier("example", state);
assertThrows(state, IllegalArgumentException.class, () -> ExternalDataSVC.parseState(id));
assertFalse(state, service.getBlockData(id).isPresent());
}
verify(provider, never()).getBlockData(any(), any());
}
@Test
public void stateParserPreservesDecimalsAndTrimsProperties() {
Pair<Identifier, KMap<String, String>> parsed = ExternalDataSVC.parseState(
Identifier.fromString("craftengine:example/chair[ yaw = 22.5 ]"));
assertEquals(Identifier.fromString("craftengine:example/chair"), parsed.getA());
assertEquals("22.5", parsed.getB().get("yaw"));
assertEquals("craftengine:example/chair[yaw=22.5]", ExternalDataSVC.buildState(parsed.getA(), parsed.getB()).toString());
}
private static ExternalDataProvider provider(String pluginId, Identifier id) {
ExternalDataProvider provider = mock(ExternalDataProvider.class);
when(provider.getPluginId()).thenReturn(pluginId);
when(provider.isValidProvider(id, DataType.BLOCK)).thenReturn(true);
when(provider.getTypes(DataType.BLOCK)).thenReturn(List.of(id));
when(provider.getBlockProperties(id)).thenReturn(List.of());
return provider;
}
private static ExternalDataSVC service(ExternalDataProvider... providers) throws Exception {
ExternalDataSVC service = new ExternalDataSVC();
for (ExternalDataProvider provider : providers) {
addProvider(service, provider);
}
return service;
}
@SuppressWarnings("unchecked")
private static void addProvider(ExternalDataSVC service, ExternalDataProvider provider) throws Exception {
Field field = ExternalDataSVC.class.getDeclaredField("activeProviders");
field.setAccessible(true);
List<ExternalDataProvider> providers = (List<ExternalDataProvider>) field.get(service);
providers.add(provider);
}
}
@@ -0,0 +1,116 @@
package art.arcane.iris.core.service;
import art.arcane.iris.core.link.ExternalDataProvider;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.link.data.DataType;
import org.junit.Test;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.PluginManager;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class ExternalDataSVCContentChangeTest {
@Test
public void enabledPluginIsPendingUntilItsProviderActivates() {
ExternalDataSVC service = new ExternalDataSVC();
Plugin plugin = mock(Plugin.class);
PluginManager plugins = mock(PluginManager.class);
when(plugin.isEnabled()).thenReturn(true);
when(plugins.getPlugin("ItemsAdder")).thenReturn(plugin);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(Bukkit::getPluginManager).thenReturn(plugins);
assertTrue(service.hasPendingBlockProvider("itemsadder:example/stone"));
assertTrue(service.hasPendingBlockProvider("example:stone"));
assertFalse(service.hasPendingBlockProvider("minecraft:stone"));
assertFalse(service.hasPendingBlockProvider("oraxen:oraxen/stone"));
}
}
@Test
public void contentChangesAdvanceTheRevisionWithoutAListener() {
ExternalDataSVC service = new ExternalDataSVC();
long revision = service.contentRevision();
service.notifyContentChanged();
assertEquals(revision + 1L, service.contentRevision());
}
@Test
public void activationAndRegistryReloadNotifyTheListener() {
ExternalDataSVC service = new ExternalDataSVC();
service.notifyContentChanged();
AtomicInteger changes = new AtomicInteger();
service.setContentChangeListener(changes::incrementAndGet);
ExternalDataProvider provider = enabledProvider("TestBlocks");
service.registerProvider(provider);
assertEquals(1, changes.get());
service.notifyContentChanged();
assertEquals(2, changes.get());
service.onDisable();
service.notifyContentChanged();
assertEquals(2, changes.get());
}
@Test
public void unavailableRegistryDoesNotHideOtherProviderBlocks() {
ExternalDataSVC service = new ExternalDataSVC();
ExternalDataProvider loading = enabledProvider("LoadingBlocks");
when(loading.getTypes(DataType.BLOCK)).thenThrow(new IllegalStateException("Content is still loading"));
service.registerProvider(loading);
ExternalDataProvider ready = enabledProvider("ReadyBlocks");
when(ready.getTypes(DataType.BLOCK)).thenReturn(List.of(Identifier.fromString("example:stone")));
service.registerProvider(ready);
assertEquals(Set.of(Identifier.fromString("example:stone"), Identifier.fromString("readyblocks:example/stone")),
Set.copyOf(service.getAllIdentifiers(DataType.BLOCK)));
}
@Test
public void delayedRegistryInitializesBeforeItsContentIsReady() {
ExternalDataSVC service = new ExternalDataSVC();
ExternalDataProvider provider = enabledProvider("DelayedBlocks");
when(provider.isReady()).thenReturn(false);
when(provider.getTypes(DataType.BLOCK)).thenReturn(List.of());
service.registerProvider(provider);
verify(provider).init();
assertTrue(service.hasPendingBlockProvider("delayedblocks:example/stone"));
assertTrue(service.getAllIdentifiers(DataType.BLOCK).isEmpty());
Identifier identifier = Identifier.fromString("example:stone");
when(provider.isReady()).thenReturn(true);
when(provider.getTypes(DataType.BLOCK)).thenReturn(List.of(identifier));
service.notifyContentChanged();
assertFalse(service.hasPendingBlockProvider("delayedblocks:example/stone"));
assertEquals(Set.of(identifier, Identifier.fromString("delayedblocks:example/stone")),
Set.copyOf(service.getAllIdentifiers(DataType.BLOCK)));
}
private static ExternalDataProvider enabledProvider(String pluginId) {
Plugin plugin = mock(Plugin.class);
when(plugin.isEnabled()).thenReturn(true);
ExternalDataProvider provider = mock(ExternalDataProvider.class);
when(provider.getPluginId()).thenReturn(pluginId);
when(provider.getPlugin()).thenReturn(plugin);
when(provider.isReady()).thenReturn(true);
return provider;
}
}
@@ -31,6 +31,7 @@ public class ExternalDataSVCRegistryTest {
assertEquals(List.of(
"CraftEngine",
"Nexo",
"Oraxen",
"ItemsAdder",
"ExecutableItems",
"MMOItems",
@@ -93,6 +93,50 @@ public class EngineShutdownDrainTest {
}
}
@Test
public void queuedWorldManagerRoutesAreCancelledBeforeRouterDrain() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
AtomicBoolean queuedRoute = new AtomicBoolean(true);
doAnswer(invocation -> {
queuedRoute.set(false);
return null;
}).when(fixture.manager).close();
doAnswer(invocation -> {
assertFalse("Router drain must not wait for a queued region callback", queuedRoute.get());
return null;
}).when(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
fixture.shutdown.close();
}
InOrder shutdownOrder = inOrder(fixture.manager, fixture.engine, fixture.sessions);
shutdownOrder.verify(fixture.manager).close();
shutdownOrder.verify(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
shutdownOrder.verify(fixture.sessions).sealAndAwait("close", IrisEngine.SESSION_DRAIN_TIMEOUT_MILLIS, true);
verify(fixture.manager).close();
assertTrue(fixture.engine.closed);
}
@Test
public void failedWorldManagerStopPreventsRouterDrainAndCanBeRetried() throws Exception {
ShutdownFixture fixture = new ShutdownFixture();
doThrow(new IllegalStateException("World manager still has work")).doNothing().when(fixture.manager).close();
assertThrows(IllegalStateException.class, fixture.shutdown::close);
verify(fixture.engine, never()).closeAttachedGenerationHistoryRuntimeRouter();
verify(fixture.mantle, never()).close();
assertFalse(fixture.engine.closed);
try (MockedStatic<NativeStructureOwnershipStore> ownership = mockStatic(NativeStructureOwnershipStore.class)) {
fixture.shutdown.close();
}
verify(fixture.manager, times(2)).close();
verify(fixture.engine).closeAttachedGenerationHistoryRuntimeRouter();
assertTrue(fixture.engine.closed);
}
@Test
public void successfulEngineCloseAllowsTheSameWorldToPrepareGenerationAgain() throws Exception {
GenerationHistory history = history();
@@ -7,6 +7,8 @@ import org.bukkit.Chunk;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
@@ -34,8 +36,11 @@ public class IrisWorldManagerNativePoiTest {
}
@Test
public void chunkLoadPropagatesPoiFailuresToTheRuntimeEventReporter() {
public void chunkLoadPropagatesPoiFailuresToTheRuntimeEventReporter() throws Exception {
IrisWorldManager manager = mock(IrisWorldManager.class, CALLS_REAL_METHODS);
Field maintenance = IrisWorldManager.class.getDeclaredField("chunkMaintenance");
maintenance.setAccessible(true);
maintenance.set(manager, new WorldChunkMaintenance(manager));
Engine engine = mock(Engine.class);
Chunk chunk = mock(Chunk.class);
INMSBinding binding = mock(INMSBinding.class);
@@ -35,6 +35,7 @@ import java.util.Optional;
import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
@@ -58,6 +59,57 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public final class ResolvedTerrainProviderTest {
@Test
public void cachedTransitionTerrainRetainsDeferredBlocksWhenCopied() {
Fixture fixture = new Fixture();
String key = "itemsadder:rocks/ruby_ore";
PlatformBlockState custom = Fixture.block(key, false);
when(custom.isCustom()).thenReturn(true);
when(custom.placementBaseState()).thenReturn(fixture.stone);
when(custom.deferredPlacementKey()).thenReturn(key);
TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class);
when(plan.hasTransitionAtChunk(0, 0)).thenReturn(true);
when(plan.newEpochWeightAt(anyInt(), anyInt())).thenReturn(1D);
when(fixture.complex.getTransitionGenerationPlan()).thenReturn(plan);
fixture.terrainOverride = (x, z, blocks, biomes, multicore, context) -> {
for (int localX = 0; localX < 16; localX++) {
for (int localZ = 0; localZ < 16; localZ++) {
context.setTerrainHeight(localX, localZ, 2);
for (int y = 0; y < 16; y++) {
blocks.setRaw(localX, y, localZ, y == 2 ? custom : y < 2 ? fixture.stone : fixture.air);
biomes.setRaw(localX, y, localZ, fixture.biome);
}
}
}
};
TerrainBoundarySignature captured = fixture.provider.column(0, 0);
assertEquals(key, captured.geometry().voxelAt(2).stateKey());
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(platform.registries()).thenReturn(registries);
when(registries.blockOrNull(key)).thenReturn(custom);
when(registries.blockOrNull("minecraft:stone")).thenReturn(fixture.stone);
when(registries.blockOrNull("minecraft:air")).thenReturn(fixture.air);
when(registries.biome("minecraft:plains")).thenReturn(fixture.biome);
ChunkContext context = new ChunkContext(0, 0, fixture.complex, false, ChunkContext.PrefillPlan.NONE, null);
AtomicInteger customWrites = new AtomicInteger();
Hunk<PlatformBlockState> blocks = Hunk.<PlatformBlockState>newArrayHunk(16, 16, 16).listen((x, y, z, state) -> {
if (state.isCustom()) {
customWrites.incrementAndGet();
}
});
try (MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
IrisContext.Scope ignored = IrisContext.open(fixture.engine, 5L, context)) {
platforms.when(IrisPlatforms::get).thenReturn(platform);
fixture.provider.generate(fixture.mode, 0, 0, blocks, Hunk.newArrayHunk(16, 16, 16), false, context);
}
assertEquals(256, customWrites.get());
assertSame(custom, blocks.getRaw(0, 2, 0));
assertEquals(key, blocks.getRaw(0, 2, 0).deferredPlacementKey());
assertEquals(Integer.valueOf(1), fixture.computations.get("0,0"));
}
@Test
public void cachedSpeculativeFloatingIdentityIsPublishedOnlyByActualGeneration() throws Exception {
Fixture fixture = new Fixture();
@@ -0,0 +1,266 @@
package art.arcane.iris.engine;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBlockDrops;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.platform.EngineBukkitOps;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.collection.KList;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.inventory.ItemStack;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayDeque;
import java.util.Queue;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class WorldBlockDropRouterTest {
@Test
public void coldBiomeCancelsBeforeChangingDropsOrSchedulingWork() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location))
.thenThrow(new SavedBiomeUnavailableException("Biome loading", true));
BlockBreakEvent event = fixture.event();
fixture.router.onBlockBreak(event);
assertTrue(event.isCancelled());
assertTrue(event.isDropItems());
fixture.assertNoSideEffects();
fixture.queries.verify(() -> EngineBukkitOps.getRegion(fixture.engine, fixture.location), never());
}
}
@Test
public void coldRegionCancelsBeforeFillingAlreadyMatchedBiomeDrops() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> EngineBukkitOps.getRegion(fixture.engine, fixture.location))
.thenThrow(new SavedBiomeUnavailableException("Region loading", true));
BlockBreakEvent event = fixture.event();
fixture.router.onBlockBreak(event);
verify(fixture.drops).shouldDropFor(fixture.blockData, fixture.data);
assertTrue(event.isCancelled());
assertTrue(event.isDropItems());
fixture.assertNoSideEffects();
}
}
@Test
public void alreadyCancelledBreakSkipsSavedLookups() {
try (Fixture fixture = new Fixture()) {
BlockBreakEvent event = fixture.event();
event.setCancelled(true);
fixture.router.onBlockBreak(event);
assertTrue(event.isCancelled());
fixture.queries.verifyNoInteractions();
fixture.assertNoSideEffects();
}
}
@Test
public void otherWorldBreakSkipsSavedLookups() {
try (Fixture fixture = new Fixture()) {
when(fixture.block.getWorld()).thenReturn(mock(World.class));
BlockBreakEvent event = fixture.event();
fixture.router.onBlockBreak(event);
assertFalse(event.isCancelled());
fixture.queries.verifyNoInteractions();
fixture.assertNoSideEffects();
}
}
@Test
public void freshAttemptAfterLoadingProducesDropsOnceWithoutReplayingTheCancelledEvent() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location))
.thenThrow(new SavedBiomeUnavailableException("Biome loading", true))
.thenReturn(fixture.biome);
BlockBreakEvent cold = fixture.event();
fixture.router.onBlockBreak(cold);
fixture.assertNoSideEffects();
BlockBreakEvent retry = fixture.event();
fixture.router.onBlockBreak(retry);
assertTrue(cold.isCancelled());
assertTrue(cold.isDropItems());
assertFalse(retry.isCancelled());
assertFalse(retry.isDropItems());
assertEquals(1, fixture.delayed.size());
verify(fixture.drops).fillDrops(eq(false), any());
fixture.delayed.remove().run();
verify(fixture.world).dropItemNaturally(fixture.location.clone().add(.5, .5, .5), fixture.item);
assertEquals(1, fixture.markers.size());
assertTrue(fixture.delayed.isEmpty());
}
}
@Test
public void permanentSavedBiomeFailureCancelsAndPropagates() {
verifyReportedFailure(new SavedBiomeUnavailableException("Saved biome missing", false));
}
@Test
public void loadingWithSuppressedFailureCancelsAndPropagates() {
SavedBiomeUnavailableException failure = new SavedBiomeUnavailableException("Biome loading", true);
failure.addSuppressed(new IllegalStateException("Saved storage cleanup failed"));
verifyReportedFailure(failure);
}
@Test
public void protectionProbeCancelledAfterDispatchProducesNoFinalDropsOrMarkerCleanup() {
try (Fixture fixture = new Fixture()) {
BlockBreakEvent probe = new ProtectionProbe(fixture.block, fixture.player);
probe.setDropItems(false);
fixture.router.onBlockBreak(probe);
assertFalse(probe.isCancelled());
assertFalse(probe.isDropItems());
assertEquals(1, fixture.delayed.size());
probe.setCancelled(true);
fixture.delayed.remove().run();
verify(fixture.world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class));
assertTrue(fixture.markers.isEmpty());
verify(fixture.manager, never()).getMantle();
}
}
private void verifyReportedFailure(SavedBiomeUnavailableException failure) {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> EngineBukkitOps.getBiome(fixture.engine, fixture.location)).thenThrow(failure);
BlockBreakEvent event = fixture.event();
assertSame(failure, assertThrows(SavedBiomeUnavailableException.class,
() -> fixture.router.onBlockBreak(event)));
assertTrue(event.isCancelled());
assertTrue(event.isDropItems());
fixture.assertNoSideEffects();
}
}
private static final class ProtectionProbe extends BlockBreakEvent {
private ProtectionProbe(Block block, Player player) {
super(block, player);
}
}
private static final class Fixture implements AutoCloseable {
private final IrisWorldManager manager = mock(IrisWorldManager.class);
private final Engine engine = mock(Engine.class);
private final IrisData data = mock(IrisData.class);
private final World world = mock(World.class);
private final Block block = mock(Block.class);
private final BlockData blockData = mock(BlockData.class);
private final Player player = mock(Player.class);
private final Location location = new Location(world, 16, 72, 32);
private final IrisBiome biome = mock(IrisBiome.class);
private final IrisBlockDrops drops = mock(IrisBlockDrops.class);
private final ItemStack item = mock(ItemStack.class);
private final WorldBlockDropRouter router = new WorldBlockDropRouter(manager);
private final Queue<Runnable> delayed = new ArrayDeque<>();
private final Queue<Runnable> markers = new ArrayDeque<>();
private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class);
private final MockedStatic<EngineBukkitOps> queries = mockStatic(EngineBukkitOps.class);
private final MockedStatic<J> scheduling = mockStatic(J.class);
private Fixture() {
IrisWorld irisWorld = mock(IrisWorld.class);
EngineTarget target = mock(EngineTarget.class);
IrisRegion region = mock(IrisRegion.class);
IrisDimension dimension = mock(IrisDimension.class);
when(manager.getEngine()).thenReturn(engine);
when(manager.getTarget()).thenReturn(target);
when(manager.getData()).thenReturn(data);
when(target.getWorld()).thenReturn(irisWorld);
when(engine.getWorld()).thenReturn(irisWorld);
when(irisWorld.minHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension);
binding.when(() -> BukkitWorldBinding.world(irisWorld)).thenReturn(world);
when(block.getWorld()).thenReturn(world);
when(block.getX()).thenReturn(16);
when(block.getY()).thenReturn(72);
when(block.getZ()).thenReturn(32);
when(block.getLocation()).thenReturn(location);
when(block.getBlockData()).thenReturn(blockData);
when(biome.getBlockDrops()).thenReturn(new KList<>(drops));
when(region.getBlockDrops()).thenReturn(new KList<>());
when(dimension.getBlockDrops()).thenReturn(new KList<>());
when(drops.shouldDropFor(blockData, data)).thenReturn(true);
when(drops.isReplaceVanillaDrops()).thenReturn(true);
doAnswer(invocation -> {
KList<ItemStack> results = invocation.getArgument(1);
results.add(item);
return null;
}).when(drops).fillDrops(eq(false), any());
queries.when(() -> EngineBukkitOps.getBiome(engine, location)).thenReturn(biome);
queries.when(() -> EngineBukkitOps.getRegion(engine, location)).thenReturn(region);
when(manager.managedTask(anyString(), any(Runnable.class)))
.thenAnswer(invocation -> invocation.getArgument(1));
scheduling.when(() -> J.runAt(eq(location), any(Runnable.class), eq(1))).thenAnswer(invocation -> {
delayed.add(invocation.getArgument(1));
return true;
});
scheduling.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> {
markers.add(invocation.getArgument(0));
return null;
});
}
private BlockBreakEvent event() {
return new BlockBreakEvent(block, player);
}
private void assertNoSideEffects() {
verify(drops, never()).fillDrops(anyBoolean(), any());
verify(world, never()).dropItemNaturally(any(Location.class), any(ItemStack.class));
verify(manager, never()).getMantle();
scheduling.verifyNoInteractions();
assertTrue(delayed.isEmpty());
assertTrue(markers.isEmpty());
}
@Override
public void close() {
scheduling.close();
queries.close();
binding.close();
}
}
}
@@ -0,0 +1,391 @@
package art.arcane.iris.engine;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter;
import art.arcane.iris.engine.history.GenerationHistoryRuntimeRouter.SavedChunkMantle;
import art.arcane.iris.engine.mantle.EngineMantle;
import art.arcane.iris.engine.platform.EngineBukkitOps;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.matter.Matter;
import org.bukkit.Chunk;
import org.bukkit.World;
import org.junit.Test;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.ArrayDeque;
import java.util.Map;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class WorldChunkMaintenanceRoutingTest {
private static IrisPlatform previousPlatform;
@BeforeClass
public static void bindPlatform() {
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
IrisPlatforms.unbind();
IrisPlatform platform = mock(IrisPlatform.class);
PlatformRegistries registries = mock(PlatformRegistries.class);
when(platform.registries()).thenReturn(registries);
when(registries.block(anyString())).thenReturn(mock(PlatformBlockState.class));
IrisPlatforms.bind(platform);
}
@AfterClass
public static void restorePlatform() {
IrisPlatforms.unbind();
if (previousPlatform != null) {
IrisPlatforms.bind(previousPlatform);
}
}
@Test
public void materializesTheSavedOwnerAndKeepsSpawningAfterCompletion() throws Exception {
try (Fixture fixture = new Fixture()) {
fixture.schedule();
verify(fixture.currentMantle, never()).getChunk(2, -1);
fixture.prepare();
verify(fixture.savedChunk).use();
verify(fixture.saved).detachThread();
verify(fixture.saved, never()).close();
verify(fixture.savedChunk, never()).release();
fixture.apply();
fixture.awaitRelease();
fixture.updates.verify(() -> EngineBukkitOps.updateChunk(fixture.engine, fixture.chunk, fixture.savedMantle));
InOrder lifetime = inOrder(fixture.savedChunk, fixture.saved);
lifetime.verify(fixture.savedChunk).use();
lifetime.verify(fixture.saved).detachThread();
lifetime.verify(fixture.savedChunk).release();
lifetime.verify(fixture.saved).close();
assertNotSame(Thread.currentThread(), fixture.releaseThread.get());
assertFalse(fixture.scoped.get());
fixture.schedule();
assertTrue(fixture.async.isEmpty());
verify(fixture.router).openSavedChunkMantle(2, -1);
verify(fixture.spawner, times(2)).spawnInitially(fixture.chunk);
}
}
@Test
public void managerCloseReleasesQueuedStorageWithoutRunningItsRegionCallback() throws Exception {
try (Fixture fixture = new Fixture()) {
fixture.schedule();
fixture.prepare();
fixture.maintenance.close();
fixture.awaitRelease();
fixture.apply();
fixture.schedule();
fixture.updates.verifyNoInteractions();
verify(fixture.saved).close();
verify(fixture.spawner, never()).spawnInitially(fixture.chunk);
assertTrue(fixture.async.isEmpty());
}
}
@Test
public void managerCloseDuringPreparationReleasesLocallyHeldStorage() throws Exception {
try (Fixture fixture = new Fixture()) {
when(fixture.savedMantle.getChunk(2, -1)).thenAnswer(invocation -> {
fixture.maintenance.close();
return fixture.savedChunk;
});
fixture.schedule();
fixture.prepare();
fixture.awaitRelease();
fixture.updates.verifyNoInteractions();
assertTrue(fixture.regions.isEmpty());
verify(fixture.savedChunk).release();
verify(fixture.saved).close();
}
}
@Test
public void rejectedRegionSchedulingReleasesStorageAndAllowsRetry() throws Exception {
try (Fixture fixture = new Fixture()) {
fixture.acceptRegion.set(false);
fixture.schedule();
fixture.prepare();
fixture.awaitRelease();
fixture.acceptRegion.set(true);
fixture.schedule();
assertEquals(1, fixture.async.size());
fixture.updates.verifyNoInteractions();
verify(fixture.savedChunk).release();
}
}
@Test
public void rejectedLifecycleAdmissionReleasesPreparedStorage() throws Exception {
try (Fixture fixture = new Fixture()) {
fixture.schedule();
fixture.prepare();
fixture.admitted.set(false);
fixture.apply();
fixture.awaitRelease();
fixture.updates.verifyNoInteractions();
verify(fixture.saved).close();
verify(fixture.savedChunk).release();
}
}
@Test
public void unloadedChunkDoesNotBecomeCompleted() throws Exception {
try (Fixture fixture = new Fixture()) {
fixture.schedule();
fixture.prepare();
when(fixture.world.isChunkLoaded(2, -1)).thenReturn(false);
fixture.apply();
fixture.awaitRelease();
when(fixture.world.isChunkLoaded(2, -1)).thenReturn(true);
fixture.schedule();
assertEquals(1, fixture.async.size());
fixture.updates.verifyNoInteractions();
}
}
@Test
public void failedMaterializationRetainsRetryAndReportsTheFailure() throws Exception {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("Provider placement failed");
fixture.updates.when(() -> EngineBukkitOps.updateChunk(fixture.engine, fixture.chunk, fixture.savedMantle)).thenThrow(failure);
fixture.schedule();
fixture.prepare();
fixture.apply();
fixture.awaitRelease();
fixture.logging.verify(() -> IrisLogging.reportError(anyString(), eq(failure)));
fixture.schedule();
assertEquals(1, fixture.async.size());
verify(fixture.spawner, never()).spawnInitially(fixture.chunk);
}
}
@Test
public void skippedMaterializationRetainsRetryWithoutSuppressingSpawning() throws Exception {
try (Fixture fixture = new Fixture()) {
fixture.updates.when(() -> EngineBukkitOps.updateChunk(fixture.engine, fixture.chunk, fixture.savedMantle))
.thenAnswer(invocation -> null);
fixture.schedule();
fixture.prepare();
fixture.apply();
fixture.awaitRelease();
fixture.schedule();
assertEquals(1, fixture.async.size());
verify(fixture.spawner).spawnInitially(fixture.chunk);
}
}
@Test
public void regeneratedChunkInvalidatesCompletedMaterialization() throws Exception {
try (Fixture fixture = new Fixture()) {
fixture.schedule();
fixture.prepare();
fixture.apply();
fixture.awaitRelease();
fixture.maintenance.invalidateMaterialization(2, -1);
fixture.schedule();
assertEquals(1, fixture.async.size());
}
}
@Test
public void pendingMaterializationIsBoundedAndCancelledBeforePreparation() throws Exception {
try (Fixture fixture = new Fixture()) {
for (int x = 0; x < 256; x++) {
fixture.maintenance.updateChunkRegion(fixture.world, x, 0);
}
assertEquals(128, fixture.async.size());
fixture.maintenance.close();
while (!fixture.async.isEmpty()) {
fixture.prepare();
}
verify(fixture.router, never()).openSavedChunkMantle(anyInt(), anyInt());
}
}
private static final class Fixture implements AutoCloseable {
private final IrisEngine engine = mock(IrisEngine.class);
private final IrisWorldManager manager = mock(IrisWorldManager.class);
private final GenerationHistoryRuntimeRouter router = mock(GenerationHistoryRuntimeRouter.class);
private final SavedChunkMantle saved = mock(SavedChunkMantle.class);
private final SavedChunkMantle.Scope scope = mock(SavedChunkMantle.Scope.class);
private final EngineMantle currentEngineMantle = mock(EngineMantle.class);
private final Mantle<Matter> currentMantle;
private final Mantle<Matter> savedMantle;
private final MantleChunk<Matter> savedChunk;
private final WorldEntitySpawner spawner = mock(WorldEntitySpawner.class);
private final World world = mock(World.class);
private final Chunk chunk = mock(Chunk.class);
private final WorldChunkMaintenance maintenance = new WorldChunkMaintenance(manager);
private final Map<?, ?> pending;
private final Queue<Runnable> async = new ArrayDeque<>();
private final Queue<Runnable> regions = new ArrayDeque<>();
private final AtomicBoolean etched = new AtomicBoolean();
private final AtomicBoolean scoped = new AtomicBoolean();
private final AtomicBoolean acceptRegion = new AtomicBoolean(true);
private final AtomicBoolean admitted = new AtomicBoolean(true);
private final AtomicReference<Thread> releaseThread = new AtomicReference<>();
private final Semaphore released = new Semaphore(0);
private final MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
private final MockedStatic<J> scheduling = mockStatic(J.class);
private final MockedStatic<EngineBukkitOps> updates = mockStatic(EngineBukkitOps.class);
private final MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class);
@SuppressWarnings("unchecked")
private Fixture() throws Exception {
currentMantle = mock(Mantle.class);
savedMantle = mock(Mantle.class);
savedChunk = mock(MantleChunk.class);
Field pendingField = WorldChunkMaintenance.class.getDeclaredField("materializations");
pendingField.setAccessible(true);
pending = (Map<?, ?>) pendingField.get(maintenance);
Field field = IrisWorldManager.class.getDeclaredField("entitySpawner");
field.setAccessible(true);
field.set(manager, spawner);
when(manager.getEngine()).thenReturn(engine);
when(engine.getGenerationHistoryRuntimeRouter()).thenReturn(Optional.of(router));
when(manager.getMantle()).thenReturn(currentMantle);
when(engine.getMantle()).thenReturn(currentEngineMantle);
when(currentEngineMantle.getMantle()).thenReturn(currentMantle);
when(saved.mantle()).thenReturn(savedMantle);
when(savedMantle.getChunk(2, -1)).thenReturn(savedChunk);
when(savedChunk.use()).thenReturn(savedChunk);
when(savedMantle.isChunkLoaded(2, -1)).thenReturn(true);
when(savedMantle.hasFlag(2, -1, MantleFlag.ETCHED)).thenAnswer(invocation -> etched.get());
when(router.openSavedChunkMantle(2, -1)).thenReturn(saved);
when(saved.openScope()).thenAnswer(invocation -> {
assertFalse(scoped.getAndSet(true));
return scope;
});
doAnswer(invocation -> {
assertTrue(scoped.getAndSet(false));
return null;
}).when(scope).close();
doAnswer(invocation -> {
releaseThread.set(Thread.currentThread());
released.release();
return null;
}).when(saved).close();
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(world.getChunkAt(anyInt(), anyInt())).thenReturn(chunk);
when(spawner.isEntitySpawningEnabledForCurrentWorld()).thenReturn(true);
when(manager.managedTask(anyString(), any(Runnable.class), any(Runnable.class)))
.thenAnswer(invocation -> {
Runnable task = invocation.getArgument(1);
Runnable rejected = invocation.getArgument(2);
return (Runnable) () -> {
if (admitted.get()) {
task.run();
} else {
rejected.run();
}
};
});
IrisSettings settings = new IrisSettings();
settings.getWorld().setPostLoadBlockUpdates(true);
configured.when(IrisSettings::get).thenReturn(settings);
scheduling.when(() -> J.a(any(Runnable.class))).thenAnswer(invocation -> {
async.add(invocation.getArgument(0));
return null;
});
scheduling.when(() -> J.runRegion(eq(world), eq(2), eq(-1), any(Runnable.class)))
.thenAnswer(invocation -> {
if (!acceptRegion.get()) {
return false;
}
regions.add(invocation.getArgument(3));
return true;
});
updates.when(() -> EngineBukkitOps.updateChunk(engine, chunk, savedMantle)).thenAnswer(invocation -> {
assertTrue(scoped.get());
assertSame(currentMantle, engine.getMantle().getMantle());
etched.set(true);
return null;
});
}
private void schedule() {
maintenance.updateChunkRegion(world, 2, -1);
}
private void prepare() {
async.remove().run();
}
private void apply() {
regions.remove().run();
}
private void awaitRelease() throws InterruptedException {
assertTrue("Saved mantle handle was not released", released.tryAcquire(5L, TimeUnit.SECONDS));
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5L);
while (!pending.isEmpty() && System.nanoTime() < deadline) {
Thread.sleep(1L);
}
assertTrue("Saved mantle cleanup did not finish", pending.isEmpty());
}
@Override
public void close() {
maintenance.close();
logging.close();
updates.close();
scheduling.close();
configured.close();
}
}
}
@@ -73,7 +73,7 @@ public class WorldChunkMaintenanceSavedBiomeTest {
try (MockedStatic<IrisSettings> configured = mockStatic(IrisSettings.class);
MockedStatic<EngineBukkitOps> updates = mockStatic(EngineBukkitOps.class)) {
configured.when(IrisSettings::get).thenReturn(settings);
updates.when(() -> EngineBukkitOps.updateChunk(engine, chunk))
updates.when(() -> EngineBukkitOps.updateChunk(engine, chunk, mantle))
.thenThrow(failure).thenAnswer(invocation -> null);
if (loading) {
@@ -4,6 +4,8 @@ import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.history.BoundaryColumnGeometry;
import art.arcane.iris.engine.history.BoundaryGeometryInfluence;
import art.arcane.iris.engine.history.NativeTerrainReceipt;
import art.arcane.iris.engine.history.SavedTerrainChunk;
import art.arcane.iris.engine.history.TransitionGenerationPlan;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
@@ -20,6 +22,8 @@ import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
@@ -55,6 +59,34 @@ public class IrisTransitionGeometryActuatorTest {
}
}
@Test
public void boundaryReceiptRetainsCustomKeyAndCarrierPhysics() throws Exception {
String key = "itemsadder:rocks/ruby_ore";
PlatformBlockState carrier = state("minecraft:oak_slab[type=bottom,waterlogged=true]", false);
when(carrier.isWaterLogged()).thenReturn(true);
PlatformBlockState custom = state(key, false);
when(custom.isCustom()).thenReturn(true);
when(custom.placementBaseState()).thenReturn(carrier);
Hunk<PlatformBlockState> blocks = filled();
blocks.setRaw(0, 3, 0, custom);
Hunk<PlatformBiome> biomes = Hunk.newArrayHunk(16, 16, 16);
PlatformBiome biome = mock(PlatformBiome.class);
when(biome.key()).thenReturn("minecraft:plains");
biomes.fill(biome);
ChunkContext context = mock(ChunkContext.class);
when(context.getRoundedHeight(anyInt(), anyInt())).thenReturn(3);
SavedTerrainChunk captured = IrisTransitionGeometryActuator.capture(0, 0, blocks, biomes, 0, context, true);
byte[] encoded = NativeTerrainReceipt.encode(captured, 7, "epoch");
SavedTerrainChunk restored = NativeTerrainReceipt.decode(encoded, "minecraft:noise").terrain();
BoundaryColumnGeometry.Voxel voxel = restored.column(0, 0).geometry().voxelAt(3);
assertEquals(key, voxel.stateKey());
assertEquals(BoundaryColumnGeometry.Phase.SOLID, voxel.phase());
assertEquals("minecraft:water[level=0]", voxel.fluidStateKey());
assertTrue(voxel.protectedContent());
}
@Test
public void appliesCaveAndIslandGeometryThenUpdatesDecorationHeight() {
Hunk<PlatformBlockState> blocks = filled();
@@ -1,7 +1,9 @@
package art.arcane.iris.engine.history;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.pack.AtomicDirectoryPublisher;
import art.arcane.iris.engine.IrisEngine;
import art.arcane.iris.engine.IrisEngineMantle;
import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
@@ -17,6 +19,7 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -44,6 +47,7 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
@@ -180,6 +184,138 @@ public final class GenerationHistoryRuntimeRouterTest {
router.close();
}
@Test
public void savedChunkMantlesShareRecordedStorageWithoutLoadingHistoricalRuntimes() throws Exception {
Path world = temporaryFolder.newFolder("router-saved-chunk-world").toPath();
GenerationHistory history = createThreeActivationHistory(world, "router-saved-chunk");
int recordedChunks = history.explicitChunkCount();
IrisEngine engine = mock(IrisEngine.class);
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation());
when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active);
AtomicReference<IrisEngine.GenerationRuntimeBinding> scoped = installScopeTracking(engine);
EngineMantle activeEngineMantle = mock(EngineMantle.class);
Mantle<Matter> activeMantle = mock(Mantle.class);
when(engine.getMantle()).thenReturn(activeEngineMantle);
when(activeEngineMantle.getMantle()).thenReturn(activeMantle);
Map<File, IrisData> dataByPack = new HashMap<>();
Map<Path, Mantle<Matter>> storageByPath = new HashMap<>();
for (long activationId : List.of(1L, 2L)) {
dataByPack.put(history.packRoot(activationId).toFile(), mock(IrisData.class));
storageByPath.put(history.paths().activationMantleRoot(activationId), mock(Mantle.class));
}
try (MockedStatic<IrisData> data = mockStatic(IrisData.class);
MockedStatic<IrisEngineMantle> storage = mockStatic(IrisEngineMantle.class);
GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach(
engine, history, (ignored, x, z) -> signature(x, z), runtimes)) {
data.when(() -> IrisData.openRuntime(any(File.class)))
.thenAnswer(invocation -> dataByPack.get(invocation.getArgument(0, File.class)));
storage.when(() -> IrisEngineMantle.createMantle(any(), any(), any()))
.thenAnswer(invocation -> storageByPath.get(invocation.getArgument(1, Path.class)));
for (int chunkX = 0; chunkX < 3; chunkX++) {
long activationId = chunkX + 1L;
Mantle<Matter> expected = activationId == 3L ? activeMantle
: storageByPath.get(history.paths().activationMantleRoot(activationId));
try (GenerationHistoryRuntimeRouter.SavedChunkMantle first = router.openSavedChunkMantle(chunkX, 0)) {
assertSame(expected, first.mantle());
assertNull(scoped.get());
try (GenerationHistoryRuntimeRouter.SavedChunkMantle second = router.openSavedChunkMantle(chunkX, 0)) {
assertSame(first.mantle(), second.mantle());
}
verify(expected, never()).close();
}
if (activationId != 3L) {
verify(expected).close();
IrisData savedData = dataByPack.get(history.packRoot(activationId).toFile());
verify(savedData).bindGenerationRegistryContract(history.resolveEpoch(chunkX, 0).registryContract());
verify(savedData).registerEngine(engine);
verify(savedData).unregisterEngine(engine);
verify(savedData).close();
}
try (GenerationHistoryRuntimeRouter.RuntimeStage stage = router.openStage(chunkX, 0)) {
assertEquals(3L, stage.activation().activationId());
assertSame(active, scoped.get());
}
assertTrue(history.semantics(chunkX, 0).isEmpty());
}
storage.verify(() -> IrisEngineMantle.createMantle(any(), any(), any()), times(2));
assertEquals(recordedChunks, history.explicitChunkCount());
assertEquals(3L, history.activeActivation().activationId());
assertEquals(1L, history.resolveActivation(0, 0).activationId());
assertEquals(2L, history.resolveActivation(1, 0).activationId());
assertEquals(Set.of(3L), runtimes.bindings.keySet());
verify(activeMantle, never()).close();
verify(engine, never()).setDefaultGenerationRuntime(any());
}
}
@Test
public void savedChunkMantleOpensAfterItsGeneratorKernelIsNoLongerAvailable() throws Exception {
Path world = temporaryFolder.newFolder("router-saved-old-kernel-world").toPath();
Path pack = createPack("router-saved-old-kernel-pack", "alpha");
GenerationKernelRegistry.Version oldVersion = new GenerationKernelRegistry.Version(1, 1, 1);
GenerationKernelRegistry.Version currentVersion = new GenerationKernelRegistry.Version(2, 1, 1);
GenerationKernelRegistry kernels = kernels(currentVersion);
createHistory(world, pack, oldVersion, kernels);
writeRegion(Files.createDirectories(world.resolve("region")).resolve("r.0.0.mca"), new int[][]{{0, 0}});
GenerationHistory history = GenerationHistory.open(world,
new GenerationKernelRegistry(currentVersion, List.of(kernels.requireSupported(currentVersion))));
history.stageCurrentKernel(256);
promoteWithSignatures(history);
assertEquals(oldVersion, history.resolveEpoch(0, 0).kernelVersion());
IrisEngine engine = mock(IrisEngine.class);
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation());
when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active);
IrisData savedData = mock(IrisData.class);
Mantle<Matter> savedStorage = mock(Mantle.class);
try (MockedStatic<IrisData> data = mockStatic(IrisData.class);
MockedStatic<IrisEngineMantle> storage = mockStatic(IrisEngineMantle.class);
GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach(
engine, history, (ignored, x, z) -> signature(x, z), runtimes)) {
data.when(() -> IrisData.openRuntime(history.packRoot(1L).toFile())).thenReturn(savedData);
storage.when(() -> IrisEngineMantle.createMantle(any(), any(), any())).thenReturn(savedStorage);
try (GenerationHistoryRuntimeRouter.SavedChunkMantle saved = router.openSavedChunkMantle(0, 0)) {
assertSame(savedStorage, saved.mantle());
}
verify(savedStorage).close();
storage.verify(() -> IrisEngineMantle.createMantle(
any(), eq(history.paths().activationMantleRoot(1L)), any()));
assertEquals(Set.of(2L), runtimes.bindings.keySet());
assertEquals(0, runtimes.loadCount(1L));
verify(engine, never()).openGenerationRuntimeScope(any());
}
}
@Test
public void savedChunkScopeRejectsReentrantCloseAndCutoverAfterThreadHandoff() throws Exception {
Path world = temporaryFolder.newFolder("router-saved-scope-world").toPath();
GenerationHistory history = createHistory(world, createPack("router-saved-scope-pack", "alpha"));
IrisEngine engine = mock(IrisEngine.class);
when(engine.isStudio()).thenReturn(true);
FakeRuntimeFactory runtimes = new FakeRuntimeFactory();
IrisEngine.GenerationRuntimeBinding active = runtimes.binding(history, history.activeActivation());
when(engine.getActiveGenerationRuntimeBinding()).thenReturn(active);
AtomicReference<IrisEngine.GenerationRuntimeBinding> scoped = installScopeTracking(engine);
EngineMantle engineMantle = mock(EngineMantle.class);
when(engine.getMantle()).thenReturn(engineMantle);
when(engineMantle.getMantle()).thenReturn(mock(Mantle.class));
try (GenerationHistoryRuntimeRouter router = GenerationHistoryRuntimeRouter.attach(
engine, history, (ignored, x, z) -> signature(x, z), runtimes);
GenerationHistoryRuntimeRouter.SavedChunkMantle saved = router.openSavedChunkMantle(0, 0)) {
saved.detachThread();
try (GenerationHistoryRuntimeRouter.SavedChunkMantle.Scope ignored = saved.openScope()) {
assertNull(scoped.get());
assertThrows(IllegalStateException.class, router::close);
assertThrows(IllegalStateException.class, saved::close);
assertThrows(IllegalStateException.class, () -> router.beginStudioCutover(1L));
assertThrows(IllegalStateException.class,
() -> engine.getGenerationSessions().transitionGate().beginTransition(1L));
}
}
}
@Test
public void historicalTerrainRoutesWorkWhenFrozenPackFixturesAreMissing() throws Exception {
Path world = temporaryFolder.newFolder("router-archived-world").toPath();
@@ -89,6 +89,7 @@ public class BukkitChunkGeneratorCloseTest {
private CloseFixture() throws Exception {
setField(generator, "closeFuture", new AtomicReference<CompletableFuture<Void>>());
setField(generator, "startupReady", CompletableFuture.completedFuture(null));
setField(generator, "engine", engine);
setField(generator, "folder", mock(ReactiveFolder.class));
setField(generator, "populators", new KList<BlockPopulator>());
@@ -0,0 +1,139 @@
package art.arcane.iris.engine.platform;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.EngineTarget;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.util.common.plugin.VolmitPlugin;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.volmlib.util.bukkit.WorldIdentity;
import org.bukkit.Bukkit;
import org.bukkit.HeightMap;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.event.world.WorldInitEvent;
import org.bukkit.generator.WorldInfo;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class BukkitChunkGeneratorStartupContentTest {
@Test(timeout = 1000L)
public void pendingContentRejectsGenerationWithoutWaitingOnTheServerThread() {
CompletableFuture<Void> ready = new CompletableFuture<>();
assertThrows(IllegalStateException.class,
() -> BukkitChunkGenerator.requireStartupContentReady(ready, true));
assertFalse(ready.isDone());
ready.complete(null);
assertThrows(IllegalStateException.class,
() -> BukkitChunkGenerator.requireStartupContentReady(ready, true));
BukkitChunkGenerator.requireStartupContentReady(ready, false);
}
@Test
public void failedContentNeverAdmitsGeneration() {
IllegalStateException failure = new IllegalStateException("Required provider is unavailable");
CompletableFuture<Void> ready = CompletableFuture.failedFuture(failure);
CompletionException rejected = assertThrows(CompletionException.class,
() -> BukkitChunkGenerator.requireStartupContentReady(ready, false));
assertSame(failure, rejected.getCause());
}
@Test
public void failedInitializationRejectsTerrainEvenAfterContentBecomesReady() throws Exception {
BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
IllegalStateException failure = new IllegalStateException("Generator injection failed");
setField(generator, "startupContentReady", CompletableFuture.completedFuture(null));
setField(generator, "startupReady", CompletableFuture.failedFuture(failure));
CompletionException rejected = assertThrows(CompletionException.class,
() -> generator.getBaseHeight(mock(WorldInfo.class), new Random(1L), 0, 0, HeightMap.WORLD_SURFACE));
assertSame(failure, rejected.getCause());
verify(generator, never()).getTarget();
}
@Test
public void pendingWorldExposesItsStemTargetAndFailsReadinessOnProviderTimeout() throws Exception {
BukkitChunkGenerator generator = mock(BukkitChunkGenerator.class, CALLS_REAL_METHODS);
IrisWorld irisWorld = mock(IrisWorld.class);
World world = mock(World.class);
EngineTarget target = mock(EngineTarget.class);
IrisData data = mock(IrisData.class);
VolmitPlugin plugin = mock(VolmitPlugin.class);
CompletableFuture<Void> contentReady = new CompletableFuture<>();
List<Runnable> tasks = new ArrayList<>();
when(irisWorld.identity()).thenReturn("iris:test");
when(irisWorld.name()).thenReturn("world_iris_test");
when(world.getGenerator()).thenReturn(generator);
when(target.getData()).thenReturn(data);
setField(generator, "world", irisWorld);
setField(generator, "setup", new AtomicBoolean());
setField(generator, "lock", new ReentrantLock());
setField(generator, "spawnChunks", new CompletableFuture<Integer>());
setField(generator, "initialSpawnReady", new CompletableFuture<Void>());
generator.deferStartup(target, contentReady);
assertSame(target, generator.getTarget());
generator.touch(world);
try (MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
MockedStatic<WorldIdentity> identity = mockStatic(WorldIdentity.class);
MockedStatic<IrisLogging> logging = mockStatic(IrisLogging.class);
MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
platform.when(BukkitPlatform::volmitPlugin).thenReturn(plugin);
identity.when(() -> WorldIdentity.key(world)).thenReturn(NamespacedKey.fromString("iris:test"));
scheduling.when(() -> J.s(any(Runnable.class))).thenAnswer(invocation -> {
tasks.add(invocation.getArgument(0, Runnable.class));
return null;
});
generator.onWorldInit(new WorldInitEvent(world));
assertTrue(tasks.isEmpty());
TimeoutException failure = new TimeoutException("Provider startup expired");
contentReady.completeExceptionally(failure);
assertFalse(generator.getStartupReady().isDone());
assertEquals(1, tasks.size());
tasks.removeFirst().run();
assertTrue(generator.getStartupReady().isCompletedExceptionally());
assertTrue(generator.getInitialSpawnReady().isCompletedExceptionally());
assertSame(failure, generator.getInitializationFailure());
generator.touch(world);
verify(data).close();
bukkit.verify(Bukkit::shutdown);
}
}
private static void setField(BukkitChunkGenerator generator, String name, Object value) throws Exception {
Field field = BukkitChunkGenerator.class.getDeclaredField(name);
field.setAccessible(true);
field.set(generator, value);
}
}
@@ -19,6 +19,7 @@ import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
@@ -202,6 +203,8 @@ public class BukkitChunkGeneratorStudioSelectionTest {
field(generator, "studio", true);
field(generator, "world", world);
field(generator, "setup", setup);
field(generator, "startupContentReady", CompletableFuture.completedFuture(null));
field(generator, "startupReady", CompletableFuture.completedFuture(null));
field(generator, "lock", new ReentrantLock());
field(generator, "targetCache", new AtomicCache<EngineTarget>());
field(generator, "populators", new KList<>());
@@ -2,6 +2,7 @@ package art.arcane.iris.engine.platform;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.core.service.ExternalDataSVC;
import art.arcane.iris.engine.IrisEngineMantle;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineMetrics;
@@ -9,12 +10,15 @@ import art.arcane.iris.engine.history.SavedBiomeUnavailableException;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisServices;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.util.common.scheduling.J;
import art.arcane.iris.util.project.matter.TileWrapper;
import art.arcane.iris.util.project.matter.slices.PreObjectMatterTest;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.flag.MantleFlag;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
@@ -23,10 +27,12 @@ import art.arcane.volmlib.util.matter.MatterUpdate;
import art.arcane.volmlib.util.matter.slices.UpdateMatter;
import org.bukkit.Chunk;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.junit.Test;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
@@ -41,16 +47,58 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
public class EngineBukkitOpsDeferredMaterializationTest {
@Test
@SuppressWarnings("unchecked")
public void savedMantlePlacementUsesCurrentProviderWithoutReadingCurrentMantle() {
Mantle<Matter> saved = mock(Mantle.class);
MantleDataAdapter<Matter> adapter = mock(MantleDataAdapter.class);
MantleChunk<Matter> stored = spy(new MantleChunk<>(1, 0, 0, adapter, MantleHooks.NONE));
Identifier identifier = Identifier.fromString("itemsadder:forest/amber_ore");
doAnswer(invocation -> {
Consumer4<Integer, Integer, Integer, Identifier> iterator = invocation.getArgument(1);
iterator.accept(1, 2, 3, identifier);
return null;
}).when(stored).iterate(eq(Identifier.class), any());
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(mock(IrisWorld.class));
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
World world = mock(World.class);
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(world.getMaxHeight()).thenReturn(16);
Chunk chunk = mock(Chunk.class);
when(chunk.getWorld()).thenReturn(world);
Block block = mock(Block.class);
when(chunk.getBlock(1, 2, 3)).thenReturn(block);
when(saved.isLoaded(chunk)).thenReturn(true);
when(saved.getChunk(chunk)).thenReturn(stored);
ExternalDataSVC provider = mock(ExternalDataSVC.class);
try (MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<IrisServices> services = mockStatic(IrisServices.class)) {
scheduling.when(J::isPrimaryThread).thenReturn(true);
services.when(() -> IrisServices.get(ExternalDataSVC.class)).thenReturn(provider);
EngineBukkitOps.updateChunk(engine, chunk, saved);
}
verify(provider).processUpdate(engine, block, identifier);
verify(engine, never()).getMantle();
verify(stored).deleteSlices(Identifier.class);
assertTrue(stored.isFlagged(MantleFlag.ETCHED));
}
@Test(timeout = 5000)
public void loadingRetryPreservesPendingUpdatesWithoutRepeatingCompletedUpdates() {
MantleChunk<Matter> mantleChunk = pendingUpdates();
@@ -18,11 +18,14 @@
package art.arcane.iris.platform.bukkit;
import art.arcane.iris.core.link.Identifier;
import art.arcane.iris.spi.IrisPlatform;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformBlockProperty;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.iris.spi.PlatformRegistries;
import art.arcane.iris.util.common.data.IrisCustomData;
import art.arcane.iris.util.project.matter.slices.PlatformBlockMatter;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Server;
@@ -34,6 +37,10 @@ import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.invocation.InvocationOnMock;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
@@ -120,6 +127,39 @@ public class BukkitSpiConformanceTest {
assertEquals("iristest:key_block[facing=north,lit=true]", BukkitBlockState.of(data).key());
}
@Test
public void customStateKeepsProviderIdentityAndProperties() throws Exception {
BlockData base = blockData("minecraft:note_block[instrument=harp,note=5,powered=false]");
Identifier identifier = Identifier.fromString("craftengine:forest/amber_log[axis=x]");
PlatformBlockState state = BukkitBlockState.of(IrisCustomData.of(base, identifier));
assertEquals(identifier.toString(), state.key());
assertEquals(identifier.toString(), state.deferredPlacementKey());
assertEquals("craftengine", state.namespace());
assertEquals("craftengine:forest/amber_log", state.materialKey());
assertEquals(base.getAsString(), state.placementBaseState().key());
assertTrue(state.isCustom());
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
new PlatformBlockMatter().writeNode(state, new DataOutputStream(bytes));
assertEquals(identifier.toString(), new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())).readUTF());
}
@Test
public void customCarrierPropertyChangeKeepsProviderPropertiesSeparate() {
BlockData base = blockData("minecraft:oak_slab[type=bottom,waterlogged=false]");
Identifier identifier = Identifier.fromString("itemsadder:forest/amber_slab");
PlatformBlockState state = BukkitBlockState.of(IrisCustomData.of(base, identifier));
PlatformBlockState merged = state.withProperty("waterlogged", "true");
assertEquals(identifier.toString(), merged.key());
assertEquals(identifier.toString(), merged.deferredPlacementKey());
assertEquals("minecraft:oak_slab[type=bottom,waterlogged=true]", merged.placementBaseState().key());
assertEquals("minecraft:oak_slab[type=bottom,waterlogged=false]", state.placementBaseState().key());
assertTrue(merged.isCustom());
}
@Test
public void blockStateNamespaceParsesWithAndWithoutProperties() {
BlockData withProps = blockData("iristest:ns_block[axis=y]");
+2
View File
@@ -35,6 +35,7 @@ jaxen = "2.0.6" # https://central.sonatype.com/artifact/jaxen/jaxen
# Third Party Integrations
nexo = "1.25.0" # https://repo.nexomc.com/#/releases/com/nexomc/nexo
oraxen = "1.218.0"
itemsadder = "4.0.10" # https://github.com/LoneDev6/API-ItemsAdder
placeholderApi = "2.11.6" # https://repo.extendedclip.com/#/releases/me/clip/placeholderapi
score = "5.25.3.9" # https://github.com/Ssomar-Developement/SCore
@@ -95,6 +96,7 @@ jaxen = { module = "jaxen:jaxen", version.ref = "jaxen" }
# Third Party Integrations
nexo = { module = "com.nexomc:nexo", version.ref = "nexo" }
oraxen = { module = "io.th0rgal:oraxen", version.ref = "oraxen" }
itemsadder = { module = "dev.lone:api-itemsadder", version.ref = "itemsadder" }
placeholderApi = { module = "me.clip:placeholderapi", version.ref = "placeholderApi" }
score = { module = "com.github.Ssomar-Developement:SCore", version.ref = "score" }