diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java index 7e0946386..a9f94b0df 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/VanillaStructureBiomes.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.nms.v26_2_R1; +import art.arcane.iris.nativegen.NativeStructureGenerationKeys; import net.minecraft.core.Holder; import net.minecraft.core.Registry; import net.minecraft.core.RegistryAccess; @@ -12,7 +13,6 @@ import net.minecraft.world.level.biome.BiomeSource; import net.minecraft.world.level.levelgen.structure.Structure; import java.util.LinkedHashSet; -import java.util.Map; import java.util.Optional; import java.util.Set; @@ -74,18 +74,7 @@ final class VanillaStructureBiomes { if (level == null) { throw new IllegalStateException("Minecraft server level is unavailable"); } - Set reachable = new LinkedHashSet<>(); Set possible = possibleBiomeKeys(source); - Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); - for (Map.Entry, Structure> entry : registry.entrySet()) { - for (Holder holder : entry.getValue().biomes()) { - Optional> key = holder.unwrapKey(); - if (key.isPresent() && possible.contains(key.get().identifier().toString())) { - reachable.add(entry.getKey().identifier().toString()); - break; - } - } - } - return reachable; + return NativeStructureGenerationKeys.reachable(level, possible); } } diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureGenerationKeysContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureGenerationKeysContractTest.java new file mode 100644 index 000000000..0519e6007 --- /dev/null +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/nativegen/NativeStructureGenerationKeysContractTest.java @@ -0,0 +1,60 @@ +package art.arcane.iris.nativegen; + +import net.minecraft.SharedConstants; +import net.minecraft.core.Vec3i; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadStructurePlacement; +import net.minecraft.world.level.levelgen.structure.placement.RandomSpreadType; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class NativeStructureGenerationKeysContractTest { + @BeforeClass + public static void bootstrapMinecraft() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + public void reachabilityUsesEnabledActiveStructureSetsWithoutLazyPlacementGeneration() throws IOException { + String source = Files.readString(Path.of( + System.getProperty("iris.nativeStructureGenerationKeysSource"))); + + assertTrue(source.contains("getWorldGenSettings().options().generateStructures()")); + assertTrue(source.contains("getGeneratorState().possibleStructureSets()")); + assertTrue(source.contains("isEnabledPlacement(structureSet.placement())")); + assertTrue(source.contains("StructurePlacement.class.getDeclaredMethods()")); + assertTrue(source.contains("entry.weight() <= 0")); + assertTrue(source.contains("hasPossibleBiome(entry.structure().value(), possibleBiomes)")); + assertFalse(source.contains("getPlacementsForStructure(")); + } + + @Test + public void frequencyGateUsesMappedAccessorAndRejectsNonPositivePlacements() { + assertFalse(NativeStructureGenerationKeys.isEnabledPlacement(placement(0.0F))); + assertFalse(NativeStructureGenerationKeys.isEnabledPlacement(placement(-0.1F))); + assertFalse(NativeStructureGenerationKeys.isEnabledPlacement(placement(Float.NaN))); + assertTrue(NativeStructureGenerationKeys.isEnabledPlacement(placement(0.25F))); + } + + private static RandomSpreadStructurePlacement placement(float frequency) { + return new RandomSpreadStructurePlacement( + Vec3i.ZERO, + StructurePlacement.FrequencyReductionMethod.DEFAULT, + frequency, + 1, + Optional.empty(), + 2, + 1, + RandomSpreadType.LINEAR); + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java index 14f04b483..a3577b6d8 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java @@ -19,17 +19,23 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; -import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.core.localization.BukkitCommandMessages; +import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; +import art.arcane.iris.core.localization.IrisLanguage; +import art.arcane.iris.core.datapack.DatapackIngestService; import art.arcane.iris.core.service.ObjectStudioSaveService; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; -import art.arcane.iris.engine.platform.EngineBukkitOps; import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import art.arcane.iris.engine.platform.EngineBukkitOps; +import art.arcane.iris.platform.bukkit.BukkitPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.iris.util.common.director.specialhandlers.ObjectHandler; import art.arcane.iris.util.common.director.specialhandlers.StructureHandler; @@ -40,6 +46,7 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.director.DirectorOrigin; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; +import art.arcane.volmlib.util.localization.MessageArgument; import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.NamespacedKey; @@ -49,10 +56,14 @@ import org.bukkit.entity.Player; import org.bukkit.generator.structure.Structure; import org.bukkit.util.StructureSearchResult; -import art.arcane.iris.core.localization.IrisLanguage; -import art.arcane.iris.core.localization.BukkitCommandMessages; -import art.arcane.volmlib.util.localization.MessageArgument; -import art.arcane.iris.core.localization.BukkitCommandMessagesExtended; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + @Director(name = "find", origin = DirectorOrigin.PLAYER, description = "Iris Find commands", descriptionKey = "iris.director.commandfind.director.iris_find_commands", aliases = "goto") public class CommandFind implements DirectorExecutor { @Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome") @@ -123,57 +134,66 @@ public class CommandFind implements DirectorExecutor { return; } - String structureKey = structure == null ? "" : structure.trim(); - Structure nativeStructure = resolveNativeStructure(structureKey); - boolean irisReplacement = false; - if (nativeStructure != null) { - IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve( - e, structureKey, false); - if (!decision.generate() - && decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS) { - commandSender.sendMessage(C.RED + NativeStructureGenerationPolicy.generationStatusMessage( - structureKey, decision.status())); - return; - } - irisReplacement = decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS; - if (irisReplacement && !IrisStructureLocator.hasNativePlacement(e, structureKey)) { - locateIrisStructure(e, structureKey, commandSender); - return; - } - } - - if (nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)) { - locateIrisStructure(e, structureKey, commandSender); - return; - } - - if (nativeStructure == null) { - commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_UNKNOWN_STRUCTURE, MessageArgument.untrusted("structureKey", structureKey))); - return; - } - final boolean replacementLocate = irisReplacement; - final boolean explicitNativePlacement = IrisStructureLocator.hasNativePlacement( - e, structureKey); - Player target = player(); if (target == null) { commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_RUN_THIS_GAME_TELEPORT_STRUCTURE)); return; } + String structureKey = structure == null ? "" : structure.trim(); + Structure nativeStructure = resolveNativeStructure(structureKey); + boolean registered = nativeStructure != null; + IrisNativeStructureDecision decision = registered + ? NativeStructureGenerationPolicy.resolve(e, structureKey, false) + : null; + boolean nativePlacement = IrisStructureLocator.hasNativePlacement(e, structureKey); + boolean locatableNativePlacement = IrisStructureLocator.hasLocatableNativePlacement(e, structureKey); + boolean locatableEditablePlacement = IrisStructureLocator.hasLocatableEditablePlacement(e, structureKey); World targetWorld = target.getWorld(); + boolean nativeGenerationEnabled = targetWorld.canGenerateStructures(); + boolean requiresReachability = registered && decision.generate() + && decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS + && nativeGenerationEnabled; + boolean reachable = !requiresReachability || StructureReachability.isReachable(e, structureKey); + StructureLookupRoute route = selectStructureLookupRoute( + registered, decision, nativePlacement, locatableNativePlacement, + locatableEditablePlacement, nativeGenerationEnabled, reachable); + + if (route == StructureLookupRoute.UNKNOWN) { + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_UNKNOWN_STRUCTURE, MessageArgument.untrusted("structureKey", structureKey))); + return; + } + if (route == StructureLookupRoute.POLICY_DISABLED) { + commandSender.sendMessage(C.RED + NativeStructureGenerationPolicy.generationStatusMessage( + structureKey, decision.status())); + return; + } + if (route == StructureLookupRoute.NO_ACTIVE_PLACEMENT) { + commandSender.sendMessage(C.YELLOW + structureKey + + " has no active placement in this world."); + return; + } + if (route == StructureLookupRoute.WORLD_DISABLED) { + commandSender.sendMessage(C.YELLOW + structureKey + + " cannot generate because native structure generation is disabled for this world."); + return; + } + if (route == StructureLookupRoute.UNREACHABLE) { + KList miss = StructureReachability.missingBiomeKeys(e, structureKey); + commandSender.sendMessage(C.YELLOW + structureKey + + " cannot generate in this world (its required biomes are not produced by this pack" + + (miss.isEmpty() ? "" : ": needs " + String.join("/", miss)) + ")."); + return; + } + if (route == StructureLookupRoute.IRIS) { + locateIrisStructure(e, structureKey, commandSender); + return; + } + Location origin = target.getLocation(); commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_LOCATING, MessageArgument.untrusted("structureKey", structureKey))); J.s(() -> { try { - if (!replacementLocate && !explicitNativePlacement - && !StructureReachability.isReachable(e, structureKey)) { - KList miss = StructureReachability.missingBiomeKeys(e, structureKey); - sendStructureMessage(target, commandSender, - C.YELLOW + structureKey + " cannot generate in this world (its required biomes are not produced by this pack" - + (miss.isEmpty() ? "" : ": needs " + String.join("/", miss)) + ")."); - return; - } StructureSearchResult result = targetWorld.locateNearestStructure( origin, nativeStructure, 100, false); if (result == null || result.getLocation() == null) { @@ -191,6 +211,365 @@ public class CommandFind implements DirectorExecutor { }); } + @Director(description = "Print every structure excluded from /iris goto and its rejection reason to the server console", sync = true) + public void unregistered() { + VolmitSender commandSender = sender(); + if (commandSender == null) { + Iris.reportError("Structure exclusion report started without a command sender context.", + new IllegalStateException("Missing command sender context")); + return; + } + Engine activeEngine = engine(); + if (activeEngine == null) { + commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessages.COMMAND_FIND_NOT_IRIS_WORLD)); + return; + } + Player target = player(); + if (target == null) { + commandSender.sendMessage(C.RED + "Run this command from the Iris world to inspect its structures."); + return; + } + + StructureExclusionReport snapshot; + try { + snapshot = collectStructureExclusions( + activeEngine, target.getWorld().canGenerateStructures()); + } catch (Throwable error) { + commandSender.sendMessage(C.RED + "Could not build the structure exclusion report; see the server console."); + Iris.reportError("Could not snapshot /iris goto unregistered report for world '" + + target.getWorld().getName() + "'.", error); + return; + } + + String worldName = target.getWorld().getName(); + J.a(() -> { + try { + StructureExclusionReport report = includeManagedDatapackStructures( + snapshot, DatapackIngestService.installed()); + printStructureExclusionReport(worldName, report); + sendStructureMessage(target, commandSender, C.GREEN + "Printed " + report.entries().size() + + " non-generating structure candidate(s) and their reasons to the server console. " + + "This was an eligibility check; no chunks were searched."); + } catch (Throwable error) { + sendStructureMessage(target, commandSender, + C.RED + "Could not finish the structure exclusion report; see the server console."); + Iris.reportError("Could not finish /iris goto unregistered report for world '" + + worldName + "'.", error); + } + }); + } + + private static StructureExclusionReport collectStructureExclusions( + Engine engine, boolean nativeGenerationEnabled) { + PlatformStructureHooks structureHooks = IrisPlatforms.get().structureHooks(); + Map registeredKeys = distinctStructureKeys(structureHooks.structureKeys()); + Set reachableKeys = StructureReachability.reachableKeys(engine); + Set possibleBiomeKeys = normalizedStructureKeys( + structureHooks.possibleBiomeKeys(engine.getWorld().platformWorld())); + List exclusions = new ArrayList<>(); + int registeredExcluded = 0; + + for (Map.Entry entry : registeredKeys.entrySet()) { + String normalizedKey = entry.getKey(); + String key = entry.getValue(); + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, key, false); + boolean nativePlacement = IrisStructureLocator.hasNativePlacement(engine, key); + boolean locatableNativePlacement = IrisStructureLocator.hasLocatableNativePlacement(engine, key); + boolean locatableEditablePlacement = IrisStructureLocator.hasLocatableEditablePlacement(engine, key); + StructureLookupRoute route = selectStructureLookupRoute( + true, decision, nativePlacement, locatableNativePlacement, + locatableEditablePlacement, nativeGenerationEnabled, + reachableKeys.contains(normalizedKey)); + if (route == StructureLookupRoute.IRIS || route == StructureLookupRoute.NATIVE) { + continue; + } + List requiredBiomes = needsNativeReachabilityReason(route, decision) + ? structureHooks.structureBiomeKeys(key) + : List.of(); + exclusions.add(new StructureExclusion( + StructureExclusionKind.EXCLUDED, key, null, + describeStructureExclusion( + route, key, decision.status(), nativePlacement, + requiredBiomes, possibleBiomeKeys))); + registeredExcluded++; + } + + int configuredUnregistered = 0; + Set configuredUnregisteredKeys = new LinkedHashSet<>(); + for (String configuredKey : IrisStructureLocator.placedKeys(engine)) { + String normalizedKey = normalizeStructureKey(configuredKey); + if (normalizedKey.isEmpty() + || registeredKeys.containsKey(normalizedKey) + || !IrisStructureLocator.hasNativePlacement(engine, configuredKey) + || !configuredUnregisteredKeys.add(normalizedKey)) { + continue; + } + exclusions.add(new StructureExclusion( + StructureExclusionKind.UNREGISTERED, configuredKey, null, + describeConfiguredUnregisteredNative( + IrisStructureLocator.hasLocatableNativePlacement(engine, configuredKey)))); + configuredUnregistered++; + } + + int editableUnplaced = 0; + Set unplacedEditableKeys = new LinkedHashSet<>(); + Set locatableEditableKeys = normalizedStructureKeys( + IrisStructureLocator.locatableEditableKeys(engine)); + for (String editableKey : engine.getData().getStructureLoader().getPossibleKeys()) { + String normalizedKey = normalizeStructureKey(editableKey); + if (!isUnplacedEditableCandidate( + normalizedKey, registeredKeys.keySet(), locatableEditableKeys, + IrisStructureLocator.hasNativePlacement(engine, editableKey)) + || !unplacedEditableKeys.add(normalizedKey)) { + continue; + } + boolean configuredPlacement = IrisStructureLocator.hasEditablePlacement(engine, editableKey); + String reason = configuredPlacement + ? "editable Iris structure has a configured placement, but no matching placement is active " + + "because its density is not positive or its Y band does not intersect this world" + : "editable Iris structure exists in this pack, but no structure placement references it"; + exclusions.add(new StructureExclusion( + StructureExclusionKind.UNPLACED, editableKey, null, reason)); + editableUnplaced++; + } + + Set configuredImportUrls = normalizedImportUrls( + engine.getDimension().getDatapackImports()); + return new StructureExclusionReport( + List.copyOf(exclusions), Set.copyOf(registeredKeys.keySet()), + configuredImportUrls, registeredExcluded, configuredUnregistered, + editableUnplaced, 0); + } + + static boolean isUnplacedEditableCandidate( + String normalizedKey, Set registeredKeys, + Set locatableEditableKeys, boolean nativePlacement) { + return normalizedKey != null + && !normalizedKey.isEmpty() + && !registeredKeys.contains(normalizedKey) + && !locatableEditableKeys.contains(normalizedKey) + && !nativePlacement; + } + + private static StructureExclusionReport includeManagedDatapackStructures( + StructureExclusionReport snapshot, List installedEntries) { + Map unregisteredStructures = new LinkedHashMap<>(); + for (DatapackIngestService.Entry entry : installedEntries) { + String importUrl = normalizeImportUrl(entry.url); + if (importUrl.isEmpty() || !snapshot.configuredImportUrls().contains(importUrl)) { + continue; + } + String sourceId = entry.id == null || entry.id.isBlank() ? "unknown" : entry.id.trim(); + List structureKeys = entry.structureKeys == null ? List.of() : entry.structureKeys; + for (String key : structureKeys) { + String normalizedKey = normalizeStructureKey(key); + if (normalizedKey.isEmpty() || snapshot.registeredKeys().contains(normalizedKey)) { + continue; + } + ManagedDatapackStructure managed = unregisteredStructures.computeIfAbsent( + normalizedKey, ignored -> new ManagedDatapackStructure(key.trim())); + managed.sourceIds().add(sourceId); + } + } + + List entries = new ArrayList<>(); + int configuredUnregistered = snapshot.configuredUnregistered(); + for (StructureExclusion exclusion : snapshot.entries()) { + boolean replacedByManagedSource = exclusion.kind() == StructureExclusionKind.UNREGISTERED + && unregisteredStructures.containsKey(normalizeStructureKey(exclusion.key())); + if (replacedByManagedSource) { + configuredUnregistered--; + continue; + } + entries.add(exclusion); + } + for (ManagedDatapackStructure managed : unregisteredStructures.values()) { + entries.add(new StructureExclusion( + StructureExclusionKind.UNREGISTERED, managed.key(), + String.join(",", managed.sourceIds()), + "declared by an Iris-managed datapack but absent from the live registry; " + + "restart, enablement, or datapack validation may be required")); + } + sortStructureExclusions(entries); + return new StructureExclusionReport( + List.copyOf(entries), snapshot.registeredKeys(), snapshot.configuredImportUrls(), + snapshot.registeredExcluded(), Math.max(0, configuredUnregistered), + snapshot.editableUnplaced(), unregisteredStructures.size()); + } + + private static void printStructureExclusionReport( + String worldName, StructureExclusionReport report) { + Iris.info("Iris goto unregistered report for world '%s': %d non-generating structure candidate(s).", + worldName, report.entries().size()); + for (StructureExclusion exclusion : report.entries()) { + String source = exclusion.source() == null + ? "" + : " (source " + exclusion.source() + ")"; + Iris.info("[%s] %s%s: %s", + exclusion.kind().label(), exclusion.key(), source, exclusion.reason()); + } + Iris.info("Iris goto unregistered summary: %d registered key(s) excluded, " + + "%d managed datapack key(s) unregistered, %d configured native key(s) unregistered, " + + "%d editable Iris structure(s) unplaced.", + report.registeredExcluded(), report.managedUnregistered(), + report.configuredUnregistered(), report.editableUnplaced()); + Iris.info("Eligibility report only; no chunks were searched and existing generated starts were not scanned."); + } + + private static boolean needsNativeReachabilityReason( + StructureLookupRoute route, IrisNativeStructureDecision decision) { + return route == StructureLookupRoute.UNREACHABLE + || route == StructureLookupRoute.NO_ACTIVE_PLACEMENT && decision.generate(); + } + + static String describeStructureExclusion( + StructureLookupRoute route, String key, NativeStructureGenerationStatus status, + boolean nativePlacement, List requiredBiomes, Set possibleBiomeKeys) { + return switch (route) { + case UNKNOWN -> "the key is not registered by the active server/datapack"; + case POLICY_DISABLED -> NativeStructureGenerationPolicy.generationStatusMessage(key, status); + case WORLD_DISABLED -> "native structure generation is disabled for this world"; + case UNREACHABLE -> nativeReachabilityReason(requiredBiomes, possibleBiomeKeys); + case NO_ACTIVE_PLACEMENT -> { + String placementType = nativePlacement + ? "configured nativeStructures placement" + : "configured Iris replacement"; + String reason = placementType + " is inactive: no matching placement has positive density " + + "when density-based and a Y band intersecting this world"; + if (status == NativeStructureGenerationStatus.GENERATE_NATIVE) { + reason += "; the native route is also inactive because " + + nativeReachabilityReason(requiredBiomes, possibleBiomeKeys); + } + yield reason; + } + case IRIS, NATIVE -> throw new IllegalArgumentException( + "Active structure route cannot be described as excluded: " + route); + }; + } + + static String nativeReachabilityReason( + List requiredBiomes, Set possibleBiomeKeys) { + if (requiredBiomes == null || requiredBiomes.isEmpty()) { + return "its resolved biome filter is empty"; + } + Set possible = possibleBiomeKeys == null ? Set.of() : possibleBiomeKeys; + for (String requiredBiome : requiredBiomes) { + if (possible.contains(normalizeStructureKey(requiredBiome))) { + return "no active positive-weight, positive-frequency structure-set entry includes it in this world"; + } + } + return "this pack does not produce any of its required biome(s): " + + String.join("/", requiredBiomes); + } + + static String describeConfiguredUnregisteredNative(boolean locatablePlacement) { + String reason = "configured in nativeStructures, but the key is not registered by the active " + + "server/datapack, so Minecraft cannot create its native structure start"; + return locatablePlacement + ? reason + : reason + "; its Iris placement is also inactive because no matching placement has positive " + + "density when density-based and a Y band intersecting this world"; + } + + private static Map distinctStructureKeys(List keys) { + Map distinctKeys = new LinkedHashMap<>(); + for (String key : keys) { + String normalizedKey = normalizeStructureKey(key); + if (!normalizedKey.isEmpty()) { + distinctKeys.putIfAbsent(normalizedKey, key.trim()); + } + } + return distinctKeys; + } + + private static Set normalizedStructureKeys(Iterable keys) { + Set normalizedKeys = new LinkedHashSet<>(); + if (keys == null) { + return normalizedKeys; + } + for (String key : keys) { + String normalizedKey = normalizeStructureKey(key); + if (!normalizedKey.isEmpty()) { + normalizedKeys.add(normalizedKey); + } + } + return normalizedKeys; + } + + private static Set normalizedImportUrls(Iterable urls) { + Set normalizedUrls = new LinkedHashSet<>(); + if (urls == null) { + return normalizedUrls; + } + for (String url : urls) { + String normalizedUrl = normalizeImportUrl(url); + if (!normalizedUrl.isEmpty()) { + normalizedUrls.add(normalizedUrl); + } + } + return Set.copyOf(normalizedUrls); + } + + private static String normalizeImportUrl(String url) { + return url == null ? "" : url.trim(); + } + + private static void sortStructureExclusions(List exclusions) { + exclusions.sort((left, right) -> { + int kindComparison = left.kind().compareTo(right.kind()); + return kindComparison == 0 + ? left.key().compareToIgnoreCase(right.key()) + : kindComparison; + }); + } + + private static String normalizeStructureKey(String key) { + return key == null ? "" : key.trim().toLowerCase(Locale.ROOT); + } + + static StructureLookupRoute selectStructureLookupRoute( + boolean registered, IrisNativeStructureDecision decision, boolean nativePlacement, + boolean locatableNativePlacement, boolean locatableEditablePlacement, + boolean nativeGenerationEnabled, boolean reachable) { + if (!registered) { + return !nativePlacement && locatableEditablePlacement + ? StructureLookupRoute.IRIS + : StructureLookupRoute.UNKNOWN; + } + if (decision == null) { + throw new IllegalArgumentException("Registered structure lookup requires a generation decision"); + } + if (!decision.generate() + && decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + return StructureLookupRoute.POLICY_DISABLED; + } + if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + if (!nativePlacement) { + return locatableEditablePlacement + ? StructureLookupRoute.IRIS + : StructureLookupRoute.NO_ACTIVE_PLACEMENT; + } + if (!nativeGenerationEnabled) { + return StructureLookupRoute.WORLD_DISABLED; + } + return locatableNativePlacement + ? StructureLookupRoute.NATIVE + : StructureLookupRoute.NO_ACTIVE_PLACEMENT; + } + if (!nativeGenerationEnabled) { + return StructureLookupRoute.WORLD_DISABLED; + } + if (nativePlacement && locatableNativePlacement) { + return StructureLookupRoute.NATIVE; + } + if (reachable) { + return StructureLookupRoute.NATIVE; + } + return nativePlacement + ? StructureLookupRoute.NO_ACTIVE_PLACEMENT + : StructureLookupRoute.UNREACHABLE; + } + private static Structure resolveNativeStructure(String structureKey) { Registry structureRegistry = Bukkit.getRegistry(Structure.class); if (structureRegistry == null) { @@ -314,4 +693,46 @@ public class CommandFind implements DirectorExecutor { private void sendStructureMessage(Player target, VolmitSender commandSender, String message) { J.runEntity(target, () -> commandSender.sendMessage(message)); } + + enum StructureLookupRoute { + UNKNOWN, + POLICY_DISABLED, + NO_ACTIVE_PLACEMENT, + WORLD_DISABLED, + UNREACHABLE, + IRIS, + NATIVE + } + + private enum StructureExclusionKind { + UNREGISTERED("unregistered"), + EXCLUDED("excluded"), + UNPLACED("unplaced"); + + private final String label; + + StructureExclusionKind(String label) { + this.label = label; + } + + private String label() { + return label; + } + } + + private record StructureExclusion( + StructureExclusionKind kind, String key, String source, String reason) { + } + + private record StructureExclusionReport( + List entries, Set registeredKeys, + Set configuredImportUrls, int registeredExcluded, + int configuredUnregistered, int editableUnplaced, int managedUnregistered) { + } + + private record ManagedDatapackStructure(String key, Set sourceIds) { + private ManagedDatapackStructure(String key) { + this(key, new LinkedHashSet<>()); + } + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java index 329a46763..56a3b6db9 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/IrisStructureLocateCommandContractTest.java @@ -1,12 +1,15 @@ package art.arcane.iris.core.commands; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import org.junit.Test; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.Set; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -25,32 +28,189 @@ public class IrisStructureLocateCommandContractTest { } @Test - public void findRoutesRegisteredReplacementThroughPersistedNativeLocate() throws IOException { + public void findRoutesOnlyActiveWorldStructures() throws IOException { String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource"))); int methodStart = source.indexOf("public void structure("); - int methodEnd = source.indexOf("private static Structure resolveNativeStructure", methodStart); + int methodEnd = source.indexOf("static StructureLookupRoute selectStructureLookupRoute", methodStart); String method = source.substring(methodStart, methodEnd); int nativeResolution = method.indexOf("resolveNativeStructure(structureKey)"); int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(", nativeResolution); - int replacementCheck = method.indexOf( - "decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); - int genericIrisLookup = method.indexOf( - "nativeStructure == null && IrisStructureLocator.isPlaced(e, structureKey)", replacementCheck); - int replacementLocate = method.indexOf("final boolean replacementLocate = irisReplacement", genericIrisLookup); - int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", genericIrisLookup); + int nativeLocatableCheck = method.indexOf( + "IrisStructureLocator.hasLocatableNativePlacement(e, structureKey)", policyResolution); + int editableLocatableCheck = method.indexOf( + "IrisStructureLocator.hasLocatableEditablePlacement(e, structureKey)", nativeLocatableCheck); + int worldGenerationCheck = method.indexOf("targetWorld.canGenerateStructures()", editableLocatableCheck); + int routeResolution = method.indexOf("selectStructureLookupRoute(", editableLocatableCheck); + int nativeLocate = method.indexOf("targetWorld.locateNearestStructure(", routeResolution); + assertTrue(nativeResolution >= 0); assertTrue(policyResolution > nativeResolution); - assertTrue(replacementCheck > policyResolution); - assertTrue(genericIrisLookup > replacementCheck); - assertTrue(replacementLocate > genericIrisLookup); - assertTrue(nativeLocate > policyResolution); - assertTrue(method.contains("irisReplacement && !IrisStructureLocator.hasNativePlacement")); - assertTrue(method.contains("!replacementLocate && !explicitNativePlacement")); - assertTrue(method.contains("&& !StructureReachability.isReachable")); - assertTrue(method.contains("decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS")); + assertTrue(nativeLocatableCheck > policyResolution); + assertTrue(editableLocatableCheck > nativeLocatableCheck); + assertTrue(worldGenerationCheck > editableLocatableCheck); + assertTrue(routeResolution > editableLocatableCheck); + assertTrue(nativeLocate > routeResolution); + assertFalse(method.contains("IrisStructureLocator.isPlaced(e, structureKey)")); assertFalse(method.contains("NativeStructureLocateCapability")); } + @Test + public void findRejectsUnregisteredNativeAndDormantPlacements() { + assertEquals(CommandFind.StructureLookupRoute.UNKNOWN, + CommandFind.selectStructureLookupRoute( + false, null, true, true, false, true, true)); + assertEquals(CommandFind.StructureLookupRoute.UNKNOWN, + CommandFind.selectStructureLookupRoute( + false, null, false, false, false, true, true)); + assertEquals(CommandFind.StructureLookupRoute.NO_ACTIVE_PLACEMENT, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.REPLACED_BY_IRIS), + false, false, false, true, true)); + assertEquals(CommandFind.StructureLookupRoute.NO_ACTIVE_PLACEMENT, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.GENERATE_NATIVE), + true, false, false, true, false)); + } + + @Test + public void findAllowsLocatableEditablePlacementsWithoutNativeGeneration() { + assertEquals(CommandFind.StructureLookupRoute.IRIS, + CommandFind.selectStructureLookupRoute( + false, null, false, false, true, false, false)); + assertEquals(CommandFind.StructureLookupRoute.IRIS, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.REPLACED_BY_IRIS), + false, false, true, false, false)); + } + + @Test + public void findRequiresWorldGenerationAndReachabilityForNativeRoutes() { + assertEquals(CommandFind.StructureLookupRoute.WORLD_DISABLED, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.REPLACED_BY_IRIS), + true, true, false, false, true)); + assertEquals(CommandFind.StructureLookupRoute.WORLD_DISABLED, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.GENERATE_NATIVE), + false, false, false, false, true)); + assertEquals(CommandFind.StructureLookupRoute.UNREACHABLE, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.GENERATE_NATIVE), + false, false, false, true, false)); + assertEquals(CommandFind.StructureLookupRoute.NATIVE, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.GENERATE_NATIVE), + true, true, false, true, false)); + assertEquals(CommandFind.StructureLookupRoute.NATIVE, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.GENERATE_NATIVE), + true, false, false, true, true)); + } + + @Test + public void findHonorsPackDisableBeforePlacementRouting() { + assertEquals(CommandFind.StructureLookupRoute.POLICY_DISABLED, + CommandFind.selectStructureLookupRoute( + true, decision(NativeStructureGenerationStatus.DISABLED_BY_PACK), + false, false, true, true, true)); + } + + @Test + public void unregisteredDiagnosticExplainsEveryRejectedRoute() { + assertEquals( + "Native structure minecraft:village is disabled by this dimension's importedStructures settings.", + CommandFind.describeStructureExclusion( + CommandFind.StructureLookupRoute.POLICY_DISABLED, "minecraft:village", + NativeStructureGenerationStatus.DISABLED_BY_PACK, + false, List.of(), Set.of())); + assertEquals( + "native structure generation is disabled for this world", + CommandFind.describeStructureExclusion( + CommandFind.StructureLookupRoute.WORLD_DISABLED, "minecraft:village", + NativeStructureGenerationStatus.GENERATE_NATIVE, + false, List.of(), Set.of())); + assertEquals( + "its resolved biome filter is empty", + CommandFind.describeStructureExclusion( + CommandFind.StructureLookupRoute.UNREACHABLE, "towns_and_towers:exclusive", + NativeStructureGenerationStatus.GENERATE_NATIVE, + false, List.of(), Set.of())); + assertEquals( + "this pack does not produce any of its required biome(s): terralith:alpine_grove/bwg:aspen_boreal", + CommandFind.describeStructureExclusion( + CommandFind.StructureLookupRoute.UNREACHABLE, "towns_and_towers:exclusive", + NativeStructureGenerationStatus.GENERATE_NATIVE, + false, List.of("terralith:alpine_grove", "bwg:aspen_boreal"), + Set.of("minecraft:plains"))); + assertEquals( + "no active positive-weight, positive-frequency structure-set entry includes it in this world", + CommandFind.describeStructureExclusion( + CommandFind.StructureLookupRoute.UNREACHABLE, "example:dormant", + NativeStructureGenerationStatus.GENERATE_NATIVE, + false, List.of("minecraft:plains"), Set.of("minecraft:plains"))); + assertEquals( + "no active positive-weight, positive-frequency structure-set entry includes it in this world", + CommandFind.describeStructureExclusion( + CommandFind.StructureLookupRoute.UNREACHABLE, "example:partial_overlap", + NativeStructureGenerationStatus.GENERATE_NATIVE, + false, List.of("minecraft:plains", "mod:absent"), Set.of("minecraft:plains"))); + } + + @Test + public void unregisteredDiagnosticExplainsInactiveAndMissingNativePlacements() { + assertEquals( + "configured Iris replacement is inactive: no matching placement has positive density when " + + "density-based and a Y band intersecting this world", + CommandFind.describeStructureExclusion( + CommandFind.StructureLookupRoute.NO_ACTIVE_PLACEMENT, "example:replacement", + NativeStructureGenerationStatus.REPLACED_BY_IRIS, + false, List.of(), Set.of())); + assertEquals( + "configured nativeStructures placement is inactive: no matching placement has positive density " + + "when density-based and a Y band intersecting this world; the native route is also " + + "inactive because its resolved biome filter is empty", + CommandFind.describeStructureExclusion( + CommandFind.StructureLookupRoute.NO_ACTIVE_PLACEMENT, "example:native", + NativeStructureGenerationStatus.GENERATE_NATIVE, + true, List.of(), Set.of())); + assertTrue(CommandFind.describeConfiguredUnregisteredNative(true) + .contains("key is not registered by the active server/datapack")); + assertFalse(CommandFind.describeConfiguredUnregisteredNative(true) + .contains("Iris placement is also inactive")); + assertTrue(CommandFind.describeConfiguredUnregisteredNative(false) + .contains("Iris placement is also inactive")); + } + + @Test + public void unplacedEditableDiagnosticDefersToRegistryAndNativeCategories() { + assertTrue(CommandFind.isUnplacedEditableCandidate( + "pack:editable", Set.of(), Set.of(), false)); + assertFalse(CommandFind.isUnplacedEditableCandidate( + "pack:editable", Set.of("pack:editable"), Set.of(), false)); + assertFalse(CommandFind.isUnplacedEditableCandidate( + "pack:editable", Set.of(), Set.of("pack:editable"), false)); + assertFalse(CommandFind.isUnplacedEditableCandidate( + "pack:editable", Set.of(), Set.of(), true)); + } + + @Test + public void unregisteredDiagnosticPrintsDetailsOnlyToConsole() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.commandFindSource"))); + int methodStart = source.indexOf("public void unregistered()"); + int methodEnd = source.indexOf("private static StructureExclusionReport", methodStart); + String method = source.substring(methodStart, methodEnd); + + assertTrue(methodStart >= 0); + assertTrue(source.contains("Iris.info(\"[%s] %s%s: %s\"")); + assertTrue(method.contains("non-generating structure candidate(s) and their reasons to the server console")); + assertTrue(method.contains("no chunks were searched")); + assertTrue(method.indexOf("DatapackIngestService.installed()") > method.indexOf("J.a(() ->")); + assertTrue(source.contains("engine.getDimension().getDatapackImports()")); + assertTrue(source.contains("snapshot.configuredImportUrls().contains(importUrl)")); + assertTrue(source.contains("engine.getData().getStructureLoader().getPossibleKeys()")); + assertFalse(method.contains("commandSender.sendMessage(exclusion")); + } + @Test public void findNativePolicyMessagesMatchModdedDiagnostics() { assertEquals( @@ -95,4 +255,9 @@ public class IrisStructureLocateCommandContractTest { assertTrue(reachabilityGuard > nativeRequirement); assertTrue(reachabilityLookup > reachabilityGuard); } + + private static IrisNativeStructureDecision decision(NativeStructureGenerationStatus status) { + return new IrisNativeStructureDecision( + status, 0, null, false, false, null, null); + } } diff --git a/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationKeys.java b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationKeys.java new file mode 100644 index 000000000..aa88abae9 --- /dev/null +++ b/adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationKeys.java @@ -0,0 +1,117 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.nativegen; + +import net.minecraft.core.Holder; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructureSet; +import net.minecraft.world.level.levelgen.structure.placement.StructurePlacement; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class NativeStructureGenerationKeys { + private static final Method FREQUENCY_METHOD = resolveFrequencyMethod(); + + private NativeStructureGenerationKeys() { + } + + public static Set reachable(ServerLevel level, Set possibleBiomeKeys) { + ServerLevel activeLevel = Objects.requireNonNull(level, "Native structure reachability requires a level"); + Set possibleBiomes = Objects.requireNonNull( + possibleBiomeKeys, "Native structure reachability requires possible biome keys"); + if (!activeLevel.getServer().getWorldGenSettings().options().generateStructures()) { + return Collections.emptySet(); + } + + Set keys = new LinkedHashSet<>(); + List> structureSets = + activeLevel.getChunkSource().getGeneratorState().possibleStructureSets(); + for (Holder structureSetHolder : structureSets) { + StructureSet structureSet = structureSetHolder.value(); + if (!isEnabledPlacement(structureSet.placement())) { + continue; + } + for (StructureSet.StructureSelectionEntry entry : structureSet.structures()) { + if (entry.weight() <= 0 || !hasPossibleBiome(entry.structure().value(), possibleBiomes)) { + continue; + } + Optional> key = entry.structure().unwrapKey(); + key.ifPresent(resourceKey -> keys.add(resourceKey.identifier().toString())); + } + } + return Collections.unmodifiableSet(keys); + } + + static boolean hasPossibleBiome(Structure structure, Set possibleBiomeKeys) { + for (Holder holder : structure.biomes()) { + Optional> key = holder.unwrapKey(); + if (key.isPresent() && possibleBiomeKeys.contains(key.get().identifier().toString())) { + return true; + } + } + return false; + } + + static boolean isEnabledPlacement(StructurePlacement placement) { + return placementFrequency(placement) > 0.0F; + } + + private static float placementFrequency(StructurePlacement placement) { + StructurePlacement activePlacement = Objects.requireNonNull( + placement, "Native structure placement frequency requires a placement"); + try { + return ((Float) FREQUENCY_METHOD.invoke(activePlacement)).floatValue(); + } catch (ReflectiveOperationException error) { + throw new IllegalStateException( + "Unable to read native structure placement frequency from " + + activePlacement.getClass().getName(), error); + } + } + + private static Method resolveFrequencyMethod() { + Method frequencyMethod = null; + for (Method method : StructurePlacement.class.getDeclaredMethods()) { + if (Modifier.isStatic(method.getModifiers()) + || method.getParameterCount() != 0 + || method.getReturnType() != float.class) { + continue; + } + if (frequencyMethod != null) { + throw new IllegalStateException( + "Native structure placement exposes multiple zero-argument float accessors"); + } + frequencyMethod = method; + } + if (frequencyMethod == null || !frequencyMethod.trySetAccessible()) { + throw new IllegalStateException( + "Native structure placement frequency accessor is unavailable"); + } + return frequencyMethod; + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java index e36bdc40e..a874ba674 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStructureHooks.java @@ -19,6 +19,7 @@ package art.arcane.iris.modded; import art.arcane.iris.nativegen.NativeStructureGenerationException; +import art.arcane.iris.nativegen.NativeStructureGenerationKeys; import art.arcane.iris.nativegen.NativeStructureFactory; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformStructureHooks; @@ -225,22 +226,15 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { @Override public List reachableStructureKeys(PlatformWorld world) { - List keys = new ArrayList<>(); ServerLevel level = requireLevel(world, "resolve reachable structures"); try { BiomeSource source = level.getChunkSource().getGenerator().getBiomeSource(); Set possibleBiomes = possibleBiomeKeys(source); - Registry registry = level.registryAccess().lookupOrThrow(Registries.STRUCTURE); - for (Map.Entry, Structure> entry : registry.entrySet()) { - if (hasPossibleBiome(entry.getValue(), possibleBiomes)) { - keys.add(entry.getKey().identifier().toString()); - } - } + return new ArrayList<>(NativeStructureGenerationKeys.reachable(level, possibleBiomes)); } catch (RuntimeException error) { throw new IllegalStateException("Iris failed to resolve reachable structures for modded level '" + level.dimension().identifier() + "'", error); } - return keys; } @Override @@ -401,16 +395,6 @@ public final class ModdedStructureHooks implements PlatformStructureHooks { return keys; } - private static boolean hasPossibleBiome(Structure structure, Set possibleBiomeKeys) { - for (Holder holder : structure.biomes()) { - Optional> key = holder.unwrapKey(); - if (key.isPresent() && possibleBiomeKeys.contains(key.get().identifier().toString())) { - return true; - } - } - return false; - } - private static ServerLevel level(PlatformWorld world) { return world instanceof ModdedPlatformWorld moddedWorld ? moddedWorld.level() : null; } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java index 6814fe948..a13fb0614 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandHelp.java @@ -54,6 +54,9 @@ import static art.arcane.iris.modded.command.ModdedCommandFeedback.USAGE_ICON; final class ModdedCommandHelp { private static final int PAGE_SIZE = 17; private static final int PAGE_BUTTON_WIDTH = 10; + private static final TextKey COMMAND_UNREGISTERED = TextKey.of( + "iris.modded.help.entry.command.unregistered", + "Print every structure excluded from goto completion and its exact reason to the server console"); private static final Map> SECTIONS = new LinkedHashMap<>(); static { @@ -98,6 +101,7 @@ final class ModdedCommandHelp { Entry.command("biome", "", ModdedHelpMessages.COMMAND_BIOME_FIND_AN_IRIS_BIOME), Entry.command("region", "", ModdedHelpMessages.COMMAND_REGION_FIND_AN_IRIS_REGION), Entry.command("object", "", ModdedHelpMessages.COMMAND_OBJECT_FIND_AN_OBJECT_PLACEMENT), + Entry.command("unregistered", "", COMMAND_UNREGISTERED), Entry.command("structure", "", ModdedHelpMessages.COMMAND_STRUCTURE_FIND_AN_IRIS_PLACED_OR_NATIVE_DATAPACK_STRUCTURE), Entry.command("poi", "", ModdedHelpMessages.COMMAND_POI_FIND_A_SUPPORTED_POINT_OF_INTEREST) )); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java index adc1e16fb..c4ca14844 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java @@ -21,6 +21,10 @@ package art.arcane.iris.modded.command; import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.ModdedEngineBootstrap; import art.arcane.iris.modded.ModdedServerLevels; @@ -41,11 +45,16 @@ import org.slf4j.LoggerFactory; import java.io.File; import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Map; import java.util.Set; +import java.util.TreeMap; import java.util.TreeSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; final class ModdedCommandSuggestions { static final SuggestionProvider BIOME_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestBiomeKeys(context, builder); @@ -103,22 +112,62 @@ final class ModdedCommandSuggestions { } static CompletableFuture suggestStructureKeys(CommandContext context, SuggestionsBuilder builder) { - ModdedCommandFeedback.tab(context.getSource()); + CommandSourceStack source = context.getSource(); + ModdedCommandFeedback.tab(source); try { - Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); - Collection irisKeys = engine == null ? List.of() : IrisStructureLocator.placedKeys(engine); - Registry registry = context.getSource().getServer().registryAccess().lookupOrThrow(Registries.STRUCTURE); - List nativeKeys = new ArrayList<>(registry.keySet().size()); - for (Identifier identifier : registry.keySet()) { - nativeKeys.add(identifier.toString()); + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null) { + return builder.buildFuture(); } - return SharedSuggestionProvider.suggest(combineStructureKeys(irisKeys, nativeKeys), builder); + boolean nativeGenerationEnabled = + source.getServer().getWorldGenSettings().options().generateStructures(); + Collection irisKeys = IrisStructureLocator.locatableEditableKeys(engine); + Set reachableNativeKeys = StructureReachability.reachableKeys(engine); + Registry registry = source.getServer().registryAccess().lookupOrThrow(Registries.STRUCTURE); + List nativeKeys = new ArrayList<>(registry.keySet().size()); + Set registeredKeys = new HashSet<>(registry.keySet().size()); + for (Identifier identifier : registry.keySet()) { + String key = identifier.toString(); + registeredKeys.add(normalizeKey(key)); + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, key, false); + boolean nativePlacement = IrisStructureLocator.hasNativePlacement(engine, key); + boolean locatableNativePlacement = nativePlacement + && IrisStructureLocator.hasLocatableNativePlacement(engine, key); + boolean locatableEditableReplacement = !nativePlacement + && decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS + && IrisStructureLocator.hasLocatableEditablePlacement(engine, key); + if (isEligibleRegisteredStructure(decision, nativePlacement, + locatableNativePlacement, locatableEditableReplacement, + reachableNativeKeys.contains(normalizeKey(key)), nativeGenerationEnabled)) { + nativeKeys.add(key); + } + } + Collection unregisteredIrisKeys = eligibleUnregisteredEditableKeys( + irisKeys, registeredKeys, + (String candidate) -> IrisStructureLocator.hasNativePlacement(engine, candidate)); + return SharedSuggestionProvider.suggest( + combineStructureKeys(unregisteredIrisKeys, nativeKeys), builder); } catch (Throwable e) { - warnTabFailure("structure keys", context.getSource(), e); + warnTabFailure("structure keys", source, e); } return builder.buildFuture(); } + static boolean isEligibleRegisteredStructure(IrisNativeStructureDecision decision, + boolean nativePlacement, + boolean locatableNativePlacement, + boolean locatableEditableReplacement, + boolean reachable, + boolean nativeGenerationEnabled) { + if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + return locatableEditableReplacement + || nativeGenerationEnabled && nativePlacement && locatableNativePlacement; + } + return nativeGenerationEnabled && decision.generate() + && (reachable || nativePlacement && locatableNativePlacement); + } + static void warnTabFailure(String suggestion, CommandSourceStack source, Throwable error) { String origin = tabOrigin(source); if (!REPORTED_TAB_FAILURES.add(suggestion + '|' + origin + '|' + error.getClass().getName())) { @@ -142,10 +191,38 @@ final class ModdedCommandSuggestions { } static List combineStructureKeys(Collection irisKeys, Collection nativeKeys) { - Set combined = new TreeSet<>(); - combined.addAll(irisKeys); - combined.addAll(nativeKeys); - return List.copyOf(combined); + Map combined = new TreeMap<>(); + addStructureKeys(combined, irisKeys); + addStructureKeys(combined, nativeKeys); + return List.copyOf(combined.values()); + } + + static List eligibleUnregisteredEditableKeys( + Collection irisKeys, Set normalizedRegisteredKeys, + Predicate nativePlacement) { + List filtered = new ArrayList<>(irisKeys.size()); + for (String key : irisKeys) { + String normalizedKey = normalizeKey(key); + if (!normalizedKey.isEmpty() + && !normalizedRegisteredKeys.contains(normalizedKey) + && !nativePlacement.test(key)) { + filtered.add(key.trim()); + } + } + return List.copyOf(filtered); + } + + private static void addStructureKeys(Map combined, Collection keys) { + for (String key : keys) { + String normalizedKey = normalizeKey(key); + if (!normalizedKey.isEmpty()) { + combined.putIfAbsent(normalizedKey, key.trim()); + } + } + } + + static String normalizeKey(String key) { + return key == null ? "" : key.trim().toLowerCase(Locale.ROOT); } private static CompletableFuture suggestPackNames(CommandContext context, SuggestionsBuilder builder) { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java index 4f973d69c..e1af70f5c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java @@ -242,6 +242,9 @@ final class ModdedCommandTree { .then(Commands.literal("object") .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.OBJECT_KEYS) .executes((CommandContext context) -> ModdedLocateCommands.gotoObject(context.getSource(), StringArgumentType.getString(context, "key"))))) + .then(Commands.literal("unregistered") + .executes((CommandContext context) -> + ModdedUnregisteredStructures.print(context.getSource()))) .then(Commands.literal("structure") .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.STRUCTURE_KEYS) .executes((CommandContext context) -> ModdedLocateCommands.gotoStructure(context.getSource(), StringArgumentType.getString(context, "key"))))) diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java index b48cfd2a7..5e40a83cf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java @@ -26,6 +26,7 @@ import art.arcane.iris.engine.framework.GenerationSessionLease; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.Locator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructureDecision; @@ -157,7 +158,8 @@ final class ModdedLocateCommands { } Optional resolved = resolveNativeStructure(source, level, engine, key); if (resolved.isEmpty()) { - if (IrisStructureLocator.isPlaced(engine, key)) { + if (!IrisStructureLocator.hasNativePlacement(engine, key) + && IrisStructureLocator.hasLocatableEditablePlacement(engine, key)) { locateIrisStructure(source, level, engine, player, key); return 1; } @@ -166,31 +168,74 @@ final class ModdedLocateCommands { } NativeStructureTarget target = resolved.get(); IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, target.key(), false); - if (!decision.generate() - && decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS) { - IrisModdedCommands.fail(source, NativeStructureGenerationPolicy.generationStatusMessage( - target.key(), decision.status())); + boolean nativeGenerationEnabled = + source.getServer().getWorldGenSettings().options().generateStructures(); + boolean nativePlacement = IrisStructureLocator.hasNativePlacement(engine, target.key()); + boolean locatableNativePlacement = nativePlacement + && IrisStructureLocator.hasLocatableNativePlacement(engine, target.key()); + boolean locatableEditableReplacement = !nativePlacement + && decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS + && IrisStructureLocator.hasLocatableEditablePlacement(engine, target.key()); + boolean reachable = StructureReachability.isReachable(engine, target.key()); + if (!ModdedCommandSuggestions.isEligibleRegisteredStructure( + decision, nativePlacement, locatableNativePlacement, locatableEditableReplacement, + reachable, nativeGenerationEnabled)) { + IrisModdedCommands.fail(source, registeredStructureUnavailableMessage( + target.key(), target.availability(), decision, + nativePlacement, locatableNativePlacement, + nativeGenerationEnabled, reachable)); return 0; } - if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { - if (!IrisStructureLocator.hasNativePlacement(engine, target.key())) { - locateIrisStructure(source, level, engine, player, target.key()); - return 1; - } - IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_SEARCHING_NATIVE_STRUCTURE_WITHIN_CHUNKS, MessageArgument.untrusted("value", target.key()), MessageArgument.untrusted("NATIVESTRUCTURELOCATERADIUS", NATIVE_STRUCTURE_LOCATE_RADIUS))); - runNativeStructureLocate(source, level, player, target); + if (locatableEditableReplacement) { + locateIrisStructure(source, level, engine, player, target.key()); return 1; } - if (!IrisStructureLocator.hasNativePlacement(engine, target.key()) - && target.availability() != NativeStructureAvailability.AVAILABLE) { - IrisModdedCommands.fail(source, nativeUnavailableMessage(target.key(), target.availability())); - return 0; - } IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_SEARCHING_NATIVE_STRUCTURE_WITHIN_CHUNKS, MessageArgument.untrusted("value", target.key()), MessageArgument.untrusted("NATIVESTRUCTURELOCATERADIUS", NATIVE_STRUCTURE_LOCATE_RADIUS))); runNativeStructureLocate(source, level, player, target); return 1; } + static String registeredStructureUnavailableMessage( + String key, NativeStructureAvailability nativeAvailability, + IrisNativeStructureDecision decision, + boolean nativePlacement, boolean locatableNativePlacement, + boolean nativeGenerationEnabled, boolean reachable) { + if (!decision.generate() + && decision.status() != NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + return NativeStructureGenerationPolicy.generationStatusMessage(key, decision.status()); + } + if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + if (nativePlacement && !nativeGenerationEnabled) { + return withInactiveNativePlacement( + nativeUnavailableMessage(key, NativeStructureAvailability.WORLD_DISABLED), + nativePlacement, locatableNativePlacement); + } + return "Iris replacement " + key + + " is configured, but every matching placement has non-positive density or a Y band " + + "outside this world's height range."; + } + if (!nativeGenerationEnabled) { + return withInactiveNativePlacement( + nativeUnavailableMessage(key, NativeStructureAvailability.WORLD_DISABLED), + nativePlacement, locatableNativePlacement); + } + NativeStructureAvailability availability = nativeAvailability; + if (availability == NativeStructureAvailability.AVAILABLE && !reachable) { + availability = NativeStructureAvailability.NO_PLACEMENT; + } + return withInactiveNativePlacement( + nativeUnavailableMessage(key, availability), nativePlacement, locatableNativePlacement); + } + + private static String withInactiveNativePlacement( + String reason, boolean nativePlacement, boolean locatableNativePlacement) { + if (!nativePlacement || locatableNativePlacement) { + return reason; + } + return reason + " A matching Iris nativeStructures placement is also configured, but has non-positive " + + "density or a Y band outside this world's height range."; + } + private static void locateIrisStructure(CommandSourceStack source, ServerLevel level, Engine engine, ServerPlayer player, String key) { MinecraftServer server = source.getServer(); @@ -311,7 +356,7 @@ final class ModdedLocateCommands { case AVAILABLE -> available++; case WORLD_DISABLED, FILTERED -> disabled++; case IRIS_SUPPRESSED -> suppressed++; - case BIOME_UNREACHABLE -> unreachableBiomes++; + case EMPTY_BIOME_FILTER, BIOME_UNREACHABLE -> unreachableBiomes++; case NO_PLACEMENT -> unsupported++; } } @@ -361,25 +406,28 @@ final class ModdedLocateCommands { return Optional.of(new NativeStructureTarget(key, holder.get(), availability)); } - private static NativeStructureAvailability nativeAvailability(CommandSourceStack source, ServerLevel level, - Engine engine, String key, - Holder.Reference holder) { + static NativeStructureAvailability nativeAvailability(CommandSourceStack source, ServerLevel level, + Engine engine, String key, + Holder.Reference holder) { boolean worldEnabled = source.getServer().getWorldGenSettings().options().generateStructures(); IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, key, false); boolean selected = decision.status() != NativeStructureGenerationStatus.DISABLED_BY_PACK; boolean suppressed = decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS; + boolean biomeFilterEmpty = holder.value().biomes().stream().findAny().isEmpty(); ChunkGenerator chunkGenerator = level.getChunkSource().getGenerator(); boolean biomeReachable = chunkGenerator instanceof IrisModdedChunkGenerator irisGenerator && irisGenerator.isNativeStructureReachable(holder); boolean hasPlacement = false; - if (worldEnabled && selected && !suppressed && biomeReachable) { + if (worldEnabled && selected && !suppressed && !biomeFilterEmpty && biomeReachable) { hasPlacement = !level.getChunkSource().getGeneratorState().getPlacementsForStructure(holder).isEmpty(); } - return classifyNativeAvailability(worldEnabled, selected, suppressed, biomeReachable, hasPlacement); + return classifyNativeAvailability( + worldEnabled, selected, suppressed, biomeFilterEmpty, biomeReachable, hasPlacement); } static NativeStructureAvailability classifyNativeAvailability(boolean worldEnabled, boolean selected, - boolean suppressed, boolean biomeReachable, + boolean suppressed, boolean biomeFilterEmpty, + boolean biomeReachable, boolean hasPlacement) { if (!worldEnabled) { return NativeStructureAvailability.WORLD_DISABLED; @@ -390,6 +438,9 @@ final class ModdedLocateCommands { if (suppressed) { return NativeStructureAvailability.IRIS_SUPPRESSED; } + if (biomeFilterEmpty) { + return NativeStructureAvailability.EMPTY_BIOME_FILTER; + } if (!biomeReachable) { return NativeStructureAvailability.BIOME_UNREACHABLE; } @@ -399,15 +450,19 @@ final class ModdedLocateCommands { return NativeStructureAvailability.AVAILABLE; } - private static String nativeUnavailableMessage(String key, NativeStructureAvailability availability) { + static String nativeUnavailableMessage(String key, NativeStructureAvailability availability) { return switch (availability) { case WORLD_DISABLED -> "Native structure generation is disabled for this world, so " + key + " cannot generate or be located."; case FILTERED -> NativeStructureGenerationPolicy.generationStatusMessage( key, NativeStructureGenerationStatus.DISABLED_BY_PACK); case IRIS_SUPPRESSED -> NativeStructureGenerationPolicy.generationStatusMessage( key, NativeStructureGenerationStatus.REPLACED_BY_IRIS); + case EMPTY_BIOME_FILTER -> "Native structure " + key + + " has a biome tag or filter that resolves to zero registered biomes."; case BIOME_UNREACHABLE -> "Native structure " + key + " cannot generate because none of its required biomes are produced by this Iris pack."; - case NO_PLACEMENT -> "Native structure " + key + " is registered, but its structure set has no placement supported by this dimension's generator state."; + case NO_PLACEMENT -> "Native structure " + key + + " is registered and biome-compatible, but has no active positive-weight, " + + "positive-frequency structure-set placement in this dimension's generator state."; case AVAILABLE -> "Native structure " + key + " is available."; }; } @@ -531,6 +586,7 @@ final class ModdedLocateCommands { WORLD_DISABLED, FILTERED, IRIS_SUPPRESSED, + EMPTY_BIOME_FILTER, BIOME_UNREACHABLE, NO_PLACEMENT } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedUnregisteredStructures.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedUnregisteredStructures.java new file mode 100644 index 000000000..584caff0d --- /dev/null +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedUnregisteredStructures.java @@ -0,0 +1,204 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.modded.command; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.core.Holder; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.levelgen.structure.Structure; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.Predicate; + +final class ModdedUnregisteredStructures { + private static final Logger LOGGER = LoggerFactory.getLogger("Iris"); + + private ModdedUnregisteredStructures() { + } + + static int print(CommandSourceStack source) { + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null) { + IrisModdedCommands.fail(source, + "This dimension is not generated by Iris, so it has no Iris structure eligibility report."); + return 0; + } + try { + List excluded = collect(source, level, engine); + String dimension = level.dimension().identifier().toString(); + long unregistered = excluded.stream() + .filter((ExcludedStructure entry) -> entry.status() == ReportStatus.UNREGISTERED) + .count(); + long unplaced = excluded.stream() + .filter((ExcludedStructure entry) -> entry.status() == ReportStatus.UNPLACED) + .count(); + long hidden = excluded.size() - unregistered - unplaced; + LOGGER.info("[Iris goto unregistered] {} structure candidate(s) excluded from /iris goto structure in {}", + excluded.size(), dimension); + for (ExcludedStructure entry : excluded) { + LOGGER.info("[Iris goto unregistered] [{}] {} - {}", + entry.status().label(), entry.key(), entry.reason()); + } + LOGGER.info("[Iris goto unregistered] Inventory scope is the live registry, this pack's " + + "nativeStructures placements, and structureLoader editable resources. This is deterministic " + + "eligibility analysis and performs no chunk search; absent unmanaged datapack resources " + + "cannot be inferred after registry loading."); + IrisModdedCommands.ok(source, "Iris wrote " + excluded.size() + " structure candidate(s) for " + + dimension + " to the server console (" + hidden + " excluded, " + + unregistered + " unregistered, " + unplaced + " unplaced)."); + return 1; + } catch (Throwable error) { + LOGGER.error("Iris failed to build the excluded structure report for {}", + level.dimension().identifier(), error); + IrisModdedCommands.fail(source, + "Iris could not build the excluded structure report; see the server console."); + return 0; + } + } + + static List collect(CommandSourceStack source, ServerLevel level, Engine engine) { + boolean nativeGenerationEnabled = + source.getServer().getWorldGenSettings().options().generateStructures(); + Set reachableNativeKeys = StructureReachability.reachableKeys(engine); + Registry registry = source.getServer().registryAccess().lookupOrThrow(Registries.STRUCTURE); + Map excluded = new TreeMap<>(); + Set registeredKeys = new HashSet<>(registry.keySet().size()); + Set eligibleRegisteredKeys = new HashSet<>(registry.keySet().size()); + for (Identifier identifier : registry.keySet()) { + String key = identifier.toString(); + String normalizedKey = ModdedCommandSuggestions.normalizeKey(key); + registeredKeys.add(normalizedKey); + Optional> holder = registry.get(identifier); + if (holder.isEmpty()) { + excluded.put(normalizedKey, new ExcludedStructure(ReportStatus.EXCLUDED, + key, "The live structure registry contains this key but has no bound structure holder.")); + continue; + } + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, key, false); + boolean nativePlacement = IrisStructureLocator.hasNativePlacement(engine, key); + boolean locatableNativePlacement = nativePlacement + && IrisStructureLocator.hasLocatableNativePlacement(engine, key); + boolean locatableEditableReplacement = !nativePlacement + && decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS + && IrisStructureLocator.hasLocatableEditablePlacement(engine, key); + boolean reachable = reachableNativeKeys.contains(normalizedKey); + if (ModdedCommandSuggestions.isEligibleRegisteredStructure( + decision, nativePlacement, locatableNativePlacement, + locatableEditableReplacement, reachable, nativeGenerationEnabled)) { + eligibleRegisteredKeys.add(normalizedKey); + continue; + } + ModdedLocateCommands.NativeStructureAvailability availability = + ModdedLocateCommands.nativeAvailability(source, level, engine, key, holder.get()); + String reason = ModdedLocateCommands.registeredStructureUnavailableMessage( + key, availability, decision, nativePlacement, locatableNativePlacement, + nativeGenerationEnabled, reachable); + excluded.put(normalizedKey, new ExcludedStructure(ReportStatus.EXCLUDED, key, reason)); + } + List absentNativeKeys = missingConfiguredNativeKeys( + IrisStructureLocator.placedKeys(engine), registeredKeys, + (String key) -> IrisStructureLocator.hasNativePlacement(engine, key)); + for (String key : absentNativeKeys) { + String normalizedKey = ModdedCommandSuggestions.normalizeKey(key); + excluded.putIfAbsent(normalizedKey, new ExcludedStructure(ReportStatus.UNREGISTERED, key, + "Configured by an Iris nativeStructures placement, but absent from the live structure registry; " + + "install or enable the datapack or mod that provides this key.")); + } + if (engine.getData().getStructureLoader() != null) { + for (String key : engine.getData().getStructureLoader().getPossibleKeys()) { + String normalizedKey = ModdedCommandSuggestions.normalizeKey(key); + if (normalizedKey.isEmpty() || eligibleRegisteredKeys.contains(normalizedKey) + || excluded.containsKey(normalizedKey)) { + continue; + } + boolean registered = registeredKeys.contains(normalizedKey); + boolean nativePlacement = IrisStructureLocator.hasNativePlacement(engine, key); + boolean locatableEditable = IrisStructureLocator.hasLocatableEditablePlacement(engine, key); + if (!registered && !nativePlacement && locatableEditable) { + continue; + } + boolean configured = IrisStructureLocator.hasEditablePlacement(engine, key); + excluded.putIfAbsent(normalizedKey, new ExcludedStructure( + ReportStatus.UNPLACED, key.trim(), editableExclusionReason(configured))); + } + } + return List.copyOf(excluded.values()); + } + + static List missingConfiguredNativeKeys( + Collection placedKeys, Set normalizedRegisteredKeys, + Predicate nativePlacement) { + Map missing = new TreeMap<>(); + for (String key : placedKeys) { + String normalizedKey = ModdedCommandSuggestions.normalizeKey(key); + if (!normalizedKey.isEmpty() + && !normalizedRegisteredKeys.contains(normalizedKey) + && nativePlacement.test(key)) { + missing.putIfAbsent(normalizedKey, key.trim()); + } + } + return List.copyOf(missing.values()); + } + + static String editableExclusionReason(boolean configured) { + if (!configured) { + return "Editable Iris structure resource is loaded, but no biome, region, or dimension structure " + + "placement references it."; + } + return "Editable Iris structure resource is referenced by a placement, but every matching placement " + + "has non-positive density or a Y band outside this world's height range."; + } + + enum ReportStatus { + EXCLUDED("excluded"), + UNREGISTERED("unregistered"), + UNPLACED("unplaced"); + + private final String label; + + ReportStatus(String label) { + this.label = label; + } + + String label() { + return label; + } + } + + record ExcludedStructure(ReportStatus status, String key, String reason) { + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java index 94c986e2d..8fc562539 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedCommandParityTest.java @@ -39,6 +39,7 @@ public class IrisModdedCommandParityTest { child(iris, "height"); child(iris, "worlds"); child(iris, "accesslist"); + child(child(iris, "goto"), "unregistered"); CommandNode edit = child(iris, "edit"); child(edit, "b"); @@ -75,6 +76,7 @@ public class IrisModdedCommandParityTest { assertTrue(ModdedCommandHelp.documents("studio", "pkg")); assertTrue(ModdedCommandHelp.documents("object", "we")); assertTrue(ModdedCommandHelp.documents("world", "mainworld")); + assertTrue(ModdedCommandHelp.documents("goto", "unregistered")); } @Test diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java index f593598b0..6a799d4d8 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedStructureCommandTest.java @@ -1,5 +1,7 @@ package art.arcane.iris.modded.command; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.nativegen.NativeStructureLocateResults; import com.mojang.datafixers.util.Pair; import net.minecraft.core.BlockPos; @@ -8,6 +10,8 @@ import org.junit.Test; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; @@ -49,7 +53,7 @@ public class IrisModdedStructureCommandTest { String source = source("ModdedLocateCommands.java"); String suggestions = source("ModdedCommandSuggestions.java"); - assertTrue(source.contains("IrisStructureLocator.isPlaced(engine, key)")); + assertTrue(source.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)")); assertTrue(source.contains("registry.get(identifier)")); assertTrue(source.contains("getPlacementsForStructure(holder)")); assertTrue(source.contains("generator.findNearestMapStructure(")); @@ -57,7 +61,7 @@ public class IrisModdedStructureCommandTest { assertTrue(source.contains("HolderSet.direct(target.holder())")); assertFalse(source.contains("NativeStructureLocateCapability")); assertTrue(source.contains("boolean teleported = player.teleportTo(")); - assertTrue(suggestions.contains("combineStructureKeys(irisKeys, nativeKeys)")); + assertTrue(suggestions.contains("combineStructureKeys(unregisteredIrisKeys, nativeKeys)")); assertTrue(source.contains("irisGenerator.isNativeStructureReachable(holder)")); assertTrue(source.contains("LocateStatus.SEARCH_LIMIT_REACHED")); assertTrue(source.contains("IRIS_MODDED_COMMANDS_UNABLE_LOCATE_IRIS_PLACED_STRUCTURE_DENSITY_SEARCH_SAFETY_LIMIT_WAS")); @@ -68,6 +72,148 @@ public class IrisModdedStructureCommandTest { assertFalse(source.contains("at[2] + 8")); } + @Test + public void registeredStructureEligibilityMatchesGotoAndSuggestions() { + IrisNativeStructureDecision nativeDecision = decision(NativeStructureGenerationStatus.GENERATE_NATIVE); + IrisNativeStructureDecision replacementDecision = decision( + NativeStructureGenerationStatus.REPLACED_BY_IRIS); + IrisNativeStructureDecision disabledDecision = decision( + NativeStructureGenerationStatus.DISABLED_BY_PACK); + + assertTrue(ModdedCommandSuggestions.isEligibleRegisteredStructure( + nativeDecision, false, false, false, true, true)); + assertTrue(ModdedCommandSuggestions.isEligibleRegisteredStructure( + nativeDecision, true, true, false, false, true)); + assertFalse(ModdedCommandSuggestions.isEligibleRegisteredStructure( + nativeDecision, true, false, false, false, true)); + assertFalse(ModdedCommandSuggestions.isEligibleRegisteredStructure( + nativeDecision, true, true, false, true, false)); + assertTrue(ModdedCommandSuggestions.isEligibleRegisteredStructure( + replacementDecision, false, false, true, false, false)); + assertTrue(ModdedCommandSuggestions.isEligibleRegisteredStructure( + replacementDecision, true, true, false, false, true)); + assertFalse(ModdedCommandSuggestions.isEligibleRegisteredStructure( + replacementDecision, true, true, false, false, false)); + assertFalse(ModdedCommandSuggestions.isEligibleRegisteredStructure( + replacementDecision, false, false, false, true, true)); + assertFalse(ModdedCommandSuggestions.isEligibleRegisteredStructure( + disabledDecision, true, true, true, true, true)); + } + + @Test + public void structureSuggestionsDedupeAndSortPlacedAndNativeKeys() { + List suggestions = ModdedCommandSuggestions.combineStructureKeys( + List.of("towns_and_towers:village_forest", "minecraft:village_plains", + "towns_and_towers:village_forest", "MINECRAFT:VILLAGE_PLAINS"), + List.of("minecraft:stronghold", "minecraft:village_plains")); + + assertEquals(List.of("minecraft:stronghold", "minecraft:village_plains", + "towns_and_towers:village_forest"), suggestions); + } + + @Test + public void structureSuggestionsCannotReintroduceRegistryOrNativePlacementCollisions() { + List suggestions = ModdedCommandSuggestions.eligibleUnregisteredEditableKeys( + List.of("iris:custom", "minecraft:disabled", "MINECRAFT:UNREACHABLE", + "iris:native_collision"), + Set.of("minecraft:disabled", "minecraft:unreachable"), + (String key) -> key.equalsIgnoreCase("iris:native_collision")); + + assertEquals(List.of("iris:custom"), suggestions); + } + + @Test + public void unregisteredReportFindsOnlyConfiguredNativeKeysAbsentFromRegistry() { + List missing = ModdedUnregisteredStructures.missingConfiguredNativeKeys( + List.of("iris:editable", "missing:native", "MISSING:NATIVE", "registered:native"), + Set.of("registered:native"), + (String key) -> key.equalsIgnoreCase("missing:native") + || key.equalsIgnoreCase("registered:native")); + + assertEquals(List.of("missing:native"), missing); + } + + @Test + public void unregisteredReportDistinguishesEveryNativeExclusionReason() { + assertEquals(ModdedLocateCommands.NativeStructureAvailability.WORLD_DISABLED, + ModdedLocateCommands.classifyNativeAvailability(false, true, false, false, true, true)); + assertEquals(ModdedLocateCommands.NativeStructureAvailability.FILTERED, + ModdedLocateCommands.classifyNativeAvailability(true, false, false, false, true, true)); + assertEquals(ModdedLocateCommands.NativeStructureAvailability.IRIS_SUPPRESSED, + ModdedLocateCommands.classifyNativeAvailability(true, true, true, false, true, true)); + assertEquals(ModdedLocateCommands.NativeStructureAvailability.EMPTY_BIOME_FILTER, + ModdedLocateCommands.classifyNativeAvailability(true, true, false, true, false, false)); + assertEquals(ModdedLocateCommands.NativeStructureAvailability.BIOME_UNREACHABLE, + ModdedLocateCommands.classifyNativeAvailability(true, true, false, false, false, false)); + assertEquals(ModdedLocateCommands.NativeStructureAvailability.NO_PLACEMENT, + ModdedLocateCommands.classifyNativeAvailability(true, true, false, false, true, false)); + assertEquals(ModdedLocateCommands.NativeStructureAvailability.AVAILABLE, + ModdedLocateCommands.classifyNativeAvailability(true, true, false, false, true, true)); + + assertTrue(ModdedLocateCommands.nativeUnavailableMessage( + "towns_and_towers:exclusive", + ModdedLocateCommands.NativeStructureAvailability.EMPTY_BIOME_FILTER) + .contains("resolves to zero registered biomes")); + assertTrue(ModdedLocateCommands.nativeUnavailableMessage( + "minecraft:village_plains", + ModdedLocateCommands.NativeStructureAvailability.NO_PLACEMENT) + .contains("no active positive-weight, positive-frequency structure-set placement")); + String combined = ModdedLocateCommands.registeredStructureUnavailableMessage( + "towns_and_towers:exclusive", + ModdedLocateCommands.NativeStructureAvailability.EMPTY_BIOME_FILTER, + decision(NativeStructureGenerationStatus.GENERATE_NATIVE), + true, false, true, false); + assertTrue(combined.contains("resolves to zero registered biomes")); + assertTrue(combined.contains("matching Iris nativeStructures placement is also configured")); + } + + @Test + public void unregisteredReportExplainsEditablePlacementState() { + assertTrue(ModdedUnregisteredStructures.editableExclusionReason(false) + .contains("no biome, region, or dimension structure placement")); + assertTrue(ModdedUnregisteredStructures.editableExclusionReason(true) + .contains("non-positive density or a Y band outside")); + } + + @Test + public void unregisteredCommandUsesGotoEligibilityAndPrintsToConsole() throws IOException { + String command = source("ModdedUnregisteredStructures.java"); + String tree = source("ModdedCommandTree.java"); + + assertTrue(tree.contains("Commands.literal(\"unregistered\")")); + assertTrue(tree.contains("ModdedUnregisteredStructures.print(context.getSource())")); + assertTrue(command.contains("ModdedCommandSuggestions.isEligibleRegisteredStructure(")); + assertTrue(command.contains("ModdedLocateCommands.registeredStructureUnavailableMessage(")); + assertTrue(command.contains("engine.getData().getStructureLoader().getPossibleKeys()")); + assertTrue(command.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)")); + assertTrue(command.contains("LOGGER.info(\"[Iris goto unregistered] [{}] {} - {}\"")); + assertTrue(command.contains("UNREGISTERED(\"unregistered\")")); + assertFalse(command.contains("DatapackIngestService")); + } + + @Test + public void structureSuggestionsUseActiveLevelAvailability() throws IOException { + String suggestions = source("ModdedCommandSuggestions.java"); + int methodStart = suggestions.indexOf("static CompletableFuture suggestStructureKeys("); + int methodEnd = suggestions.indexOf("static boolean isEligibleRegisteredStructure(", methodStart); + String method = suggestions.substring(methodStart, methodEnd); + + assertTrue(method.contains("ServerLevel level = source.getLevel()")); + assertTrue(method.contains("Engine engine = IrisModdedCommands.engineFor(level)")); + assertFalse(method.contains("IrisStructureLocator.locatableKeys(engine)")); + assertTrue(method.contains("IrisStructureLocator.locatableEditableKeys(engine)")); + assertTrue(method.contains("StructureReachability.reachableKeys(engine)")); + assertTrue(method.contains("NativeStructureGenerationPolicy.resolve(engine, key, false)")); + assertTrue(method.contains("IrisStructureLocator.hasLocatableNativePlacement(engine, key)")); + assertTrue(method.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)")); + assertTrue(method.contains("reachableNativeKeys.contains(normalizeKey(key))")); + assertTrue(method.contains("nativeGenerationEnabled")); + assertTrue(method.contains("eligibleUnregisteredEditableKeys(")); + assertTrue(method.contains("IrisStructureLocator.hasNativePlacement(engine, candidate)")); + assertFalse(method.contains("NativeStructureGenerationKeys.active(level)")); + assertFalse(method.contains("getPlacementsForStructure")); + } + @Test public void generatorLocateUsesEveryIrisPlacedNativeStructure() throws IOException { String source = moddedSource("ModdedNativeStructureStage.java"); @@ -111,20 +257,29 @@ public class IrisModdedStructureCommandTest { int methodEnd = source.indexOf("private static void locateIrisStructure(", methodStart); String method = source.substring(methodStart, methodEnd); int nativeResolution = method.indexOf("resolveNativeStructure(source, level, engine, key)"); - int genericIrisLookup = method.indexOf("IrisStructureLocator.isPlaced(engine, key)", nativeResolution); - int policyResolution = method.indexOf("NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", genericIrisLookup); - int replacementCheck = method.indexOf( - "decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS", policyResolution); - int replacementLocate = method.indexOf("runNativeStructureLocate(source, level, player, target)", - replacementCheck); + int editableIrisLookup = method.indexOf( + "IrisStructureLocator.hasLocatableEditablePlacement(engine, key)", nativeResolution); + int policyResolution = method.indexOf( + "NativeStructureGenerationPolicy.resolve(engine, target.key(), false)", editableIrisLookup); + int eligibilityCheck = method.indexOf( + "ModdedCommandSuggestions.isEligibleRegisteredStructure(", policyResolution); + int replacementLocate = method.indexOf( + "locateIrisStructure(source, level, engine, player, target.key())", eligibilityCheck); + int nativeLocate = method.indexOf("runNativeStructureLocate(source, level, player, target)", + replacementLocate); assertTrue(nativeResolution >= 0); - assertTrue(genericIrisLookup > nativeResolution); - assertTrue(policyResolution > genericIrisLookup); - assertTrue(replacementCheck > policyResolution); - assertTrue(replacementLocate > replacementCheck); - assertTrue(method.contains("!IrisStructureLocator.hasNativePlacement(engine, target.key())")); - assertTrue(method.contains("&& target.availability() != NativeStructureAvailability.AVAILABLE")); + assertTrue(editableIrisLookup > nativeResolution); + assertTrue(policyResolution > editableIrisLookup); + assertTrue(eligibilityCheck > policyResolution); + assertTrue(replacementLocate > eligibilityCheck); + assertTrue(nativeLocate > replacementLocate); + assertTrue(method.contains("IrisStructureLocator.hasLocatableNativePlacement(engine, target.key())")); + assertTrue(method.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, target.key())")); + assertTrue(method.contains("!IrisStructureLocator.hasNativePlacement(engine, key)")); + assertTrue(method.contains("StructureReachability.isReachable(engine, target.key())")); + assertTrue(method.contains("getWorldGenSettings().options().generateStructures()")); + assertFalse(method.contains("IrisStructureLocator.isPlaced(engine, key)")); } @Test @@ -175,4 +330,8 @@ public class IrisModdedStructureCommandTest { .resolve("../modded-common/src/main/java") .normalize(); } + + private IrisNativeStructureDecision decision(NativeStructureGenerationStatus status) { + return new IrisNativeStructureDecision(status, 0, null, false, false, null, null); + } } diff --git a/build.gradle b/build.gradle index 6fff5da8e..aee8c41f3 100644 --- a/build.gradle +++ b/build.gradle @@ -122,6 +122,8 @@ nmsBindings.each { key, value -> rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructurePostProcessor.java').absolutePath) systemProperty('iris.nativeStructureStartInjectorSource', rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureStartInjector.java').absolutePath) + systemProperty('iris.nativeStructureGenerationKeysSource', + rootProject.file('adapters/minecraft-common/src/main/java/art/arcane/iris/nativegen/NativeStructureGenerationKeys.java').absolutePath) systemProperty('iris.customBiomeSource', rootProject.file("adapters/bukkit/nms/${key}/src/main/java/art/arcane/iris/core/nms/${key}/CustomBiomeSource.java").absolutePath) systemProperty('iris.vanillaStructureBiomesSource', diff --git a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java index 8e1e1436b..963f44bde 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/IrisStructureLocator.java @@ -69,9 +69,14 @@ public final class IrisStructureLocator { private static final Pattern NAMESPACED_RESOURCE_KEY = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); private static final Cache INDEX_CACHE = Caffeine.newBuilder().weakKeys().build(); + private static final Cache LOCATABLE_INDEX_CACHE = + Caffeine.newBuilder().weakKeys().build(); private static final PlacementIndex EMPTY_INDEX = new PlacementIndex( Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyList()); + private static final LocatableIndex EMPTY_LOCATABLE_INDEX = new LocatableIndex( + Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), + Collections.emptySet(), Collections.emptySet(), Collections.emptySet()); private static final LocateResult NOT_FOUND_RESULT = new LocateResult(LocateStatus.NOT_FOUND, 0, 0, 0); private static final LocateResult SEARCH_LIMIT_RESULT = new LocateResult(LocateStatus.SEARCH_LIMIT_REACHED, 0, 0, 0); @@ -97,6 +102,39 @@ public final class IrisStructureLocator { || placementIndex.vanillaAliases.contains(normalizedKey); } + public static boolean hasLocatablePlacement(Engine engine, String key) { + if (engine == null || key == null || key.isBlank()) { + return false; + } + return locatableIndex(engine).normalizedKeys().contains(normalize(key)); + } + + public static boolean hasLocatableEditablePlacement(Engine engine, String key) { + if (engine == null || key == null || key.isBlank()) { + return false; + } + return locatableIndex(engine).normalizedEditableKeys().contains(normalize(key)); + } + + public static boolean hasLocatableNativePlacement(Engine engine, String key) { + if (engine == null || key == null || key.isBlank()) { + return false; + } + return locatableIndex(engine).normalizedNativeKeys().contains(normalize(key)); + } + + public static Set locatableKeys(Engine engine) { + return locatableIndex(engine).keys(); + } + + public static Set locatableEditableKeys(Engine engine) { + return locatableIndex(engine).editableKeys(); + } + + public static Set locatableNativeKeys(Engine engine) { + return locatableIndex(engine).nativeKeys(); + } + public static boolean hasNativePlacement(Engine engine, String key) { if (engine == null || key == null || key.isBlank()) { return false; @@ -126,6 +164,7 @@ public final class IrisStructureLocator { public static void invalidate(Engine engine) { if (engine != null) { INDEX_CACHE.invalidate(engine); + LOCATABLE_INDEX_CACHE.invalidate(engine); } } @@ -796,7 +835,8 @@ public final class IrisStructureLocator { List concentricRings = new ArrayList<>(); boolean hasDensity = false; for (IrisStructurePlacement placement : index(engine).placements) { - if (!matches(placement, key, engine.getData())) { + if (!isSearchablePlacement(engine, placement) + || !matches(placement, key, engine.getData())) { continue; } if (placement.getDistribution() == StructureDistribution.RANDOM_SPREAD) { @@ -805,7 +845,7 @@ public final class IrisStructureLocator { StructurePlacementGrid.placementSalt(placement))); } else if (placement.getDistribution() == StructureDistribution.CONCENTRIC_RINGS) { concentricRings.add(placement); - } else if (isSearchableDensityPlacement(engine, placement)) { + } else if (placement.getDistribution() == StructureDistribution.DENSITY) { hasDensity = true; } } @@ -813,8 +853,18 @@ public final class IrisStructureLocator { } static boolean isSearchableDensityPlacement(Engine engine, IrisStructurePlacement placement) { - if (engine == null || placement == null || placement.getDistribution() != StructureDistribution.DENSITY - || !(placement.getDensity() > 0.0) || engine.getHeight() <= 0) { + return placement != null + && placement.getDistribution() == StructureDistribution.DENSITY + && isSearchablePlacement(engine, placement); + } + + static boolean isSearchablePlacement(Engine engine, IrisStructurePlacement placement) { + if (engine == null || placement == null || placement.getDistribution() == null + || engine.getHeight() <= 0) { + return false; + } + if (placement.getDistribution() == StructureDistribution.DENSITY + && !(placement.getDensity() > 0.0)) { return false; } long worldMin = (long) engine.getMinHeight() + (placement.isUnderground() ? 1L : 0L); @@ -952,6 +1002,64 @@ public final class IrisStructureLocator { return INDEX_CACHE.get(engine, ignored -> build(engine)); } + private static LocatableIndex locatableIndex(Engine engine) { + if (engine == null) { + return EMPTY_LOCATABLE_INDEX; + } + return LOCATABLE_INDEX_CACHE.get(engine, ignored -> buildLocatableIndex(engine)); + } + + private static LocatableIndex buildLocatableIndex(Engine engine) { + IrisData data = engine.getData(); + Set keys = new LinkedHashSet<>(); + Set normalizedKeys = new LinkedHashSet<>(); + Set editableKeys = new LinkedHashSet<>(); + Set normalizedEditableKeys = new LinkedHashSet<>(); + Set nativeKeys = new LinkedHashSet<>(); + Set normalizedNativeKeys = new LinkedHashSet<>(); + for (IrisStructurePlacement placement : index(engine).placements) { + if (!isSearchablePlacement(engine, placement)) { + continue; + } + if (placement.hasNativeStructures()) { + for (IrisNativeStructure source : placement.getNativeStructures()) { + addLocatableKey(source.getStructure(), keys, normalizedKeys); + addLocatableKey(source.getStructure(), nativeKeys, normalizedNativeKeys); + } + continue; + } + for (String structureKey : placement.getStructures()) { + IrisStructure structure = data.load(IrisStructure.class, structureKey, false); + if (structure == null) { + throw new IllegalStateException( + "Iris structure placement references missing structure '" + structureKey + "'"); + } + addLocatableKey(structureKey, keys, normalizedKeys); + addLocatableKey(structureKey, editableKeys, normalizedEditableKeys); + addLocatableKey(structure.getLoadKey(), keys, normalizedKeys); + addLocatableKey(structure.getLoadKey(), editableKeys, normalizedEditableKeys); + addLocatableKey(structure.getVanillaSource(), keys, normalizedKeys); + addLocatableKey(structure.getVanillaSource(), editableKeys, normalizedEditableKeys); + } + } + return new LocatableIndex( + Collections.unmodifiableSet(keys), + Collections.unmodifiableSet(normalizedKeys), + Collections.unmodifiableSet(editableKeys), + Collections.unmodifiableSet(normalizedEditableKeys), + Collections.unmodifiableSet(nativeKeys), + Collections.unmodifiableSet(normalizedNativeKeys)); + } + + private static void addLocatableKey(String key, Set keys, Set normalizedKeys) { + if (key == null || key.isBlank()) { + return; + } + String trimmedKey = key.trim(); + keys.add(trimmedKey); + normalizedKeys.add(normalize(trimmedKey)); + } + private static PlacementIndex build(Engine engine) { IrisData data = engine.getData(); Set loadKeys = new LinkedHashSet<>(); @@ -1112,6 +1220,11 @@ public final class IrisStructureLocator { List concentricRings, boolean hasDensity) { } + private record LocatableIndex(Set keys, Set normalizedKeys, + Set editableKeys, Set normalizedEditableKeys, + Set nativeKeys, Set normalizedNativeKeys) { + } + private record ResolvedStart(int originX, int baseY, int originZ) { } diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java index bf302109b..80de4fad1 100644 --- a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java @@ -18,42 +18,57 @@ package art.arcane.iris.util.common.director.specialhandlers; -import art.arcane.iris.core.nms.INMS; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; -import art.arcane.volmlib.util.collection.KList; +import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; +import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.util.common.director.DirectorParameterHandler; +import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; +import org.bukkit.entity.Player; -import java.util.stream.Collectors; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; public class StructureHandler implements DirectorParameterHandler { @Override public KList getPossibilities() { - KList keys = new KList<>(); - - try { - for (String k : INMS.get().getStructureKeys()) { - if (k != null && !k.isEmpty()) { - keys.addIfMissing(k); - } - } - } catch (Throwable ignored) { + Engine activeEngine = engine(); + if (activeEngine == null) { + return new KList<>(); } - try { - Engine e = engine(); - if (e != null) { - for (String k : IrisStructureLocator.placedKeys(e)) { - if (k != null && !k.isEmpty()) { - keys.addIfMissing(k); - } - } + boolean nativeGenerationEnabled = nativeStructureGenerationEnabled(); + PlatformStructureHooks structureHooks = IrisPlatforms.get().structureHooks(); + Map registeredKeys = distinctKeys(structureHooks.structureKeys()); + Set reachableKeys = StructureReachability.reachableKeys(activeEngine); + Map suggestions = new LinkedHashMap<>(); + + for (Map.Entry entry : registeredKeys.entrySet()) { + if (isEligibleRegisteredKey(activeEngine, entry.getValue(), entry.getKey(), reachableKeys, + nativeGenerationEnabled)) { + suggestions.put(entry.getKey(), entry.getValue()); } - } catch (Throwable ignored) { } - return keys; + Set locatableKeys = IrisStructureLocator.locatableEditableKeys(activeEngine); + for (String key : locatableKeys) { + String normalizedKey = normalizeKey(key); + if (!normalizedKey.isEmpty() + && !registeredKeys.containsKey(normalizedKey) + && !IrisStructureLocator.hasNativePlacement(activeEngine, key)) { + suggestions.putIfAbsent(normalizedKey, key.trim()); + } + } + + return new KList<>(suggestions.values()); } @Override @@ -64,15 +79,12 @@ public class StructureHandler implements DirectorParameterHandler { @Override public String parse(String in, boolean force) throws DirectorParsingException { KList options = getPossibilities(in); - - if (options.isEmpty()) { - return in; - } - try { - return options.stream().filter((i) -> toString(i).equalsIgnoreCase(in)).collect(Collectors.toList()).get(0); - } catch (Throwable e) { - return in; + for (String option : options) { + if (option.equalsIgnoreCase(in)) { + return option; + } } + return in; } @Override @@ -86,4 +98,50 @@ public class StructureHandler implements DirectorParameterHandler { return f == null ? "minecraft_ancient_city" : f; } + + protected boolean nativeStructureGenerationEnabled() { + Player activePlayer = player(); + return activePlayer != null && activePlayer.getWorld().canGenerateStructures(); + } + + private static Map distinctKeys(List keys) { + Map distinct = new LinkedHashMap<>(); + for (String key : keys) { + String normalizedKey = normalizeKey(key); + if (!normalizedKey.isEmpty()) { + distinct.putIfAbsent(normalizedKey, key.trim()); + } + } + return distinct; + } + + private static boolean isEligibleRegisteredKey(Engine engine, String key, String normalizedKey, + Set reachableKeys, boolean nativeGenerationEnabled) { + IrisNativeStructureDecision decision = NativeStructureGenerationPolicy.resolve(engine, key, false); + boolean nativePlacement = IrisStructureLocator.hasNativePlacement(engine, key); + boolean locatableNativePlacement = nativePlacement + && IrisStructureLocator.hasLocatableNativePlacement(engine, key); + boolean locatableEditableReplacement = !nativePlacement + && decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS + && IrisStructureLocator.hasLocatableEditablePlacement(engine, key); + return isEligibleRegisteredKey( + decision, nativePlacement, locatableNativePlacement, locatableEditableReplacement, + reachableKeys.contains(normalizedKey), nativeGenerationEnabled); + } + + static boolean isEligibleRegisteredKey(IrisNativeStructureDecision decision, boolean nativePlacement, + boolean locatableNativePlacement, + boolean locatableEditableReplacement, + boolean reachable, boolean nativeGenerationEnabled) { + if (decision.status() == NativeStructureGenerationStatus.REPLACED_BY_IRIS) { + return locatableEditableReplacement + || nativeGenerationEnabled && nativePlacement && locatableNativePlacement; + } + return nativeGenerationEnabled && decision.generate() + && (reachable || (nativePlacement && locatableNativePlacement)); + } + + private static String normalizeKey(String key) { + return key == null ? "" : key.trim().toLowerCase(Locale.ROOT); + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java index de38234e3..2940d95d1 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java @@ -77,6 +77,70 @@ public class IrisStructureLocatorContractTest { assertFalse(IrisStructureLocator.isPlaced(engine, "")); } + @Test + public void locatablePlacementsExcludeDisabledDensityConfigurations() { + Engine disabled = densityEngine(0.0, false, -64, 384, -2032, 2032); + Engine active = densityEngine(0.01, false, -64, 384, -2032, 2032); + + assertTrue(IrisStructureLocator.isPlaced(disabled, "test:density")); + assertFalse(IrisStructureLocator.hasLocatablePlacement(disabled, "test:density")); + assertTrue(IrisStructureLocator.locatableKeys(disabled).isEmpty()); + assertTrue(IrisStructureLocator.hasLocatablePlacement(active, "test:density")); + assertTrue(IrisStructureLocator.locatableKeys(active).contains("test:density")); + assertFalse(IrisStructureLocator.hasLocatablePlacement(null, "test:density")); + assertTrue(IrisStructureLocator.locatableKeys(null).isEmpty()); + } + + @Test + public void locatablePlacementsExcludeEveryDistributionOutsideWorldHeight() { + Engine randomSpread = densityEngine(1.0, false, -64, 384, 400, 500); + randomSpread.getDimension().getStructures().get(0) + .setDistribution(StructureDistribution.RANDOM_SPREAD); + Engine concentricRings = densityEngine(1.0, false, -64, 384, 400, 500); + concentricRings.getDimension().getStructures().get(0) + .setDistribution(StructureDistribution.CONCENTRIC_RINGS); + + assertFalse(IrisStructureLocator.hasLocatablePlacement(randomSpread, "test:density")); + assertFalse(IrisStructureLocator.hasLocatablePlacement(concentricRings, "test:density")); + } + + @Test + public void locatableIndexIncludesAliasesAndSeparatesEditableFromNativePlacements() { + IrisData data = mock(IrisData.class); + IrisStructure structure = new IrisStructure(); + structure.setLoadKey("test:city"); + structure.setVanillaSource("source:city"); + when(data.load(IrisStructure.class, "test:city_definition", false)).thenReturn(structure); + + IrisStructurePlacement editable = new IrisStructurePlacement(); + editable.getStructures().add("test:city_definition"); + IrisStructurePlacement nativePlacement = new IrisStructurePlacement(); + nativePlacement.getNativeStructures().add(new IrisNativeStructure() + .setStructure("source:native_city")); + IrisDimension dimension = mock(IrisDimension.class); + KList placements = new KList<>(); + placements.add(editable); + placements.add(nativePlacement); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + when(dimension.getStructures()).thenReturn(placements); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); + + assertEquals(Set.of("test:city_definition", "test:city", "source:city", "source:native_city"), + IrisStructureLocator.locatableKeys(engine)); + assertEquals(Set.of("test:city_definition", "test:city", "source:city"), + IrisStructureLocator.locatableEditableKeys(engine)); + assertEquals(Set.of("source:native_city"), IrisStructureLocator.locatableNativeKeys(engine)); + assertTrue(IrisStructureLocator.hasLocatableEditablePlacement(engine, "SOURCE:CITY")); + assertFalse(IrisStructureLocator.hasLocatableEditablePlacement(engine, "source:native_city")); + assertTrue(IrisStructureLocator.hasLocatableNativePlacement(engine, "SOURCE:NATIVE_CITY")); + assertFalse(IrisStructureLocator.hasLocatableNativePlacement(engine, "source:city")); + } + @Test public void suppressesVanillaIsFalseForNullEngine() { assertFalse(IrisStructureLocator.suppressesVanilla(null, "minecraft:ancient_city")); diff --git a/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandlerTest.java b/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandlerTest.java new file mode 100644 index 000000000..c75a4b3e3 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandlerTest.java @@ -0,0 +1,300 @@ +package art.arcane.iris.util.common.director.specialhandlers; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.IrisStructureLocator; +import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisImportedStructureControl; +import art.arcane.iris.engine.object.IrisNativeStructure; +import art.arcane.iris.engine.object.IrisNativeStructureDecision; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.engine.object.NativeStructureGenerationStatus; +import art.arcane.iris.engine.object.StructureDistribution; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.PlatformWorld; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.List; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class StructureHandlerTest { + @Test + public void registeredEligibilityMatchesFindExecutionTruthTable() { + IrisNativeStructureDecision replacement = decision(NativeStructureGenerationStatus.REPLACED_BY_IRIS); + IrisNativeStructureDecision generated = decision(NativeStructureGenerationStatus.GENERATE_NATIVE); + IrisNativeStructureDecision disabled = decision(NativeStructureGenerationStatus.DISABLED_BY_PACK); + + assertTrue(StructureHandler.isEligibleRegisteredKey( + replacement, false, false, true, false, false)); + assertFalse(StructureHandler.isEligibleRegisteredKey( + replacement, false, false, false, true, true)); + assertTrue(StructureHandler.isEligibleRegisteredKey( + replacement, true, true, false, false, true)); + assertFalse(StructureHandler.isEligibleRegisteredKey( + replacement, true, true, false, false, false)); + assertTrue(StructureHandler.isEligibleRegisteredKey( + generated, true, true, false, false, true)); + assertFalse(StructureHandler.isEligibleRegisteredKey( + generated, true, false, false, false, true)); + assertTrue(StructureHandler.isEligibleRegisteredKey( + generated, false, false, false, true, true)); + assertFalse(StructureHandler.isEligibleRegisteredKey( + generated, false, false, false, true, false)); + assertFalse(StructureHandler.isEligibleRegisteredKey( + disabled, true, true, false, true, true)); + } + + @Test + public void requiresActiveIrisEngineBeforeReadingPlatformRegistries() throws DirectorParsingException { + IrisPlatforms.unbind(); + TestStructureHandler handler = new TestStructureHandler(null, true); + + assertTrue(handler.getPossibilities().isEmpty()); + assertEquals("manual:structure", handler.parse("manual:structure", false)); + } + + @Test + public void filtersAndDeduplicatesSuggestionsForActiveEngine() { + IrisData data = mock(IrisData.class); + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisWorld world = mock(IrisWorld.class); + PlatformWorld platformWorld = mock(PlatformWorld.class); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + IrisImportedStructureControl control = new IrisImportedStructureControl(); + control.getDisabled().add("pack:disabled"); + control.getDisabled().add("pack:replacement"); + control.getDisabled().add("pack:dormant_replacement"); + + KList placements = new KList<>(); + placements.add(nativePlacement("pack:replacement", StructureDistribution.RANDOM_SPREAD, 1.0)); + placements.add(nativePlacement("pack:dormant_replacement", StructureDistribution.DENSITY, 0.0)); + placements.add(nativePlacement("pack:explicit", StructureDistribution.RANDOM_SPREAD, 1.0)); + placements.add(nativePlacement("pack:dormant_explicit", StructureDistribution.DENSITY, 0.0)); + placements.add(nativePlacement("pack:unregistered_native", StructureDistribution.RANDOM_SPREAD, 1.0)); + placements.add(nativePlacement("iris:custom", StructureDistribution.RANDOM_SPREAD, 1.0)); + placements.add(editablePlacement("iris:custom_definition")); + placements.add(editablePlacement("iris:collision_definition")); + + IrisStructure custom = new IrisStructure(); + custom.setLoadKey("iris:custom"); + IrisStructure collision = new IrisStructure(); + collision.setLoadKey("pack:collision"); + when(data.load(IrisStructure.class, "iris:custom_definition", false)).thenReturn(custom); + when(data.load(IrisStructure.class, "iris:collision_definition", false)).thenReturn(collision); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getWorld()).thenReturn(world); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + when(world.platformWorld()).thenReturn(platformWorld); + when(dimension.getImportedStructures()).thenReturn(control); + when(dimension.getStructures()).thenReturn(placements); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenReturn(List.of( + "pack:reachable", + "PACK:REACHABLE", + "pack:unreachable", + "pack:disabled", + "pack:replacement", + "pack:dormant_replacement", + "pack:explicit", + "pack:dormant_explicit", + "PACK:COLLISION")); + when(hooks.reachableStructureKeys(platformWorld)).thenReturn(List.of("PACK:REACHABLE")); + + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + KList possibilities = new TestStructureHandler(engine, true).getPossibilities(); + Set distinctPossibilities = new LinkedHashSet<>(possibilities); + + assertEquals(Set.of( + "pack:reachable", + "pack:replacement", + "pack:explicit", + "iris:custom_definition", + "iris:collision_definition"), distinctPossibilities); + assertEquals(distinctPossibilities.size(), possibilities.size()); + assertFalse(possibilities.stream().anyMatch("pack:collision"::equalsIgnoreCase)); + assertFalse(possibilities.contains("pack:unregistered_native")); + verify(hooks, times(1)).structureKeys(); + verify(hooks, times(1)).reachableStructureKeys(platformWorld); + } finally { + IrisStructureLocator.invalidate(engine); + StructureReachability.invalidate(engine); + IrisPlatforms.unbind(); + } + } + + @Test + public void disabledWorldKeepsOnlyUnregisteredEditablePlacements() { + IrisData data = mock(IrisData.class); + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + IrisWorld world = mock(IrisWorld.class); + PlatformWorld platformWorld = mock(PlatformWorld.class); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + IrisImportedStructureControl control = new IrisImportedStructureControl(); + control.getDisabled().add("minecraft:replacement"); + + IrisStructure editable = new IrisStructure(); + editable.setLoadKey("iris:editable"); + IrisStructure replacement = new IrisStructure(); + replacement.setLoadKey("iris:replacement"); + replacement.setVanillaSource("minecraft:replacement"); + KList placements = new KList<>(); + placements.add(editablePlacement("iris:editable_definition")); + placements.add(editablePlacement("iris:replacement_definition")); + placements.add(nativePlacement("minecraft:explicit", StructureDistribution.RANDOM_SPREAD, 1.0)); + + when(data.load(IrisStructure.class, "iris:editable_definition", false)).thenReturn(editable); + when(data.load(IrisStructure.class, "iris:replacement_definition", false)).thenReturn(replacement); + when(engine.getData()).thenReturn(data); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getWorld()).thenReturn(world); + when(engine.getMinHeight()).thenReturn(-64); + when(engine.getHeight()).thenReturn(384); + when(world.platformWorld()).thenReturn(platformWorld); + when(dimension.getImportedStructures()).thenReturn(control); + when(dimension.getStructures()).thenReturn(placements); + when(dimension.getAllRegions(engine)).thenReturn(new KList<>()); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenReturn(List.of( + "minecraft:replacement", "minecraft:explicit", "minecraft:reachable")); + when(hooks.reachableStructureKeys(platformWorld)).thenReturn(List.of("minecraft:reachable")); + + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + Set possibilities = new LinkedHashSet<>( + new TestStructureHandler(engine, false).getPossibilities()); + + assertEquals(Set.of( + "minecraft:replacement", + "iris:editable_definition", + "iris:editable", + "iris:replacement_definition", + "iris:replacement"), possibilities); + assertFalse(possibilities.contains("minecraft:explicit")); + assertFalse(possibilities.contains("minecraft:reachable")); + } finally { + IrisStructureLocator.invalidate(engine); + StructureReachability.invalidate(engine); + IrisPlatforms.unbind(); + } + } + + @Test + public void propagatesRegistryFailures() { + Engine engine = mock(Engine.class); + IrisPlatform platform = mock(IrisPlatform.class); + PlatformStructureHooks hooks = mock(PlatformStructureHooks.class); + IllegalStateException failure = new IllegalStateException("registry unavailable"); + when(platform.structureHooks()).thenReturn(hooks); + when(hooks.structureKeys()).thenThrow(failure); + + IrisPlatforms.unbind(); + IrisPlatforms.bind(platform); + try { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, () -> new TestStructureHandler(engine, true).getPossibilities()); + assertSame(failure, thrown); + } finally { + IrisPlatforms.unbind(); + } + } + + @Test + public void completionUsesOneReachabilitySnapshotAndLocatableKeys() throws IOException { + Path sourcePath = Path.of( + "src/main/java/art/arcane/iris/util/common/director/specialhandlers/StructureHandler.java"); + String source = Files.readString(sourcePath); + int methodStart = source.indexOf("public KList getPossibilities()"); + int methodEnd = source.indexOf("@Override\n public String toString", methodStart); + String method = source.substring(methodStart, methodEnd); + + assertEquals(1, occurrences(method, "StructureReachability.reachableKeys(activeEngine)")); + assertEquals(0, occurrences(method, "IrisStructureLocator.locatableKeys(activeEngine)")); + assertEquals(1, occurrences(method, "IrisStructureLocator.locatableEditableKeys(activeEngine)")); + assertTrue(method.indexOf("if (activeEngine == null)") + < method.indexOf("IrisPlatforms.get().structureHooks()")); + assertFalse(method.contains("INMS")); + assertFalse(method.contains("catch (")); + } + + private static IrisNativeStructureDecision decision(NativeStructureGenerationStatus status) { + return new IrisNativeStructureDecision(status, 0, null, false, false, null, null); + } + + private static IrisStructurePlacement nativePlacement(String key, StructureDistribution distribution, + double density) { + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.getNativeStructures().add(new IrisNativeStructure().setStructure(key)); + placement.setDistribution(distribution); + placement.setDensity(density); + return placement; + } + + private static IrisStructurePlacement editablePlacement(String key) { + IrisStructurePlacement placement = new IrisStructurePlacement(); + placement.getStructures().add(key); + return placement; + } + + private static int occurrences(String source, String target) { + int count = 0; + int offset = 0; + while ((offset = source.indexOf(target, offset)) >= 0) { + count++; + offset += target.length(); + } + return count; + } + + private static final class TestStructureHandler extends StructureHandler { + private final Engine activeEngine; + private final boolean nativeGenerationEnabled; + + private TestStructureHandler(Engine activeEngine, boolean nativeGenerationEnabled) { + this.activeEngine = activeEngine; + this.nativeGenerationEnabled = nativeGenerationEnabled; + } + + @Override + public Engine engine() { + return activeEngine; + } + + @Override + protected boolean nativeStructureGenerationEnabled() { + return nativeGenerationEnabled; + } + } +}