From 516ef71c9ce9329fdb4ca6d669dbc0592a769888 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 3 Sep 2026 04:54:22 -0400 Subject: [PATCH 1/3] Version content gate core - Add the ContentGate, CompatWalker, PackCompatReport and finding types under core.compat - Resolve block keys through the live registry, the legacy rename table, dimension blockFallbacks and the entry backup on every platform - Move the legacy rename table into LegacyBlockRenames so Bukkit and the mod loaders apply it identically - Evaluate every registrant on load from IrisData.preprocessObject and store the verdict on IrisRegistrant - Add IrisDimension.blockFallbacks as a plain object property - Keep missing palette keys as MissingBlockState placeholders so a chance-1 edit rule can still rewrite them - Guard the modded rotator, merger and face fixer against non-modded states - Source the legacy potion effect aliases from the gate so the Bukkit-typed table stays unloaded on modded --- .gitignore | 3 + .../iris/modded/ModdedDecoratorHooks.java | 4 +- .../arcane/iris/modded/ModdedStateMerger.java | 5 +- .../iris/modded/ModdedStateRotator.java | 4 +- .../arcane/iris/core/compat/CompatAction.java | 40 ++ .../iris/core/compat/CompatFinding.java | 84 +++ .../iris/core/compat/CompatRegistry.java | 38 ++ .../arcane/iris/core/compat/CompatStatus.java | 36 ++ .../arcane/iris/core/compat/CompatWalker.java | 518 ++++++++++++++++ .../arcane/iris/core/compat/ContentGate.java | 500 +++++++++++++++ .../arcane/iris/core/compat/KeyStatus.java | 28 + .../iris/core/compat/LegacyBlockRenames.java | 238 ++++++++ .../iris/core/compat/MissingBlockState.java | 246 ++++++++ .../iris/core/compat/PackCompatReport.java | 226 +++++++ .../art/arcane/iris/core/loader/IrisData.java | 36 ++ .../iris/core/loader/IrisRegistrant.java | 19 + .../iris/engine/object/IrisBlockData.java | 48 +- .../arcane/iris/engine/object/IrisCompat.java | 142 +---- .../iris/engine/object/IrisDimension.java | 59 +- .../arcane/iris/engine/object/IrisObject.java | 12 + .../iris/engine/object/IrisObjectIO.java | 69 ++- .../engine/object/IrisObjectRotation.java | 4 + .../iris/engine/object/PotionEffectTypes.java | 22 +- .../iris/core/compat/CompatFixtures.java | 410 +++++++++++++ .../compat/ContentGatePlaceholderTest.java | 256 ++++++++ .../iris/core/compat/ContentGateTest.java | 190 ++++++ .../core/compat/ContentGateWalkerTest.java | 576 ++++++++++++++++++ .../compat/IrisBlockDataResolutionTest.java | 96 +++ .../core/compat/PackCompatReportTest.java | 71 +++ .../core/project/SchemaBuilderParityTest.java | 11 + 30 files changed, 3818 insertions(+), 173 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/core/compat/CompatAction.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/CompatRegistry.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/CompatStatus.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/CompatWalker.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/ContentGate.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/KeyStatus.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/LegacyBlockRenames.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/MissingBlockState.java create mode 100644 core/src/main/java/art/arcane/iris/core/compat/PackCompatReport.java create mode 100644 core/src/test/java/art/arcane/iris/core/compat/CompatFixtures.java create mode 100644 core/src/test/java/art/arcane/iris/core/compat/ContentGatePlaceholderTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/compat/ContentGateTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/compat/ContentGateWalkerTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/compat/IrisBlockDataResolutionTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/compat/PackCompatReportTest.java diff --git a/.gitignore b/.gitignore index 40ca3d447..cadf2e553 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ AGENTS.md build-lt.sh BUILDS/ .run/ + +# Version-content compat lane worktree. Generated, never source. +.compatwt/ diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java index 5a46711ab..552fb5ffb 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDecoratorHooks.java @@ -39,7 +39,9 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi @Override public PlatformBlockState fixFaces(PlatformBlockState state, Hunk hunk, int rX, int rZ, int x, int y, int z, EngineMantle mantle) { - ModdedBlockState fabric = (ModdedBlockState) state; + if (!(state instanceof ModdedBlockState fabric)) { + return state; + } BlockState cloned = fabric.handle(); Map allowed = faceProperties(cloned); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java index 569c05362..1f527d00c 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateMerger.java @@ -28,8 +28,9 @@ import java.util.Map; public final class ModdedStateMerger implements BlockDataMergeSupport.StateMerger { @Override public PlatformBlockState merge(PlatformBlockState base, PlatformBlockState update) { - ModdedBlockState fabricBase = (ModdedBlockState) base; - ModdedBlockState fabricUpdate = (ModdedBlockState) update; + if (!(base instanceof ModdedBlockState fabricBase) || !(update instanceof ModdedBlockState fabricUpdate)) { + return update; + } ModdedBlockState metadataSource = fabricUpdate.isCustom() ? fabricUpdate : fabricBase; try { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java index c24db196a..e36a1d186 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStateRotator.java @@ -47,7 +47,9 @@ public final class ModdedStateRotator implements IrisObjectRotation.StateRotator @Override public PlatformBlockState rotate(IrisObjectRotation rotation, PlatformBlockState state, int spinxx, int spinyy, int spinzz) { - ModdedBlockState fabric = (ModdedBlockState) state; + if (!(state instanceof ModdedBlockState fabric)) { + return state; + } BlockState d = fabric.handle(); boolean deleted = false; diff --git a/core/src/main/java/art/arcane/iris/core/compat/CompatAction.java b/core/src/main/java/art/arcane/iris/core/compat/CompatAction.java new file mode 100644 index 000000000..644026884 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/CompatAction.java @@ -0,0 +1,40 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import java.util.Locale; + +/** + * What the gate did about a key that is missing on the running Minecraft version. + *
    + *
  • {@code EXCLUDED}: a compat unit (registrant or object placement) composes the missing content and was removed from + * every pool that could pick it.
  • + *
  • {@code DROPPED}: a single entry or reference was removed from its container; the container keeps generating.
  • + *
  • {@code SUBSTITUTED}: a declared fallback (dimension {@code blockFallbacks} or a block {@code backup}) replaced the + * missing key; the content still generates.
  • + *
+ */ +public enum CompatAction { + EXCLUDED, + DROPPED, + SUBSTITUTED; + + public String label() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java b/core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java new file mode 100644 index 000000000..d73484b3d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/CompatFinding.java @@ -0,0 +1,84 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import java.util.Objects; + +/** + * One gating decision: {@code key} in {@code registry} is missing on this server, and the gate took {@code action} on + * {@code subjectType}/{@code subjectKey} (a registrant type name and load key, or an object path) at {@code detail} + * (a field path such as {@code layers[1].palette[2]}, or the fallback that was applied). Findings are deduplicated on + * every field except {@code detail}. + */ +public record CompatFinding(CompatRegistry registry, + String key, + CompatAction action, + String subjectType, + String subjectKey, + String detail) { + public CompatFinding { + Objects.requireNonNull(registry, "registry"); + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(action, "action"); + subjectType = subjectType == null ? "" : subjectType; + subjectKey = subjectKey == null ? "" : subjectKey; + detail = detail == null ? "" : detail; + } + + /** Grouping key shared by every finding about the same registry entry. */ + public String groupKey() { + return registry.name() + ':' + key; + } + + /** Deduplication key: one finding per registry, key, action and subject. */ + public String dedupKey() { + return groupKey() + '|' + action.name() + '|' + subjectType + '|' + subjectKey; + } + + /** {@code (): at } */ + public String line() { + StringBuilder sb = new StringBuilder(96); + sb.append(key).append(" (").append(registry.label()).append("): ").append(action.label()); + if (!subjectType.isEmpty()) { + sb.append(' ').append(subjectType); + } + if (!subjectKey.isEmpty()) { + sb.append(' ').append(subjectKey); + } + if (!detail.isEmpty()) { + sb.append(" at ").append(detail); + } + return sb.toString(); + } + + /** The part after the key, for lines already grouped under the key. */ + public String subjectLine() { + StringBuilder sb = new StringBuilder(64); + sb.append(action.label()); + if (!subjectType.isEmpty()) { + sb.append(' ').append(subjectType); + } + if (!subjectKey.isEmpty()) { + sb.append(' ').append(subjectKey); + } + if (!detail.isEmpty()) { + sb.append(" at ").append(detail); + } + return sb.toString(); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/CompatRegistry.java b/core/src/main/java/art/arcane/iris/core/compat/CompatRegistry.java new file mode 100644 index 000000000..161086600 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/CompatRegistry.java @@ -0,0 +1,38 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import java.util.Locale; + +/** + * Registry a pack key is checked against by the {@link ContentGate}. Sounds and particles are absent on purpose: the + * platform SPI exposes no registry for them and an unknown effect already plays nothing. + */ +public enum CompatRegistry { + BLOCK, + ITEM, + ENTITY, + BIOME, + STRUCTURE, + ENCHANTMENT, + POTION_EFFECT; + + public String label() { + return name().toLowerCase(Locale.ROOT).replace('_', ' '); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/CompatStatus.java b/core/src/main/java/art/arcane/iris/core/compat/CompatStatus.java new file mode 100644 index 000000000..148a7033c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/CompatStatus.java @@ -0,0 +1,36 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import java.util.List; + +/** + * Gate verdict for one compat unit. {@code excluded} units must be filtered out of every pool that could pick them; + * {@code reasons} lists every finding recorded while evaluating the unit (excluded or not). + */ +public record CompatStatus(boolean excluded, List reasons) { + public static final CompatStatus OK = new CompatStatus(false, List.of()); + + public CompatStatus { + reasons = reasons == null ? List.of() : List.copyOf(reasons); + } + + public static CompatStatus excludedBy(List reasons) { + return new CompatStatus(true, reasons); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/CompatWalker.java b/core/src/main/java/art/arcane/iris/core/compat/CompatWalker.java new file mode 100644 index 000000000..39c4463e7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/CompatWalker.java @@ -0,0 +1,518 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.annotations.RegistryListBiome; +import art.arcane.iris.engine.object.annotations.RegistryListBlockType; +import art.arcane.iris.engine.object.annotations.RegistryListEnchantment; +import art.arcane.iris.engine.object.annotations.RegistryListEntityType; +import art.arcane.iris.engine.object.annotations.RegistryListItemType; +import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure; +import art.arcane.iris.engine.object.annotations.RegistryMapBlockState; +import art.arcane.iris.spi.IrisLogging; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * One evaluation of one registrant against the policy table: walks the registrant graph through a cached per-class + * field plan, checks every annotated key, resolves every {@link IrisBlockData} through the gate chain, drops entries + * the table says to drop, and records findings on the gate's report. + *

+ * Boundaries: {@link IrisObjectPlacement} is never descended (placements evaluate their own pools), match-side block + * lists are ignored (a missing block that is only matched is harmless), registrant references + * ({@code @RegistryListResource}) belong to the pool cascade, and identity cycles are cut. + */ +final class CompatWalker { + private static final String IRIS_PACKAGE = "art.arcane.iris."; + private static final String OBJECT_PACKAGE = "art.arcane.iris.engine.object."; + private static final int MAX_BACKUP_DEPTH = 8; + private static final Map, ClassPlan> PLANS = new ConcurrentHashMap<>(); + // Annotated fields whose key defines the registrant itself: missing means the registrant is excluded. + private static final Set EXCLUSION_SITES = Set.of( + OBJECT_PACKAGE + "IrisEntity#type", + OBJECT_PACKAGE + "IrisBiome#derivative"); + // Block lists that only select which existing blocks to act on; a missing key there matches nothing and is harmless. + private static final Set MATCH_ONLY = Set.of( + OBJECT_PACKAGE + "IrisObjectReplace#find", + OBJECT_PACKAGE + "IrisObjectLoot#filter", + OBJECT_PACKAGE + "IrisObjectVanillaLoot#filter", + OBJECT_PACKAGE + "IrisObjectMarker#mark", + OBJECT_PACKAGE + "IrisBlockDrops#blocks", + OBJECT_PACKAGE + "IrisDecorator#whitelist", + OBJECT_PACKAGE + "IrisDecorator#blacklist"); + + private final ContentGate gate; + private final IrisRegistrant registrant; + private final String subjectType; + private final String subjectKey; + private final boolean customLookup; + private final List reasons = new ArrayList<>(); + private final Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + private boolean excluded; + + CompatWalker(ContentGate gate, IrisRegistrant registrant) { + this.gate = gate; + this.registrant = registrant; + // Lowercase so walker findings read like the pool-cascade findings ("excluded biome x", "dropped region y"). + this.subjectType = registrant.getTypeName().toLowerCase(Locale.ROOT); + this.subjectKey = registrant.getLoadKey(); + // A blocks/ registrant must not re-enter its own loader cache while it is being loaded. + this.customLookup = !(registrant instanceof IrisBlockData); + } + + CompatStatus run() { + if (registrant instanceof IrisBlockData block) { + resolveEntry(block, "block"); + } else { + walkObject(registrant, ""); + } + if (excluded) { + return CompatStatus.excludedBy(reasons); + } + return reasons.isEmpty() ? CompatStatus.OK : new CompatStatus(false, reasons); + } + + /** Walks one object; true when its owner must drop it (an annotated key on it is missing and it is not the registrant). */ + private boolean walkObject(Object node, String path) { + if (!visited.add(node)) { + return false; + } + ClassPlan plan = plan(node.getClass()); + boolean drop = false; + for (KeyField key : plan.keys()) { + Object value = read(key.field(), node); + if (value == null) { + continue; + } + String fieldPath = child(path, key.field().getName()); + if (key.list()) { + if (value instanceof Collection values) { + walkKeyList(key, values, fieldPath); + } + continue; + } + if (!(value instanceof String raw) || raw.isBlank()) { + continue; + } + if (key.registry() == CompatRegistry.BLOCK) { + resolveBlockKey(raw, fieldPath); + continue; + } + if (check(key.registry(), raw) != KeyStatus.MISSING) { + continue; + } + String missing = normalizedKey(raw); + if (node != registrant) { + dropped(key.registry(), missing, fieldPath); + drop = true; + } else if (key.exclusionSite()) { + exclude(key.registry(), missing, fieldPath); + } else { + dropped(key.registry(), missing, fieldPath); + write(key.field(), node, plan.defaultOf(key.field())); + } + } + for (Field field : plan.descents()) { + Object value = read(field, node); + if (value == null) { + continue; + } + String fieldPath = child(path, field.getName()); + if (value instanceof IrisBlockData block) { + resolveEntry(block, fieldPath); + } else if (value instanceof Collection values) { + walkCollection(values, fieldPath); + } else if (value instanceof Map map) { + walkMap(map, fieldPath); + } else if (isIrisObject(value.getClass()) && walkObject(value, fieldPath)) { + write(field, node, null); + } + } + return drop; + } + + private void walkKeyList(KeyField key, Collection values, String path) { + Iterator iterator = values.iterator(); + int index = 0; + while (iterator.hasNext()) { + Object element = iterator.next(); + String elementPath = index(path, index++); + if (!(element instanceof String raw) || raw.isBlank()) { + continue; + } + if (key.registry() == CompatRegistry.BLOCK) { + resolveBlockKey(raw, elementPath); + continue; + } + if (check(key.registry(), raw) == KeyStatus.MISSING) { + dropped(key.registry(), normalizedKey(raw), elementPath); + iterator.remove(); + } + } + } + + private void walkCollection(Collection values, String path) { + Iterator iterator = values.iterator(); + int index = 0; + while (iterator.hasNext()) { + Object element = iterator.next(); + if (walkValue(element, index(path, index++))) { + iterator.remove(); + } + } + } + + private void walkMap(Map map, String path) { + Iterator> iterator = map.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (walkValue(entry.getValue(), path + '[' + entry.getKey() + ']')) { + iterator.remove(); + } + } + } + + private boolean walkValue(Object value, String path) { + if (value == null || value instanceof IrisObjectPlacement) { + return false; + } + if (value instanceof IrisBlockData block) { + resolveEntry(block, path); + return false; + } + if (value instanceof Collection values) { + walkCollection(values, path); + return false; + } + if (value instanceof Map map) { + walkMap(map, path); + return false; + } + return isIrisObject(value.getClass()) && walkObject(value, path); + } + + /** An annotated block key that generates (procedural trees, formations, ...): the chain without a backup. */ + private void resolveBlockKey(String raw, String path) { + ContentGate.BlockResolution resolution = gate.resolveBlock(raw); + String key = normalizedKey(raw); + if (resolution == null) { + exclude(CompatRegistry.BLOCK, key, path); + } else if (resolution.source() == ContentGate.BlockResolution.Source.FALLBACK) { + substituted(key, path, "fallback " + resolution.resolvedKey()); + } + } + + /** A block entry the way {@code IrisBlockData.getBlockData} resolves it: custom block, chain, then backups. */ + private void resolveEntry(IrisBlockData entry, String path) { + if (!visited.add(entry)) { + return; + } + String block = entry.getBlock(); + if (block == null || block.isBlank()) { + return; + } + IrisBlockData custom = customBlock(block); + if (custom != null) { + if (custom.isCompatExcluded()) { + exclude(CompatRegistry.BLOCK, causeKey(custom, block), path); + } + return; + } + String state = entry.stateKey(); + ContentGate.BlockResolution resolution = gate.resolveBlock(state); + String key = normalizedKey(state); + if (resolution != null) { + if (resolution.source() == ContentGate.BlockResolution.Source.FALLBACK) { + substituted(key, path, "fallback " + resolution.resolvedKey()); + } + return; + } + String revived = resolveBackup(entry.getBackup(), 0); + if (revived != null) { + substituted(key, path, "backup " + revived); + return; + } + exclude(CompatRegistry.BLOCK, key, path); + } + + /** The key a backup chain generates, or null when every link is missing. */ + private String resolveBackup(IrisBlockData backup, int depth) { + if (backup == null || depth > MAX_BACKUP_DEPTH || !visited.add(backup)) { + return null; + } + String block = backup.getBlock(); + if (block == null || block.isBlank()) { + return null; + } + IrisBlockData custom = customBlock(block); + if (custom != null) { + return custom.isCompatExcluded() ? null : ContentGate.normalizeState(block); + } + ContentGate.BlockResolution resolution = gate.resolveBlock(backup.stateKey()); + if (resolution != null) { + return resolution.resolvedKey(); + } + return resolveBackup(backup.getBackup(), depth + 1); + } + + private IrisBlockData customBlock(String block) { + return customLookup ? gate.customBlock(block) : null; + } + + private static String causeKey(IrisBlockData custom, String block) { + CompatStatus status = custom.getCompat(); + if (status != null && !status.reasons().isEmpty()) { + return status.reasons().getFirst().key(); + } + return normalizedKey(block); + } + + private KeyStatus check(CompatRegistry registry, String raw) { + KeyStatus status = gate.status(registry, raw); + if (status == KeyStatus.UNKNOWN) { + gate.report().markIncomplete(registry.label() + " registry not ready"); + } + return status; + } + + private void exclude(CompatRegistry registry, String key, String detail) { + excluded = true; + record(new CompatFinding(registry, key, CompatAction.EXCLUDED, subjectType, subjectKey, detail)); + } + + private void dropped(CompatRegistry registry, String key, String detail) { + record(new CompatFinding(registry, key, CompatAction.DROPPED, subjectType, subjectKey, detail)); + } + + private void substituted(String key, String path, String how) { + record(new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.SUBSTITUTED, subjectType, subjectKey, path + " (" + how + ")")); + } + + private void record(CompatFinding finding) { + reasons.add(finding); + gate.report().record(finding); + } + + private static String normalizedKey(String raw) { + return ContentGate.baseKey(ContentGate.normalizeState(raw)); + } + + private static String child(String path, String name) { + return path.isEmpty() ? name : path + '.' + name; + } + + private static String index(String path, int index) { + return path + '[' + index + ']'; + } + + private static Object read(Field field, Object node) { + try { + return field.get(node); + } catch (IllegalAccessException | RuntimeException e) { + return null; + } + } + + private static void write(Field field, Object node, Object value) { + try { + field.set(node, value); + } catch (IllegalAccessException | RuntimeException e) { + IrisLogging.debug("Compat gate could not reset " + field.getDeclaringClass().getSimpleName() + "." + field.getName() + ": " + e.getMessage()); + } + } + + static ClassPlan plan(Class type) { + ClassPlan plan = PLANS.get(type); + if (plan == null) { + plan = ClassPlan.build(type); + PLANS.putIfAbsent(type, plan); + } + return plan; + } + + static boolean isIrisObject(Class type) { + return type.getName().startsWith(IRIS_PACKAGE) + && !type.isEnum() + && !type.isRecord() + && !type.isAnnotation() + && !IrisObjectPlacement.class.isAssignableFrom(type); + } + + private static boolean descendable(Type type) { + if (type instanceof Class clazz) { + return isIrisObject(clazz); + } + if (type instanceof ParameterizedType parameterized && parameterized.getRawType() instanceof Class raw) { + Type[] arguments = parameterized.getActualTypeArguments(); + if (Collection.class.isAssignableFrom(raw)) { + return arguments.length == 1 && descendable(arguments[0]); + } + if (Map.class.isAssignableFrom(raw)) { + return arguments.length == 2 && descendable(arguments[1]); + } + return isIrisObject(raw); + } + return false; + } + + private static boolean isStringList(Field field) { + if (!Collection.class.isAssignableFrom(field.getType())) { + return false; + } + return field.getGenericType() instanceof ParameterizedType parameterized + && parameterized.getActualTypeArguments().length == 1 + && parameterized.getActualTypeArguments()[0] == String.class; + } + + /** + * The registry an annotated field is checked against, or null when the field carries no checkable key: registrant + * references, prefix-matching vanilla structure filters, Iris structure resources ({@code @RegistryListStructure}), + * and the structure-set and native jigsaw pool keys the platform SPI exposes no registry for. + */ + private static CompatRegistry registryOf(Field field) { + if (field.isAnnotationPresent(RegistryListBlockType.class)) { + return CompatRegistry.BLOCK; + } + if (field.isAnnotationPresent(RegistryListEntityType.class)) { + return CompatRegistry.ENTITY; + } + if (field.isAnnotationPresent(RegistryListItemType.class)) { + return CompatRegistry.ITEM; + } + if (field.isAnnotationPresent(RegistryListEnchantment.class)) { + return CompatRegistry.ENCHANTMENT; + } + if (field.isAnnotationPresent(RegistryListPotionEffect.class)) { + return CompatRegistry.POTION_EFFECT; + } + if (field.isAnnotationPresent(RegistryListBiome.class)) { + return CompatRegistry.BIOME; + } + RegistryListVanillaStructure vanilla = field.getAnnotation(RegistryListVanillaStructure.class); + if (vanilla != null && !vanilla.prefixes()) { + return CompatRegistry.STRUCTURE; + } + return null; + } + + private static boolean open(Field field) { + try { + field.setAccessible(true); + return true; + } catch (RuntimeException e) { + return false; + } + } + + record KeyField(Field field, CompatRegistry registry, boolean list, boolean exclusionSite) { + } + + /** Per-class field plan: annotated key fields and the fields worth descending into. Immutable once built. */ + static final class ClassPlan { + private final Class type; + private final List keys; + private final List descents; + private volatile Object defaults; + private volatile boolean defaultsBuilt; + + private ClassPlan(Class type, List keys, List descents) { + this.type = type; + this.keys = keys; + this.descents = descents; + } + + static ClassPlan build(Class type) { + List keys = new ArrayList<>(); + List descents = new ArrayList<>(); + for (Class current = type; current != null && current != Object.class; current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + int modifiers = field.getModifiers(); + if (Modifier.isStatic(modifiers) || Modifier.isTransient(modifiers) || field.isSynthetic()) { + continue; + } + if (field.isAnnotationPresent(RegistryListResource.class) || field.isAnnotationPresent(RegistryMapBlockState.class)) { + continue; + } + String site = current.getName() + '#' + field.getName(); + if (MATCH_ONLY.contains(site)) { + continue; + } + CompatRegistry registry = registryOf(field); + if (registry != null) { + boolean list = isStringList(field); + if ((field.getType() == String.class || list) && open(field)) { + keys.add(new KeyField(field, registry, list, EXCLUSION_SITES.contains(site))); + } + continue; + } + if (descendable(field.getGenericType()) && open(field)) { + descents.add(field); + } + } + } + return new ClassPlan(type, List.copyOf(keys), List.copyOf(descents)); + } + + List keys() { + return keys; + } + + List descents() { + return descents; + } + + /** The field's value on a fresh instance (what a dropped top-level key resets to), or null. */ + Object defaultOf(Field field) { + if (!defaultsBuilt) { + defaults = newInstance(); + defaultsBuilt = true; + } + Object instance = defaults; + return instance == null ? null : read(field, instance); + } + + private Object newInstance() { + try { + Constructor constructor = type.getDeclaredConstructor(); + constructor.setAccessible(true); + return constructor.newInstance(); + } catch (Throwable e) { + return null; + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/ContentGate.java b/core/src/main/java/art/arcane/iris/core/compat/ContentGate.java new file mode 100644 index 000000000..71cf42e42 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/ContentGate.java @@ -0,0 +1,500 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.io.IO; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Answers "does this pack key exist on the running server, and if not what do we generate instead" for one pack + * instance, and records every decision in the pack's {@link PackCompatReport}. + *

+ * Existence is checked against the live {@link PlatformRegistries} only - never a version number - so the same pack + * gates identically on Bukkit and on the modded loaders for the same Minecraft version, and mods that add or remove + * content are covered for free. {@link KeyStatus#UNKNOWN} (registry not bound or not ready) never produces a finding. + *

+ * Block resolution chain: strict registry lookup, legacy rename table (not reported), dimension {@code blockFallbacks} + * (reported), then {@code null} (missing). The per-entry {@code backup} of an {@code IrisBlockData} is applied by that + * class and by the walker around this chain. Resolutions are cached per normalized state. + */ +public final class ContentGate { + private static final String DEFAULT_NAMESPACE = "minecraft"; + private static final String DIMENSIONS_FOLDER = "dimensions"; + private static final String BLOCK_FALLBACKS_FIELD = "blockFallbacks"; + private static final Object MISSING = new Object(); + /** Legacy Bukkit potion effect enum names (lowercase) to the current key path; the Bukkit-typed + * {@code PotionEffectTypes} reads this table so it stays unloaded on the modded loaders. */ + public static final Map POTION_EFFECT_ALIASES = Map.of( + "slow", "slowness", + "fast_digging", "haste", + "slow_digging", "mining_fatigue", + "increase_damage", "strength", + "heal", "instant_health", + "harm", "instant_damage", + "jump", "jump_boost", + "confusion", "nausea", + "damage_resistance", "resistance"); + + private final PlatformRegistries registries; + private final Supplier> blockFallbacks; + private final PackCompatReport report; + private final Function customBlocks; + private final Map> keySets = new ConcurrentHashMap<>(); + private final Map resolutions = new ConcurrentHashMap<>(); + private final Map placeholders = new ConcurrentHashMap<>(); + // Keys with no blocks/ registrant: the loader lists the folder on every miss, and every walked entry asks. + private final Set customMisses = ConcurrentHashMap.newKeySet(); + private volatile boolean readyKnown; + private volatile Map fallbackCache; + + public ContentGate(PlatformRegistries registries, Supplier> blockFallbacks, PackCompatReport report) { + this(registries, blockFallbacks, report, null); + } + + public ContentGate(PlatformRegistries registries, Map blockFallbacks, PackCompatReport report) { + this(registries, () -> blockFallbacks == null ? Map.of() : blockFallbacks, report, null); + } + + /** + * @param customBlocks resolves a pack block key to its {@code blocks/} registrant, or null; consulted before the + * registry the way {@code IrisBlockData.getBlockData} does. May be null. + */ + public ContentGate(PlatformRegistries registries, Supplier> blockFallbacks, PackCompatReport report, + Function customBlocks) { + this.registries = registries; + this.blockFallbacks = blockFallbacks == null ? Map::of : blockFallbacks; + this.report = report == null ? new PackCompatReport() : report; + this.customBlocks = customBlocks; + } + + /** Gate for a pack folder: live platform registries, fallbacks read from the pack's dimension JSON. */ + public static ContentGate forData(IrisData data) { + PlatformRegistries registries = IrisPlatforms.isBound() ? IrisPlatforms.get().registries() : null; + File folder = data == null ? null : data.getDataFolder(); + PackCompatReport report = data == null ? new PackCompatReport() : data.getCompatReport(); + Function customBlocks = data == null ? null : key -> { + ResourceLoader loader = data.getBlockLoader(); + return loader == null ? null : loader.load(key, false); + }; + return new ContentGate(registries, () -> readBlockFallbacks(folder), report, customBlocks); + } + + public PackCompatReport report() { + return report; + } + + /** True when the registry can be consulted; false means every status is {@link KeyStatus#UNKNOWN}. */ + public boolean ready() { + if (readyKnown) { + return true; + } + if (registries == null) { + return false; + } + try { + List blocks = registries.blockKeys(); + boolean ready = blocks != null && !blocks.isEmpty(); + if (ready) { + readyKnown = true; + } + return ready; + } catch (Throwable e) { + return false; + } + } + + public KeyStatus block(String state) { + String normalized = normalizeState(state); + if (normalized == null) { + return KeyStatus.MISSING; + } + if (!ready()) { + return KeyStatus.UNKNOWN; + } + return lookup(normalized) != null ? KeyStatus.PRESENT : KeyStatus.MISSING; + } + + public KeyStatus item(String key) { + return statusOf(CompatRegistry.ITEM, key); + } + + public KeyStatus entity(String key) { + return statusOf(CompatRegistry.ENTITY, key); + } + + public KeyStatus biome(String key) { + return statusOf(CompatRegistry.BIOME, key); + } + + public KeyStatus structure(String key) { + return statusOf(CompatRegistry.STRUCTURE, key); + } + + public KeyStatus enchantment(String key) { + return statusOf(CompatRegistry.ENCHANTMENT, key); + } + + /** Potion effects match on the key path only and accept the legacy Bukkit enum names, as {@code IrisEffect} does. */ + public KeyStatus potionEffect(String key) { + String normalized = normalizeState(key); + if (normalized == null) { + return KeyStatus.MISSING; + } + if (!ready()) { + return KeyStatus.UNKNOWN; + } + Set paths = keySet(CompatRegistry.POTION_EFFECT); + if (paths == null) { + return KeyStatus.UNKNOWN; + } + String path = pathOf(baseKey(normalized)); + return paths.contains(POTION_EFFECT_ALIASES.getOrDefault(path, path)) ? KeyStatus.PRESENT : KeyStatus.MISSING; + } + + /** Status of {@code key} in {@code registry}; {@link CompatRegistry#BLOCK} goes through {@link #block(String)}. */ + public KeyStatus status(CompatRegistry registry, String key) { + return switch (registry) { + case BLOCK -> block(key); + case ITEM -> item(key); + case ENTITY -> entity(key); + case BIOME -> biome(key); + case STRUCTURE -> structure(key); + case ENCHANTMENT -> enchantment(key); + case POTION_EFFECT -> potionEffect(key); + }; + } + + /** + * Resolved block for a pack state string, or {@code null} when it is missing on this server after the whole chain + * (or the registry is not ready). {@link BlockResolution#substituted()} is true only for a dimension fallback; a + * legacy rename is visible through {@link BlockResolution#source()} and is never reported. + */ + public BlockResolution resolveBlock(String state) { + String normalized = normalizeState(state); + if (normalized == null || !ready()) { + return null; + } + Object cached = resolutions.get(normalized); + if (cached == null) { + BlockResolution resolved = resolveUncached(normalized); + cached = resolved == null ? MISSING : resolved; + resolutions.putIfAbsent(normalized, cached); + } + return cached == MISSING ? null : (BlockResolution) cached; + } + + /** + * {@link #resolveBlock(String)}, or a {@link MissingBlockState} carrying the key when the whole chain misses, so + * object palettes and {@code edit} find lists keep the key an edit rule can still rewrite. Null only when the + * registry is not ready (callers keep their plain lookup) or the state is blank. + */ + public PlatformBlockState resolveBlockOrPlaceholder(String state) { + String normalized = normalizeState(state); + if (normalized == null || !ready()) { + return null; + } + BlockResolution resolution = resolveBlock(normalized); + if (resolution != null) { + return resolution.state(); + } + MissingBlockState placeholder = placeholders.get(normalized); + if (placeholder == null) { + PlatformBlockState air; + try { + air = registries.air(); + } catch (Throwable e) { + air = null; + } + if (air == null) { + return null; + } + placeholder = MissingBlockState.of(normalized, air); + MissingBlockState raced = placeholders.putIfAbsent(normalized, placeholder); + if (raced != null) { + placeholder = raced; + } + } + return placeholder; + } + + private BlockResolution resolveUncached(String normalized) { + PlatformBlockState direct = lookup(normalized); + if (direct != null) { + return new BlockResolution(direct, normalized, false, null, BlockResolution.Source.REGISTRY); + } + BlockResolution renamed = resolveRename(normalized); + if (renamed != null) { + return renamed; + } + String fallback = fallbacks().get(baseKey(normalized)); + if (fallback != null) { + String normalizedFallback = normalizeState(fallback); + PlatformBlockState substitute = normalizedFallback == null ? null : lookup(normalizedFallback); + if (substitute != null) { + return new BlockResolution(substitute, normalizedFallback, true, normalized, BlockResolution.Source.FALLBACK); + } + } + return null; + } + + private BlockResolution resolveRename(String normalized) { + String candidate = normalized; + for (int hop = 0; hop < LegacyBlockRenames.MAX_HOPS; hop++) { + String supplement = normalizeState(LegacyBlockRenames.supplementFor(candidate)); + if (supplement == null) { + return null; + } + PlatformBlockState state = lookup(supplement); + if (state != null) { + return new BlockResolution(state, supplement, false, null, BlockResolution.Source.RENAME); + } + candidate = supplement; + } + return null; + } + + /** The {@code blocks/} registrant for a pack block key, or null when there is none or no loader is attached. */ + public IrisBlockData customBlock(String key) { + if (customBlocks == null || key == null || key.isBlank() || customMisses.contains(key)) { + return null; + } + try { + IrisBlockData custom = customBlocks.apply(key); + if (custom == null) { + customMisses.add(key); + } + return custom; + } catch (Throwable e) { + IrisLogging.reportError(e); + return null; + } + } + + /** The dimension {@code blockFallbacks} map, base key to state, normalized. */ + public Map fallbacks() { + Map cached = fallbackCache; + if (cached == null) { + Map raw = blockFallbacks.get(); + Map normalized = new LinkedHashMap<>(); + if (raw != null) { + for (Map.Entry entry : raw.entrySet()) { + String key = normalizeState(entry.getKey()); + if (key != null && entry.getValue() != null) { + normalized.put(baseKey(key), entry.getValue()); + } + } + } + cached = Collections.unmodifiableMap(normalized); + fallbackCache = cached; + } + return cached; + } + + /** Forget cached fallbacks, key sets, resolutions, placeholders and the readiness latch (studio hotload). */ + public void invalidate() { + fallbackCache = null; + readyKnown = false; + keySets.clear(); + resolutions.clear(); + placeholders.clear(); + customMisses.clear(); + } + + /** + * Evaluates a freshly loaded registrant against the policy table, records findings, drops entries the table says + * to drop, and returns the status the caller stores on the registrant. A registry that is not ready produces no + * findings and no mutation; the report is marked incomplete instead. + */ + public CompatStatus evaluate(IrisRegistrant registrant) { + if (registrant == null) { + return CompatStatus.OK; + } + if (!ready()) { + report.markIncomplete("registry not ready"); + return CompatStatus.OK; + } + return new CompatWalker(this, registrant).run(); + } + + /** Reads {@code dimensions/*.json} {@code blockFallbacks} objects straight from disk, without the loader. */ + public static Map readBlockFallbacks(File packFolder) { + if (packFolder == null) { + return Map.of(); + } + File[] dimensions = new File(packFolder, DIMENSIONS_FOLDER).listFiles((dir, name) -> name.endsWith(".json")); + if (dimensions == null || dimensions.length == 0) { + return Map.of(); + } + Map merged = new LinkedHashMap<>(); + for (File file : dimensions) { + try { + JSONObject json = new JSONObject(IO.readAll(file)); + if (!json.has(BLOCK_FALLBACKS_FIELD)) { + continue; + } + JSONObject fallbacks = json.getJSONObject(BLOCK_FALLBACKS_FIELD); + for (String key : fallbacks.keySet()) { + Object value = fallbacks.get(key); + if (value instanceof String state && !state.isBlank()) { + merged.putIfAbsent(key, state); + } + } + } catch (Throwable e) { + IrisLogging.warn("Could not read blockFallbacks from " + file.getName() + ": " + e.getMessage()); + } + } + return merged.isEmpty() ? Map.of() : Collections.unmodifiableMap(merged); + } + + /** {@code namespace:name[props]} lowercased with the default namespace applied; null for blank input. */ + public static String normalizeState(String state) { + if (state == null) { + return null; + } + String trimmed = state.trim().toLowerCase(Locale.ROOT); + if (trimmed.isEmpty()) { + return null; + } + int props = trimmed.indexOf('['); + String base = props < 0 ? trimmed : trimmed.substring(0, props); + String rest = props < 0 ? "" : trimmed.substring(props); + if (base.indexOf(':') < 0) { + base = DEFAULT_NAMESPACE + ':' + base; + } + return base + rest; + } + + /** Strips the {@code [props]} section. */ + public static String baseKey(String normalizedState) { + if (normalizedState == null) { + return null; + } + int props = normalizedState.indexOf('['); + return props < 0 ? normalizedState : normalizedState.substring(0, props); + } + + private static String pathOf(String baseKey) { + int namespace = baseKey.indexOf(':'); + return namespace < 0 ? baseKey : baseKey.substring(namespace + 1); + } + + private PlatformBlockState lookup(String normalized) { + try { + return registries.blockOrNull(normalized, false); + } catch (Throwable e) { + return null; + } + } + + private KeyStatus statusOf(CompatRegistry registry, String key) { + String normalized = normalizeState(key); + if (normalized == null) { + return KeyStatus.MISSING; + } + if (!ready()) { + return KeyStatus.UNKNOWN; + } + Set keys = keySet(registry); + if (keys == null) { + return KeyStatus.UNKNOWN; + } + return keys.contains(baseKey(normalized)) ? KeyStatus.PRESENT : KeyStatus.MISSING; + } + + /** Normalized keys of a registry (paths only for potion effects), or null while that registry is empty. */ + private Set keySet(CompatRegistry registry) { + Set cached = keySets.get(registry); + if (cached != null) { + return cached; + } + List keys; + try { + keys = switch (registry) { + case BLOCK -> registries.blockKeys(); + case ITEM -> registries.itemKeys(); + case ENTITY -> registries.entityKeys(); + case BIOME -> registries.biomeKeys(); + case STRUCTURE -> registries.structureKeys(); + case ENCHANTMENT -> registries.enchantmentKeys(); + case POTION_EFFECT -> registries.potionEffectKeys(); + }; + } catch (Throwable e) { + return null; + } + if (keys == null || keys.isEmpty()) { + return null; + } + Set built = new HashSet<>(keys.size() * 2); + for (String key : keys) { + String normalized = normalizeState(key); + if (normalized != null) { + String base = baseKey(normalized); + built.add(registry == CompatRegistry.POTION_EFFECT ? pathOf(base) : base); + } + } + Set frozen = Collections.unmodifiableSet(built); + keySets.putIfAbsent(registry, frozen); + return frozen; + } + + /** + * @param state the state to generate + * @param resolvedKey the normalized key that produced {@code state} + * @param substituted true when a dimension fallback produced {@code state} (reported) + * @param substitutedFrom the missing key the fallback replaced, or null + * @param source which step of the chain answered + */ + public record BlockResolution(PlatformBlockState state, String resolvedKey, boolean substituted, String substitutedFrom, + Source source) { + public enum Source { + REGISTRY, + RENAME, + FALLBACK + } + + public BlockResolution { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(source, "source"); + } + + public BlockResolution(PlatformBlockState state, String resolvedKey, boolean substituted, String substitutedFrom) { + this(state, resolvedKey, substituted, substitutedFrom, substituted ? Source.FALLBACK : Source.REGISTRY); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/KeyStatus.java b/core/src/main/java/art/arcane/iris/core/compat/KeyStatus.java new file mode 100644 index 000000000..eec50caf4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/KeyStatus.java @@ -0,0 +1,28 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +/** + * Existence of a key in the live platform registry. {@code UNKNOWN} means the registry could not be consulted (not + * bound, or empty because the server is not ready yet); it must never be treated as {@code MISSING}. + */ +public enum KeyStatus { + PRESENT, + MISSING, + UNKNOWN +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/LegacyBlockRenames.java b/core/src/main/java/art/arcane/iris/core/compat/LegacyBlockRenames.java new file mode 100644 index 000000000..03cd5d393 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/LegacyBlockRenames.java @@ -0,0 +1,238 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * The legacy block rename table: keys Minecraft renamed or removed, and the key to try instead. Pure data with no + * platform types so the {@link ContentGate} applies it identically on Bukkit and the modded loaders; the Bukkit + * {@code IrisCompat} filter list is built from the same entries. + *

+ * A {@code when} without {@code exact} matches the bare block name (namespace and properties stripped, case + * insensitive); an exact entry matches the whole normalized state. Supplements can chain (the first entry whose + * supplement exists wins) and the table contains cycles, so callers bound the number of hops. + */ +public final class LegacyBlockRenames { + public static final int MAX_HOPS = 16; + + public record Rename(String when, String supplement, boolean exact) { + public Rename(String when, String supplement) { + this(when, supplement, false); + } + } + + private static final List DEFAULTS = defaults0(); + private static final Map BY_NAME = byName(DEFAULTS); + private static final Map BY_STATE = byState(DEFAULTS); + + private LegacyBlockRenames() { + } + + /** Every entry in table order. */ + public static List defaults() { + return DEFAULTS; + } + + /** + * The supplement for a normalized state ({@code namespace:name[props]}, lowercase), or null when no entry applies. + * Exact entries are consulted first, then the bare name. + */ + public static String supplementFor(String normalizedState) { + if (normalizedState == null) { + return null; + } + String exact = BY_STATE.get(normalizedState); + if (exact != null) { + return exact; + } + String base = ContentGate.baseKey(normalizedState); + int namespace = base.indexOf(':'); + return BY_NAME.get(namespace < 0 ? base : base.substring(namespace + 1)); + } + + private static Map byName(List renames) { + Map map = new LinkedHashMap<>(); + for (Rename rename : renames) { + if (!rename.exact()) { + map.putIfAbsent(rename.when().toLowerCase(Locale.ROOT), rename.supplement()); + } + } + return Collections.unmodifiableMap(map); + } + + private static Map byState(List renames) { + Map map = new LinkedHashMap<>(); + for (Rename rename : renames) { + if (rename.exact()) { + String state = ContentGate.normalizeState(rename.when()); + if (state != null) { + map.putIfAbsent(state, rename.supplement()); + } + } + } + return Collections.unmodifiableMap(map); + } + + private static List defaults0() { + List filters = new ArrayList<>(); + + filters.add(new Rename("CHAIN", "IRON_CHAIN")); + filters.add(new Rename("GRASS", "SHORT_GRASS")); + filters.add(new Rename("SHORT_GRASS", "GRASS")); + + // Below 1.16 + filters.add(new Rename("WEEPING_VINES", "NETHER_FENCE")); + filters.add(new Rename("WEEPING_VINES_PLANT", "NETHER_FENCE")); + filters.add(new Rename("WARPED_WART_BLOCK", "NETHER_WART_BLOCK")); + filters.add(new Rename("TWISTING_VINES", "BAMBOO")); + filters.add(new Rename("TWISTING_VINES_PLANT", "BAMBOO")); + filters.add(new Rename("TARGET", "COBBLESTONE")); + filters.add(new Rename("SOUL_SOIL", "SOULSAND")); + filters.add(new Rename("SOUL_TORCH", "TORCH")); + filters.add(new Rename("SOUL_LANTERN", "LANTERN")); + filters.add(new Rename("SOUL_FIRE", "FIRE")); + filters.add(new Rename("SOUL_CAMPFIRE", "CAMPFIRE")); + filters.add(new Rename("SHROOMLIGHT", "GLOWSTONE")); + filters.add(new Rename("RESPAWN_ANCHOR", "OBSIDIAN")); + filters.add(new Rename("NETHER_SPROUTS", "RED_MUSHROOM")); + filters.add(new Rename("NETHER_GOLD_ORE", "GOLD_ORE")); + filters.add(new Rename("LODESTONE", "STONE")); + filters.add(new Rename("STRIPPED_WARPED_HYPHAE", "BROWN_MUSHROOM_BLOCK")); + filters.add(new Rename("STRIPPED_CRIMSON_HYPHAE", "RED_MUSHROOM_BLOCK")); + filters.add(new Rename("WARPED_HYPHAE", "MUSHROOM_STEM")); + filters.add(new Rename("CRIMSON_HYPHAE", "RED_MUSHROOM_BLOCK")); + filters.add(new Rename("GILDED_BLACKSTONE", "COBBLESTONE")); + filters.add(new Rename("CRYING_OBSIDIAN", "OBSIDIAN")); + filters.add(new Rename("STRIPPED_WARPED_STEM", "MUSHROOM_STEM")); + filters.add(new Rename("STRIPPED_CRIMSON_STEM", "MUSHROOM_STEM")); + filters.add(new Rename("WARPED_STEM", "MUSHROOM_STEM")); + filters.add(new Rename("CRIMSON_STEM", "MUSHROOM_STEM")); + filters.add(new Rename("CRIMSON_ROOTS", "RED_MUSHROOM")); + filters.add(new Rename("WARPED_ROOTS", "BROWN_MUSHROOM")); + filters.add(new Rename("CRIMSON_PLANKS", "OAK_PLANKS")); + filters.add(new Rename("WARPED_PLANKS", "OAK_PLANKS")); + filters.add(new Rename("WARPED_NYLIUM", "MYCELIUM")); + filters.add(new Rename("CRIMSON_NYLIUM", "MYCELIUM")); + filters.add(new Rename("WARPED_FUNGUS", "BROWN_MUSHROOM")); + filters.add(new Rename("CRIMSON_FUNGUS", "RED_MUSHROOM")); + filters.add(new Rename("CRACKED_NETHER_BRICKS", "NETHER_BRICKS")); + filters.add(new Rename("CHISELED_NETHER_BRICKS", "NETHER_BRICKS")); + filters.add(new Rename("NETHER_FENCE", "LEGACY_NETHER_FENCE")); + filters.add(new Rename("IRON_CHAIN", "IRON_BARS")); + filters.add(new Rename("NETHERITE_BLOCK", "QUARTZ_BLOCK")); + filters.add(new Rename("BLACKSTONE", "COBBLESTONE")); + filters.add(new Rename("BASALT", "STONE")); + filters.add(new Rename("ANCIENT_DEBRIS", "NETHERRACK")); + filters.add(new Rename("NETHERRACK", "LEGACY_NETHERRACK")); + + // Below 1.15 + filters.add(new Rename("HONEY_BLOCK", "OAK_LEAVES")); + filters.add(new Rename("BEEHIVE", "OAK_LEAVES")); + filters.add(new Rename("BEE_NEST", "OAK_LEAVES")); + + // Below 1.14 + filters.add(new Rename("GRANITE_WALL", "COBBLESTONE_WALL")); + filters.add(new Rename("BLUE_ICE", "PACKED_ICE")); + filters.add(new Rename("DIORITE_WALL", "COBBLESTONE_WALL")); + filters.add(new Rename("ANDESITE_WALL", "COBBLESTONE_WALL")); + filters.add(new Rename("SWEET_BERRY_BUSH", "GRASS")); + filters.add(new Rename("STONECUTTER", "CRAFTING_TABLE")); + filters.add(new Rename("SANDSTONE_STAIRS", "LEGACY_SANDSTONE_STAIRS")); + filters.add(new Rename("SMOOTH_SANDSTONE_STAIRS", "LEGACY_SANDSTONE_STAIRS")); + filters.add(new Rename("MOSSY_COBBLESTONE_STAIRS", "COBBLESTONE_STAIRS")); + filters.add(new Rename("MOSSY_STONE_BRICK_STAIRS", "STONE_BRICK_STAIRS")); + filters.add(new Rename("POLISHED_GRANITE_STAIRS", "COBBLESTONE_STAIRS")); + filters.add(new Rename("GRANITE_STAIRS", "COBBLESTONE_STAIRS")); + filters.add(new Rename("POLISHED_DIORITE_STAIRS", "COBBLESTONE_STAIRS")); + filters.add(new Rename("DIORITE_STAIRS", "COBBLESTONE_STAIRS")); + filters.add(new Rename("POLISHED_ANDESITE_STAIRS", "COBBLESTONE_STAIRS")); + filters.add(new Rename("ANDESITE_STAIRS", "COBBLESTONE_STAIRS")); + filters.add(new Rename("STONE_STAIRS", "COBBLESTONE_STAIRS")); + filters.add(new Rename("END_STONE_BRICK_STAIRS", "LEGACY_SANDSTONE_STAIRS")); + filters.add(new Rename("NETHER_BRICK_STAIRS", "LEGACY_NETHER_BRICK_STAIRS")); + filters.add(new Rename("RED_NETHER_BRICK_STAIRS", "NETHER_BRICK_STAIRS")); + filters.add(new Rename("SMOOTH_QUARTZ_STAIRS", "LEGACY_QUARTZ_STAIRS")); + filters.add(new Rename("QUARTZ_STAIRS", "LEGACY_QUARTZ_STAIRS")); + filters.add(new Rename("RED_SANDSTONE_STAIRS", "LEGACY_RED_SANDSTONE_STAIRS")); + filters.add(new Rename("SMOOTH_RED_SANDSTONE_STAIRS", "LEGACY_RED_SANDSTONE_STAIRS")); + filters.add(new Rename("STONE_SLAB", "SMOOTH_STONE_SLAB")); + filters.add(new Rename("SMOKER", "FURNACE")); + filters.add(new Rename("SMITHING_TABLE", "CRAFTING_TABLE")); + filters.add(new Rename("END_STONE_BRICK_SLAB", "SANDSTONE_SLAB")); + filters.add(new Rename("RED_NETHER_BRICK_SLAB", "NETHER_BRICK_SLAB")); + filters.add(new Rename("SMOOTH_QUARTZ_SLAB", "QUARTZ_SLAB")); + filters.add(new Rename("CUT_SANDSTONE_SLAB", "SANDSTONE_SLAB")); + filters.add(new Rename("CUT_RED_SANDSTONE_SLAB", "RED_SANDSTONE_SLAB")); + filters.add(new Rename("SMOOTH_RED_SANDSTONE_SLAB", "RED_SANDSTONE_SLAB")); + filters.add(new Rename("SMOOTH_SANDSTONE_SLAB", "SANDSTONE_SLAB")); + filters.add(new Rename("MOSSY_COBBLESTONE_SLAB", "COBBLESTONE_SLAB")); + filters.add(new Rename("MOSSY_STONE_BRICK_SLAB", "STONE_BRICK_SLAB")); + filters.add(new Rename("STONE_SLAB", "SMOOTH_STONE_SLAB")); + filters.add(new Rename("ANDESITE_SLAB", "COBBLESTONE_SLAB")); + filters.add(new Rename("ANDESITE_SLAB", "COBBLESTONE_SLAB")); + filters.add(new Rename("DIORITE_SLAB", "COBBLESTONE_SLAB")); + filters.add(new Rename("GRANITE_SLAB", "COBBLESTONE_SLAB")); + filters.add(new Rename("POLISHED_ANDESITE_SLAB", "SMOOTH_STONE_SLAB")); + filters.add(new Rename("POLISHED_DIORITE_SLAB", "SMOOTH_STONE_SLAB")); + filters.add(new Rename("POLISHED_GRANITE_SLAB", "SMOOTH_STONE_SLAB")); + filters.add(new Rename("WARPED_WALL_SIGN", "LEGACY_WALL_SIGN")); + filters.add(new Rename("WARPED_SIGN", "LEGACY_SIGN_POST")); + filters.add(new Rename("SPRUCE_WALL_SIGN", "LEGACY_WALL_SIGN")); + filters.add(new Rename("SPRUCE_SIGN", "LEGACY_SIGN_POST")); + filters.add(new Rename("OAK_WALL_SIGN", "LEGACY_WALL_SIGN")); + filters.add(new Rename("OAK_SIGN", "LEGACY_SIGN_POST")); + filters.add(new Rename("JUNGLE_WALL_SIGN", "LEGACY_WALL_SIGN")); + filters.add(new Rename("JUNGLE_SIGN", "LEGACY_SIGN_POST")); + filters.add(new Rename("DARK_OAK_WALL_SIGN", "LEGACY_WALL_SIGN")); + filters.add(new Rename("DARK_OAK_SIGN", "LEGACY_SIGN_POST")); + filters.add(new Rename("CRIMSON_WALL_SIGN", "LEGACY_WALL_SIGN")); + filters.add(new Rename("CRIMSON_SIGN", "LEGACY_SIGN_POST")); + filters.add(new Rename("BIRCH_WALL_SIGN", "LEGACY_WALL_SIGN")); + filters.add(new Rename("BIRCH_SIGN", "LEGACY_SIGN_POST")); + filters.add(new Rename("ACACIA_WALL_SIGN", "LEGACY_WALL_SIGN")); + filters.add(new Rename("ACACIA_SIGN", "LEGACY_SIGN_POST")); + filters.add(new Rename("SCAFFOLDING", "BIRCH_FENCE")); + filters.add(new Rename("LECTERN", "BOOKSHELF")); + filters.add(new Rename("LANTERN", "REDSTONE_LAMP")); + filters.add(new Rename("JIGSAW", "AIR")); + filters.add(new Rename("GRINDSTONE", "COBBLESTONE")); + filters.add(new Rename("FLETCHING_TABLE", "CRAFTING_TABLE")); + filters.add(new Rename("COMPOSTER", "CHEST")); + filters.add(new Rename("CARTOGRAPHY_TABLE", "CRAFTING_TABLE")); + filters.add(new Rename("CAMPFIRE", "DARK_OAK_SLAB")); + filters.add(new Rename("BLAST_FURNACE", "FURNACE")); + filters.add(new Rename("BELL", "REDSTONE_LAMP")); + filters.add(new Rename("minecraft:barrel[facing=south]", "minecraft:hay_bale[axis=z]", true)); + filters.add(new Rename("minecraft:barrel[facing=north]", "minecraft:hay_bale[axis=z]", true)); + filters.add(new Rename("minecraft:barrel[facing=east]", "minecraft:hay_bale[axis=x]", true)); + filters.add(new Rename("minecraft:barrel[facing=west]", "minecraft:hay_bale[axis=x]", true)); + filters.add(new Rename("minecraft:barrel[facing=up]", "minecraft:hay_bale[axis=y]", true)); + filters.add(new Rename("minecraft:barrel[facing=down]", "minecraft:hay_bale[axis=y]", true)); + filters.add(new Rename("BAMBOO", "BIRCH_FENCE")); + filters.add(new Rename("BAMBOO_SAPLING", "BIRCH_SAPLING")); + filters.add(new Rename("POTTED_BAMBOO", "POTTED_BIRCH_SAPLING")); + + return Collections.unmodifiableList(filters); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/MissingBlockState.java b/core/src/main/java/art/arcane/iris/core/compat/MissingBlockState.java new file mode 100644 index 000000000..60fd59b74 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/MissingBlockState.java @@ -0,0 +1,246 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import art.arcane.iris.spi.PlatformBlockState; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Placeholder for a block key the running server does not have. It keeps the missing key so an object placement's + * {@code edit} rule can still match and rewrite it (the type-replace rescue), and behaves as the platform's air + * everywhere else: every physical predicate and {@link #nativeHandle()} come from the real air state, so a placeholder + * that does reach the world places air and never throws. + */ +public final class MissingBlockState implements PlatformBlockState { + private final String key; + private final String materialKey; + private final String namespace; + private final Map properties; + private final PlatformBlockState air; + + private MissingBlockState(String key, PlatformBlockState air) { + this.key = key; + this.materialKey = ContentGate.baseKey(key); + int colon = materialKey.indexOf(':'); + this.namespace = colon < 0 ? "minecraft" : materialKey.substring(0, colon); + this.properties = properties(key); + this.air = air; + } + + /** @param normalizedKey the missing key as {@link ContentGate#normalizeState(String)} returns it */ + public static MissingBlockState of(String normalizedKey, PlatformBlockState air) { + return new MissingBlockState(Objects.requireNonNull(normalizedKey, "key"), Objects.requireNonNull(air, "air")); + } + + public static boolean isPlaceholder(PlatformBlockState state) { + return state instanceof MissingBlockState; + } + + /** The missing key, without properties. */ + public String missingKey() { + return materialKey; + } + + @Override + public String key() { + return key; + } + + @Override + public String namespace() { + return namespace; + } + + @Override + public String materialKey() { + return materialKey; + } + + @Override + public boolean isAir() { + return true; + } + + @Override + public boolean isSolid() { + return false; + } + + @Override + public boolean isOccluding() { + return false; + } + + @Override + public boolean isCustom() { + return false; + } + + @Override + public boolean isFluid() { + return false; + } + + @Override + public boolean isWater() { + return false; + } + + @Override + public boolean isWaterLogged() { + return false; + } + + @Override + public boolean isLit() { + return false; + } + + @Override + public boolean isUpdatable() { + return false; + } + + @Override + public boolean isFoliage() { + return false; + } + + @Override + public boolean isTreeBlock() { + return false; + } + + @Override + public boolean isFoliagePlantable() { + return false; + } + + @Override + public boolean isDecorant() { + return false; + } + + @Override + public boolean isStorage() { + return false; + } + + @Override + public boolean isStorageChest() { + return false; + } + + @Override + public boolean isOre() { + return false; + } + + @Override + public boolean isDeepSlate() { + return false; + } + + @Override + public boolean isVineBlock() { + return false; + } + + @Override + public boolean canPlaceOnto(PlatformBlockState onto) { + return air.canPlaceOnto(onto); + } + + /** + * Same missing block, and every property this placeholder names is present with the same value on {@code state} - + * the partial-match contract the adapters use for exact {@code find} entries. + */ + @Override + public boolean matches(PlatformBlockState state) { + if (!(state instanceof MissingBlockState other) || !materialKey.equals(other.materialKey)) { + return false; + } + for (Map.Entry required : properties.entrySet()) { + if (!required.getValue().equals(other.properties.get(required.getKey()))) { + return false; + } + } + return true; + } + + @Override + public boolean hasTileEntity() { + return false; + } + + @Override + public PlatformBlockState withProperty(String name, String value) { + Map merged = new LinkedHashMap<>(properties); + merged.put(name, value); + StringBuilder rebuilt = new StringBuilder(materialKey).append('['); + boolean first = true; + for (Map.Entry entry : merged.entrySet()) { + if (!first) { + rebuilt.append(','); + } + rebuilt.append(entry.getKey()).append('=').append(entry.getValue()); + first = false; + } + return new MissingBlockState(rebuilt.append(']').toString(), air); + } + + @Override + public Object nativeHandle() { + return air.nativeHandle(); + } + + @Override + public boolean equals(Object other) { + return this == other || (other instanceof MissingBlockState state && key.equals(state.key)); + } + + @Override + public int hashCode() { + return key.hashCode(); + } + + @Override + public String toString() { + return "missing:" + key; + } + + private static Map properties(String key) { + int open = key.indexOf('['); + if (open < 0) { + return Map.of(); + } + int close = key.lastIndexOf(']'); + String body = key.substring(open + 1, close < open ? key.length() : close); + Map parsed = new LinkedHashMap<>(); + for (String pair : body.split(",")) { + int eq = pair.indexOf('='); + if (eq > 0) { + parsed.put(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim()); + } + } + return parsed; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/compat/PackCompatReport.java b/core/src/main/java/art/arcane/iris/core/compat/PackCompatReport.java new file mode 100644 index 000000000..3da136465 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/compat/PackCompatReport.java @@ -0,0 +1,226 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package art.arcane.iris.core.compat; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Every gating decision made for one pack instance ({@code IrisData}). Thread-safe; findings are deduplicated on + * {@link CompatFinding#dedupKey()} and kept in first-recorded order. Console rendering lives here so Bukkit and modded + * print the same text. + */ +public final class PackCompatReport { + private static final Comparator BY_ACTION = Comparator.comparingInt(finding -> finding.action().ordinal()); + + private final Map findings = new LinkedHashMap<>(); + private String incompleteReason; + + /** A report rebuilt from persisted findings, for rendering a boot listing or a command listing again. */ + public static PackCompatReport of(Iterable findings) { + PackCompatReport report = new PackCompatReport(); + report.recordAll(findings); + return report; + } + + public synchronized void record(CompatFinding finding) { + if (finding == null) { + return; + } + findings.putIfAbsent(finding.dedupKey(), finding); + } + + public synchronized void recordAll(Iterable all) { + if (all == null) { + return; + } + for (CompatFinding finding : all) { + record(finding); + } + } + + /** Forget every finding and any incomplete marker; a studio hotload rebuilds the pack's report from scratch. */ + public synchronized void clear() { + findings.clear(); + incompleteReason = null; + } + + /** The registry could not be consulted for part of the pack; the report may be missing findings. */ + public synchronized void markIncomplete(String reason) { + if (incompleteReason == null) { + incompleteReason = reason == null ? "registry not ready" : reason; + } + } + + public synchronized boolean isIncomplete() { + return incompleteReason != null; + } + + public synchronized String incompleteReason() { + return incompleteReason; + } + + public synchronized boolean isEmpty() { + return findings.isEmpty(); + } + + public synchronized int size() { + return findings.size(); + } + + public synchronized List findings() { + return List.copyOf(findings.values()); + } + + /** + * Findings grouped by {@link CompatFinding#groupKey()}, keys in first-seen order; inside a key the exclusions come + * first, then the drops, then the substitutions (first-seen within each action), so a capped listing shows the + * decisions that removed content before the ones that kept it. + */ + public synchronized Map> byKey() { + Map> grouped = new LinkedHashMap<>(); + for (CompatFinding finding : findings.values()) { + grouped.computeIfAbsent(finding.groupKey(), ignored -> new ArrayList<>()).add(finding); + } + for (List group : grouped.values()) { + group.sort(BY_ACTION); + } + return grouped; + } + + public synchronized Map countsByAction() { + Map counts = new EnumMap<>(CompatAction.class); + for (CompatFinding finding : findings.values()) { + counts.merge(finding.action(), 1, Integer::sum); + } + return counts; + } + + public synchronized int distinctKeys() { + return byKey().size(); + } + + /** + * {@code 3 content keys unavailable on Minecraft 26.1.2: 2 excluded, 5 dropped, 1 substituted.} Empty string when + * there is nothing to say. + */ + public synchronized String summaryLine(String minecraftVersion) { + if (findings.isEmpty()) { + return ""; + } + Map counts = countsByAction(); + int keys = distinctKeys(); + StringBuilder sb = new StringBuilder(96); + sb.append(keys).append(keys == 1 ? " content key" : " content keys") + .append(" unavailable on Minecraft ").append(versionOrUnknown(minecraftVersion)).append(": "); + boolean first = true; + for (CompatAction action : CompatAction.values()) { + int count = counts.getOrDefault(action, 0); + if (count == 0) { + continue; + } + if (!first) { + sb.append(", "); + } + sb.append(count).append(' ').append(action.label()); + first = false; + } + sb.append('.'); + return sb.toString(); + } + + /** + * Boot listing: header, one line per key with up to {@code perKeyCap} subjects, then the remediation hint. Empty + * list when there are no findings. + */ + public synchronized List bootLines(String packName, String minecraftVersion, int perKeyCap) { + List lines = new ArrayList<>(); + if (findings.isEmpty()) { + return lines; + } + lines.add("Pack '" + packName + "': content unavailable on Minecraft " + versionOrUnknown(minecraftVersion)); + for (String line : keyLines(perKeyCap)) { + lines.add(" " + line); + } + lines.add(" Update the server to a newer Minecraft to restore this content, or declare fallbacks" + + " (dimension blockFallbacks, block backup). /iris pack compat " + packName + " lists everything."); + return lines; + } + + /** + * One line per key, with up to {@code perKeyCap} subjects each and a {@code +N more} tail beyond that; pass 0 for + * every subject. No header and no remediation hint - the command wraps these in localized text. + */ + public synchronized List keyLines(int perKeyCap) { + List lines = new ArrayList<>(); + for (Map.Entry> entry : byKey().entrySet()) { + lines.add(renderKeyLine(entry.getValue(), perKeyCap)); + } + if (incompleteReason != null) { + lines.add("(incomplete: " + incompleteReason + ")"); + } + return lines; + } + + /** One line per finding, grouped under a key header line. Empty list when there are no findings. */ + public synchronized List detailLines() { + List lines = new ArrayList<>(); + for (Map.Entry> entry : byKey().entrySet()) { + CompatFinding head = entry.getValue().getFirst(); + lines.add(head.key() + " (" + head.registry().label() + ")"); + for (CompatFinding finding : entry.getValue()) { + lines.add(" " + finding.subjectLine()); + } + } + if (incompleteReason != null) { + lines.add("(incomplete: " + incompleteReason + ")"); + } + return lines; + } + + private static String renderKeyLine(List group, int perKeyCap) { + CompatFinding head = group.getFirst(); + StringBuilder sb = new StringBuilder(128); + sb.append(head.key()).append(" (").append(head.registry().label()).append("): "); + int cap = perKeyCap <= 0 ? Integer.MAX_VALUE : perKeyCap; + int shown = 0; + for (CompatFinding finding : group) { + if (shown == cap) { + break; + } + if (shown > 0) { + sb.append("; "); + } + sb.append(finding.subjectLine()); + shown++; + } + int rest = group.size() - shown; + if (rest > 0) { + sb.append("; +").append(rest).append(" more"); + } + return sb.toString(); + } + + private static String versionOrUnknown(String minecraftVersion) { + return minecraftVersion == null || minecraftVersion.isBlank() ? "unknown" : minecraftVersion; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java index 9b4921556..5ed2351bd 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisData.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisData.java @@ -18,6 +18,9 @@ package art.arcane.iris.core.loader; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; import com.google.gson.ExclusionStrategy; import com.google.gson.FieldAttributes; import com.google.gson.Gson; @@ -103,6 +106,8 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { private static final Set ENGINE_HOLDERS = Collections.synchronizedSet(Collections.newSetFromMap(new IdentityHashMap<>())); private final File dataFolder; + private final transient PackCompatReport compatReport = new PackCompatReport(); + private transient volatile ContentGate contentGate; private final int id; private final boolean datapackCompiler; private volatile boolean closed = false; @@ -429,6 +434,35 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { } public void preprocessObject(IrisRegistrant t) { + if (t == null) { + return; + } + try { + t.setCompat(t.evaluateCompat(getContentGate())); + } catch (Throwable e) { + IrisLogging.reportError(e); + t.setCompat(CompatStatus.OK); + } + } + + /** Every version-content gating decision made for this pack instance. */ + public PackCompatReport getCompatReport() { + return compatReport; + } + + /** The version-content gate for this pack instance (lazy; rebuilt on hotload with the loaders). */ + public ContentGate getContentGate() { + ContentGate gate = contentGate; + if (gate == null) { + synchronized (this) { + gate = contentGate; + if (gate == null) { + gate = ContentGate.forData(this); + contentGate = gate; + } + } + } + return gate; } public void close() { @@ -481,6 +515,8 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory { public synchronized void hotloaded() { StructureGraphCatalog.invalidate(this); IrisObjectScale.invalidate(this); + contentGate = null; + compatReport.clear(); closed = false; possibleSnippets = new KMap<>(); builder = new GsonBuilder() diff --git a/core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java b/core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java index 35aac5081..7e3a15570 100644 --- a/core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java +++ b/core/src/main/java/art/arcane/iris/core/loader/IrisRegistrant.java @@ -18,6 +18,8 @@ package art.arcane.iris.core.loader; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; import com.google.gson.GsonBuilder; import art.arcane.iris.spi.IrisLogging; import lombok.Data; @@ -34,6 +36,23 @@ public abstract class IrisRegistrant { private transient String loadKey; private transient File loadFile; + /** Version-content gate verdict; excluded registrants must be filtered out of every pool that could pick them. */ + @EqualsAndHashCode.Exclude + private transient CompatStatus compat = CompatStatus.OK; + + /** + * Version-content gate hook, called once per load from {@code IrisData.preprocessObject} before the registrant is + * cached. The default runs the gate's annotated-field walker; subclasses whose exclusion also depends on their pools + * (spawners, loot tables, regions, the dimension, jigsaw pieces) override, call {@code super}, and combine. + */ + public CompatStatus evaluateCompat(ContentGate gate) { + return gate.evaluate(this); + } + + public boolean isCompatExcluded() { + CompatStatus status = compat; + return status != null && status.excluded(); + } public abstract String getFolderName(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java index 2bbaa2c69..a335a340e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBlockData.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; @@ -141,10 +142,15 @@ public class IrisBlockData extends IrisRegistrant { return computeProperties(getData()); } + /** The full state this entry names, {@code namespace:block[props]}, as the gate and the registries expect it. */ + public String stateKey() { + return keyify(getBlock()) + computeProperties(); + } + public PlatformBlockState getBlockData(IrisData data) { return blockdata.aquire(() -> { - IrisBlockData customData = data.getBlockLoader().load(getBlock(), false); + IrisBlockData customData = data.getBlockLoader() == null ? null : data.getBlockLoader().load(getBlock(), false); if (customData != null) { PlatformBlockState customState = customData.getBlockData(data); @@ -168,7 +174,7 @@ public class IrisBlockData extends IrisRegistrant { IrisLogging.debug("Block Data used " + sx + " (CUSTOM)"); } - PlatformBlockState bx = B.getState(sx); + PlatformBlockState bx = resolve(data, sx); if (bx != null) { return bx; @@ -178,8 +184,8 @@ public class IrisBlockData extends IrisRegistrant { } } - String ss = keyify(getBlock()) + computeProperties(); - PlatformBlockState resolved = B.getState(ss); + String ss = stateKey(); + PlatformBlockState resolved = resolve(data, ss); if (debug) { IrisLogging.debug("Block Data used " + ss); @@ -193,10 +199,42 @@ public class IrisBlockData extends IrisRegistrant { return backup.getBlockData(data); } - return B.getState("AIR"); + IrisLogging.warnOnce("block-missing:" + ss, "Block " + ss + " does not exist on this server and has no backup; generating air"); + return B.getAirState(); }); } + /** + * {@link #getBlockData(IrisData)} for match sides such as {@code edit} find lists: a key missing on this server + * (after custom blocks, the gate chain and every backup) yields a {@link art.arcane.iris.core.compat.MissingBlockState} + * carrying the key instead of air, so a palette placeholder with the same key can still be matched and rewritten. + */ + public PlatformBlockState getBlockDataOrPlaceholder(IrisData data) { + ContentGate gate = data.getContentGate(); + String state = stateKey(); + + if (!gate.ready() || hasCustomBlock(data) || gate.resolveBlock(state) != null) { + return getBlockData(data); + } + + if (backup != null) { + return backup.getBlockDataOrPlaceholder(data); + } + + PlatformBlockState placeholder = gate.resolveBlockOrPlaceholder(state); + return placeholder == null ? getBlockData(data) : placeholder; + } + + private boolean hasCustomBlock(IrisData data) { + return data.getBlockLoader() != null && data.getBlockLoader().load(getBlock(), false) != null; + } + + /** Registry, legacy rename table, then dimension blockFallbacks; null when the state is missing on this server. */ + private static PlatformBlockState resolve(IrisData data, String state) { + ContentGate.BlockResolution resolution = data.getContentGate().resolveBlock(state); + return resolution == null ? null : resolution.state(); + } + public TileData tryGetTile(IrisData data) { //TODO Do like a registry thing with the tile data registry. Also update the parsing of data to include **block** entities. PlatformBlockState state = getBlockData(data); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java index 4dc7a9f12..cf18b690a 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCompat.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.LegacyBlockRenames; import art.arcane.iris.platform.bukkit.BukkitBlockResolution; import art.arcane.iris.spi.IrisLogging; @@ -121,144 +122,9 @@ public class IrisCompat { private static KList getDefaultBlockCompatabilityFilters() { KList filters = new KList<>(); - - filters.add(new IrisCompatabilityBlockFilter("CHAIN", "IRON_CHAIN")); - filters.add(new IrisCompatabilityBlockFilter("GRASS", "SHORT_GRASS")); - filters.add(new IrisCompatabilityBlockFilter("SHORT_GRASS", "GRASS")); - - // Below 1.16 - filters.add(new IrisCompatabilityBlockFilter("WEEPING_VINES", "NETHER_FENCE")); - filters.add(new IrisCompatabilityBlockFilter("WEEPING_VINES_PLANT", "NETHER_FENCE")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_WART_BLOCK", "NETHER_WART_BLOCK")); - filters.add(new IrisCompatabilityBlockFilter("TWISTING_VINES", "BAMBOO")); - filters.add(new IrisCompatabilityBlockFilter("TWISTING_VINES_PLANT", "BAMBOO")); - filters.add(new IrisCompatabilityBlockFilter("TARGET", "COBBLESTONE")); - filters.add(new IrisCompatabilityBlockFilter("SOUL_SOIL", "SOULSAND")); - filters.add(new IrisCompatabilityBlockFilter("SOUL_TORCH", "TORCH")); - filters.add(new IrisCompatabilityBlockFilter("SOUL_LANTERN", "LANTERN")); - filters.add(new IrisCompatabilityBlockFilter("SOUL_FIRE", "FIRE")); - filters.add(new IrisCompatabilityBlockFilter("SOUL_CAMPFIRE", "CAMPFIRE")); - filters.add(new IrisCompatabilityBlockFilter("SHROOMLIGHT", "GLOWSTONE")); - filters.add(new IrisCompatabilityBlockFilter("RESPAWN_ANCHOR", "OBSIDIAN")); - filters.add(new IrisCompatabilityBlockFilter("NETHER_SPROUTS", "RED_MUSHROOM")); - filters.add(new IrisCompatabilityBlockFilter("NETHER_GOLD_ORE", "GOLD_ORE")); - filters.add(new IrisCompatabilityBlockFilter("LODESTONE", "STONE")); - filters.add(new IrisCompatabilityBlockFilter("STRIPPED_WARPED_HYPHAE", "BROWN_MUSHROOM_BLOCK")); - filters.add(new IrisCompatabilityBlockFilter("STRIPPED_CRIMSON_HYPHAE", "RED_MUSHROOM_BLOCK")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_HYPHAE", "MUSHROOM_STEM")); - filters.add(new IrisCompatabilityBlockFilter("CRIMSON_HYPHAE", "RED_MUSHROOM_BLOCK")); - filters.add(new IrisCompatabilityBlockFilter("GILDED_BLACKSTONE", "COBBLESTONE")); - filters.add(new IrisCompatabilityBlockFilter("CRYING_OBSIDIAN", "OBSIDIAN")); - filters.add(new IrisCompatabilityBlockFilter("STRIPPED_WARPED_STEM", "MUSHROOM_STEM")); - filters.add(new IrisCompatabilityBlockFilter("STRIPPED_CRIMSON_STEM", "MUSHROOM_STEM")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_STEM", "MUSHROOM_STEM")); - filters.add(new IrisCompatabilityBlockFilter("CRIMSON_STEM", "MUSHROOM_STEM")); - filters.add(new IrisCompatabilityBlockFilter("CRIMSON_ROOTS", "RED_MUSHROOM")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_ROOTS", "BROWN_MUSHROOM")); - filters.add(new IrisCompatabilityBlockFilter("CRIMSON_PLANKS", "OAK_PLANKS")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_PLANKS", "OAK_PLANKS")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_NYLIUM", "MYCELIUM")); - filters.add(new IrisCompatabilityBlockFilter("CRIMSON_NYLIUM", "MYCELIUM")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_FUNGUS", "BROWN_MUSHROOM")); - filters.add(new IrisCompatabilityBlockFilter("CRIMSON_FUNGUS", "RED_MUSHROOM")); - filters.add(new IrisCompatabilityBlockFilter("CRACKED_NETHER_BRICKS", "NETHER_BRICKS")); - filters.add(new IrisCompatabilityBlockFilter("CHISELED_NETHER_BRICKS", "NETHER_BRICKS")); - filters.add(new IrisCompatabilityBlockFilter("NETHER_FENCE", "LEGACY_NETHER_FENCE")); - filters.add(new IrisCompatabilityBlockFilter("IRON_CHAIN", "IRON_BARS")); - filters.add(new IrisCompatabilityBlockFilter("NETHERITE_BLOCK", "QUARTZ_BLOCK")); - filters.add(new IrisCompatabilityBlockFilter("BLACKSTONE", "COBBLESTONE")); - filters.add(new IrisCompatabilityBlockFilter("BASALT", "STONE")); - filters.add(new IrisCompatabilityBlockFilter("ANCIENT_DEBRIS", "NETHERRACK")); - filters.add(new IrisCompatabilityBlockFilter("NETHERRACK", "LEGACY_NETHERRACK")); - - // Below 1.15 - filters.add(new IrisCompatabilityBlockFilter("HONEY_BLOCK", "OAK_LEAVES")); - filters.add(new IrisCompatabilityBlockFilter("BEEHIVE", "OAK_LEAVES")); - filters.add(new IrisCompatabilityBlockFilter("BEE_NEST", "OAK_LEAVES")); - - // Below 1.14 - filters.add(new IrisCompatabilityBlockFilter("GRANITE_WALL", "COBBLESTONE_WALL")); - filters.add(new IrisCompatabilityBlockFilter("BLUE_ICE", "PACKED_ICE")); - filters.add(new IrisCompatabilityBlockFilter("DIORITE_WALL", "COBBLESTONE_WALL")); - filters.add(new IrisCompatabilityBlockFilter("ANDESITE_WALL", "COBBLESTONE_WALL")); - filters.add(new IrisCompatabilityBlockFilter("SWEET_BERRY_BUSH", "GRASS")); - filters.add(new IrisCompatabilityBlockFilter("STONECUTTER", "CRAFTING_TABLE")); - filters.add(new IrisCompatabilityBlockFilter("SANDSTONE_STAIRS", "LEGACY_SANDSTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("SMOOTH_SANDSTONE_STAIRS", "LEGACY_SANDSTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("MOSSY_COBBLESTONE_STAIRS", "COBBLESTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("MOSSY_STONE_BRICK_STAIRS", "STONE_BRICK_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("POLISHED_GRANITE_STAIRS", "COBBLESTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("GRANITE_STAIRS", "COBBLESTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("POLISHED_DIORITE_STAIRS", "COBBLESTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("DIORITE_STAIRS", "COBBLESTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("POLISHED_ANDESITE_STAIRS", "COBBLESTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("ANDESITE_STAIRS", "COBBLESTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("STONE_STAIRS", "COBBLESTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("END_STONE_BRICK_STAIRS", "LEGACY_SANDSTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("NETHER_BRICK_STAIRS", "LEGACY_NETHER_BRICK_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("RED_NETHER_BRICK_STAIRS", "NETHER_BRICK_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("SMOOTH_QUARTZ_STAIRS", "LEGACY_QUARTZ_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("QUARTZ_STAIRS", "LEGACY_QUARTZ_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("RED_SANDSTONE_STAIRS", "LEGACY_RED_SANDSTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("SMOOTH_RED_SANDSTONE_STAIRS", "LEGACY_RED_SANDSTONE_STAIRS")); - filters.add(new IrisCompatabilityBlockFilter("STONE_SLAB", "SMOOTH_STONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("SMOKER", "FURNACE")); - filters.add(new IrisCompatabilityBlockFilter("SMITHING_TABLE", "CRAFTING_TABLE")); - filters.add(new IrisCompatabilityBlockFilter("END_STONE_BRICK_SLAB", "SANDSTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("RED_NETHER_BRICK_SLAB", "NETHER_BRICK_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("SMOOTH_QUARTZ_SLAB", "QUARTZ_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("CUT_SANDSTONE_SLAB", "SANDSTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("CUT_RED_SANDSTONE_SLAB", "RED_SANDSTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("SMOOTH_RED_SANDSTONE_SLAB", "RED_SANDSTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("SMOOTH_SANDSTONE_SLAB", "SANDSTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("MOSSY_COBBLESTONE_SLAB", "COBBLESTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("MOSSY_STONE_BRICK_SLAB", "STONE_BRICK_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("STONE_SLAB", "SMOOTH_STONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("ANDESITE_SLAB", "COBBLESTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("ANDESITE_SLAB", "COBBLESTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("DIORITE_SLAB", "COBBLESTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("GRANITE_SLAB", "COBBLESTONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("POLISHED_ANDESITE_SLAB", "SMOOTH_STONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("POLISHED_DIORITE_SLAB", "SMOOTH_STONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("POLISHED_GRANITE_SLAB", "SMOOTH_STONE_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_WALL_SIGN", "LEGACY_WALL_SIGN")); - filters.add(new IrisCompatabilityBlockFilter("WARPED_SIGN", "LEGACY_SIGN_POST")); - filters.add(new IrisCompatabilityBlockFilter("SPRUCE_WALL_SIGN", "LEGACY_WALL_SIGN")); - filters.add(new IrisCompatabilityBlockFilter("SPRUCE_SIGN", "LEGACY_SIGN_POST")); - filters.add(new IrisCompatabilityBlockFilter("OAK_WALL_SIGN", "LEGACY_WALL_SIGN")); - filters.add(new IrisCompatabilityBlockFilter("OAK_SIGN", "LEGACY_SIGN_POST")); - filters.add(new IrisCompatabilityBlockFilter("JUNGLE_WALL_SIGN", "LEGACY_WALL_SIGN")); - filters.add(new IrisCompatabilityBlockFilter("JUNGLE_SIGN", "LEGACY_SIGN_POST")); - filters.add(new IrisCompatabilityBlockFilter("DARK_OAK_WALL_SIGN", "LEGACY_WALL_SIGN")); - filters.add(new IrisCompatabilityBlockFilter("DARK_OAK_SIGN", "LEGACY_SIGN_POST")); - filters.add(new IrisCompatabilityBlockFilter("CRIMSON_WALL_SIGN", "LEGACY_WALL_SIGN")); - filters.add(new IrisCompatabilityBlockFilter("CRIMSON_SIGN", "LEGACY_SIGN_POST")); - filters.add(new IrisCompatabilityBlockFilter("BIRCH_WALL_SIGN", "LEGACY_WALL_SIGN")); - filters.add(new IrisCompatabilityBlockFilter("BIRCH_SIGN", "LEGACY_SIGN_POST")); - filters.add(new IrisCompatabilityBlockFilter("ACACIA_WALL_SIGN", "LEGACY_WALL_SIGN")); - filters.add(new IrisCompatabilityBlockFilter("ACACIA_SIGN", "LEGACY_SIGN_POST")); - filters.add(new IrisCompatabilityBlockFilter("SCAFFOLDING", "BIRCH_FENCE")); - //filters.add(new IrisCompatabilityBlockFilter("LOOM", "LOOM")); - filters.add(new IrisCompatabilityBlockFilter("LECTERN", "BOOKSHELF")); - filters.add(new IrisCompatabilityBlockFilter("LANTERN", "REDSTONE_LAMP")); - filters.add(new IrisCompatabilityBlockFilter("JIGSAW", "AIR")); - filters.add(new IrisCompatabilityBlockFilter("GRINDSTONE", "COBBLESTONE")); - filters.add(new IrisCompatabilityBlockFilter("FLETCHING_TABLE", "CRAFTING_TABLE")); - filters.add(new IrisCompatabilityBlockFilter("COMPOSTER", "CHEST")); - filters.add(new IrisCompatabilityBlockFilter("CARTOGRAPHY_TABLE", "CRAFTING_TABLE")); - filters.add(new IrisCompatabilityBlockFilter("CAMPFIRE", "DARK_OAK_SLAB")); - filters.add(new IrisCompatabilityBlockFilter("BLAST_FURNACE", "FURNACE")); - filters.add(new IrisCompatabilityBlockFilter("BELL", "REDSTONE_LAMP")); - filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=south]", "minecraft:hay_bale[axis=z]", true)); - filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=north]", "minecraft:hay_bale[axis=z]", true)); - filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=east]", "minecraft:hay_bale[axis=x]", true)); - filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=west]", "minecraft:hay_bale[axis=x]", true)); - filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=up]", "minecraft:hay_bale[axis=y]", true)); - filters.add(new IrisCompatabilityBlockFilter("minecraft:barrel[facing=down]", "minecraft:hay_bale[axis=y]", true)); - filters.add(new IrisCompatabilityBlockFilter("BAMBOO", "BIRCH_FENCE")); - filters.add(new IrisCompatabilityBlockFilter("BAMBOO_SAPLING", "BIRCH_SAPLING")); - filters.add(new IrisCompatabilityBlockFilter("POTTED_BAMBOO", "POTTED_BIRCH_SAPLING")); - + for (LegacyBlockRenames.Rename rename : LegacyBlockRenames.defaults()) { + filters.add(new IrisCompatabilityBlockFilter(rename.when(), rename.supplement(), rename.exact())); + } return filters; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index ba967150b..2d9aa8421 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -19,6 +19,9 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.IrisDatapackCompiler.DimensionHeight; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; @@ -60,10 +63,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.ArrayDeque; +import java.util.ArrayList; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; +import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; @@ -125,6 +130,8 @@ public class IrisDimension extends IrisRegistrant { @MinNumber(0) @Desc("The version of this dimension. Changing this will stop users from accidentally upgrading (and breaking their worlds).") private int version = 1; + @Desc("When a block key does not exist on the running Minecraft version, generate this block state instead. Keys are base block keys such as minecraft:sulfur; values are full block states. Applies to every palette, decorator, deposit, replace rule and object in this pack. Content that uses a missing block with no fallback here (and no per-entry backup) is excluded from generation on that version and listed at startup.") + private KMap blockFallbacks = new KMap<>(); @ArrayType(min = 1, type = IrisBlockDrops.class) @Desc("Define custom block drops for this dimension") private KList blockDrops = new KList<>(); @@ -459,11 +466,18 @@ public class IrisDimension extends IrisRegistrant { return regions.v(); } - for (String key : getRegions()) { + for (int index = 0; index < getRegions().size(); index++) { + String key = getRegions().get(index); IrisRegion region = data.getRegionLoader().load(key); - if (region != null) { - regions.put(key, region); + if (region == null) { + continue; } + if (region.isCompatExcluded()) { + CompatPools.drop(data, region, "dimension", getLoadKey(), + "regions[" + index + "] " + key, null); + continue; + } + regions.put(key, region); } for (IrisImageMapBinding binding : getImageMaps()) { if (binding == null || binding.getApplication() != IrisImageMapApplication.REGION) { @@ -476,7 +490,7 @@ public class IrisDimension extends IrisRegistrant { for (String target : imageMapTargets(map)) { String key = localImageMapTarget(target); IrisRegion region = data.getRegionLoader().load(key); - if (region != null) { + if (region != null && !region.isCompatExcluded()) { regions.put(key, region); } } @@ -693,9 +707,11 @@ public class IrisDimension extends IrisRegistrant { // comes from the chunk generator's generation-settings getter, not from the datapack. String derivativeKey = irisBiome.getVanillaDerivativeKey(); + ContentGate contentGate = getLoader() == null ? null : getLoader().getContentGate(); + for (IrisBiomeCustom customBiome : irisBiome.getCustomDerivitives()) { String customBiomeId = customBiome.getId(); - String json = customBiome.generateJson(fixer); + String json = customBiome.generateJson(fixer, contentGate); synchronized (biomes) { if (!biomes.add(customBiomeId)) { @@ -891,6 +907,39 @@ public class IrisDimension extends IrisRegistrant { } } + /** The declared region pool with unloadable and gate-excluded regions removed; drops are reported once. */ + private KList resolveRegionPool(IrisData data, List sink) { + return CompatPools.load(data == null ? null : data.getRegionLoader(), getRegions(), data, + "dimension", getLoadKey(), "regions", sink); + } + + /** + * Cascade: a dimension whose every region was excluded cannot generate anything. Pack validation turns this into a + * blocking error. Loading regions here is safe - regions never load dimensions. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || getRegions().isEmpty()) { + return base; + } + + List drops = new ArrayList<>(); + + if (!resolveRegionPool(data, drops).isEmpty()) { + return base; + } + + return CompatPools.cascade(data, base, drops, "dimension", getLoadKey(), "no regions remain"); + } + @Override public String getFolderName() { return "dimensions"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java index 1549eb5cb..c941e8dfc 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObject.java @@ -19,6 +19,8 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.platform.bukkit.BukkitBlockState; @@ -363,4 +365,14 @@ public class IrisObject extends IrisRegistrant { public String getTypeName() { return "Object"; } + + /** + * Objects are never gated on their own: whether a palette key that is missing on this server matters depends on + * the placement that stamps it (its {@code edit} rules may rewrite it), so the verdict is made per placement and + * per jigsaw piece. Overridden to keep the generic walker out of the block map as well. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + return CompatStatus.OK; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java index f6d311241..78a0e9773 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectIO.java @@ -18,6 +18,8 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.RuntimeUiMessages; import art.arcane.iris.spi.IrisLogging; @@ -40,6 +42,8 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; @@ -50,6 +54,8 @@ import java.util.concurrent.atomic.AtomicReference; public final class IrisObjectIO { private static final String V2_HEADER = "Iris V2 IOB;"; private static final int MAX_PALETTE_ENTRIES = 32_767; + private static final int PALETTE_CACHE_LIMIT = 8192; + private static final Map> PALETTE_CACHE = new ConcurrentHashMap<>(); private IrisObjectIO() { } @@ -86,6 +92,32 @@ public final class IrisObjectIO { } } + /** + * {@link #readPaletteKeys(File)} memoized on path, size and mtime. The version-content gate reads the same object + * headers once per placement that lists them, which at boot is the same file dozens of times. + */ + public static List readPaletteKeysCached(File file) { + if (file == null || !file.isFile()) { + return List.of(); + } + + String key = file.getPath() + '@' + file.lastModified() + '#' + file.length(); + List cached = PALETTE_CACHE.get(key); + + if (cached != null) { + return cached; + } + + List palette = readPaletteKeys(file); + + if (PALETTE_CACHE.size() >= PALETTE_CACHE_LIMIT) { + PALETTE_CACHE.clear(); + } + + PALETTE_CACHE.put(key, palette); + return palette; + } + static IrisBlockVector sampleSize(File file) throws IOException { try (DataInputStream din = new DataInputStream(new FileInputStream(file))) { return new IrisBlockVector(din.readInt(), din.readInt(), din.readInt()); @@ -104,7 +136,7 @@ public final class IrisObjectIO { for (int i = 0; i < s; i++) { IrisBlockVector pos = new IrisBlockVector(din.readShort(), din.readShort(), din.readShort()); - PlatformBlockState data = B.getState(din.readUTF()); + PlatformBlockState data = resolvePaletteState(self, din.readUTF()); if (isStructureMarker(data)) { continue; } @@ -148,7 +180,7 @@ public final class IrisObjectIO { // block count (tens of thousands) instead of times the palette size (hundreds). PlatformBlockState[] resolved = new PlatformBlockState[palette.size()]; for (i = 0; i < resolved.length; i++) { - resolved[i] = B.getState(palette.get(i)); + resolved[i] = resolvePaletteState(self, palette.get(i)); } s = din.readInt(); @@ -185,6 +217,39 @@ public final class IrisObjectIO { } } + /** + * Palette key to state. A key the server does not have goes through the pack's dimension {@code blockFallbacks} + * before the plain lookup, so a declared fallback actually reaches the world instead of becoming air. Present + * keys take exactly one registry lookup, as before. + */ + private static PlatformBlockState resolvePaletteState(IrisObject self, String key) { + PlatformBlockState direct = B.getStateOrNull(key, false); + + if (direct != null) { + return direct; + } + + IrisData data = self.getLoader(); + + if (data != null) { + try { + ContentGate gate = data.getContentGate(); + + if (gate != null && gate.ready()) { + PlatformBlockState viaGate = gate.resolveBlockOrPlaceholder(key); + + if (viaGate != null) { + return viaGate; + } + } + } catch (Throwable e) { + // The gate is advisory here; object loading must never fail because of it. + } + } + + return B.getState(key); + } + private static boolean isStructureMarker(PlatformBlockState data) { if (data == null) { return false; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java index cba70c470..764de193e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectRotation.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.core.compat.MissingBlockState; import art.arcane.iris.platform.bukkit.BukkitBlockState; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; @@ -287,6 +288,9 @@ public class IrisObjectRotation { if (state == null) { return null; } + if (MissingBlockState.isPlaceholder(state)) { + return state; + } StateRotator rotator = PLATFORM_ROTATOR; if (rotator != null) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/PotionEffectTypes.java b/core/src/main/java/art/arcane/iris/engine/object/PotionEffectTypes.java index ef5fafe57..4d4bb0aee 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/PotionEffectTypes.java +++ b/core/src/main/java/art/arcane/iris/engine/object/PotionEffectTypes.java @@ -18,37 +18,20 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.util.common.reflect.KeyedType; import org.bukkit.NamespacedKey; import org.bukkit.Registry; import org.bukkit.potion.PotionEffectType; -import java.util.Collections; -import java.util.HashMap; import java.util.Locale; -import java.util.Map; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; public final class PotionEffectTypes { - private static final Map LEGACY_ALIASES; private static final Set MISSING_WARNED = ConcurrentHashMap.newKeySet(); - static { - Map m = new HashMap<>(); - m.put("SLOW", "SLOWNESS"); - m.put("FAST_DIGGING", "HASTE"); - m.put("SLOW_DIGGING", "MINING_FATIGUE"); - m.put("INCREASE_DAMAGE", "STRENGTH"); - m.put("HEAL", "INSTANT_HEALTH"); - m.put("HARM", "INSTANT_DAMAGE"); - m.put("JUMP", "JUMP_BOOST"); - m.put("CONFUSION", "NAUSEA"); - m.put("DAMAGE_RESISTANCE", "RESISTANCE"); - LEGACY_ALIASES = Collections.unmodifiableMap(m); - } - private PotionEffectTypes() { } @@ -60,7 +43,8 @@ public final class PotionEffectTypes { if (upper.contains(":")) { upper = upper.substring(upper.indexOf(':') + 1); } - return LEGACY_ALIASES.getOrDefault(upper, upper); + String path = upper.toLowerCase(Locale.ROOT); + return ContentGate.POTION_EFFECT_ALIASES.getOrDefault(path, path).toUpperCase(Locale.ROOT); } public static PotionEffectType resolve(String rawName) { diff --git a/core/src/test/java/art/arcane/iris/core/compat/CompatFixtures.java b/core/src/test/java/art/arcane/iris/core/compat/CompatFixtures.java new file mode 100644 index 000000000..52db718e7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/compat/CompatFixtures.java @@ -0,0 +1,410 @@ +package art.arcane.iris.core.compat; + +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.LogLevel; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBiomeWriter; +import art.arcane.iris.spi.PlatformBlockProperty; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformEntityType; +import art.arcane.iris.spi.PlatformItem; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.spi.PlatformScheduler; +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.PlatformWorld; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; + +/** Registry fakes for the gate tests: every block is present unless listed as missing; other registries are explicit. */ +final class CompatFixtures { + private CompatFixtures() { + } + + static FakeRegistries registries(String... missingBlocks) { + FakeRegistries registries = new FakeRegistries(); + registries.missingBlocks.addAll(List.of(missingBlocks)); + return registries; + } + + static FakePlatform bind(FakeRegistries registries) { + IrisPlatforms.unbind(); + FakePlatform platform = new FakePlatform(registries); + IrisPlatforms.bind(platform); + IrisServices.register(PreservationRegistry.class, new NoPreservation()); + return platform; + } + + static void unbind() { + IrisPlatforms.unbind(); + IrisServices.remove(PreservationRegistry.class); + } + + /** Loaders register their caches for shutdown; nothing to preserve in a test JVM. */ + private static final class NoPreservation implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } + + static final class FakeRegistries implements PlatformRegistries { + final Set missingBlocks = new HashSet<>(); + final List blockKeys = new ArrayList<>(List.of("minecraft:stone", "minecraft:air", "minecraft:sand")); + final Set items = new HashSet<>(List.of("minecraft:diamond", "minecraft:stone")); + final Set entities = new HashSet<>(List.of("minecraft:cow", "minecraft:zombie")); + final Set biomes = new HashSet<>(List.of("minecraft:plains", "minecraft:the_void", "minecraft:desert")); + final Set structures = new HashSet<>(List.of("minecraft:village_plains")); + final Set enchantments = new HashSet<>(List.of("minecraft:sharpness")); + final Set potionEffects = new HashSet<>(List.of("minecraft:speed", "minecraft:strength")); + int blockLookups = 0; + + @Override + public PlatformBlockState block(String key) { + PlatformBlockState state = blockOrNull(key, false); + return state == null ? air() : state; + } + + @Override + public PlatformBlockState blockOrNull(String key) { + return blockOrNull(key, false); + } + + @Override + public PlatformBlockState blockOrNull(String key, boolean warn) { + blockLookups++; + String normalized = key.trim().toLowerCase(Locale.ROOT); + int props = normalized.indexOf('['); + String base = props < 0 ? normalized : normalized.substring(0, props); + if (base.indexOf(':') < 0) { + base = "minecraft:" + base; + normalized = base + (props < 0 ? "" : normalized.substring(props)); + } + if (missingBlocks.contains(base)) { + return null; + } + return new FakeBlockState(normalized); + } + + @Override + public PlatformBlockState air() { + return new FakeBlockState("minecraft:air"); + } + + @Override + public PlatformBlockState deepSlateOre(PlatformBlockState block, PlatformBlockState ore) { + return ore; + } + + @Override + public PlatformBiome biome(String key) { + return null; + } + + @Override + public PlatformItem item(String key) { + return null; + } + + @Override + public PlatformEntityType entity(String key) { + return null; + } + + @Override + public List blockKeys() { + return blockKeys; + } + + @Override + public List biomeKeys() { + return new ArrayList<>(biomes); + } + + @Override + public List structureKeys() { + return new ArrayList<>(structures); + } + + @Override + public List itemKeys() { + return new ArrayList<>(items); + } + + @Override + public List entityKeys() { + return new ArrayList<>(entities); + } + + @Override + public List blockTypeKeys() { + return blockKeys; + } + + @Override + public List enchantmentKeys() { + return new ArrayList<>(enchantments); + } + + @Override + public List potionEffectKeys() { + return new ArrayList<>(potionEffects); + } + + @Override + public List lootTableKeys() { + return List.of(); + } + + @Override + public Map> blockStateProperties() { + return Map.of(); + } + } + + record FakeBlockState(String key) implements PlatformBlockState { + private static final Map HANDLES = new java.util.concurrent.ConcurrentHashMap<>(); + + @Override + public String namespace() { + return key.substring(0, key.indexOf(':')); + } + + @Override + public boolean isAir() { + return key.equals("minecraft:air"); + } + + @Override + public boolean isSolid() { + return !isAir(); + } + + @Override + public boolean isOccluding() { + return false; + } + + @Override + public boolean isCustom() { + return false; + } + + @Override + public boolean isFluid() { + return false; + } + + @Override + public boolean isWater() { + return false; + } + + @Override + public boolean isWaterLogged() { + return false; + } + + @Override + public boolean isLit() { + return false; + } + + @Override + public boolean isUpdatable() { + return false; + } + + @Override + public boolean isFoliage() { + return false; + } + + @Override + public boolean isTreeBlock() { + return false; + } + + @Override + public boolean isFoliagePlantable() { + return false; + } + + @Override + public boolean isDecorant() { + return false; + } + + @Override + public boolean isStorage() { + return false; + } + + @Override + public boolean isStorageChest() { + return false; + } + + @Override + public boolean isOre() { + return false; + } + + @Override + public boolean isDeepSlate() { + return false; + } + + @Override + public boolean isVineBlock() { + return false; + } + + @Override + public boolean canPlaceOnto(PlatformBlockState onto) { + return true; + } + + @Override + public boolean matches(PlatformBlockState state) { + return state != null && key.equals(state.key()); + } + + @Override + public boolean hasTileEntity() { + return false; + } + + @Override + public PlatformBlockState withProperty(String name, String value) { + return this; + } + + /** One mocked {@code BlockData} per key: the Bukkit rotation path casts the handle before it inspects it. */ + @Override + public Object nativeHandle() { + return HANDLES.computeIfAbsent(key, ignored -> org.mockito.Mockito.mock(org.bukkit.block.data.BlockData.class)); + } + } + + static final class FakePlatform implements IrisPlatform { + private final PlatformRegistries registries; + private final File dataFolder; + + FakePlatform(PlatformRegistries registries) { + this.registries = registries; + File folder; + try { + folder = java.nio.file.Files.createTempDirectory("iris-compat-platform").toFile(); + } catch (java.io.IOException e) { + folder = new File(System.getProperty("java.io.tmpdir"), "iris-compat-platform"); + } + this.dataFolder = folder; + } + + @Override + public String platformName() { + return "fake"; + } + + @Override + public String minecraftVersion() { + return "26.1.2"; + } + + @Override + public PlatformRegistries registries() { + return registries; + } + + @Override + public PlatformScheduler scheduler() { + return null; + } + + @Override + public PlatformStructureHooks structureHooks() { + return null; + } + + @Override + public PlatformBiomeWriter biomeWriter() { + return null; + } + + @Override + public File dataFolder() { + return dataFolder; + } + + @Override + public File dataFile(String... path) { + return new File(dataFolder, String.join(File.separator, path)); + } + + @Override + public File pluginJar() { + return new File("."); + } + + @Override + public int irisVersionNumber() { + return 0; + } + + @Override + public int minecraftVersionNumber() { + return 0; + } + + @Override + public void callEvent(Object event) { + } + + @Override + public void dispatchConsoleCommand(String command) { + } + + @Override + public boolean spawnEntity(PlatformWorld world, String entityKey, double x, double y, double z) { + return false; + } + + @Override + public void log(LogLevel level, String message) { + if (level == LogLevel.WARN || level == LogLevel.ERROR) { + System.err.println("[compat-test " + level + "] " + message); + } + } + + @Override + public void msg(String message) { + } + + @Override + public void reportError(Throwable error) { + error.printStackTrace(System.err); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/compat/ContentGatePlaceholderTest.java b/core/src/test/java/art/arcane/iris/core/compat/ContentGatePlaceholderTest.java new file mode 100644 index 000000000..9fa954236 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/compat/ContentGatePlaceholderTest.java @@ -0,0 +1,256 @@ +package art.arcane.iris.core.compat; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IObjectPlacer; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisObjectReplace; +import art.arcane.iris.engine.object.ObjectPlaceMode; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class ContentGatePlaceholderTest { + private static CompatFixtures.FakeRegistries registries; + + @BeforeClass + public static void bindPlatform() { + registries = CompatFixtures.registries("minecraft:sulfur", "minecraft:brimstone"); + CompatFixtures.bind(registries); + } + + @AfterClass + public static void unbindPlatform() { + CompatFixtures.unbind(); + } + + private static ContentGate gate() { + return new ContentGate(registries, Map.of(), new PackCompatReport()); + } + + @Test + public void placeholderOnlyForMissingKeysAndOnlyWhenReady() { + ContentGate gate = gate(); + PlatformBlockState present = gate.resolveBlockOrPlaceholder("STONE"); + assertFalse(MissingBlockState.isPlaceholder(present)); + assertEquals("minecraft:stone", present.key()); + + PlatformBlockState missing = gate.resolveBlockOrPlaceholder("minecraft:sulfur[lit=true]"); + assertTrue(MissingBlockState.isPlaceholder(missing)); + assertEquals("minecraft:sulfur[lit=true]", missing.key()); + assertEquals("minecraft:sulfur", missing.materialKey()); + assertEquals("minecraft", missing.namespace()); + assertSame(missing, gate.resolveBlockOrPlaceholder("minecraft:sulfur[lit=true]")); + assertNull(gate.resolveBlockOrPlaceholder(" ")); + + ContentGate unbound = new ContentGate(null, Map.of(), new PackCompatReport()); + assertNull(unbound.resolveBlockOrPlaceholder("minecraft:sulfur")); + } + + @Test + public void placeholderBehavesAsAirButKeepsItsKey() { + PlatformBlockState air = registries.air(); + MissingBlockState placeholder = MissingBlockState.of("minecraft:sulfur[lit=true,axis=y]", air); + assertTrue(placeholder.isAir()); + assertFalse(placeholder.isSolid()); + assertFalse(placeholder.isCustom()); + assertFalse(placeholder.hasTileEntity()); + assertEquals(air.nativeHandle(), placeholder.nativeHandle()); + assertEquals("minecraft:sulfur", placeholder.missingKey()); + + MissingBlockState exact = MissingBlockState.of("minecraft:sulfur[lit=true]", air); + MissingBlockState other = MissingBlockState.of("minecraft:brimstone", air); + assertTrue(exact.matches(placeholder)); + assertFalse(placeholder.matches(exact)); + assertFalse(exact.matches(other)); + assertFalse(exact.matches(air)); + assertFalse(air.matches(exact)); + assertEquals(MissingBlockState.of("minecraft:sulfur[lit=true]", air), exact); + assertEquals(exact.hashCode(), MissingBlockState.of("minecraft:sulfur[lit=true]", air).hashCode()); + assertEquals("minecraft:sulfur[lit=true,axis=y]", exact.withProperty("axis", "y").key()); + } + + @Test + public void blockDataPlaceholderFollowsTheChainBeforeGivingUp() throws Exception { + IrisData data = IrisData.get(Files.createTempDirectory("iris-compat-placeholder").toFile()); + PlatformBlockState present = new IrisBlockData("minecraft:stone").getBlockDataOrPlaceholder(data); + assertFalse(MissingBlockState.isPlaceholder(present)); + assertEquals("minecraft:stone", present.key()); + + PlatformBlockState revived = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand")).getBlockDataOrPlaceholder(data); + assertEquals("minecraft:sand", revived.key()); + assertFalse(MissingBlockState.isPlaceholder(revived)); + + IrisBlockData missing = new IrisBlockData("minecraft:sulfur"); + missing.getData().put("lit", true); + PlatformBlockState placeholder = missing.getBlockDataOrPlaceholder(data); + assertTrue(MissingBlockState.isPlaceholder(placeholder)); + assertEquals("minecraft:sulfur[lit=true]", placeholder.key()); + assertTrue(missing.getBlockData(data).isAir()); + assertFalse(MissingBlockState.isPlaceholder(missing.getBlockData(data))); + } + + @Test + public void fullChanceEditRuleRewritesAMissingPaletteKeyDuringPlacement() throws Exception { + File pack = Files.createTempDirectory("iris-compat-rescue").toFile(); + writeObject(pack, "rescue", "minecraft:sulfur[lit=true]"); + IrisData data = IrisData.get(pack); + IrisObject object = data.getObjectLoader().load("rescue"); + assertNotNull(object); + PlatformBlockState loaded = object.getBlocks().values().iterator().next(); + assertTrue(loaded.toString(), MissingBlockState.isPlaceholder(loaded)); + + IrisObjectPlacement placement = placement(); + placement.getEdit().add(replace(1F, false, "minecraft:sulfur", "minecraft:stone")); + RecordingPlacer placer = new RecordingPlacer(); + object.place(0, 64, 0, placer, placement, new RNG(7L), data); + assertEquals(1, placer.writes.size()); + assertEquals("minecraft:stone", placer.writes.getFirst().key()); + + IrisObjectPlacement exact = placement(); + exact.getEdit().add(replace(1F, true, "minecraft:sulfur[lit=true]", "minecraft:sand")); + RecordingPlacer exactPlacer = new RecordingPlacer(); + object.place(0, 64, 0, exactPlacer, exact, new RNG(7L), data); + assertEquals("minecraft:sand", exactPlacer.writes.getFirst().key()); + + IrisObjectPlacement mismatch = placement(); + mismatch.getEdit().add(replace(1F, true, "minecraft:sulfur[lit=false]", "minecraft:sand")); + RecordingPlacer mismatchPlacer = new RecordingPlacer(); + object.place(0, 64, 0, mismatchPlacer, mismatch, new RNG(7L), data); + PlatformBlockState untouched = mismatchPlacer.writes.getFirst(); + assertTrue(MissingBlockState.isPlaceholder(untouched)); + assertTrue(untouched.isAir()); + assertEquals(registries.air().nativeHandle(), untouched.nativeHandle()); + } + + private static IrisObjectPlacement placement() { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setPlace(new KList<>(List.of("rescue"))); + placement.setMode(ObjectPlaceMode.CENTER_HEIGHT); + placement.setRequireSurfaceSupport(false); + return placement; + } + + private static IrisObjectReplace replace(float chance, boolean exact, String find, String to) { + IrisObjectReplace rule = new IrisObjectReplace(); + rule.setChance(chance); + rule.setExact(exact); + rule.getFind().add(IrisBlockData.from(find)); + rule.getReplace().qclear(); + rule.getReplace().add(to); + return rule; + } + + /** A one-block V2 .iob whose only palette entry is {@code state}. */ + private static void writeObject(File pack, String key, String state) throws Exception { + File file = new File(pack, "objects/" + key + ".iob"); + assertTrue(file.getParentFile().mkdirs()); + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { + out.writeInt(1); + out.writeInt(1); + out.writeInt(1); + out.writeUTF("Iris V2 IOB;"); + out.writeShort(1); + out.writeUTF(state); + out.writeInt(1); + out.writeShort(0); + out.writeShort(0); + out.writeShort(0); + out.writeShort(0); + out.writeInt(0); + } + } + + private static final class RecordingPlacer implements IObjectPlacer { + private final List writes = new ArrayList<>(); + + @Override + public int getHighest(int x, int z, IrisData data) { + return 64; + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + return 64; + } + + @Override + public void set(int x, int y, int z, PlatformBlockState d) { + writes.add(d); + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + return null; + } + + @Override + public boolean isPreventingDecay() { + return false; + } + + @Override + public boolean isCarved(int x, int y, int z) { + return false; + } + + @Override + public boolean isSolid(int x, int y, int z) { + return false; + } + + @Override + public boolean isUnderwater(int x, int z) { + return false; + } + + @Override + public int getFluidHeight() { + return 0; + } + + @Override + public boolean isDebugSmartBore() { + return false; + } + + @Override + public void setTile(int xx, int yy, int zz, TileData tile) { + } + + @Override + public void setData(int xx, int yy, int zz, T data) { + } + + @Override + public T getData(int xx, int yy, int zz, Class t) { + return null; + } + + @Override + public Engine getEngine() { + return null; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/core/compat/ContentGateTest.java b/core/src/test/java/art/arcane/iris/core/compat/ContentGateTest.java new file mode 100644 index 000000000..46c98d13e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/compat/ContentGateTest.java @@ -0,0 +1,190 @@ +package art.arcane.iris.core.compat; + +import art.arcane.iris.core.compat.ContentGate.BlockResolution; +import art.arcane.iris.core.compat.ContentGate.BlockResolution.Source; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class ContentGateTest { + private static ContentGate gate(CompatFixtures.FakeRegistries registries, Map fallbacks) { + return new ContentGate(registries, fallbacks, new PackCompatReport()); + } + + @Test + public void presentBlockResolvesFromRegistry() { + ContentGate gate = gate(CompatFixtures.registries(), Map.of()); + BlockResolution resolution = gate.resolveBlock("STONE"); + assertNotNull(resolution); + assertEquals("minecraft:stone", resolution.resolvedKey()); + assertEquals("minecraft:stone", resolution.state().key()); + assertFalse(resolution.substituted()); + assertNull(resolution.substitutedFrom()); + assertEquals(Source.REGISTRY, resolution.source()); + assertEquals(KeyStatus.PRESENT, gate.block("minecraft:stone[axis=y]")); + } + + @Test + public void missingBlockResolvesToNull() { + ContentGate gate = gate(CompatFixtures.registries("minecraft:sulfur"), Map.of()); + assertNull(gate.resolveBlock("minecraft:sulfur")); + assertNull(gate.resolveBlock("sulfur[lit=true]")); + assertEquals(KeyStatus.MISSING, gate.block("minecraft:sulfur")); + } + + @Test + public void unknownWhenRegistriesAbsentOrEmpty() { + ContentGate unbound = gate(null, Map.of()); + assertFalse(unbound.ready()); + assertNull(unbound.resolveBlock("minecraft:stone")); + assertEquals(KeyStatus.UNKNOWN, unbound.block("minecraft:stone")); + assertEquals(KeyStatus.UNKNOWN, unbound.entity("minecraft:cow")); + + CompatFixtures.FakeRegistries empty = CompatFixtures.registries(); + empty.blockKeys.clear(); + ContentGate notReady = gate(empty, Map.of()); + assertFalse(notReady.ready()); + assertEquals(KeyStatus.UNKNOWN, notReady.block("minecraft:stone")); + assertEquals(KeyStatus.UNKNOWN, notReady.item("minecraft:diamond")); + } + + @Test + public void perRegistryUnknownWhenThatKeyListIsEmpty() { + CompatFixtures.FakeRegistries registries = CompatFixtures.registries(); + registries.biomes.clear(); + registries.structures.clear(); + ContentGate gate = gate(registries, Map.of()); + assertTrue(gate.ready()); + assertEquals(KeyStatus.UNKNOWN, gate.biome("minecraft:plains")); + assertEquals(KeyStatus.UNKNOWN, gate.structure("minecraft:village_plains")); + assertEquals(KeyStatus.PRESENT, gate.entity("COW")); + assertEquals(KeyStatus.MISSING, gate.entity("minecraft:camel")); + } + + @Test + public void nonBlockRegistriesNormalizeKeys() { + ContentGate gate = gate(CompatFixtures.registries(), Map.of()); + assertEquals(KeyStatus.PRESENT, gate.item("DIAMOND")); + assertEquals(KeyStatus.MISSING, gate.item("minecraft:netherite_upgrade_template")); + assertEquals(KeyStatus.PRESENT, gate.biome("minecraft:plains")); + assertEquals(KeyStatus.MISSING, gate.biome("minecraft:pale_garden")); + assertEquals(KeyStatus.PRESENT, gate.enchantment("sharpness")); + assertEquals(KeyStatus.MISSING, gate.enchantment("minecraft:wind_burst")); + assertEquals(KeyStatus.PRESENT, gate.potionEffect("SPEED")); + assertEquals(KeyStatus.PRESENT, gate.potionEffect("INCREASE_DAMAGE")); + assertEquals(KeyStatus.MISSING, gate.potionEffect("minecraft:infested")); + assertEquals(KeyStatus.MISSING, gate.item(" ")); + } + + @Test + public void dimensionFallbackSubstitutesAndIsReported() { + ContentGate gate = gate(CompatFixtures.registries("minecraft:sulfur"), Map.of("minecraft:sulfur", "minecraft:stone")); + BlockResolution resolution = gate.resolveBlock("minecraft:sulfur[lit=true]"); + assertNotNull(resolution); + assertTrue(resolution.substituted()); + assertEquals(Source.FALLBACK, resolution.source()); + assertEquals("minecraft:stone", resolution.resolvedKey()); + assertEquals("minecraft:sulfur[lit=true]", resolution.substitutedFrom()); + assertEquals("minecraft:stone", gate.fallbacks().get("minecraft:sulfur")); + } + + @Test + public void fallbackThatIsItselfMissingCountsAsMissing() { + ContentGate gate = gate(CompatFixtures.registries("minecraft:sulfur", "minecraft:brimstone"), + Map.of("minecraft:sulfur", "minecraft:brimstone")); + assertNull(gate.resolveBlock("minecraft:sulfur")); + } + + @Test + public void legacyRenameHitIsSubstitutedButNotReported() { + ContentGate gate = gate(CompatFixtures.registries("minecraft:grass"), Map.of()); + BlockResolution resolution = gate.resolveBlock("minecraft:grass"); + assertNotNull(resolution); + assertEquals("minecraft:short_grass", resolution.resolvedKey()); + assertEquals(Source.RENAME, resolution.source()); + assertFalse(resolution.substituted()); + assertNull(resolution.substitutedFrom()); + assertTrue(gate.report().isEmpty()); + } + + @Test + public void legacyRenameFollowsChainsAndGivesUpOnCycles() { + ContentGate chained = gate(CompatFixtures.registries("minecraft:soul_soil", "minecraft:soulsand"), Map.of()); + assertNull(chained.resolveBlock("minecraft:soul_soil")); + + ContentGate cyclic = gate(CompatFixtures.registries("minecraft:grass", "minecraft:short_grass"), Map.of()); + assertNull(cyclic.resolveBlock("minecraft:grass")); + } + + @Test + public void legacyRenameExactEntriesMatchFullState() { + ContentGate gate = gate(CompatFixtures.registries("minecraft:barrel"), Map.of()); + BlockResolution resolution = gate.resolveBlock("minecraft:barrel[facing=east]"); + assertNotNull(resolution); + assertEquals("minecraft:hay_bale[axis=x]", resolution.resolvedKey()); + assertEquals(Source.RENAME, resolution.source()); + } + + @Test + public void renameRunsBeforeFallback() { + ContentGate gate = gate(CompatFixtures.registries("minecraft:grass"), Map.of("minecraft:grass", "minecraft:sand")); + BlockResolution resolution = gate.resolveBlock("minecraft:grass"); + assertNotNull(resolution); + assertEquals(Source.RENAME, resolution.source()); + assertEquals("minecraft:short_grass", resolution.resolvedKey()); + } + + @Test + public void resolutionsAreCachedPerNormalizedState() { + CompatFixtures.FakeRegistries registries = CompatFixtures.registries("minecraft:sulfur"); + ContentGate gate = gate(registries, Map.of()); + BlockResolution first = gate.resolveBlock("minecraft:stone"); + int after = registries.blockLookups; + BlockResolution second = gate.resolveBlock("STONE"); + assertSame(first, second); + assertEquals(after, registries.blockLookups); + assertNull(gate.resolveBlock("minecraft:sulfur")); + int misses = registries.blockLookups; + assertNull(gate.resolveBlock("minecraft:sulfur")); + assertEquals(misses, registries.blockLookups); + gate.invalidate(); + gate.resolveBlock("minecraft:stone"); + assertTrue(registries.blockLookups > after); + } + + @Test + public void normalizeStateAndBaseKey() { + assertEquals("minecraft:stone", ContentGate.normalizeState(" STONE ")); + assertEquals("minecraft:oak_log[axis=y]", ContentGate.normalizeState("Oak_Log[AXIS=Y]")); + assertEquals("create:cogwheel", ContentGate.normalizeState("create:cogwheel")); + assertNull(ContentGate.normalizeState(null)); + assertNull(ContentGate.normalizeState(" ")); + assertEquals("minecraft:oak_log", ContentGate.baseKey("minecraft:oak_log[axis=y]")); + assertEquals("minecraft:oak_log", ContentGate.baseKey("minecraft:oak_log")); + assertNull(ContentGate.baseKey(null)); + } + + @Test + public void readBlockFallbacksMergesDimensionFiles() throws Exception { + java.io.File pack = java.nio.file.Files.createTempDirectory("iris-compat-fallbacks").toFile(); + java.io.File dimensions = new java.io.File(pack, "dimensions"); + assertTrue(dimensions.mkdirs()); + java.nio.file.Files.writeString(new java.io.File(dimensions, "overworld.json").toPath(), + "{\"name\":\"x\",\"blockFallbacks\":{\"minecraft:sulfur\":\"minecraft:stone\"}}"); + java.nio.file.Files.writeString(new java.io.File(dimensions, "other.json").toPath(), + "{\"name\":\"y\",\"blockFallbacks\":{\"minecraft:sulfur\":\"minecraft:sand\",\"minecraft:x\":\"minecraft:dirt\"}}"); + Map read = ContentGate.readBlockFallbacks(pack); + assertEquals(2, read.size()); + assertTrue(List.of("minecraft:stone", "minecraft:sand").contains(read.get("minecraft:sulfur"))); + assertEquals("minecraft:dirt", read.get("minecraft:x")); + assertTrue(ContentGate.readBlockFallbacks(new java.io.File(pack, "nope")).isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/compat/ContentGateWalkerTest.java b/core/src/test/java/art/arcane/iris/core/compat/ContentGateWalkerTest.java new file mode 100644 index 000000000..f5651fc23 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/compat/ContentGateWalkerTest.java @@ -0,0 +1,576 @@ +package art.arcane.iris.core.compat; + +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.engine.object.IrisBiomeCustomSpawn; +import art.arcane.iris.engine.object.IrisBiomePaletteLayer; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisDecorator; +import art.arcane.iris.engine.object.IrisEffect; +import art.arcane.iris.engine.object.IrisEnchantment; +import art.arcane.iris.engine.object.IrisEntity; +import art.arcane.iris.engine.object.IrisLoot; +import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.IrisMaterialPalette; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisObjectReplace; +import art.arcane.iris.engine.object.annotations.RegistryListBlockType; +import art.arcane.iris.engine.object.annotations.RegistryListItemType; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ContentGateWalkerTest { + private static ContentGate gate(String... missingBlocks) { + return new ContentGate(CompatFixtures.registries(missingBlocks), Map.of(), new PackCompatReport()); + } + + private static ContentGate gate(CompatFixtures.FakeRegistries registries) { + return new ContentGate(registries, Map.of(), new PackCompatReport()); + } + + private static IrisBiome biome(String key) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + return biome; + } + + private static IrisBiomePaletteLayer layer(String... blocks) { + KList palette = new KList<>(); + for (String block : blocks) { + palette.add(new IrisBlockData(block)); + } + return new IrisBiomePaletteLayer().setPalette(palette); + } + + private static IrisLoot loot(String item) { + return new IrisLoot().setType(item); + } + + /** Reads a field directly; several registrant getters resolve through Bukkit registries that are absent here. */ + private static Object rawField(Object owner, String name) throws Exception { + java.lang.reflect.Field field = owner.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(owner); + } + + private static CompatFinding only(CompatStatus status) { + assertEquals(status.reasons().toString(), 1, status.reasons().size()); + return status.reasons().getFirst(); + } + + private static void assertFinding(CompatFinding finding, CompatRegistry registry, String key, CompatAction action, + String subjectType, String subjectKey, String detail) { + assertEquals(registry, finding.registry()); + assertEquals(key, finding.key()); + assertEquals(action, finding.action()); + assertEquals(subjectType, finding.subjectType()); + assertEquals(subjectKey, finding.subjectKey()); + assertEquals(detail, finding.detail()); + } + + public static final class Probe extends IrisRegistrant { + @RegistryListBlockType + private String block = "minecraft:stone"; + @RegistryListItemType + private KList items = new KList<>(); + private KList edit = new KList<>(); + private IrisObjectPlacement placement = null; + private IrisLoot gear = null; + private Probe self = null; + private KList children = new KList<>(); + private KList decorators = new KList<>(); + + @Override + public String getFolderName() { + return "probes"; + } + + @Override + public String getTypeName() { + return "Probe"; + } + + void setBlock(String block) { + this.block = block; + } + + KList getItems() { + return items; + } + + void setItems(KList items) { + this.items = items; + } + + void setEdit(KList edit) { + this.edit = edit; + } + + void setPlacement(IrisObjectPlacement placement) { + this.placement = placement; + } + + IrisLoot getGear() { + return gear; + } + + void setGear(IrisLoot gear) { + this.gear = gear; + } + + void setSelf(Probe self) { + this.self = self; + } + + void setChildren(KList children) { + this.children = children; + } + + void setDecorators(KList decorators) { + this.decorators = decorators; + } + } + + @Test + public void missingLayerBlockExcludesBiomeWithFieldPath() { + ContentGate gate = gate("minecraft:sulfur"); + IrisBiome biome = biome("cave/sulfur-grotto"); + biome.setLayers(new KList<>(List.of(layer("minecraft:stone"), layer("minecraft:stone", "minecraft:dirt", "minecraft:sulfur")))); + + CompatStatus status = gate.evaluate(biome); + + assertTrue(status.excluded()); + assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "biome", "cave/sulfur-grotto", "layers[1].palette[2]"); + assertEquals(1, gate.report().size()); + assertEquals(status.reasons().getFirst(), gate.report().findings().getFirst()); + } + + @Test + public void blockPropertiesTravelWithTheKey() { + ContentGate gate = gate("minecraft:sulfur"); + IrisBiome biome = biome("b"); + IrisBlockData entry = new IrisBlockData("sulfur"); + entry.getData().put("lit", true); + biome.setLayers(new KList<>(List.of(new IrisBiomePaletteLayer().setPalette(new KList<>(List.of(entry)))))); + + CompatStatus status = gate.evaluate(biome); + + assertTrue(status.excluded()); + assertEquals("minecraft:sulfur", only(status).key()); + } + + @Test + public void backupRevivesEntryAsSubstitution() { + ContentGate gate = gate("minecraft:sulfur"); + IrisBiome biome = biome("desert/dunes"); + IrisBlockData entry = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand")); + biome.setLayers(new KList<>(List.of(new IrisBiomePaletteLayer().setPalette(new KList<>(List.of(entry)))))); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.SUBSTITUTED, "biome", "desert/dunes", "layers[0].palette[0] (backup minecraft:sand)"); + assertEquals(1, gate.report().size()); + } + + @Test + public void backupChainsAndMissingBackupStillExcludes() { + ContentGate gate = gate("minecraft:sulfur", "minecraft:brimstone"); + IrisBiome chained = biome("chained"); + IrisBlockData deep = new IrisBlockData("minecraft:sulfur") + .setBackup(new IrisBlockData("minecraft:brimstone").setBackup(new IrisBlockData("minecraft:sand"))); + chained.setLayers(new KList<>(List.of(new IrisBiomePaletteLayer().setPalette(new KList<>(List.of(deep)))))); + CompatStatus revived = gate.evaluate(chained); + assertFalse(revived.excluded()); + assertEquals("layers[0].palette[0] (backup minecraft:sand)", only(revived).detail()); + + IrisBiome dead = biome("dead"); + IrisBlockData deadEntry = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:brimstone")); + dead.setLayers(new KList<>(List.of(new IrisBiomePaletteLayer().setPalette(new KList<>(List.of(deadEntry)))))); + CompatStatus excluded = gate.evaluate(dead); + assertTrue(excluded.excluded()); + assertFinding(only(excluded), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "biome", "dead", "layers[0].palette[0]"); + } + + @Test + public void dimensionFallbackSubstitutesEntry() { + ContentGate gate = new ContentGate(CompatFixtures.registries("minecraft:sulfur"), Map.of("minecraft:sulfur", "minecraft:stone"), new PackCompatReport()); + IrisBiome biome = biome("desert/flats"); + biome.setLayers(new KList<>(List.of(layer("minecraft:sulfur")))); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.SUBSTITUTED, "biome", "desert/flats", "layers[0].palette[0] (fallback minecraft:stone)"); + } + + @Test + public void legacyRenameHitIsNeitherExcludedNorReported() { + ContentGate gate = gate("minecraft:grass"); + IrisBiome biome = biome("plains"); + biome.setLayers(new KList<>(List.of(layer("minecraft:grass")))); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertTrue(status.reasons().isEmpty()); + assertTrue(gate.report().isEmpty()); + } + + @Test + public void missingEntityTypeExcludesEntity() { + ContentGate gate = gate(); + IrisEntity entity = new IrisEntity().setType("minecraft:camel"); + entity.setLoadKey("camel"); + + CompatStatus status = gate.evaluate(entity); + + assertTrue(status.excluded()); + assertFinding(only(status), CompatRegistry.ENTITY, "minecraft:camel", CompatAction.EXCLUDED, "entity", "camel", "type"); + assertEquals("minecraft:camel", entity.getType()); + } + + @Test + public void inlinePassengerWithMissingTypeIsDroppedFromItsList() { + ContentGate gate = gate(); + IrisEntity entity = new IrisEntity().setType("minecraft:zombie"); + entity.setLoadKey("rider"); + entity.setPassengers(new KList<>(List.of(new IrisEntity().setType("minecraft:cow"), new IrisEntity().setType("minecraft:camel")))); + + CompatStatus status = gate.evaluate(entity); + + assertFalse(status.excluded()); + assertEquals(1, entity.getPassengers().size()); + assertEquals("minecraft:cow", entity.getPassengers().getFirst().getType()); + assertFinding(only(status), CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, "entity", "rider", "passengers[1].type"); + } + + @Test + public void customBiomeSpawnIsDroppedBeforeDatapackWrite() { + ContentGate gate = gate(); + IrisBiome biome = biome("custom/lush"); + IrisBiomeCustom custom = new IrisBiomeCustom().setId("lush"); + custom.setSpawns(new KList<>(List.of(new IrisBiomeCustomSpawn().setType("minecraft:cow"), new IrisBiomeCustomSpawn().setType("camel")))); + biome.setCustomDerivitives(new KList<>(List.of(custom))); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertEquals(1, custom.getSpawns().size()); + assertEquals("minecraft:cow", custom.getSpawns().getFirst().getTypeKey()); + assertFinding(only(status), CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, "biome", "custom/lush", "customDerivitives[0].spawns[1].type"); + } + + @Test + public void lootItemIsDroppedFromTable() { + ContentGate gate = gate(); + IrisLootTable table = new IrisLootTable(); + table.setLoadKey("chests/common"); + table.setLoot(new KList<>(List.of(loot("minecraft:diamond"), loot("minecraft:mace")))); + + CompatStatus status = gate.evaluate(table); + + assertFalse(status.excluded()); + assertEquals(1, table.getLoot().size()); + assertEquals("minecraft:diamond", table.getLoot().getFirst().getTypeKey()); + assertFinding(only(status), CompatRegistry.ITEM, "minecraft:mace", CompatAction.DROPPED, "loot", "chests/common", "loot[1].type"); + } + + @Test + public void missingEnchantmentIsDroppedButLootEntryStays() { + ContentGate gate = gate(); + IrisLootTable table = new IrisLootTable(); + table.setLoadKey("chests/rare"); + IrisLoot sword = loot("minecraft:diamond"); + sword.setEnchantments(new KList<>(List.of(new IrisEnchantment().setEnchantment("sharpness"), new IrisEnchantment().setEnchantment("minecraft:wind_burst")))); + table.setLoot(new KList<>(List.of(sword))); + + CompatStatus status = gate.evaluate(table); + + assertFalse(status.excluded()); + assertEquals(1, table.getLoot().size()); + assertEquals(1, sword.getEnchantments().size()); + assertEquals("sharpness", sword.getEnchantments().getFirst().getEnchantment()); + assertFinding(only(status), CompatRegistry.ENCHANTMENT, "minecraft:wind_burst", CompatAction.DROPPED, "loot", "chests/rare", "loot[0].enchantments[1].enchantment"); + } + + @Test + public void missingPotionEffectDropsEffectAndBlankEffectStays() { + ContentGate gate = gate(); + IrisBiome biome = biome("swamp"); + biome.setEffects(new KList<>(List.of(new IrisEffect().setPotionEffect("SPEED"), new IrisEffect().setPotionEffect("minecraft:infested"), new IrisEffect().setPotionEffect("")))); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertEquals(2, biome.getEffects().size()); + assertEquals("SPEED", biome.getEffects().get(0).getPotionEffect()); + assertEquals("", biome.getEffects().get(1).getPotionEffect()); + assertFinding(only(status), CompatRegistry.POTION_EFFECT, "minecraft:infested", CompatAction.DROPPED, "biome", "swamp", "effects[1].potionEffect"); + } + + @Test + public void scatterEntriesAreDroppedAndBiomeKeepsGenerating() { + ContentGate gate = gate(); + IrisBiome biome = biome("plains"); + biome.setBiomeScatter(new KList<>(List.of("minecraft:plains", "minecraft:pale_garden"))); + biome.setBiomeSkyScatter(new KList<>(List.of("minecraft:pale_garden", "minecraft:desert"))); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertEquals(List.of("minecraft:plains"), biome.getBiomeScatter()); + assertEquals(List.of("minecraft:desert"), biome.getBiomeSkyScatter()); + assertEquals(2, status.reasons().size()); + assertFinding(status.reasons().get(0), CompatRegistry.BIOME, "minecraft:pale_garden", CompatAction.DROPPED, "biome", "plains", "biomeScatter[1]"); + assertFinding(status.reasons().get(1), CompatRegistry.BIOME, "minecraft:pale_garden", CompatAction.DROPPED, "biome", "plains", "biomeSkyScatter[0]"); + assertEquals(1, gate.report().size()); + } + + @Test + public void missingDerivativeExcludesBiome() { + ContentGate gate = gate(); + IrisBiome biome = biome("pale"); + biome.setDerivative("minecraft:pale_garden"); + + CompatStatus status = gate.evaluate(biome); + + assertTrue(status.excluded()); + assertFinding(only(status), CompatRegistry.BIOME, "minecraft:pale_garden", CompatAction.EXCLUDED, "biome", "pale", "derivative"); + assertEquals("minecraft:pale_garden", biome.getDerivativeKey()); + } + + @Test + public void missingVanillaDerivativeIsNulled() throws Exception { + ContentGate gate = gate(); + IrisBiome biome = biome("pale"); + biome.setVanillaDerivative("minecraft:pale_garden"); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertNull(rawField(biome, "vanillaDerivative")); + assertFinding(only(status), CompatRegistry.BIOME, "minecraft:pale_garden", CompatAction.DROPPED, "biome", "pale", "vanillaDerivative"); + } + + @Test + public void objectPlacementsAreNotDescended() { + ContentGate gate = gate("minecraft:sulfur"); + IrisBiome biome = biome("plains"); + IrisObjectReplace replace = new IrisObjectReplace() + .setFind(new KList<>(List.of(new IrisBlockData("minecraft:stone")))) + .setReplace(new IrisMaterialPalette().setPalette(new KList<>(List.of(new IrisBlockData("minecraft:sulfur"))))); + IrisObjectPlacement placement = new IrisObjectPlacement().setPlace(new KList<>(List.of("trees/oak"))).setEdit(new KList<>(List.of(replace))); + biome.setObjects(new KList<>(List.of(placement))); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertTrue(status.reasons().isEmpty()); + assertTrue(gate.report().isEmpty()); + assertEquals(1, biome.getObjects().size()); + assertEquals(1, replace.getReplace().getPalette().size()); + } + + @Test + public void replaceFindIsMatchOnlyButReplacePaletteCounts() { + ContentGate gate = gate("minecraft:sulfur"); + Probe matchOnly = new Probe(); + matchOnly.setLoadKey("match"); + matchOnly.setEdit(new KList<>(List.of(new IrisObjectReplace() + .setFind(new KList<>(List.of(new IrisBlockData("minecraft:sulfur")))) + .setReplace(new IrisMaterialPalette().setPalette(new KList<>(List.of(new IrisBlockData("minecraft:stone")))))))); + assertFalse(gate.evaluate(matchOnly).excluded()); + assertTrue(gate.report().isEmpty()); + + Probe generated = new Probe(); + generated.setLoadKey("gen"); + generated.setEdit(new KList<>(List.of(new IrisObjectReplace() + .setFind(new KList<>(List.of(new IrisBlockData("minecraft:stone")))) + .setReplace(new IrisMaterialPalette().setPalette(new KList<>(List.of(new IrisBlockData("minecraft:sulfur")))))))); + CompatStatus status = gate.evaluate(generated); + assertTrue(status.excluded()); + assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "probe", "gen", "edit[0].replace.palette[0]"); + } + + @Test + public void decoratorMatchListsAreIgnoredButPaletteCounts() { + ContentGate gate = gate("minecraft:sulfur"); + Probe whitelisted = new Probe(); + whitelisted.setLoadKey("white"); + whitelisted.setDecorators(new KList<>(List.of(new IrisDecorator() + .setWhitelist(new KList<>(List.of(new IrisBlockData("minecraft:sulfur")))) + .setBlacklist(new KList<>(List.of(new IrisBlockData("minecraft:sulfur"))))))); + assertFalse(gate.evaluate(whitelisted).excluded()); + assertTrue(gate.report().isEmpty()); + + Probe placing = new Probe(); + placing.setLoadKey("place"); + placing.setDecorators(new KList<>(List.of(new IrisDecorator().setPalette(new KList<>(List.of(new IrisBlockData("minecraft:sulfur"))))))); + CompatStatus status = gate.evaluate(placing); + assertTrue(status.excluded()); + assertEquals("decorators[0].palette[0]", only(status).detail()); + } + + @Test + public void cyclicGraphsTerminate() { + ContentGate gate = gate(); + Probe probe = new Probe(); + probe.setLoadKey("loop"); + probe.setSelf(probe); + probe.setChildren(new KList<>(List.of(probe))); + + CompatStatus status = gate.evaluate(probe); + + assertFalse(status.excluded()); + assertTrue(status.reasons().isEmpty()); + } + + @Test + public void notReadyProducesNoFindingsNoMutationAndMarksIncomplete() { + ContentGate gate = new ContentGate(null, Map.of(), new PackCompatReport()); + IrisBiome biome = biome("custom/lush"); + IrisBiomeCustom custom = new IrisBiomeCustom().setId("lush"); + custom.setSpawns(new KList<>(List.of(new IrisBiomeCustomSpawn().setType("minecraft:camel")))); + biome.setCustomDerivitives(new KList<>(List.of(custom))); + biome.setLayers(new KList<>(List.of(layer("minecraft:sulfur")))); + + CompatStatus status = gate.evaluate(biome); + + assertFalse(status.excluded()); + assertTrue(status.reasons().isEmpty()); + assertEquals(1, custom.getSpawns().size()); + assertTrue(gate.report().isEmpty()); + assertTrue(gate.report().isIncomplete()); + } + + @Test + public void unknownSingleRegistryLeavesItsKeysAlone() { + CompatFixtures.FakeRegistries registries = CompatFixtures.registries("minecraft:sulfur"); + registries.biomes.clear(); + ContentGate gate = gate(registries); + IrisBiome biome = biome("plains"); + biome.setDerivative("minecraft:pale_garden"); + biome.setBiomeScatter(new KList<>(List.of("minecraft:pale_garden"))); + biome.setLayers(new KList<>(List.of(layer("minecraft:sulfur")))); + + CompatStatus status = gate.evaluate(biome); + + assertTrue(status.excluded()); + assertEquals(1, status.reasons().size()); + assertEquals(CompatRegistry.BLOCK, only(status).registry()); + assertEquals(List.of("minecraft:pale_garden"), biome.getBiomeScatter()); + assertTrue(gate.report().isIncomplete()); + assertTrue(gate.report().incompleteReason(), gate.report().incompleteReason().contains("biome")); + } + + @Test + public void annotatedStringFieldsOnTheRegistrantFollowTheTable() { + ContentGate gate = gate("minecraft:sulfur"); + Probe blocked = new Probe(); + blocked.setLoadKey("blocked"); + blocked.setBlock("minecraft:sulfur"); + CompatStatus status = gate.evaluate(blocked); + assertTrue(status.excluded()); + assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "probe", "blocked", "block"); + + Probe items = new Probe(); + items.setLoadKey("items"); + items.setItems(new KList<>(List.of("minecraft:diamond", "minecraft:mace"))); + CompatStatus dropped = gate.evaluate(items); + assertFalse(dropped.excluded()); + assertEquals(List.of("minecraft:diamond"), items.getItems()); + assertFinding(only(dropped), CompatRegistry.ITEM, "minecraft:mace", CompatAction.DROPPED, "probe", "items", "items[1]"); + } + + @Test + public void droppedObjectHeldByDirectFieldIsNulled() { + ContentGate gate = gate(); + Probe probe = new Probe(); + probe.setLoadKey("gear"); + probe.setGear(loot("minecraft:mace")); + + CompatStatus status = gate.evaluate(probe); + + assertFalse(status.excluded()); + assertNull(probe.getGear()); + assertFinding(only(status), CompatRegistry.ITEM, "minecraft:mace", CompatAction.DROPPED, "probe", "gear", "gear.type"); + } + + @Test + public void everyFindingReachesReasonsAndReportDedups() { + ContentGate gate = gate("minecraft:sulfur", "minecraft:brimstone"); + IrisBiome biome = biome("multi"); + biome.setLayers(new KList<>(List.of(layer("minecraft:sulfur", "minecraft:brimstone"), layer("minecraft:sulfur")))); + + CompatStatus status = gate.evaluate(biome); + + assertTrue(status.excluded()); + assertEquals(3, status.reasons().size()); + assertEquals(2, gate.report().size()); + assertEquals("layers[0].palette[0]", gate.report().byKey().get("BLOCK:minecraft:sulfur").getFirst().detail()); + } + + @Test + public void customPackBlocksResolveThroughTheLoaderStatus() { + IrisBlockData fine = new IrisBlockData("minecraft:stone"); + fine.setCompat(CompatStatus.OK); + IrisBlockData broken = new IrisBlockData("minecraft:sulfur"); + broken.setCompat(CompatStatus.excludedBy(List.of(new CompatFinding(CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "block", "broken", "block")))); + Function customBlocks = key -> switch (key) { + case "fancy" -> fine; + case "broken" -> broken; + default -> null; + }; + ContentGate gate = new ContentGate(CompatFixtures.registries("minecraft:sulfur"), Map::of, new PackCompatReport(), customBlocks); + + IrisBiome ok = biome("ok"); + ok.setLayers(new KList<>(List.of(layer("fancy")))); + assertFalse(gate.evaluate(ok).excluded()); + assertTrue(gate.report().isEmpty()); + + IrisBiome bad = biome("bad"); + bad.setLayers(new KList<>(List.of(layer("broken")))); + CompatStatus status = gate.evaluate(bad); + assertTrue(status.excluded()); + assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "biome", "bad", "layers[0].palette[0]"); + } + + @Test + public void blockRegistrantEvaluatesItsOwnStateWithoutTheLoader() { + Function customBlocks = key -> { + throw new IllegalStateException("loader must not be consulted for a block registrant"); + }; + ContentGate gate = new ContentGate(CompatFixtures.registries("minecraft:sulfur"), Map::of, new PackCompatReport(), customBlocks); + IrisBlockData registrant = new IrisBlockData("minecraft:sulfur"); + registrant.setLoadKey("sulfur-variant"); + + CompatStatus status = gate.evaluate(registrant); + + assertTrue(status.excluded()); + assertFinding(only(status), CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, "block", "sulfur-variant", "block"); + + IrisBlockData revived = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand")); + revived.setLoadKey("sulfur-backup"); + CompatStatus substituted = gate.evaluate(revived); + assertFalse(substituted.excluded()); + assertEquals("block (backup minecraft:sand)", only(substituted).detail()); + } + + @Test + public void nullRegistrantIsOk() { + assertNotNull(gate().evaluate(null)); + assertFalse(gate().evaluate(null).excluded()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/compat/IrisBlockDataResolutionTest.java b/core/src/test/java/art/arcane/iris/core/compat/IrisBlockDataResolutionTest.java new file mode 100644 index 000000000..843987a38 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/compat/IrisBlockDataResolutionTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.core.compat; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.object.IrisBlockData; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.File; +import java.nio.file.Files; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisBlockDataResolutionTest { + private static CompatFixtures.FakeRegistries registries; + + @BeforeClass + public static void bindPlatform() { + registries = CompatFixtures.registries("minecraft:sulfur", "minecraft:brimstone", "minecraft:grass"); + CompatFixtures.bind(registries); + } + + @AfterClass + public static void unbindPlatform() { + CompatFixtures.unbind(); + } + + private static IrisData pack(String dimensionJson, String customBlockJson) throws Exception { + File folder = Files.createTempDirectory("iris-compat-blockdata").toFile(); + if (dimensionJson != null) { + File dimensions = new File(folder, "dimensions"); + assertTrue(dimensions.mkdirs()); + Files.writeString(new File(dimensions, "overworld.json").toPath(), dimensionJson); + } + if (customBlockJson != null) { + File blocks = new File(folder, "blocks"); + assertTrue(blocks.mkdirs()); + Files.writeString(new File(blocks, "fancy.json").toPath(), customBlockJson); + } + return IrisData.get(folder); + } + + @Test + public void presentBlockResolvesWithProperties() throws Exception { + IrisData data = pack(null, null); + IrisBlockData entry = new IrisBlockData("minecraft:oak_log"); + entry.getData().put("axis", "y"); + assertEquals("minecraft:oak_log[axis=y]", entry.getBlockData(data).key()); + assertEquals("minecraft:stone", new IrisBlockData("STONE").getBlockData(data).key()); + } + + @Test + public void backupRevivesMissingBlock() throws Exception { + IrisData data = pack(null, null); + IrisBlockData entry = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand")); + assertEquals("minecraft:sand", entry.getBlockData(data).key()); + + IrisBlockData chained = new IrisBlockData("minecraft:sulfur") + .setBackup(new IrisBlockData("minecraft:brimstone").setBackup(new IrisBlockData("minecraft:sand"))); + assertEquals("minecraft:sand", chained.getBlockData(data).key()); + } + + @Test + public void missingBlockWithoutBackupFallsBackToAir() throws Exception { + IrisData data = pack(null, null); + IrisBlockData entry = new IrisBlockData("minecraft:sulfur"); + assertTrue(entry.getBlockData(data).isAir()); + assertTrue(new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:brimstone")).getBlockData(data).isAir()); + } + + @Test + public void dimensionFallbackAppliesBeforeBackup() throws Exception { + IrisData data = pack("{\"name\":\"overworld\",\"blockFallbacks\":{\"minecraft:sulfur\":\"minecraft:stone\"}}", null); + IrisBlockData entry = new IrisBlockData("minecraft:sulfur").setBackup(new IrisBlockData("minecraft:sand")); + assertEquals("minecraft:stone", entry.getBlockData(data).key()); + } + + @Test + public void legacyRenameAppliesOnEveryPlatform() throws Exception { + IrisData data = pack(null, null); + assertEquals("minecraft:short_grass", new IrisBlockData("minecraft:grass").getBlockData(data).key()); + assertTrue(data.getCompatReport().isEmpty()); + } + + @Test + public void customPackBlockStillResolvesFirstAndUsesItsOwnBackup() throws Exception { + IrisData data = pack(null, "{\"block\":\"minecraft:sulfur\",\"backup\":{\"block\":\"minecraft:sand\"}}"); + IrisBlockData entry = new IrisBlockData("fancy"); + assertEquals("minecraft:sand", entry.getBlockData(data).key()); + assertFalse(data.getCompatReport().isEmpty()); + assertEquals(CompatAction.SUBSTITUTED, data.getCompatReport().findings().getFirst().action()); + assertEquals("block", data.getCompatReport().findings().getFirst().subjectType()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/compat/PackCompatReportTest.java b/core/src/test/java/art/arcane/iris/core/compat/PackCompatReportTest.java new file mode 100644 index 000000000..63db0b14e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/compat/PackCompatReportTest.java @@ -0,0 +1,71 @@ +package art.arcane.iris.core.compat; + +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class PackCompatReportTest { + private static CompatFinding finding(CompatAction action, String subjectKey, String detail) { + return new CompatFinding(CompatRegistry.BLOCK, "minecraft:sulfur", action, "Biome", subjectKey, detail); + } + + @Test + public void recordDedupsOnRegistryKeyActionAndSubject() { + PackCompatReport report = new PackCompatReport(); + report.record(finding(CompatAction.EXCLUDED, "cave/sulfur", "layers[0].palette[1]")); + report.record(finding(CompatAction.EXCLUDED, "cave/sulfur", "layers[2].palette[0]")); + report.record(finding(CompatAction.SUBSTITUTED, "cave/sulfur", "layers[1].palette[0] (backup minecraft:sand)")); + report.record(finding(CompatAction.EXCLUDED, "desert/flats", "layers[0].palette[0]")); + report.record(null); + assertEquals(3, report.size()); + List findings = report.findings(); + assertEquals("layers[0].palette[1]", findings.get(0).detail()); + assertEquals(CompatAction.SUBSTITUTED, findings.get(1).action()); + assertEquals("desert/flats", findings.get(2).subjectKey()); + Map counts = report.countsByAction(); + assertEquals(Integer.valueOf(2), counts.get(CompatAction.EXCLUDED)); + assertEquals(Integer.valueOf(1), counts.get(CompatAction.SUBSTITUTED)); + assertEquals(1, report.distinctKeys()); + assertEquals(3, report.byKey().get("BLOCK:minecraft:sulfur").size()); + } + + @Test + public void markIncompleteKeepsFirstReason() { + PackCompatReport report = new PackCompatReport(); + assertFalse(report.isIncomplete()); + report.markIncomplete("biome registry not ready"); + report.markIncomplete("structure registry not ready"); + assertTrue(report.isIncomplete()); + assertEquals("biome registry not ready", report.incompleteReason()); + report.clear(); + assertFalse(report.isIncomplete()); + assertNull(report.incompleteReason()); + } + + @Test + public void clearForgetsFindingsForHotload() { + PackCompatReport report = new PackCompatReport(); + report.record(finding(CompatAction.DROPPED, "cave/sulfur", "x")); + assertFalse(report.isEmpty()); + report.clear(); + assertTrue(report.isEmpty()); + assertEquals(0, report.size()); + assertTrue(report.bootLines("overworld", "26.1.2", 3).isEmpty()); + assertEquals("", report.summaryLine("26.1.2")); + } + + @Test + public void findingLineAndDedupKey() { + CompatFinding finding = finding(CompatAction.EXCLUDED, "cave/sulfur", "layers[0].palette[1]"); + assertEquals("minecraft:sulfur (block): excluded Biome cave/sulfur at layers[0].palette[1]", finding.line()); + assertEquals("BLOCK:minecraft:sulfur|EXCLUDED|Biome|cave/sulfur", finding.dedupKey()); + CompatFinding blank = new CompatFinding(CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, null, null, null); + assertEquals("minecraft:camel (entity): dropped", blank.line()); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java index fd4c14809..696374b43 100644 --- a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderParityTest.java @@ -252,6 +252,17 @@ public class SchemaBuilderParityTest { assertEquals("boolean", properties.getJSONObject("requireObjectSurfaceSupport").getString("type")); } + @Test + public void dimensionSchemaListsBlockFallbacksAsPlainObjectProperty() { + JSONObject properties = rootProperties( + new SchemaBuilder(IrisDimension.class, structureSchemaData()).construct()); + JSONObject fallbacks = properties.getJSONObject("blockFallbacks"); + + assertEquals("object", fallbacks.getString("type")); + assertFalse(fallbacks.has("!top")); + assertFalse(fallbacks.has("anyOf")); + } + @Test public void jigsawStructureSchemaExposesBranchFailurePolicy() { JSONObject schema = new SchemaBuilder(IrisStructure.class, structureSchemaData()).construct(); From 0d464aabf3498adf8caf4225743d2cbf7703af60 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 3 Sep 2026 04:54:35 -0400 Subject: [PATCH 2/3] Gate pools, placements and structures - Filter excluded biomes, regions, spawners, loot tables, jigsaw pieces and structures out of every pool through CompatPools and cascade empty pools to the container - Evaluate each object placement per object from the .iob palette header, honouring chance-1 edit rules and reporting every missing palette key - Treat edit find lists and marker mark lists as match-only - Exclude jigsaw pieces on missing palette keys, drop them from pools and exclude structures whose start pool is gone - Drop custom biome spawns whose entity is missing before the datapack JSON is written - Skip excluded placements in the object, floating object, tree and locator paths while keeping the per-chunk RNG draws unchanged - Keep the engine focus biome and region null when the gate excluded them --- .../iris/core/IrisDatapackCompiler.java | 4 +- .../art/arcane/iris/core/service/TreeSVC.java | 22 +- .../art/arcane/iris/engine/IrisComplex.java | 29 +- .../iris/engine/MarkerSpawnScanner.java | 6 + .../iris/engine/WorldEntitySpawner.java | 3 +- .../arcane/iris/engine/framework/Engine.java | 13 +- .../iris/engine/framework/HintedLocator.java | 9 +- .../framework/IrisStructureLocator.java | 4 + .../engine/framework/StructureAssembler.java | 8 + .../hydrology/policy/RiverPolicyResolver.java | 46 ++- .../MantleFloatingObjectComponent.java | 12 + .../components/MantleObjectComponent.java | 16 +- .../iris/engine/object/CompatPools.java | 219 ++++++++++++++ .../arcane/iris/engine/object/IrisBiome.java | 10 +- .../iris/engine/object/IrisBiomeCustom.java | 16 + .../object/IrisDimensionCarvingEntry.java | 9 +- .../iris/engine/object/IrisEntitySpawn.java | 13 +- .../object/IrisFloatingChildBiomes.java | 5 + .../iris/engine/object/IrisJigsawPiece.java | 42 +++ .../iris/engine/object/IrisJigsawPool.java | 75 +++++ .../iris/engine/object/IrisLootReference.java | 19 +- .../iris/engine/object/IrisLootTable.java | 21 ++ .../arcane/iris/engine/object/IrisMod.java | 46 +++ .../iris/engine/object/IrisObjectMarker.java | 2 +- .../engine/object/IrisObjectPlacement.java | 284 +++++++++++++++++- .../object/IrisObjectPlacementRunner.java | 2 +- .../iris/engine/object/IrisObjectReplace.java | 91 +++++- .../arcane/iris/engine/object/IrisRegion.java | 95 +++--- .../iris/engine/object/IrisRiverPolicy.java | 14 + .../iris/engine/object/IrisSpawner.java | 65 ++++ .../iris/engine/object/IrisStructure.java | 42 +++ .../engine/object/IrisStructurePlacement.java | 21 ++ .../StructureAssemblerCompatTest.java | 145 +++++++++ .../iris/engine/object/CompatFixtures.java | 84 ++++++ .../engine/object/CompatTestPlatform.java | 254 ++++++++++++++++ .../object/IrisBiomeCompatChildrenTest.java | 95 ++++++ .../object/IrisBiomeCustomCompatTest.java | 91 ++++++ .../object/IrisBiomeDerivativeCompatTest.java | 34 +++ .../object/IrisDimensionCompatRegionTest.java | 92 ++++++ .../engine/object/IrisJigsawCompatTest.java | 158 ++++++++++ .../object/IrisLootTableCompatTest.java | 101 +++++++ .../object/IrisObjectPlacementCompatTest.java | 282 +++++++++++++++++ .../object/IrisRegionCompatPoolTest.java | 142 +++++++++ .../engine/object/IrisSpawnerCompatTest.java | 126 ++++++++ 44 files changed, 2774 insertions(+), 93 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/engine/object/CompatPools.java create mode 100644 core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/CompatTestPlatform.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCompatChildrenTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisBiomeDerivativeCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCompatRegionTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisJigsawCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisLootTableCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisRegionCompatPoolTest.java create mode 100644 core/src/test/java/art/arcane/iris/engine/object/IrisSpawnerCompatTest.java diff --git a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java index feade6483..3f52fa5d5 100644 --- a/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java +++ b/core/src/main/java/art/arcane/iris/core/IrisDatapackCompiler.java @@ -2,6 +2,7 @@ package art.arcane.iris.core; import art.arcane.iris.BuildConstants; import art.arcane.iris.core.lifecycle.BukkitWorldConfiguration.IrisGeneratorBinding; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.core.nms.datapack.DataVersion; @@ -235,6 +236,7 @@ public final class IrisDatapackCompiler { fingerprintContent(dimension.getDimensionType().toJson(fixer))); String namespace = dimension.getLoadKey().toLowerCase(Locale.ROOT); + ContentGate contentGate = dimension.getLoader() == null ? null : dimension.getLoader().getContentGate(); for (IrisBiome biome : dimension.getAllBiomes(dimension::getLoader)) { if (biome == null || !biome.isCustom()) { continue; @@ -250,7 +252,7 @@ public final class IrisDatapackCompiler { } requirements.put( "worldgen/biome/" + biomeKey, - fingerprintContent(customBiome.generateJson(fixer))); + fingerprintContent(customBiome.generateJson(fixer, contentGate))); TreeSet tags = new TreeSet<>(); for (String tag : customBiome.getEffectiveTags(derivativeKey)) { String normalizedTag = normalizeRegistryKey(tag); diff --git a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java index 5dcd00d24..ce8b75c91 100644 --- a/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java +++ b/core/src/main/java/art/arcane/iris/core/service/TreeSVC.java @@ -142,8 +142,19 @@ public class TreeSVC implements IrisService { return; } + KList pool = placement.compatPlace(worldAccess.getData()); + + if (pool.isEmpty()) { + IrisLogging.debug("Tree placement has no object available on this Minecraft version"); + return; + } + saplingPlane.forEach(block -> block.setType(Material.AIR)); - IrisObject object = worldAccess.getData().getObjectLoader().load(placement.getPlace().getRandom(RNG.r)); + IrisObject object = worldAccess.getData().getObjectLoader().load(pool.getRandom(RNG.r)); + + if (object == null) { + return; + } String treeMarker = object.getLoadKey() + "@" + ThreadLocalRandom.current().nextInt(Integer.MAX_VALUE); List blockStateList = new KList<>(); KMap dataCache = new KMap<>(); @@ -311,12 +322,12 @@ public class TreeSVC implements IrisService { // Retrieve objectPlacements of type `species` from biome IrisBiome biome = worldAccess.getEngine().getBiome(location.getBlockX(), location.getBlockY()-worldAccess.getTarget().getWorld().minHeight(), location.getBlockZ()); - placements.addAll(matchObjectPlacements(biome.getObjects(), size, type)); + placements.addAll(matchObjectPlacements(biome.getObjects(), size, type, worldAccess.getData())); // Add more or find any in the region if (isUseAll || placements.isEmpty()) { IrisRegion region = worldAccess.getEngine().getRegion(location.getBlockX(), location.getBlockZ()); - placements.addAll(matchObjectPlacements(region.getObjects(), size, type)); + placements.addAll(matchObjectPlacements(region.getObjects(), size, type, worldAccess.getData())); } // Check if no matches were found, return a random one if they are @@ -329,14 +340,15 @@ public class TreeSVC implements IrisService { * @param objects The object placements to check * @param size The size of the sapling area to filter with * @param type The type of the tree to filter with + * @param data The pack, for the version-content gate * @return A list of objectPlacements that matched. May be empty. */ - private KList matchObjectPlacements(KList objects, IrisTreeSize size, TreeType type) { + private KList matchObjectPlacements(KList objects, IrisTreeSize size, TreeType type, IrisData data) { KList p = new KList<>(); for (IrisObjectPlacement i : objects) { - if (i.matches(size, type)) { + if (i.matches(size, type) && !i.isCompatExcluded(data)) { p.add(i); } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index d4e92e1a5..d3df023f7 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; @@ -178,8 +179,9 @@ public class IrisComplex implements DataProvider { fluidHeight = engine.getDimension().getFluidHeight(); generators = new HashMap<>(); generatorBiomes = Collections.newSetFromMap(new IdentityHashMap<>()); - focusBiome = engine.getFocus(); - focusRegion = engine.getFocusRegion(); + // A registrant the version-content gate excluded never enters a pool, focus included. + focusBiome = compatUsable(engine.getFocus()); + focusRegion = compatUsable(engine.getFocusRegion()); Map> inferredStreams = new HashMap<>(); KList preparedRegions = new KList<>(); @@ -195,7 +197,7 @@ public class IrisComplex implements DataProvider { } else { engine.getDimension().getRegions().forEach(regionKey -> { IrisRegion region = data.getRegionLoader().load(regionKey); - if (region == null) { + if (region == null || region.isCompatExcluded()) { return; } prepareInferredBiomes(region, preparedRegions); @@ -244,7 +246,7 @@ public class IrisComplex implements DataProvider { ProceduralStream.of((x, z) -> focusRegion, Interpolated.of(a -> 0D, a -> focusRegion)) : regionStyleStream - .selectRarity(data.getRegionLoader().loadAll(engine.getDimension().getRegions())) + .selectRarity(compatRegionPool(engine)) .cache2D("regionStream", engine, cacheSize); regionStream = focusRegion != null ? proceduralRegionStream : proceduralRegionStream .convertAware2D((region, x, z) -> { @@ -1083,11 +1085,30 @@ public class IrisComplex implements DataProvider { private KList loadInferredBiomes(KList keys, InferredType type) { KList inferred = new KList<>(); for (IrisBiome biome : data.getBiomeLoader().loadAll(keys)) { + // Excluded biomes cannot generate on this Minecraft version, so they leave the selection pool. + if (biome.isCompatExcluded()) { + continue; + } inferred.add(biome.withInferredType(type)); } return inferred; } + /** The dimension's region pool with regions the version-content gate excluded removed. */ + private KList compatRegionPool(Engine engine) { + KList pool = new KList<>(); + for (IrisRegion region : data.getRegionLoader().loadAll(engine.getDimension().getRegions())) { + if (!region.isCompatExcluded()) { + pool.add(region); + } + } + return pool; + } + + private static T compatUsable(T registrant) { + return registrant == null || registrant.isCompatExcluded() ? null : registrant; + } + private void registerGenerators(IrisBiome biome) { generatorBiomes.add(biome); biome.getGenerators().forEach(c -> registerGenerator(c.getCachedGenerator(this))); diff --git a/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java b/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java index 5abb03270..cf752e7d0 100644 --- a/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java +++ b/core/src/main/java/art/arcane/iris/engine/MarkerSpawnScanner.java @@ -83,6 +83,9 @@ final class MarkerSpawnScanner { IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName()); continue; } + if (m.isCompatExcluded()) { + continue; + } m.setReferenceMarker(mark); // This is so fucking incorrect its a joke @@ -182,6 +185,9 @@ final class MarkerSpawnScanner { IrisLogging.error("Cannot load spawner: " + i + " for marker on " + manager.getName()); continue; } + if (spawner.isCompatExcluded()) { + continue; + } spawner.setReferenceMarker(mark); data.spawners.add(spawner); } diff --git a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java index acb9f6b27..e5a36ffdd 100644 --- a/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/WorldEntitySpawner.java @@ -299,7 +299,8 @@ final class WorldEntitySpawner { } //@builder - Predicate filter = i -> i.canSpawn(manager.getEngine(), c.getX(), c.getZ()); + // Excluded spawners cannot spawn anything on this Minecraft version, so they never enter the selection pool. + Predicate filter = i -> !i.isCompatExcluded() && i.canSpawn(manager.getEngine(), c.getX(), c.getZ()); ChunkCounter counter = new ChunkCounter(c.getEntities()); IrisBiome biome = EngineBukkitOps.getSurfaceBiome(manager.getEngine(), c); diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index b7c508ca7..4f7560f23 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -502,7 +502,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, IrisRegion region = getRegion(x, z); for (IrisObjectPlacement i : region.getObjects()) { - if (i.getPlace().contains(object)) { + if (i.compatPlace(getData()).contains(object)) { return new PlacedObject(i, getData().getObjectLoader().load(object), id, x, z); } } @@ -510,7 +510,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, IrisBiome biome = getSurfaceBiome(x, z); for (IrisObjectPlacement i : biome.getObjects()) { - if (i.getPlace().contains(object)) { + if (i.compatPlace(getData()).contains(object)) { return new PlacedObject(i, getData().getObjectLoader().load(object), id, x, z); } } @@ -526,19 +526,20 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return false; } + IrisData data = getData(); Set biomeKeys = getDimension().getReachableBiomes(this).stream() - .filter((i) -> containsObjectPlacement(i.getObjects(), normalizedObjectKey)) + .filter((i) -> containsObjectPlacement(i.getObjects(), normalizedObjectKey, data)) .map(IrisRegistrant::getLoadKey) .collect(Collectors.toSet()); Set regionKeys = getDimension().getAllRegions(this).stream() .filter((i) -> i.getAllBiomeIds().stream().anyMatch(biomeKeys::contains) - || containsObjectPlacement(i.getObjects(), normalizedObjectKey)) + || containsObjectPlacement(i.getObjects(), normalizedObjectKey, data)) .map(IrisRegistrant::getLoadKey) .collect(Collectors.toSet()); return !regionKeys.isEmpty(); } - private static boolean containsObjectPlacement(KList placements, String normalizedObjectKey) { + private static boolean containsObjectPlacement(KList placements, String normalizedObjectKey, IrisData data) { if (placements == null || placements.isEmpty() || normalizedObjectKey.isBlank()) { return false; } @@ -548,7 +549,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, continue; } - for (String placedObject : placement.getPlace()) { + for (String placedObject : placement.compatPlace(data)) { String normalizedPlacedObject = normalizeObjectPlacementKey(placedObject); if (!normalizedPlacedObject.isBlank() && normalizedPlacedObject.equals(normalizedObjectKey)) { return true; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java index f0bd5a62b..00bec61ba 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java @@ -20,6 +20,7 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; @@ -410,7 +411,7 @@ public final class HintedLocator implements Locator { continue; } - if (placesObject(region.getSurfaceObjects(), objectKey)) { + if (placesObject(region.getSurfaceObjects(), objectKey, engine.getData())) { directRegions.add(region.getLoadKey()); } @@ -511,9 +512,9 @@ public final class HintedLocator implements Locator { }; } - private static boolean placesObject(KList placements, String objectKey) { + private static boolean placesObject(KList placements, String objectKey, IrisData data) { for (IrisObjectPlacement placement : placements) { - if (placement != null && placement.getPlace().contains(objectKey)) { + if (placement != null && placement.compatPlace(data).contains(objectKey)) { return true; } } @@ -637,7 +638,7 @@ public final class HintedLocator implements Locator { continue; } - if (placesObject(biome.getSurfaceObjects(), objectKey)) { + if (placesObject(biome.getSurfaceObjects(), objectKey, engine.getData())) { return true; } 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 0a920b312..5d99e823b 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 @@ -507,6 +507,10 @@ public final class IrisStructureLocator { throw new IllegalStateException("Iris structure placement references missing structure '" + selectedKey + "'"); } + if (structure.isCompatExcluded()) { + // Gated out on this Minecraft version; the pack report already says why. + return null; + } StructureGraphCompilation compilation = StructureGraphCatalog.compile(engine.getData(), structure); if (!compilation.isAssemblyViable()) { throw new IllegalStateException("Iris structure '" + selectedKey diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java index 3ff75d307..fb7d048d2 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureAssembler.java @@ -1179,6 +1179,11 @@ public final class StructureAssembler { if (pool.getPieces() == null) { throw assemblyFailure("pool has no piece list"); } + // A pool the version-content gate excluded has nothing left to place: treat it as empty so the branch + // terminates or falls back, instead of failing the whole assembly. + if (pool.isCompatExcluded()) { + return List.of(); + } List eligible = new ArrayList<>(); List required = new ArrayList<>(); for (IrisJigsawPieceEntry entry : pool.getPieces()) { @@ -1199,6 +1204,9 @@ public final class StructureAssembler { if (piece == null) { throw assemblyFailure("pool references missing piece '" + pieceKey + "'"); } + if (piece.isCompatExcluded()) { + continue; + } int placements = placementCounts.getOrDefault(pieceKey, 0); if (!pieceEnabled(piece) || terminalOnly && !piece.resolvedRules().isTerminal() diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java index c521f949e..8d401f2f4 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java @@ -1,5 +1,7 @@ package art.arcane.iris.engine.hydrology.policy; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRegion; @@ -17,7 +19,8 @@ public final class RiverPolicyResolver { return resolve( dimension == null ? null : dimension.getRiverPolicy(), region == null ? null : region.getRiverPolicy(), - biome == null ? null : biome.getRiverPolicy() + biome == null ? null : biome.getRiverPolicy(), + loaderOf(biome, region, dimension) ); } @@ -25,14 +28,37 @@ public final class RiverPolicyResolver { IrisRiverPolicy dimensionPolicy, IrisRiverPolicy regionPolicy, IrisRiverPolicy biomePolicy + ) { + return resolve(dimensionPolicy, regionPolicy, biomePolicy, null); + } + + /** + * @param data pack the policies came from, used to drop river biome references the version-content gate excluded. + * Null skips that filtering. + */ + public static EffectiveRiverPolicy resolve( + IrisRiverPolicy dimensionPolicy, + IrisRiverPolicy regionPolicy, + IrisRiverPolicy biomePolicy, + IrisData data ) { State state = new State(); - state.apply(dimensionPolicy); - state.apply(regionPolicy); - state.apply(biomePolicy); + state.apply(dimensionPolicy, data); + state.apply(regionPolicy, data); + state.apply(biomePolicy, data); return state.build(); } + private static IrisData loaderOf(IrisRegistrant... registrants) { + for (IrisRegistrant registrant : registrants) { + if (registrant != null && registrant.getLoader() != null) { + return registrant.getLoader(); + } + } + + return null; + } + private static final class State { private IrisRiverPlacementMode placement = IrisRiverPlacementMode.NATURAL; private IrisRiverRoutingMode routing = IrisRiverRoutingMode.ALLOW; @@ -50,7 +76,7 @@ public final class RiverPolicyResolver { private double routingMultiplier = 1D; private double bankMultiplier = 1D; - private void apply(IrisRiverPolicy policy) { + private void apply(IrisRiverPolicy policy, IrisData data) { if (policy == null) { return; } @@ -67,19 +93,19 @@ public final class RiverPolicyResolver { profiles = List.copyOf(policy.getProfiles()); } if (policy.getSurfaceBiomes() != null) { - surfaceBiomes = List.copyOf(policy.getSurfaceBiomes()); + surfaceBiomes = List.copyOf(policy.compatBiomes(policy.getSurfaceBiomes(), data, "surfaceBiomes")); } if (policy.getMouthBiomes() != null) { - mouthBiomes = List.copyOf(policy.getMouthBiomes()); + mouthBiomes = List.copyOf(policy.compatBiomes(policy.getMouthBiomes(), data, "mouthBiomes")); } if (policy.getShoreBiomes() != null) { - shoreBiomes = List.copyOf(policy.getShoreBiomes()); + shoreBiomes = List.copyOf(policy.compatBiomes(policy.getShoreBiomes(), data, "shoreBiomes")); } if (policy.getBankBiomes() != null) { - bankBiomes = List.copyOf(policy.getBankBiomes()); + bankBiomes = List.copyOf(policy.compatBiomes(policy.getBankBiomes(), data, "bankBiomes")); } if (policy.getFloodedCaveBiomes() != null) { - floodedCaveBiomes = List.copyOf(policy.getFloodedCaveBiomes()); + floodedCaveBiomes = List.copyOf(policy.compatBiomes(policy.getFloodedCaveBiomes(), data, "floodedCaveBiomes")); } if (policy.getSurfacePools() != null) { surfacePools = List.copyOf(policy.getSurfacePools()); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index c1f611cc4..192ed44f3 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -171,6 +171,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (placement == null || columns == null || columns.isEmpty()) { return; } + + if (placement.isCompatExcluded(data)) { + return; + } int density = placement.getDensity(rng, minX, minZ, data); double perAttempt = placement.getChance(); for (int i = 0; i < density; i++) { @@ -217,6 +221,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (placement == null || columns.isEmpty()) { return; } + + if (placement.isCompatExcluded(data)) { + return; + } int density = placement.getDensity(rng, minX, minZ, data); double perAttempt = placement.getChance(); @@ -302,6 +310,10 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { if (placement == null || columns.isEmpty()) { return; } + + if (placement.isCompatExcluded(data)) { + return; + } int density = placement.getDensity(rng, minX, minZ, data); double perAttempt = placement.getChance(); diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index fe2cad55f..a49bd7d8a 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -417,7 +417,7 @@ public class MantleObjectComponent extends IrisMantleComponent { for (IrisObjectPlacement i : surfaceBiome.getSurfaceObjects()) { biomeSurfaceChecked++; - boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)); + boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData()); if (traceRegen) { IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z + " scope=biome-surface" @@ -450,7 +450,7 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } biomeCaveChecked++; - boolean chance = rng.chance(i.getChance()); + boolean chance = rng.chance(i.getChance()) && !i.isCompatExcluded(getData()); if (traceRegen) { IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z + " scope=biome-cave" @@ -480,7 +480,7 @@ public class MantleObjectComponent extends IrisMantleComponent { for (IrisObjectPlacement i : region.getSurfaceObjects()) { regionSurfaceChecked++; - boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)); + boolean chance = rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData()); if (traceRegen) { IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z + " scope=region-surface" @@ -513,7 +513,7 @@ public class MantleObjectComponent extends IrisMantleComponent { continue; } regionCaveChecked++; - boolean chance = rng.chance(i.getChance()); + boolean chance = rng.chance(i.getChance()) && !i.isCompatExcluded(getData()); if (traceRegen) { IrisLogging.debug("Regen object placer chance: chunk=" + x + "," + z + " scope=region-cave" @@ -1182,7 +1182,7 @@ public class MantleObjectComponent extends IrisMantleComponent { boolean forcePlace = dimension.isUpperObjectsForcePlace(); if (upperBiome != null) { for (IrisObjectPlacement i : upperBiome.getSurfaceObjects()) { - if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005))) { + if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005)) || i.isCompatExcluded(getData())) { continue; } try { @@ -1197,7 +1197,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (upperRegion != null) { for (IrisObjectPlacement i : upperRegion.getSurfaceObjects()) { - if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005))) { + if (!rng.chance(i.getChance() + rng.d(-0.005, 0.005)) || i.isCompatExcluded(getData())) { continue; } try { @@ -1635,13 +1635,13 @@ public class MantleObjectComponent extends IrisMantleComponent { IrisRegion region = getEngineMantle().getEngine().getRegion((x << 4) + 8, (z << 4) + 8); Set v = new KSet<>(); for (IrisObjectPlacement i : biome.getSurfaceObjects()) { - if (rng.chance(i.getChance() + rng.d(-0.005, 0.005))) { + if (rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData())) { v.addAll(guessPlacedKeys(rng, x, z, i)); } } for (IrisObjectPlacement i : region.getSurfaceObjects()) { - if (rng.chance(i.getChance() + rng.d(-0.005, 0.005))) { + if (rng.chance(i.getChance() + rng.d(-0.005, 0.005)) && !i.isCompatExcluded(getData())) { v.addAll(guessPlacedKeys(rng, x, z, i)); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java b/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java new file mode 100644 index 000000000..186f95436 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/CompatPools.java @@ -0,0 +1,219 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; + +import java.util.ArrayList; +import java.util.List; + +/** + * Registrant reference pools filtered by the version-content gate. A reference to a registrant the gate EXCLUDED is + * dropped from every pool that could pick it and reported once against the containing registrant; a container whose + * required pool ends up empty cascades to EXCLUDED through {@link #cascade}. Cascade and drop findings reuse the + * registry and key of the cause so the console groups everything under the one missing content key. + *

+ * Surviving entries keep their declaration order, so only the removed entries change what a pool picks. + */ +final class CompatPools { + private CompatPools() { + } + + static boolean excluded(IrisRegistrant registrant) { + return registrant != null && registrant.isCompatExcluded(); + } + + /** + * Loads a registrant reference pool, skipping entries that fail to load and entries the gate excluded. Each + * excluded reference produces one DROPPED finding in {@code sink} (when given) and in the pack report. + * + * @param field the JSON field the keys came from, used for the finding detail ({@code landBiomes[2] key}) + */ + static KList load(ResourceLoader loader, + List keys, + IrisData data, + String subjectType, + String subjectKey, + String field, + List sink) { + KList kept = new KList<>(); + + if (loader == null || keys == null) { + return kept; + } + + for (int index = 0; index < keys.size(); index++) { + String key = keys.get(index); + T loaded = loader.load(key); + + if (loaded == null) { + continue; + } + + if (loaded.isCompatExcluded()) { + drop(data, loaded, subjectType, subjectKey, field + "[" + index + "] " + key, sink); + continue; + } + + kept.add(loaded); + } + + return kept; + } + + /** The surviving keys of a reference pool, in declaration order. */ + static KList surviving(ResourceLoader loader, + List keys, + IrisData data, + String subjectType, + String subjectKey, + String field) { + KList kept = new KList<>(); + + if (keys == null) { + return kept; + } + + if (loader == null) { + kept.addAll(keys); + return kept; + } + + for (int index = 0; index < keys.size(); index++) { + String key = keys.get(index); + T loaded = loader.load(key); + + if (loaded != null && loaded.isCompatExcluded()) { + drop(data, loaded, subjectType, subjectKey, field + "[" + index + "] " + key, null); + continue; + } + + kept.add(key); + } + + return kept; + } + + /** + * Records one DROPPED finding for a reference to an excluded registrant, reusing the registry and key of the + * child's exclusion cause. Returns null when the child carries no cause to attribute the drop to. + */ + static CompatFinding drop(IrisData data, + IrisRegistrant child, + String subjectType, + String subjectKey, + String detail, + List sink) { + CompatFinding cause = causeOf(child == null ? null : child.getCompat()); + + if (cause == null) { + return null; + } + + CompatFinding finding = new CompatFinding(cause.registry(), cause.key(), CompatAction.DROPPED, + subjectType, subjectKey, detail); + record(data, finding); + + if (sink != null) { + sink.add(finding); + } + + return finding; + } + + /** + * The EXCLUDED status for a container whose required pool lost every entry. Returns {@code base} untouched when no + * cause was collected, so a container that never declared the pool is not excluded for being empty. + */ + static CompatStatus cascade(IrisData data, + CompatStatus base, + List causes, + String subjectType, + String subjectKey, + String detail) { + if (causes == null || causes.isEmpty()) { + return base; + } + + CompatFinding cause = causes.getFirst(); + CompatFinding finding = new CompatFinding(cause.registry(), cause.key(), CompatAction.EXCLUDED, + subjectType, subjectKey, detail); + record(data, finding); + + List reasons = new ArrayList<>(base == null ? List.of() : base.reasons()); + reasons.add(finding); + return CompatStatus.excludedBy(reasons); + } + + /** The findings on a status that dropped an entry, used as the cause set of a cascade. */ + static List droppedReasons(CompatStatus status) { + List dropped = new ArrayList<>(); + + if (status == null) { + return dropped; + } + + for (CompatFinding reason : status.reasons()) { + if (reason.action() == CompatAction.DROPPED) { + dropped.add(reason); + } + } + + return dropped; + } + + static void record(IrisData data, CompatFinding finding) { + if (data == null || finding == null) { + return; + } + + try { + data.getCompatReport().record(finding); + } catch (Throwable ignored) { + // A report is best effort: never let reporting break a pool build. + } + } + + /** The finding that explains an exclusion: the EXCLUDED reason if there is one, otherwise the first reason. */ + private static CompatFinding causeOf(CompatStatus status) { + if (status == null) { + return null; + } + + CompatFinding fallback = null; + + for (CompatFinding reason : status.reasons()) { + if (reason.action() == CompatAction.EXCLUDED) { + return reason; + } + + if (fallback == null) { + fallback = reason; + } + } + + return fallback; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 985dcae2f..2db10c7da 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -559,13 +559,9 @@ public class IrisBiome extends IrisRegistrant implements IRare { public KList getRealChildren(DataProvider g) { return realChildren.aquire(() -> { - KList realChildren = new KList<>(); - - for (String i : getChildren()) { - realChildren.add(g.getData().getBiomeLoader().load(i)); - } - - return realChildren; + IrisData data = g == null ? null : g.getData(); + return CompatPools.load(data == null ? null : data.getBiomeLoader(), getChildren(), data, + "biome", getLoadKey(), "children", null); }); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java index 2e61d0b3f..aba7b5b05 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeCustom.java @@ -18,6 +18,8 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.KeyStatus; import art.arcane.iris.core.nms.datapack.IDataFixer; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.DependsOn; @@ -132,6 +134,16 @@ public class IrisBiomeCustom { } public String generateJson(IDataFixer fixer) { + return generateJson(fixer, null); + } + + /** + * @param gate version-content gate used to keep spawns whose entity type is missing on this server out of the + * generated datapack (the game rejects a datapack that names an unknown entity). Null consults the + * live platform registries; an unreadable registry keeps every spawn. + */ + public String generateJson(IDataFixer fixer, ContentGate gate) { + ContentGate contentGate = gate == null ? ContentGate.forData(null) : gate; JSONObject effects = new JSONObject(); effects.put("sky_color", parseColor(getSkyColor())); effects.put("fog_color", parseColor(getFogColor())); @@ -196,6 +208,10 @@ public class IrisBiomeCustom { IrisLogging.warn("Skipping custom biome spawn with null entity type in biome " + getId()); continue; } + if (contentGate.entity(key) == KeyStatus.MISSING) { + IrisLogging.debug("Dropping custom biome spawn " + key + " in biome " + getId() + ": entity missing on this server"); + continue; + } IrisBiomeCustomSpawnType group = i.getGroup() == null ? IrisBiomeCustomSpawnType.MISC : i.getGroup(); JSONArray g = groups.computeIfAbsent(group, (k) -> new JSONArray()); JSONObject o = new JSONObject(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java index 7dde2e11c..129c6a563 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimensionCarvingEntry.java @@ -63,7 +63,14 @@ public class IrisDimensionCarvingEntry { return null; } - return data.getBiomeLoader().load(biomeKey.trim()); + IrisBiome loaded = data.getBiomeLoader().load(biomeKey.trim()); + + if (loaded != null && loaded.isCompatExcluded()) { + CompatPools.drop(data, loaded, "carving entry", getId(), "biome " + biomeKey.trim(), null); + return null; + } + + return loaded; }); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java index a9ccde1c6..1c4e24062 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEntitySpawn.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.platform.bukkit.BukkitWorldBinding; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.engine.data.cache.AtomicCache; @@ -147,7 +148,17 @@ public class IrisEntitySpawn implements IRare { } public IrisEntity getRealEntity(Engine g) { - return ent.aquire(() -> g.getData().getEntityLoader().load(getEntity())); + return ent.aquire(() -> resolveEntity(g.getData())); + } + + /** The referenced entity, or null when it does not load or the version-content gate excluded it. */ + IrisEntity resolveEntity(IrisData data) { + if (data == null || data.getEntityLoader() == null) { + return null; + } + + IrisEntity entity = data.getEntityLoader().load(getEntity()); + return entity == null || entity.isCompatExcluded() ? null : entity; } public Entity spawn(Engine g, Location at) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java b/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java index 6c0df7bdf..bca701a99 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisFloatingChildBiomes.java @@ -358,6 +358,11 @@ public class IrisFloatingChildBiomes implements IRare { return parent; } + if (loaded.isCompatExcluded()) { + CompatPools.drop(data, loaded, "floating child biome", parent.getLoadKey(), "biome " + biome, null); + return parent; + } + return loaded; }); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java index a8e943bb3..9203dffb4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPiece.java @@ -18,6 +18,12 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -81,6 +87,42 @@ public class IrisJigsawPiece extends IrisRegistrant { return rules == null ? new IrisJigsawPieceRules() : rules; } + /** + * A piece is one object stamped verbatim - nothing rewrites its blocks - so a palette key the server does not + * have (after the pack's declared fallbacks) takes the whole piece out of every pool that lists it. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded() || gate == null || !gate.ready()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || object == null || object.isBlank()) { + return base; + } + + for (String paletteKey : IrisObjectIO.readPaletteKeysCached(data.getObjectLoader().findFile(object.trim()))) { + String key = ContentGate.baseKey(ContentGate.normalizeState(paletteKey)); + + if (key == null || gate.resolveBlock(key) != null) { + continue; + } + + CompatFinding finding = new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.EXCLUDED, + "jigsaw piece", getLoadKey(), "object " + object.trim()); + gate.report().record(finding); + KList reasons = new KList<>(base.reasons()); + reasons.add(finding); + return CompatStatus.excludedBy(reasons); + } + + return base; + } + @Override public String getFolderName() { return "jigsaw-pieces"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java index 849e3655d..76862e026 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisJigsawPool.java @@ -18,6 +18,12 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -51,6 +57,75 @@ public class IrisJigsawPool extends IrisRegistrant { return structureRequiresCaps || mandatoryFallback; } + /** + * Excluded pieces drop out of the pool; a pool with nothing left to place is excluded in turn so every connector + * that targets it terminates instead of failing assembly. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded() || gate == null || !gate.ready()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || pieces == null || pieces.isEmpty()) { + return base; + } + + KList reasons = new KList<>(base.reasons()); + CompatFinding cause = null; + int usable = 0; + + for (int i = 0; i < pieces.size(); i++) { + IrisJigsawPieceEntry entry = pieces.get(i); + + if (entry == null) { + continue; + } + + String key = entry.getPiece() == null ? "" : entry.getPiece().trim(); + + // An empty membership terminates a branch on purpose, and a piece key that does not resolve at all is a + // pack error the loader already reports - neither is a version-content problem. + if (entry.isEmpty() || key.isEmpty()) { + usable++; + continue; + } + + IrisJigsawPiece piece = data.load(IrisJigsawPiece.class, key, false); + + if (piece == null || !piece.isCompatExcluded()) { + usable++; + continue; + } + + CompatFinding reason = piece.getCompat().reasons().isEmpty() ? null : piece.getCompat().reasons().getFirst(); + CompatFinding dropped = new CompatFinding( + reason == null ? CompatRegistry.BLOCK : reason.registry(), + reason == null ? key : reason.key(), + CompatAction.DROPPED, "jigsaw pool", getLoadKey(), "pieces[" + i + "] " + key); + gate.report().record(dropped); + reasons.add(dropped); + + if (cause == null) { + cause = dropped; + } + } + + if (usable > 0 || cause == null) { + return new CompatStatus(false, reasons); + } + + CompatFinding excluded = new CompatFinding(cause.registry(), cause.key(), CompatAction.EXCLUDED, + "jigsaw pool", getLoadKey(), "no pieces remain"); + gate.report().record(excluded); + reasons.add(excluded); + return CompatStatus.excludedBy(reasons); + } + @Override public String getFolderName() { return "jigsaw-pools"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java index 189876a40..457785fac 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootReference.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -57,9 +58,25 @@ public class IrisLootReference { return tt.aquire(() -> { KList t = new KList<>(); + IrisData data = g == null ? null : g.getData(); + + if (data == null || data.getLootLoader() == null) { + return t; + } for (String i : tables) { - t.add(g.getData().getLootLoader().load(i)); + IrisLootTable table = data.getLootLoader().load(i); + + if (table == null) { + continue; + } + + if (table.isCompatExcluded()) { + CompatPools.drop(data, table, "loot reference", table.getLoadKey(), "tables " + i, null); + continue; + } + + t.add(table); } return t; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java b/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java index 692f18b55..4db606ea0 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisLootTable.java @@ -18,6 +18,8 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.framework.LootResolver; import art.arcane.iris.engine.object.annotations.ArrayType; @@ -103,6 +105,25 @@ public class IrisLootTable extends IrisRegistrant { return lootf; } + /** + * Cascade: the gate walker drops loot entries whose item is missing on this server. A table with nothing left to + * roll leaves every reference pool that could pick it. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + return cascadeEmptyLoot(super.evaluateCompat(gate)); + } + + /** EXCLUDED when the walker dropped every loot entry; the walker's verdict is passed in as {@code base}. */ + CompatStatus cascadeEmptyLoot(CompatStatus base) { + if (base.excluded() || !loot.isEmpty()) { + return base; + } + + return CompatPools.cascade(getLoader(), base, CompatPools.droppedReasons(base), + "loot", getLoadKey(), "no loot entries remain"); + } + @Override public String getFolderName() { return "loot"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java b/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java index d8c40fc89..081de8ee3 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisMod.java @@ -18,6 +18,9 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -104,6 +107,49 @@ public class IrisMod extends IrisRegistrant { @Desc("Replace noise styles with other styles") private KList styleReplacers = new KList<>(); + /** + * A pack modification only references other registrants, so it never cascades: excluded biome and region + * references are dropped from its lists and reported, and the modification keeps applying whatever is left. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + IrisData data = getLoader(); + + if (data == null) { + return base; + } + + removeBiomes = biomes(data, removeBiomes, "removeBiomes"); + removeRegions = regions(data, removeRegions, "removeRegions"); + injectRegions = regions(data, injectRegions, "injectRegions"); + + for (int index = 0; index < biomeInjectors.size(); index++) { + IrisModBiomeInjector injector = biomeInjectors.get(index); + injector.setInject(biomes(data, injector.getInject(), "biomeInjectors[" + index + "].inject")); + } + + for (int index = 0; index < biomeReplacers.size(); index++) { + IrisModBiomeReplacer replacer = biomeReplacers.get(index); + replacer.setFind(biomes(data, replacer.getFind(), "biomeReplacers[" + index + "].find")); + } + + for (int index = 0; index < regionReplacers.size(); index++) { + IrisModRegionReplacer replacer = regionReplacers.get(index); + replacer.setFind(regions(data, replacer.getFind(), "regionReplacers[" + index + "].find")); + } + + return base; + } + + private KList biomes(IrisData data, KList keys, String field) { + return CompatPools.surviving(data.getBiomeLoader(), keys, data, "mod", getLoadKey(), field); + } + + private KList regions(IrisData data, KList keys, String field) { + return CompatPools.surviving(data.getRegionLoader(), keys, data, "mod", getLoadKey(), field); + } + @Override public String getFolderName() { return "mods"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java index d89c2d7db..2d4ffc1cd 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectMarker.java @@ -63,7 +63,7 @@ public class IrisObjectMarker { KList b = new KList<>(); for (IrisBlockData i : mark) { - PlatformBlockState bx = i.getBlockData(rdata); + PlatformBlockState bx = i.getBlockDataOrPlaceholder(rdata); if (bx != null) { b.add(bx); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java index d070edd45..da38d8ef5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacement.java @@ -20,6 +20,12 @@ package art.arcane.iris.engine.object; import art.arcane.iris.platform.bukkit.BukkitBlockResolution; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.data.cache.LazyBoundedCache; @@ -181,6 +187,274 @@ public class IrisObjectPlacement { @SerializedName(value = "forcePlace", alternate = {"force"}) private boolean forcePlace = false; private transient AtomicCache cache = new AtomicCache<>(); + private transient AtomicCache compatCache = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private transient volatile KList compatPlaceCache; + + /** + * Version-content gate verdict for this placement on the running server. The placement's own generated blocks + * (every {@code edit[].replace} palette option) exclude the whole placement when one of them is unavailable; each + * object in {@code place} is then dropped when its .iob palette carries a key that neither resolves nor is + * rewritten by a matching {@code edit} rule with chance 1. Match sides ({@code edit[].find}, {@code markers[].mark}) + * never count. Cached per placement. + */ + public CompatStatus evaluateCompat(IrisData data) { + return compatCache.aquire(() -> computeCompat(data)); + } + + /** True when nothing in this placement can generate on the running server. */ + public boolean isCompatExcluded(IrisData data) { + return evaluateCompat(data).excluded(); + } + + /** The {@code place} pool with objects that cannot generate on the running server removed. */ + public KList compatPlace(IrisData data) { + evaluateCompat(data); + KList survivors = compatPlaceCache; + return survivors == null ? place : survivors; + } + + private CompatStatus computeCompat(IrisData data) { + if (data == null) { + compatPlaceCache = place; + return CompatStatus.OK; + } + + ContentGate gate = data.getContentGate(); + PackCompatReport report = data.getCompatReport(); + + if (gate == null || !gate.ready()) { + // Registry not bound or not ready: UNKNOWN is never MISSING, so nothing is gated. + report.markIncomplete("registry not ready while evaluating object placements"); + compatPlaceCache = place; + return CompatStatus.OK; + } + + String subject = compatSubject(); + KList reasons = new KList<>(); + CompatFinding blocked = evaluatePlacementBlocks(data, gate, subject, reasons); + record(report, reasons); + + if (blocked != null) { + compatPlaceCache = new KList<>(); + return CompatStatus.excludedBy(reasons); + } + + KList survivors = new KList<>(place.size()); + CompatFinding firstDrop = null; + + for (int i = 0; i < place.size(); i++) { + String objectKey = place.get(i); + KList objectReasons = new KList<>(); + KList missing = unplaceableKeys(objectKey, data, gate, subject + " place[" + i + "]", objectReasons); + record(report, objectReasons); + reasons.addAll(objectReasons); + + if (missing.isEmpty()) { + survivors.add(objectKey); + continue; + } + + // One finding per missing key, so the operator sees every fallback the object needs at once. + for (String key : missing) { + CompatFinding dropped = new CompatFinding(CompatRegistry.BLOCK, key, CompatAction.DROPPED, + "object", objectKey, subject + " place[" + i + "]"); + report.record(dropped); + reasons.add(dropped); + + if (firstDrop == null) { + firstDrop = dropped; + } + } + } + + if (firstDrop != null && survivors.isEmpty()) { + CompatFinding excluded = new CompatFinding(firstDrop.registry(), firstDrop.key(), CompatAction.EXCLUDED, + "placement", subject, "no objects remain"); + report.record(excluded); + reasons.add(excluded); + compatPlaceCache = new KList<>(); + return CompatStatus.excludedBy(reasons); + } + + compatPlaceCache = survivors.size() == place.size() ? place : survivors; + return new CompatStatus(false, reasons); + } + + /** The subject a finding names: the single object, or the whole place list when there is more than one. */ + private String compatSubject() { + if (place.isEmpty()) { + return ""; + } + return place.size() == 1 ? place.getFirst() : place.toString(", "); + } + + /** + * The placement's own composition blocks: the {@code edit[].replace} palettes. {@code edit[].find} and + * {@code markers[].mark} are match sides and never count - matching a block the server does not have is harmless. + * + * @return the finding that excludes this placement, or null when every declared block resolves + */ + private CompatFinding evaluatePlacementBlocks(IrisData data, ContentGate gate, String subject, KList reasons) { + CompatFinding blocked = null; + + for (int i = 0; i < edit.size(); i++) { + IrisObjectReplace rule = edit.get(i); + + if (rule == null || rule.getReplace() == null) { + continue; + } + + KList palette = rule.getReplace().getPalette(); + + for (int j = 0; j < palette.size(); j++) { + CompatFinding finding = evaluateBlockEntry(palette.get(j), data, gate, subject, + "edit[" + i + "].replace.palette[" + j + "]", reasons); + + if (finding != null && blocked == null) { + blocked = finding; + } + } + } + + return blocked; + } + + /** Records what happened to one declared block entry; returns the finding when it excludes the placement. */ + private CompatFinding evaluateBlockEntry(IrisBlockData block, IrisData data, ContentGate gate, String subject, + String detail, KList reasons) { + if (block == null) { + return null; + } + + BlockCompat resolution = resolveBlockEntry(block, data, gate, new KList<>()); + + if (resolution.missing()) { + CompatFinding finding = new CompatFinding(CompatRegistry.BLOCK, resolution.key(), CompatAction.EXCLUDED, + "placement", subject, detail); + reasons.add(finding); + return finding; + } + + if (resolution.substituted()) { + reasons.add(new CompatFinding(CompatRegistry.BLOCK, resolution.key(), CompatAction.SUBSTITUTED, + "placement", subject, detail + " (using " + resolution.replacement() + ")")); + } + + return null; + } + + /** + * Every distinct palette key of {@code objectKey} that cannot reach the world, in palette order; empty when the + * object still places. A key survives when the gate resolves it (dimension fallbacks count) or when an + * {@code edit} rule with chance 1 matches it - that rule's replace palette was already verified by + * {@link #evaluatePlacementBlocks}. A rule below chance 1 does not cover the key: the original block can still be + * rolled. + */ + private KList unplaceableKeys(String objectKey, IrisData data, ContentGate gate, String detail, KList reasons) { + KList missing = new KList<>(); + + // Palette header only - no IrisObject is built and no state is resolved. A legacy (V1) object reads as an + // empty palette and is therefore never dropped; V1 has no palette block to scan without a full load. + for (String paletteKey : IrisObjectIO.readPaletteKeysCached(data.getObjectLoader().findFile(objectKey))) { + String base = ContentGate.baseKey(ContentGate.normalizeState(paletteKey)); + + if (base == null) { + continue; + } + + // Existence is a registry question about the block, never about its properties: a property that was + // renamed on a block the server still has is out of scope for the gate. + ContentGate.BlockResolution resolution = gate.resolveBlock(base); + + if (resolution != null) { + if (resolution.substituted()) { + reasons.add(new CompatFinding(CompatRegistry.BLOCK, base, CompatAction.SUBSTITUTED, + "object", objectKey, detail + " (using " + resolution.resolvedKey() + ")")); + } + + continue; + } + + if (coveredByEdit(paletteKey)) { + continue; + } + + missing.addIfMissing(base); + } + + return missing; + } + + /** True when an {@code edit} rule certainly rewrites this palette state before it is written. */ + private boolean coveredByEdit(String paletteKey) { + for (IrisObjectReplace rule : edit) { + if (rule == null || rule.getChance() < 1F) { + continue; + } + + if (rule.matchesState(paletteKey)) { + return true; + } + } + + return false; + } + + /** + * Mirrors {@code IrisBlockData.getBlockData} against the gate: pack custom block first, then the registry with + * dimension fallbacks, then the entry's own {@code backup}. + */ + private static BlockCompat resolveBlockEntry(IrisBlockData block, IrisData data, ContentGate gate, KList seen) { + for (IrisBlockData visited : seen) { + if (visited == block) { + return new BlockCompat(String.valueOf(block.getBlock()), null, true, false); + } + } + + seen.add(block); + String declared = block.getBlock(); + IrisBlockData custom = data.getBlockLoader() == null ? null : data.getBlockLoader().load(declared, false); + + if (custom != null && custom != block) { + return resolveBlockEntry(custom, data, gate, seen); + } + + String base = ContentGate.baseKey(ContentGate.normalizeState(declared)); + + if (base == null) { + return new BlockCompat(String.valueOf(declared), null, true, false); + } + + ContentGate.BlockResolution resolution = gate.resolveBlock(base); + + if (resolution != null) { + return new BlockCompat(base, resolution.resolvedKey(), false, resolution.substituted()); + } + + IrisBlockData backup = block.getBackup(); + + if (backup != null) { + BlockCompat viaBackup = resolveBlockEntry(backup, data, gate, seen); + + if (!viaBackup.missing()) { + return new BlockCompat(base, viaBackup.replacement(), false, true); + } + } + + return new BlockCompat(base, null, true, false); + } + + private static void record(PackCompatReport report, KList findings) { + for (CompatFinding finding : findings) { + report.record(finding); + } + } + + /** One block entry's verdict: the key asked for, what will actually generate, and how it got there. */ + private record BlockCompat(String key, String replacement, boolean missing, boolean substituted) { + } public IrisObjectPlacement toPlacement(String... place) { IrisObjectPlacement p = new IrisObjectPlacement(); @@ -244,11 +518,14 @@ public class IrisObjectPlacement { } public IrisObject getObject(DataProvider g, RNG random) { - if (place.isEmpty()) { + IrisData data = g.getData(); + KList pool = compatPlace(data); + + if (pool.isEmpty()) { return null; } - return g.getData().getObjectLoader().load(place.get(random.nextInt(place.size()))); + return data.getObjectLoader().load(pool.get(random.nextInt(pool.size()))); } public boolean matches(IrisTreeSize size, TreeType type) { @@ -298,6 +575,9 @@ public class IrisObjectPlacement { IrisLogging.warn("Couldn't find loot table " + loot.getName()); continue; } + if (table.isCompatExcluded()) { + continue; + } if (loot.getFilter().isEmpty()) //Table applies to all containers { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index 93a780e8e..9f27492e7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -424,7 +424,7 @@ final class IrisObjectPlacementRunner { IrisMarker marker = self.getLoader().getMarkerLoader().load(j.getMarker()); int markerSalt = markerIndex++; - if (marker == null) { + if (marker == null || marker.isCompatExcluded()) { continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java index 38c621d5f..401882b08 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectReplace.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; import art.arcane.iris.engine.object.annotations.ArrayType; @@ -35,6 +36,9 @@ import lombok.NoArgsConstructor; import art.arcane.iris.spi.PlatformBlockState; import lombok.experimental.Accessors; +import java.util.LinkedHashMap; +import java.util.Map; + @Snippet("object-block-replacer") @Accessors(chain = true) @NoArgsConstructor @@ -65,7 +69,7 @@ public class IrisObjectReplace { KList b = new KList<>(); for (IrisBlockData i : find) { - PlatformBlockState bx = i.getBlockData(rdata); + PlatformBlockState bx = i.getBlockDataOrPlaceholder(rdata); if (bx != null) { b.add(bx); @@ -79,4 +83,89 @@ public class IrisObjectReplace { public PlatformBlockState getReplace(RNG seed, double x, double y, double z, IrisData rdata) { return getReplace().get(seed, x, y, z, rdata); } + + /** + * Key-level mirror of the runtime find match, for the version-content gate: it has to decide whether a palette + * key the server does not have is rewritten by this rule, and a missing key has no resolved state to compare. + * Non-exact compares the base block; exact requires every property named by the find entry to match, the same + * partial-match contract {@code PlatformBlockState.matches} uses at runtime. + */ + public boolean matchesState(String stateKey) { + String state = ContentGate.normalizeState(stateKey); + + if (state == null) { + return false; + } + + String stateBase = ContentGate.baseKey(state); + + for (IrisBlockData f : find) { + if (f == null) { + continue; + } + + String findState = ContentGate.normalizeState(declaredState(f)); + + if (findState == null || !ContentGate.baseKey(findState).equals(stateBase)) { + continue; + } + + if (!exact || propertiesMatch(findState, state)) { + return true; + } + } + + return false; + } + + /** The find entry as one state string; {@code block} already carrying properties wins over the data map. */ + private static String declaredState(IrisBlockData block) { + String declared = block.getBlock(); + + if (declared == null) { + return null; + } + + return declared.indexOf('[') < 0 ? declared + block.computeProperties() : declared; + } + + /** Every property the find side names must be present with the same value on the candidate state. */ + private static boolean propertiesMatch(String findState, String candidateState) { + Map required = properties(findState); + + if (required.isEmpty()) { + return true; + } + + Map candidate = properties(candidateState); + + for (Map.Entry entry : required.entrySet()) { + if (!entry.getValue().equals(candidate.get(entry.getKey()))) { + return false; + } + } + + return true; + } + + private static Map properties(String state) { + int open = state.indexOf('['); + int close = state.lastIndexOf(']'); + + if (open < 0 || close < open) { + return Map.of(); + } + + Map properties = new LinkedHashMap<>(); + + for (String part : state.substring(open + 1, close).split(",")) { + int split = part.indexOf('='); + + if (split > 0) { + properties.put(part.substring(0, split).trim(), part.substring(split + 1).trim()); + } + } + + return properties; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index 97b30d808..bddb10265 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -19,6 +19,9 @@ package art.arcane.iris.engine.object; import art.arcane.iris.engine.framework.render.RenderType; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBlockState; @@ -44,6 +47,8 @@ import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import java.awt.Color; +import java.util.ArrayList; +import java.util.List; import java.util.Random; @@ -321,6 +326,11 @@ public class IrisRegion extends IrisRegistrant implements IRare { continue; } + if (biome.isCompatExcluded()) { + CompatPools.drop(g.getData(), biome, "region", getLoadKey(), "biomes " + i, null); + continue; + } + names.add(biome.getCarvingBiome()); b.put(biome.getLoadKey(), biome); names.addAll(biome.getChildren()); @@ -345,55 +355,26 @@ public class IrisRegion extends IrisRegistrant implements IRare { } public KList getRealCaveBiomes(DataProvider g) { - return realCaveBiomes.aquire(() -> - { - KList realCaveBiomes = new KList<>(); - - for (String i : getCaveBiomes()) { - realCaveBiomes.add(g.getData().getBiomeLoader().load(i)); - } - - return realCaveBiomes; - }); + return realCaveBiomes.aquire(() -> resolvePool(g, getCaveBiomes(), "caveBiomes", null)); } public KList getRealShoreBiomes(DataProvider g) { - return realShoreBiomes.aquire(() -> - { - KList realShoreBiomes = new KList<>(); - - for (String i : getShoreBiomes()) { - realShoreBiomes.add(g.getData().getBiomeLoader().load(i)); - } - - return realShoreBiomes; - }); + return realShoreBiomes.aquire(() -> resolvePool(g, getShoreBiomes(), "shoreBiomes", null)); } public KList getRealSeaBiomes(DataProvider g) { - return realSeaBiomes.aquire(() -> - { - KList realSeaBiomes = new KList<>(); - - for (String i : getSeaBiomes()) { - realSeaBiomes.add(g.getData().getBiomeLoader().load(i)); - } - - return realSeaBiomes; - }); + return realSeaBiomes.aquire(() -> resolvePool(g, getSeaBiomes(), "seaBiomes", null)); } public KList getRealLandBiomes(DataProvider g) { - return realLandBiomes.aquire(() -> - { - KList realLandBiomes = new KList<>(); + return realLandBiomes.aquire(() -> resolvePool(g, getLandBiomes(), "landBiomes", null)); + } - for (String i : getLandBiomes()) { - realLandBiomes.add(g.getData().getBiomeLoader().load(i)); - } - - return realLandBiomes; - }); + /** A biome reference pool with unloadable and gate-excluded biomes removed; drops are reported once. */ + private KList resolvePool(DataProvider g, KList keys, String field, List sink) { + IrisData data = g == null ? null : g.getData(); + return CompatPools.load(data == null ? null : data.getBiomeLoader(), keys, data, + "region", getLoadKey(), field, sink); } public KList getAllAnyBiomes() { @@ -468,6 +449,42 @@ public class IrisRegion extends IrisRegistrant implements IRare { } + /** + * Cascade: a region that lost every land biome to the gate cannot generate, so it leaves the dimension's region + * pool too. Loading biomes here is safe - biomes never load regions. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null) { + return base; + } + + for (int index = 0; index < structures.size(); index++) { + structures.get(index).reportExcludedCaveBiomes(data, "region", getLoadKey(), + "structures[" + index + "].caveBiomes"); + } + + if (getLandBiomes().isEmpty()) { + return base; + } + + List drops = new ArrayList<>(); + + if (!resolvePool(() -> data, getLandBiomes(), "landBiomes", drops).isEmpty()) { + return base; + } + + return CompatPools.cascade(data, base, drops, "region", getLoadKey(), "no land biomes remain"); + } + @Override public String getFolderName() { return "regions"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java index 89c088dca..57f0ccb3d 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java @@ -4,6 +4,7 @@ import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.annotations.RegistryListResource; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KSet; @@ -83,6 +84,19 @@ public class IrisRiverPolicy { @Desc("Multiplier applied to the eroded bank width outside the shore band.") private Double bankMultiplier = null; + /** + * A declared river biome selection with biomes the version-content gate excluded removed. A declared-but-now-empty + * selection keeps its "explicitly clears the inherited selection" meaning; null (undeclared) stays null. + */ + public KList compatBiomes(KList declared, IrisData data, String field) { + if (declared == null) { + return null; + } + + return CompatPools.surviving(data == null ? null : data.getBiomeLoader(), declared, data, + "river policy", "", field); + } + public KSet getAllBiomeIds() { KSet biomeIds = new KSet<>(); addAll(biomeIds, surfaceBiomes); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java index 1c4a0b6a8..38c9c69eb 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSpawner.java @@ -18,6 +18,10 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.ArrayType; @@ -30,6 +34,9 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import java.util.ArrayList; +import java.util.List; + @EqualsAndHashCode(callSuper = true) @Accessors(chain = true) @NoArgsConstructor @@ -123,6 +130,64 @@ public class IrisSpawner extends IrisRegistrant { engine.getEngineData().getChunk(x, z).getCooldown(this).spawn(engine); } + /** + * Cascade: a spawn whose entity the gate excluded can never spawn, so it leaves this spawner's lists; a spawner + * with nothing left to spawn leaves every pool that references it. Loading entities here is safe - entities never + * load spawners. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || data.getEntityLoader() == null) { + return base; + } + + boolean declared = !spawns.isEmpty() || !initialSpawns.isEmpty(); + List drops = new ArrayList<>(); + spawns = dropExcludedSpawns(data, spawns, "spawns", drops); + initialSpawns = dropExcludedSpawns(data, initialSpawns, "initialSpawns", drops); + + if (!declared || !spawns.isEmpty() || !initialSpawns.isEmpty()) { + return base; + } + + return CompatPools.cascade(data, base, drops, "spawner", getLoadKey(), "no entity spawns remain"); + } + + private KList dropExcludedSpawns(IrisData data, + KList declared, + String field, + List drops) { + KList kept = new KList<>(); + + for (int index = 0; index < declared.size(); index++) { + IrisEntitySpawn spawn = declared.get(index); + + if (spawn == null) { + continue; + } + + IrisEntity entity = data.getEntityLoader().load(spawn.getEntity()); + + if (entity != null && entity.isCompatExcluded()) { + CompatPools.drop(data, entity, "spawner", getLoadKey(), + field + "[" + index + "] " + spawn.getEntity(), drops); + continue; + } + + kept.add(spawn); + } + + return kept; + } + @Override public String getFolderName() { return "spawners"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java index 74113d3ce..5d123c0e5 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructure.java @@ -18,6 +18,12 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; @@ -126,6 +132,42 @@ public class IrisStructure extends IrisRegistrant { return placement; } + /** + * The start pool is the only mandatory entry point into an assembly: when it has nothing left to place, the + * structure cannot generate on this server and every placement that lists it skips it. + */ + @Override + public CompatStatus evaluateCompat(ContentGate gate) { + CompatStatus base = super.evaluateCompat(gate); + + if (base.excluded() || gate == null || !gate.ready()) { + return base; + } + + IrisData data = getLoader(); + + if (data == null || startPool == null || startPool.isBlank()) { + return base; + } + + IrisJigsawPool pool = data.load(IrisJigsawPool.class, startPool.trim(), false); + + if (pool == null || !pool.isCompatExcluded()) { + return base; + } + + CompatFinding reason = pool.getCompat().reasons().isEmpty() ? null : pool.getCompat().reasons().getLast(); + CompatFinding excluded = new CompatFinding( + reason == null ? CompatRegistry.BLOCK : reason.registry(), + reason == null ? startPool.trim() : reason.key(), + CompatAction.EXCLUDED, "structure", getLoadKey(), + "start pool " + startPool.trim() + " is unavailable"); + gate.report().record(excluded); + KList reasons = new KList<>(base.reasons()); + reasons.add(excluded); + return CompatStatus.excludedBy(reasons); + } + @Override public String getFolderName() { return "structures"; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java index eb456f9dc..5afb331d1 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisStructurePlacement.java @@ -18,6 +18,7 @@ package art.arcane.iris.engine.object; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; @@ -147,4 +148,24 @@ public class IrisStructurePlacement { IrisStructureAnchorMode activeAnchor = resolvedAnchor(); return activeAnchor == IrisStructureAnchorMode.HEIGHT_BAND || activeAnchor.isCave(); } + + /** + * Reports cave biome references the version-content gate excluded. The list is deliberately not filtered: it is an + * allowlist whose emptiness means "any cave biome", and it feeds the placement determinism signature. An excluded + * biome never generates, so the entry is inert - this only makes it visible in the pack compat report. + */ + void reportExcludedCaveBiomes(IrisData data, String subjectType, String subjectKey, String field) { + if (data == null || data.getBiomeLoader() == null || caveBiomes == null) { + return; + } + + for (int index = 0; index < caveBiomes.size(); index++) { + String key = caveBiomes.get(index); + IrisBiome biome = data.getBiomeLoader().load(key); + + if (biome != null && biome.isCompatExcluded()) { + CompatPools.drop(data, biome, subjectType, subjectKey, field + "[" + index + "] " + key, null); + } + } + } } diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerCompatTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerCompatTest.java new file mode 100644 index 000000000..f9f339348 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureAssemblerCompatTest.java @@ -0,0 +1,145 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.framework; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.structure.StructureAssemblyResult; +import art.arcane.iris.engine.framework.structure.StructureAssemblyStatus; +import art.arcane.iris.engine.object.IrisDirection; +import art.arcane.iris.engine.object.IrisJigsawConnector; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisJigsawPieceEntry; +import art.arcane.iris.engine.object.IrisJigsawPool; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.JigsawJoint; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.math.RNG; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class StructureAssemblerCompatTest { + @BeforeClass + public static void bindPlatform() { + IrisPlatforms.unbind(); + PlatformBlockState block = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.block(anyString())).thenReturn(block); + IrisPlatform platform = mock(IrisPlatform.class); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + } + + @AfterClass + public static void unbindPlatform() { + IrisPlatforms.unbind(); + } + + @Test + public void anExcludedPieceIsNeverDrawnFromItsPool() { + IrisData data = mock(IrisData.class); + IrisJigsawPiece good = piece("good-object"); + IrisJigsawPiece bad = excluded(piece("bad-object")); + when(data.load(IrisJigsawPool.class, "start", false)).thenReturn(pool("good-piece", "bad-piece")); + when(data.load(IrisJigsawPool.class, "terminal", false)).thenReturn(new IrisJigsawPool()); + when(data.load(IrisJigsawPiece.class, "good-piece", false)).thenReturn(good); + when(data.load(IrisJigsawPiece.class, "bad-piece", false)).thenReturn(bad); + when(data.load(IrisObject.class, "good-object", false)).thenReturn(new IrisObject(1, 1, 1)); + when(data.load(IrisObject.class, "bad-object", false)).thenReturn(new IrisObject(1, 1, 1)); + + for (int seed = 0; seed < 32; seed++) { + StructureAssemblyResult result = StructureAssembler + .forData(data, structure("start"), new IrisPosition(0, 0, 0)) + .assemble(new RNG(seed)); + + assertTrue(result.detail(), result.hasOutput()); + assertTrue(result.pieces().toString(), result.pieces().stream() + .allMatch(placed -> "good-object".equals(placed.getPiece().getObject()))); + } + } + + @Test + public void aPoolWithOnlyExcludedPiecesStopsTheAssemblyWithoutThrowing() { + IrisData data = mock(IrisData.class); + IrisJigsawPool startPool = pool("bad-piece"); + startPool.setCompat(CompatStatus.excludedBy(List.of(finding(CompatAction.EXCLUDED)))); + when(data.load(IrisJigsawPool.class, "start", false)).thenReturn(startPool); + when(data.load(IrisJigsawPool.class, "terminal", false)).thenReturn(new IrisJigsawPool()); + when(data.load(IrisJigsawPiece.class, "bad-piece", false)).thenReturn(excluded(piece("bad-object"))); + when(data.load(IrisObject.class, "bad-object", false)).thenReturn(new IrisObject(1, 1, 1)); + + StructureAssemblyResult result = StructureAssembler + .forData(data, structure("start"), new IrisPosition(0, 0, 0)) + .assemble(new RNG(3)); + + assertFalse(result.hasOutput()); + assertEquals(StructureAssemblyStatus.FAILED_RULES, result.status()); + } + + private static IrisJigsawPiece excluded(IrisJigsawPiece piece) { + piece.setCompat(CompatStatus.excludedBy(List.of(finding(CompatAction.EXCLUDED)))); + return piece; + } + + private static CompatFinding finding(CompatAction action) { + return new CompatFinding(CompatRegistry.BLOCK, "minecraft:sulfur", action, "jigsaw piece", "bad-piece", "object bad-object"); + } + + private static IrisJigsawPiece piece(String object) { + IrisJigsawPiece piece = new IrisJigsawPiece().setObject(object).setRotatable(false); + piece.getConnectors().add(new IrisJigsawConnector() + .setPosition(new IrisPosition()) + .setDirection(IrisDirection.EAST_POSITIVE_X) + .setPool("terminal") + .setName("start") + .setTargetName("door") + .setJoint(JigsawJoint.ROLLABLE)); + return piece; + } + + private static IrisJigsawPool pool(String... pieces) { + IrisJigsawPool pool = new IrisJigsawPool(); + for (String piece : pieces) { + pool.getPieces().add(new IrisJigsawPieceEntry().setPiece(piece).setWeight(1)); + } + return pool; + } + + private static IrisStructure structure(String startPool) { + return new IrisStructure().setStartPool(startPool).setMaxDepth(1).setMaxSizeChunks(8); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java b/core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java new file mode 100644 index 000000000..d652bffe1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/CompatFixtures.java @@ -0,0 +1,84 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; + +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Shared fixtures for the version-content pool cascade tests. The gate walker (lane L1) is not exercised here: the + * tests stamp {@code compat} on a registrant directly, which is exactly what the walker stores. + */ +final class CompatFixtures { + static final String MISSING_BLOCK = "minecraft:sulfur"; + static final String MISSING_ENTITY = "minecraft:camel"; + + private CompatFixtures() { + } + + static IrisData data(PackCompatReport report) { + IrisData data = mock(IrisData.class); + when(data.getCompatReport()).thenReturn(report); + return data; + } + + /** Marks a registrant excluded the way the gate walker would for a missing composed block. */ + static T excludeBlock(T registrant) { + return exclude(registrant, CompatRegistry.BLOCK, MISSING_BLOCK); + } + + /** Marks a registrant excluded the way the gate walker would for a missing entity type. */ + static T excludeEntity(T registrant) { + return exclude(registrant, CompatRegistry.ENTITY, MISSING_ENTITY); + } + + static T exclude(T registrant, CompatRegistry registry, String key) { + registrant.setCompat(CompatStatus.excludedBy(List.of(new CompatFinding(registry, key, + CompatAction.EXCLUDED, registrant.getTypeName().toLowerCase(java.util.Locale.ROOT), + registrant.getLoadKey(), "composition")))); + return registrant; + } + + static IrisBiome biome(String key) { + IrisBiome biome = new IrisBiome(); + biome.setLoadKey(key); + return biome; + } + + static IrisRegion region(String key) { + IrisRegion region = new IrisRegion(); + region.setLoadKey(key); + return region; + } + + static IrisEntity entity(String key) { + IrisEntity entity = new IrisEntity(); + entity.setLoadKey(key); + return entity; + } + + static IrisLootTable lootTable(String key) { + IrisLootTable table = new IrisLootTable(); + table.setLoadKey(key); + return table; + } + + static CompatFinding find(PackCompatReport report, CompatAction action, String subjectType, String subjectKey) { + for (CompatFinding finding : report.findings()) { + if (finding.action() == action + && finding.subjectType().equals(subjectType) + && finding.subjectKey().equals(subjectKey)) { + return finding; + } + } + return null; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/CompatTestPlatform.java b/core/src/test/java/art/arcane/iris/engine/object/CompatTestPlatform.java new file mode 100644 index 000000000..3068f1fe5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/CompatTestPlatform.java @@ -0,0 +1,254 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockProperty; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformEntityType; +import art.arcane.iris.spi.PlatformItem; +import art.arcane.iris.spi.PlatformRegistries; +import org.mockito.Answers; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Binds a platform whose block registry holds exactly the keys a compat test declares, so the gate can be exercised + * without a server. Also writes the .iob and JSON fixtures those tests need. + */ +final class CompatTestPlatform { + private final IrisPlatform previous; + + private CompatTestPlatform(IrisPlatform previous) { + this.previous = previous; + } + + static CompatTestPlatform bind(File dataFolder, List blockKeys) { + IrisPlatform previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + IrisPlatform platform = mock(IrisPlatform.class, Answers.CALLS_REAL_METHODS); + when(platform.dataFolder()).thenReturn(dataFolder); + when(platform.registries()).thenReturn(new FixedRegistries(blockKeys)); + IrisPlatforms.bind(platform); + IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry()); + return new CompatTestPlatform(previous); + } + + void unbind() { + IrisServices.clear(); + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + + /** A minimal V2 .iob under {@code /objects/.iob} carrying only the given palette. */ + static File writeObject(File pack, String key, String... palette) throws IOException { + File file = new File(pack, "objects/" + key + ".iob"); + file.getParentFile().mkdirs(); + try (DataOutputStream out = new DataOutputStream(new FileOutputStream(file))) { + out.writeInt(1); + out.writeInt(1); + out.writeInt(1); + out.writeUTF("Iris V2 IOB;"); + out.writeShort(palette.length); + for (String entry : palette) { + out.writeUTF(entry); + } + out.writeInt(0); + out.writeInt(0); + } + return file; + } + + static File write(File pack, String path, String content) throws IOException { + File file = new File(pack, path); + file.getParentFile().mkdirs(); + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return file; + } + + private record FixedRegistries(List blocks) implements PlatformRegistries { + private static String base(String key) { + if (key == null) { + return null; + } + String trimmed = key.trim().toLowerCase(Locale.ROOT); + int props = trimmed.indexOf('['); + String head = props < 0 ? trimmed : trimmed.substring(0, props); + return head.indexOf(':') < 0 ? "minecraft:" + head : head; + } + + private boolean has(String key) { + String normalized = base(key); + if (normalized == null) { + return false; + } + for (String block : blocks) { + if (base(block).equals(normalized)) { + return true; + } + } + return false; + } + + @Override + public PlatformBlockState block(String key) { + PlatformBlockState state = blockOrNull(key, false); + return state == null ? air() : state; + } + + @Override + public PlatformBlockState blockOrNull(String key) { + return blockOrNull(key, false); + } + + @Override + public PlatformBlockState blockOrNull(String key, boolean warn) { + return has(key) ? StateCache.of(base(key)) : null; + } + + @Override + public PlatformBlockState air() { + return StateCache.of("minecraft:air"); + } + + @Override + public PlatformBlockState deepSlateOre(PlatformBlockState block, PlatformBlockState ore) { + return ore; + } + + @Override + public PlatformBiome biome(String key) { + return null; + } + + @Override + public PlatformItem item(String key) { + return null; + } + + @Override + public PlatformEntityType entity(String key) { + return null; + } + + @Override + public List blockKeys() { + return blocks; + } + + @Override + public List biomeKeys() { + return List.of(); + } + + @Override + public List structureKeys() { + return List.of(); + } + + @Override + public List itemKeys() { + return List.of(); + } + + @Override + public List entityKeys() { + return List.of(); + } + + @Override + public List blockTypeKeys() { + return blocks; + } + + @Override + public List enchantmentKeys() { + return List.of(); + } + + @Override + public List potionEffectKeys() { + return List.of(); + } + + @Override + public List lootTableKeys() { + return List.of(); + } + + @Override + public Map> blockStateProperties() { + return Map.of(); + } + } + + /** One interned mock per key so {@code matches} and identity behave like a real adapter. */ + private static final class StateCache { + private static final Map STATES = new LinkedHashMap<>(); + + private StateCache() { + } + + static synchronized PlatformBlockState of(String key) { + return STATES.computeIfAbsent(key, k -> { + PlatformBlockState state = mock(PlatformBlockState.class); + when(state.key()).thenReturn(k); + return state; + }); + } + } + + private static final class NoOpPreservationRegistry implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCompatChildrenTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCompatChildrenTest.java new file mode 100644 index 000000000..f1666a1ae --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCompatChildrenTest.java @@ -0,0 +1,95 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import static art.arcane.iris.engine.object.CompatFixtures.MISSING_BLOCK; +import static art.arcane.iris.engine.object.CompatFixtures.biome; +import static art.arcane.iris.engine.object.CompatFixtures.excludeBlock; +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBiomeCompatChildrenTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisBiome... biomes) { + IrisData data = CompatFixtures.data(report); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + for (IrisBiome biome : biomes) { + when(biomeLoader.load(biome.getLoadKey())).thenReturn(biome); + } + return data; + } + + @Test + public void realChildrenDropsExcludedChildAndRecordsFinding() { + PackCompatReport report = new PackCompatReport(); + IrisBiome parent = biome("land/plains"); + IrisBiome hills = biome("land/hills"); + IrisBiome sulfur = excludeBlock(biome("land/sulfur-flats")); + parent.setChildren(new KList<>("land/hills", "land/sulfur-flats")); + IrisData data = dataWith(report, parent, hills, sulfur); + parent.setLoader(data); + + KList children = parent.getRealChildren(() -> data); + + assertEquals(1, children.size()); + assertEquals("land/hills", children.get(0).getLoadKey()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "biome", "land/plains"); + assertNotNull(dropped); + assertEquals(MISSING_BLOCK, dropped.key()); + assertEquals("children[1] land/sulfur-flats", dropped.detail()); + } + + @Test + public void realChildrenSkipsUnloadableChild() { + PackCompatReport report = new PackCompatReport(); + IrisBiome parent = biome("land/plains"); + parent.setChildren(new KList<>("land/gone")); + IrisData data = dataWith(report, parent); + parent.setLoader(data); + + assertTrue(parent.getRealChildren(() -> data).isEmpty()); + } + + @Test + public void floatingChildBiomeFallsBackToParentWhenTargetExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisBiome parent = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("sky/sulfur-isles")); + IrisData data = dataWith(report, parent, sulfur); + parent.setLoader(data); + IrisFloatingChildBiomes floating = new IrisFloatingChildBiomes().setBiome("sky/sulfur-isles"); + + assertSame(parent, floating.getRealBiome(parent, data)); + CompatFinding dropped = find(report, CompatAction.DROPPED, "floating child biome", "land/plains"); + assertNotNull(dropped); + assertEquals(MISSING_BLOCK, dropped.key()); + } + + @Test + public void carvingEntryResolvesNullWhenBiomeExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-grotto")); + IrisData data = dataWith(report, sulfur); + IrisDimensionCarvingEntry entry = new IrisDimensionCarvingEntry() + .setId("deep-band") + .setBiome("cave/sulfur-grotto"); + + assertNull(entry.getRealBiome(data)); + CompatFinding dropped = find(report, CompatAction.DROPPED, "carving entry", "deep-band"); + assertNotNull(dropped); + assertEquals(MISSING_BLOCK, dropped.key()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomCompatTest.java new file mode 100644 index 000000000..b48c9c7d6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeCustomCompatTest.java @@ -0,0 +1,91 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.nms.datapack.IDataFixer; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisBiomeCustomCompatTest { + private static final IDataFixer PASSTHROUGH = new IDataFixer() { + @Override + public JSONObject resolve(IDataFixer.Dimension dimension, art.arcane.iris.engine.object.IrisDimensionTypeOptions options) { + return new JSONObject(); + } + + @Override + public void fixDimension(IDataFixer.Dimension dimension, JSONObject json) { + } + }; + + private static ContentGate gateWithEntities(String... present) { + List entities = new ArrayList<>(); + entities.add("minecraft:pig"); + entities.addAll(List.of(present)); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockKeys()).thenReturn(List.of("minecraft:stone")); + when(registries.entityKeys()).thenReturn(entities); + return new ContentGate(registries, Map.of(), new PackCompatReport()); + } + + private static IrisBiomeCustom customBiome() { + IrisBiomeCustom biome = new IrisBiomeCustom(); + biome.setId("sulfur_grotto"); + biome.setSpawnRarity(1); + biome.setSpawns(new KList<>( + new IrisBiomeCustomSpawn().setType("minecraft:zombie"), + new IrisBiomeCustomSpawn().setType("minecraft:camel"))); + return biome; + } + + @Test + public void generateJsonOmitsSpawnWhoseEntityTypeIsMissing() { + JSONObject json = new JSONObject(customBiome().generateJson(PASSTHROUGH, gateWithEntities("minecraft:zombie"))); + JSONArray monsters = json.getJSONObject("spawners").getJSONArray("misc"); + + assertEquals(1, monsters.length()); + assertEquals("minecraft:zombie", monsters.getJSONObject(0).getString("type")); + assertFalse(json.getJSONObject("spawners").toString().contains("minecraft:camel")); + } + + @Test + public void generateJsonKeepsEverySpawnWhenAllEntitiesArePresent() { + JSONObject json = new JSONObject(customBiome() + .generateJson(PASSTHROUGH, gateWithEntities("minecraft:zombie", "minecraft:camel"))); + + assertEquals(2, json.getJSONObject("spawners").getJSONArray("misc").length()); + } + + @Test + public void generateJsonKeepsEverySpawnWhenRegistryCannotBeConsulted() { + // Registry not ready (modded early boot): UNKNOWN must never behave like MISSING. + PlatformRegistries registries = mock(PlatformRegistries.class); + when(registries.blockKeys()).thenReturn(List.of()); + when(registries.entityKeys()).thenReturn(List.of()); + ContentGate gate = new ContentGate(registries, Map.of(), new PackCompatReport()); + + JSONObject json = new JSONObject(customBiome().generateJson(PASSTHROUGH, gate)); + + assertEquals(2, json.getJSONObject("spawners").getJSONArray("misc").length()); + } + + @Test + public void generateJsonWritesNoSpawnersWhenEveryEntityIsMissing() { + JSONObject json = new JSONObject(customBiome().generateJson(PASSTHROUGH, gateWithEntities())); + + assertEquals(0, json.getJSONObject("spawners").length()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeDerivativeCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeDerivativeCompatTest.java new file mode 100644 index 000000000..19055378c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomeDerivativeCompatTest.java @@ -0,0 +1,34 @@ +package art.arcane.iris.engine.object; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +/** + * The gate walker drops a {@code vanillaDerivative} whose biome key is missing (policy table: entry DROPPED, falls back + * to derivative). This pins the fallback the drop relies on; the drop itself is simulated by clearing the field, which + * is exactly what the walker does. + */ +public class IrisBiomeDerivativeCompatTest { + @Test + public void droppedVanillaDerivativeFallsBackToDerivative() { + IrisBiome biome = CompatFixtures.biome("cave/sulfur-grotto"); + biome.setDerivative("minecraft:dripstone_caves"); + biome.setVanillaDerivative("minecraft:sulfur_caves"); + + assertEquals("minecraft:sulfur_caves", biome.getVanillaDerivativeKey()); + + biome.setVanillaDerivative(null); + + assertEquals("minecraft:dripstone_caves", biome.getVanillaDerivativeKey()); + } + + @Test + public void blankVanillaDerivativeFallsBackToDerivative() { + IrisBiome biome = CompatFixtures.biome("cave/sulfur-grotto"); + biome.setDerivative("minecraft:dripstone_caves"); + biome.setVanillaDerivative(""); + + assertEquals("minecraft:dripstone_caves", biome.getVanillaDerivativeKey()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCompatRegionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCompatRegionTest.java new file mode 100644 index 000000000..e9dc36dfe --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionCompatRegionTest.java @@ -0,0 +1,92 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.Map; + +import static art.arcane.iris.engine.object.CompatFixtures.MISSING_BLOCK; +import static art.arcane.iris.engine.object.CompatFixtures.excludeBlock; +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static art.arcane.iris.engine.object.CompatFixtures.region; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisDimensionCompatRegionTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisRegion... regions) { + IrisData data = CompatFixtures.data(report); + ResourceLoader regionLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + for (IrisRegion region : regions) { + when(regionLoader.load(region.getLoadKey())).thenReturn(region); + } + return data; + } + + private static ContentGate gate(PackCompatReport report) { + return new ContentGate(null, Map.of(), report); + } + + @Test + public void allRegionsDropsExcludedRegionAndRecordsFinding() { + PackCompatReport report = new PackCompatReport(); + IrisRegion plains = region("overworld"); + IrisRegion sulfur = excludeBlock(region("sulfur-lands")); + IrisData data = dataWith(report, plains, sulfur); + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("overworld", "sulfur-lands")); + dimension.setLoadKey("overworld"); + dimension.setLoader(data); + + KList pool = dimension.getAllRegions(() -> data); + + assertEquals(1, pool.size()); + assertEquals("overworld", pool.get(0).getLoadKey()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "dimension", "overworld"); + assertNotNull(dropped); + assertEquals(MISSING_BLOCK, dropped.key()); + assertEquals("regions[1] sulfur-lands", dropped.detail()); + } + + @Test + public void evaluateCompatExcludesDimensionWithNoRegionsLeft() { + PackCompatReport report = new PackCompatReport(); + IrisRegion sulfur = excludeBlock(region("sulfur-lands")); + IrisData data = dataWith(report, sulfur); + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("sulfur-lands")); + dimension.setLoadKey("sulfurworld"); + dimension.setLoader(data); + + CompatStatus status = dimension.evaluateCompat(gate(report)); + + assertTrue(status.excluded()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "dimension", "sulfurworld"); + assertNotNull(cascade); + assertEquals(MISSING_BLOCK, cascade.key()); + assertEquals("no regions remain", cascade.detail()); + } + + @Test + public void evaluateCompatKeepsDimensionWithSurvivingRegion() { + PackCompatReport report = new PackCompatReport(); + IrisRegion plains = region("overworld"); + IrisRegion sulfur = excludeBlock(region("sulfur-lands")); + IrisData data = dataWith(report, plains, sulfur); + IrisDimension dimension = new IrisDimension().setRegions(new KList<>("overworld", "sulfur-lands")); + dimension.setLoadKey("overworld"); + dimension.setLoader(data); + + assertFalse(dimension.evaluateCompat(gate(report)).excluded()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawCompatTest.java new file mode 100644 index 000000000..e8ef7c161 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisJigsawCompatTest.java @@ -0,0 +1,158 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.loader.IrisData; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class IrisJigsawCompatTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private CompatTestPlatform platform; + private IrisSettings previousSettings; + private IrisData data; + + @Before + public void bindPlatform() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + platform = CompatTestPlatform.bind(temporaryFolder.getRoot(), + List.of("minecraft:stone", "minecraft:cobblestone", "minecraft:air")); + } + + @After + public void restorePlatform() { + if (data != null) { + data.close(); + data = null; + } + if (platform != null) { + platform.unbind(); + platform = null; + } + IrisSettings.settings = previousSettings; + } + + @Test + public void aPieceWhoseObjectNeedsAMissingBlockIsExcluded() throws Exception { + File pack = pack("piece"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + CompatTestPlatform.write(pack, "jigsaw-pieces/bad.json", "{\"object\":\"bad\"}"); + data = IrisData.get(pack); + + IrisJigsawPiece piece = data.load(IrisJigsawPiece.class, "bad", false); + + assertNotNull(piece); + assertTrue(piece.isCompatExcluded()); + assertTrue(data.getCompatReport().findings().stream() + .anyMatch(finding -> "minecraft:sulfur".equals(finding.key()) + && finding.action() == CompatAction.EXCLUDED)); + } + + @Test + public void aPieceWhoseObjectResolvesIsKept() throws Exception { + File pack = pack("piece-ok"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + CompatTestPlatform.write(pack, "jigsaw-pieces/good.json", "{\"object\":\"good\"}"); + data = IrisData.get(pack); + + IrisJigsawPiece piece = data.load(IrisJigsawPiece.class, "good", false); + + assertNotNull(piece); + assertFalse(piece.isCompatExcluded()); + } + + @Test + public void aPoolKeepsGeneratingWhileOnePieceSurvives() throws Exception { + File pack = pack("pool-partial"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + CompatTestPlatform.write(pack, "jigsaw-pieces/good.json", "{\"object\":\"good\"}"); + CompatTestPlatform.write(pack, "jigsaw-pieces/bad.json", "{\"object\":\"bad\"}"); + CompatTestPlatform.write(pack, "jigsaw-pools/pool.json", + "{\"pieces\":[{\"piece\":\"good\",\"weight\":1},{\"piece\":\"bad\",\"weight\":1}]}"); + data = IrisData.get(pack); + + IrisJigsawPool pool = data.load(IrisJigsawPool.class, "pool", false); + + assertNotNull(pool); + assertFalse(pool.isCompatExcluded()); + assertTrue(data.getCompatReport().findings().stream() + .anyMatch(finding -> finding.action() == CompatAction.DROPPED + && "jigsaw pool".equals(finding.subjectType()))); + } + + @Test + public void pieceExclusionCascadesToThePoolAndTheStructure() throws Exception { + File pack = pack("cascade"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + CompatTestPlatform.write(pack, "jigsaw-pieces/bad.json", "{\"object\":\"bad\"}"); + CompatTestPlatform.write(pack, "jigsaw-pools/pool.json", + "{\"pieces\":[{\"piece\":\"bad\",\"weight\":1}]}"); + CompatTestPlatform.write(pack, "structures/keep.json", "{\"startPool\":\"pool\"}"); + data = IrisData.get(pack); + + IrisJigsawPool pool = data.load(IrisJigsawPool.class, "pool", false); + IrisStructure structure = data.load(IrisStructure.class, "keep", false); + + assertNotNull(pool); + assertNotNull(structure); + assertTrue(pool.isCompatExcluded()); + assertTrue(structure.isCompatExcluded()); + assertTrue(data.getCompatReport().findings().stream() + .anyMatch(finding -> "structure".equals(finding.subjectType()) + && finding.action() == CompatAction.EXCLUDED + && "minecraft:sulfur".equals(finding.key()))); + } + + @Test + public void aStructureOverAHealthyPoolIsKept() throws Exception { + File pack = pack("structure-ok"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + CompatTestPlatform.write(pack, "jigsaw-pieces/good.json", "{\"object\":\"good\"}"); + CompatTestPlatform.write(pack, "jigsaw-pools/pool.json", + "{\"pieces\":[{\"piece\":\"good\",\"weight\":1}]}"); + CompatTestPlatform.write(pack, "structures/keep.json", "{\"startPool\":\"pool\"}"); + data = IrisData.get(pack); + + IrisStructure structure = data.load(IrisStructure.class, "keep", false); + + assertNotNull(structure); + assertFalse(structure.isCompatExcluded()); + assertTrue(data.getCompatReport().isEmpty()); + } + + private File pack(String name) throws Exception { + return temporaryFolder.newFolder(name); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisLootTableCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisLootTableCompatTest.java new file mode 100644 index 000000000..6212b714f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisLootTableCompatTest.java @@ -0,0 +1,101 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static art.arcane.iris.engine.object.CompatFixtures.lootTable; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisLootTableCompatTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisLootTable... tables) { + IrisData data = CompatFixtures.data(report); + ResourceLoader lootLoader = mock(ResourceLoader.class); + when(data.getLootLoader()).thenReturn(lootLoader); + for (IrisLootTable table : tables) { + when(lootLoader.load(table.getLoadKey())).thenReturn(table); + } + return data; + } + + private static ContentGate gate(PackCompatReport report) { + return new ContentGate(null, Map.of(), report); + } + + @Test + public void evaluateCompatExcludesTableWhoseEntriesWereAllDropped() { + PackCompatReport report = new PackCompatReport(); + IrisLootTable table = lootTable("chest/sulfur-cache"); + table.setLoader(CompatFixtures.data(report)); + // The gate walker drops every entry with a missing item and hands the cascade its reasons. + CompatStatus walked = new CompatStatus(false, List.of(new CompatFinding(CompatRegistry.ITEM, + "minecraft:sulfur_dust", CompatAction.DROPPED, "loot", "chest/sulfur-cache", "loot[0]"))); + + CompatStatus status = table.cascadeEmptyLoot(walked); + + assertTrue(status.excluded()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "loot", "chest/sulfur-cache"); + assertNotNull(cascade); + assertEquals("minecraft:sulfur_dust", cascade.key()); + assertEquals(CompatRegistry.ITEM, cascade.registry()); + assertEquals("no loot entries remain", cascade.detail()); + } + + @Test + public void evaluateCompatKeepsTableWithSurvivingEntries() { + PackCompatReport report = new PackCompatReport(); + IrisLootTable table = lootTable("chest/mixed"); + table.setLoader(CompatFixtures.data(report)); + table.setLoot(new KList<>(new IrisLoot())); + CompatStatus walked = new CompatStatus(false, List.of(new CompatFinding(CompatRegistry.ITEM, + "minecraft:sulfur_dust", CompatAction.DROPPED, "loot", "chest/mixed", "loot[1]"))); + + assertFalse(table.cascadeEmptyLoot(walked).excluded()); + } + + @Test + public void evaluateCompatKeepsTableThatNeverDeclaredLoot() { + PackCompatReport report = new PackCompatReport(); + IrisLootTable table = lootTable("chest/empty"); + table.setLoader(CompatFixtures.data(report)); + + assertFalse(table.evaluateCompat(gate(report)).excluded()); + assertTrue(report.isEmpty()); + } + + @Test + public void lootReferenceSkipsExcludedAndUnloadableTables() { + PackCompatReport report = new PackCompatReport(); + IrisLootTable kept = lootTable("chest/common"); + IrisLootTable excluded = CompatFixtures.exclude(lootTable("chest/sulfur-cache"), + CompatRegistry.ITEM, "minecraft:sulfur_dust"); + IrisData data = dataWith(report, kept, excluded); + IrisLootReference reference = new IrisLootReference() + .setTables(new KList<>("chest/common", "chest/sulfur-cache", "chest/gone")); + + KList tables = reference.getLootTables(() -> data); + + assertEquals(1, tables.size()); + assertEquals("chest/common", tables.get(0).getLoadKey()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "loot reference", "chest/sulfur-cache"); + assertNotNull(dropped); + assertEquals("minecraft:sulfur_dust", dropped.key()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementCompatTest.java new file mode 100644 index 000000000..651178eb2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementCompatTest.java @@ -0,0 +1,282 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.util.common.data.DataProvider; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisObjectPlacementCompatTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private CompatTestPlatform platform; + private IrisSettings previousSettings; + private IrisData data; + + @Before + public void bindPlatform() { + previousSettings = IrisSettings.settings; + IrisSettings.settings = new IrisSettings(); + platform = CompatTestPlatform.bind(temporaryFolder.getRoot(), + List.of("minecraft:stone", "minecraft:cobblestone", "minecraft:air", "minecraft:oak_log")); + } + + @After + public void restorePlatform() { + if (data != null) { + data.close(); + data = null; + } + if (platform != null) { + platform.unbind(); + platform = null; + } + IrisSettings.settings = previousSettings; + } + + @Test + public void missingPaletteKeyDropsTheObjectAndRecordsAFinding() throws Exception { + File pack = pack("drop"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:stone", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good", "bad"); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(new KList().qadd("good"), placement.compatPlace(data)); + + List findings = data.getCompatReport().findings(); + assertEquals(findings.toString(), 1, findings.size()); + assertEquals("minecraft:sulfur", findings.getFirst().key()); + assertEquals(CompatAction.DROPPED, findings.getFirst().action()); + assertEquals("object", findings.getFirst().subjectType()); + assertEquals("bad", findings.getFirst().subjectKey()); + } + + @Test + public void editRuleWithFullChanceKeepsTheObject() throws Exception { + File pack = pack("edit-keeps"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("bad"); + placement.getEdit().add(replace(1F, false, "minecraft:sulfur", "minecraft:stone")); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(new KList().qadd("bad"), placement.compatPlace(data)); + assertTrue(data.getCompatReport().findings().toString(), data.getCompatReport().isEmpty()); + } + + @Test + public void editRuleBelowFullChanceDoesNotCoverTheMissingKey() throws Exception { + File pack = pack("edit-chance"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("bad"); + placement.getEdit().add(replace(0.5F, false, "minecraft:sulfur", "minecraft:stone")); + + assertTrue(placement.isCompatExcluded(data)); + assertTrue(placement.compatPlace(data).isEmpty()); + } + + @Test + public void exactEditRuleOnlyCoversTheMatchingState() throws Exception { + File pack = pack("edit-exact"); + CompatTestPlatform.writeObject(pack, "axisY", "minecraft:sulfur[axis=y]"); + CompatTestPlatform.writeObject(pack, "axisX", "minecraft:sulfur[axis=x]"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("axisY", "axisX"); + IrisObjectReplace rule = replace(1F, true, "minecraft:sulfur", "minecraft:stone"); + rule.getFind().getFirst().getData().put("axis", "y"); + placement.getEdit().add(rule); + + assertEquals(new KList().qadd("axisY"), placement.compatPlace(data)); + } + + @Test + public void nonExactEditRuleCoversEveryStateOfTheSameBlock() throws Exception { + File pack = pack("edit-non-exact"); + CompatTestPlatform.writeObject(pack, "axisX", "minecraft:sulfur[axis=x]"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("axisX"); + placement.getEdit().add(replace(1F, false, "minecraft:sulfur", "minecraft:stone")); + + assertFalse(placement.isCompatExcluded(data)); + } + + @Test + public void missingReplacePaletteOptionExcludesTheWholePlacement() throws Exception { + File pack = pack("replace-missing"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good"); + placement.getEdit().add(replace(1F, false, "minecraft:stone", "minecraft:sulfur")); + + assertTrue(placement.isCompatExcluded(data)); + assertTrue(placement.compatPlace(data).isEmpty()); + + List findings = data.getCompatReport().findings(); + assertEquals(findings.toString(), 1, findings.size()); + assertEquals(CompatAction.EXCLUDED, findings.getFirst().action()); + assertEquals("placement", findings.getFirst().subjectType()); + assertTrue(findings.getFirst().detail(), findings.getFirst().detail().contains("edit[0].replace.palette[0]")); + } + + @Test + public void missingFindKeyOnTheMatchSideIsIgnored() throws Exception { + File pack = pack("find-missing"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good"); + placement.getEdit().add(replace(1F, false, "minecraft:sulfur", "minecraft:cobblestone")); + + assertFalse(placement.isCompatExcluded(data)); + assertTrue(data.getCompatReport().findings().toString(), data.getCompatReport().isEmpty()); + } + + @Test + public void missingMarkerBlockIsMatchOnly() throws Exception { + File pack = pack("marker-missing"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good"); + IrisObjectMarker marker = new IrisObjectMarker(); + marker.setMarker("thing"); + marker.getMark().add(new IrisBlockData("minecraft:sulfur")); + placement.getMarkers().add(marker); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(new KList().qadd("good"), placement.compatPlace(data)); + assertTrue(data.getCompatReport().isEmpty()); + } + + @Test + public void dimensionFallbackRescuesTheObjectAndRecordsASubstitution() throws Exception { + File pack = pack("fallback"); + CompatTestPlatform.write(pack, "dimensions/main.json", + "{\"blockFallbacks\":{\"minecraft:sulfur\":\"minecraft:cobblestone\"}}"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("bad"); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(new KList().qadd("bad"), placement.compatPlace(data)); + + List findings = data.getCompatReport().findings(); + assertEquals(findings.toString(), 1, findings.size()); + assertEquals(CompatAction.SUBSTITUTED, findings.getFirst().action()); + } + + @Test + public void blockBackupRescuesAReplacePaletteEntry() throws Exception { + File pack = pack("backup"); + CompatTestPlatform.writeObject(pack, "good", "minecraft:stone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("good"); + IrisObjectReplace rule = replace(1F, false, "minecraft:stone", "minecraft:sulfur"); + rule.getReplace().getPalette().getFirst().setBackup(new IrisBlockData("minecraft:cobblestone")); + placement.getEdit().add(rule); + + assertFalse(placement.isCompatExcluded(data)); + assertEquals(CompatAction.SUBSTITUTED, data.getCompatReport().findings().getFirst().action()); + } + + @Test + public void everyObjectDroppedExcludesThePlacement() throws Exception { + File pack = pack("all-dropped"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("bad"); + + assertTrue(placement.isCompatExcluded(data)); + assertTrue(placement.compatPlace(data).isEmpty()); + assertTrue(data.getCompatReport().findings().stream() + .anyMatch(finding -> finding.action() == CompatAction.EXCLUDED + && "placement".equals(finding.subjectType()))); + } + + @Test + public void survivingPoolKeepsAuthoredOrderAndGetObjectOnlyDrawsSurvivors() throws Exception { + File pack = pack("draw"); + CompatTestPlatform.writeObject(pack, "a", "minecraft:stone"); + CompatTestPlatform.writeObject(pack, "bad", "minecraft:sulfur"); + CompatTestPlatform.writeObject(pack, "b", "minecraft:cobblestone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("a", "bad", "b"); + + assertEquals(new KList().qadd("a").qadd("b"), placement.compatPlace(data)); + + IrisData loaded = data; + DataProvider provider = () -> loaded; + for (int seed = 0; seed < 64; seed++) { + IrisObject drawn = placement.getObject(provider, new RNG(seed)); + assertTrue("Draw must never return the dropped object", + drawn != null && !"bad".equals(drawn.getLoadKey())); + } + } + + @Test + public void aCompletePackLeavesThePoolIdentical() throws Exception { + File pack = pack("complete"); + CompatTestPlatform.writeObject(pack, "a", "minecraft:stone"); + CompatTestPlatform.writeObject(pack, "b", "minecraft:cobblestone"); + data = IrisData.get(pack); + IrisObjectPlacement placement = placement("a", "b"); + + assertTrue("A complete pack must hand back the authored pool untouched", + placement.getPlace() == placement.compatPlace(data)); + assertTrue(data.getCompatReport().isEmpty()); + } + + private File pack(String name) throws Exception { + return temporaryFolder.newFolder(name); + } + + private static IrisObjectPlacement placement(String... place) { + IrisObjectPlacement placement = new IrisObjectPlacement(); + placement.setPlace(new KList<>(place)); + return placement; + } + + private static IrisObjectReplace replace(float chance, boolean exact, String find, String to) { + IrisObjectReplace rule = new IrisObjectReplace(); + rule.setChance(chance); + rule.setExact(exact); + rule.getFind().add(new IrisBlockData(find)); + rule.getReplace().qclear(); + rule.getReplace().add(to); + return rule; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisRegionCompatPoolTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisRegionCompatPoolTest.java new file mode 100644 index 000000000..e659ed70e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRegionCompatPoolTest.java @@ -0,0 +1,142 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.Map; + +import static art.arcane.iris.engine.object.CompatFixtures.MISSING_BLOCK; +import static art.arcane.iris.engine.object.CompatFixtures.biome; +import static art.arcane.iris.engine.object.CompatFixtures.excludeBlock; +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisRegionCompatPoolTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisBiome... biomes) { + IrisData data = CompatFixtures.data(report); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + for (IrisBiome biome : biomes) { + when(biomeLoader.load(biome.getLoadKey())).thenReturn(biome); + } + return data; + } + + private static ContentGate gate(PackCompatReport report) { + return new ContentGate(null, Map.of(), report); + } + + @Test + public void realLandBiomesDropsExcludedReferenceAndRecordsFinding() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-grotto")); + IrisData data = dataWith(report, plains, sulfur); + IrisRegion region = CompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains", "cave/sulfur-grotto")); + + KList pool = region.getRealLandBiomes(() -> data); + + assertEquals(1, pool.size()); + assertEquals("land/plains", pool.get(0).getLoadKey()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "region", "overworld"); + assertNotNull(dropped); + assertEquals(CompatRegistry.BLOCK, dropped.registry()); + assertEquals(MISSING_BLOCK, dropped.key()); + assertEquals("landBiomes[1] cave/sulfur-grotto", dropped.detail()); + } + + @Test + public void realPoolsSkipUnloadableReferences() { + PackCompatReport report = new PackCompatReport(); + IrisBiome shore = biome("shore/sand"); + IrisData data = dataWith(report, shore); + IrisRegion region = CompatFixtures.region("overworld"); + region.setLoader(data); + region.setSeaBiomes(new KList<>("sea/gone")); + region.setShoreBiomes(new KList<>("shore/sand")); + region.setCaveBiomes(new KList<>("cave/gone")); + + assertTrue(region.getRealSeaBiomes(() -> data).isEmpty()); + assertEquals(1, region.getRealShoreBiomes(() -> data).size()); + assertTrue(region.getRealCaveBiomes(() -> data).isEmpty()); + assertTrue(report.isEmpty()); + } + + @Test + public void naturalBiomesSkipExcludedBiomesAndTheirChildren() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-grotto")); + sulfur.setChildren(new KList<>("cave/sulfur-deep")); + IrisBiome sulfurDeep = biome("cave/sulfur-deep"); + IrisData data = dataWith(report, plains, sulfur, sulfurDeep); + IrisRegion region = CompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains")); + region.setCaveBiomes(new KList<>("cave/sulfur-grotto")); + + KList natural = region.getNaturalBiomes(() -> data); + + assertEquals(1, natural.size()); + assertEquals("land/plains", natural.get(0).getLoadKey()); + } + + @Test + public void evaluateCompatExcludesRegionWithNoLandBiomesLeft() { + PackCompatReport report = new PackCompatReport(); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-flats")); + IrisData data = dataWith(report, sulfur); + IrisRegion region = CompatFixtures.region("sulfur-lands"); + region.setLoader(data); + region.setLandBiomes(new KList<>("cave/sulfur-flats")); + + CompatStatus status = region.evaluateCompat(gate(report)); + + assertTrue(status.excluded()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "region", "sulfur-lands"); + assertNotNull(cascade); + assertEquals(MISSING_BLOCK, cascade.key()); + assertEquals(CompatRegistry.BLOCK, cascade.registry()); + assertEquals("no land biomes remain", cascade.detail()); + } + + @Test + public void evaluateCompatKeepsRegionWithSurvivingLandBiome() { + PackCompatReport report = new PackCompatReport(); + IrisBiome plains = biome("land/plains"); + IrisBiome sulfur = excludeBlock(biome("cave/sulfur-flats")); + IrisData data = dataWith(report, plains, sulfur); + IrisRegion region = CompatFixtures.region("overworld"); + region.setLoader(data); + region.setLandBiomes(new KList<>("land/plains", "cave/sulfur-flats")); + + assertFalse(region.evaluateCompat(gate(report)).excluded()); + } + + @Test + public void evaluateCompatDoesNotExcludeRegionThatNeverDeclaredLandBiomes() { + PackCompatReport report = new PackCompatReport(); + IrisData data = dataWith(report); + IrisRegion region = CompatFixtures.region("empty"); + region.setLoader(data); + + assertFalse(region.evaluateCompat(gate(report)).excluded()); + assertTrue(report.isEmpty()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisSpawnerCompatTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisSpawnerCompatTest.java new file mode 100644 index 000000000..5d73e237f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisSpawnerCompatTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.compat.CompatStatus; +import art.arcane.iris.core.compat.ContentGate; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.Map; + +import static art.arcane.iris.engine.object.CompatFixtures.MISSING_ENTITY; +import static art.arcane.iris.engine.object.CompatFixtures.entity; +import static art.arcane.iris.engine.object.CompatFixtures.excludeEntity; +import static art.arcane.iris.engine.object.CompatFixtures.find; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisSpawnerCompatTest { + @SuppressWarnings("unchecked") + private static IrisData dataWith(PackCompatReport report, IrisEntity... entities) { + IrisData data = CompatFixtures.data(report); + ResourceLoader entityLoader = mock(ResourceLoader.class); + when(data.getEntityLoader()).thenReturn(entityLoader); + for (IrisEntity entity : entities) { + when(entityLoader.load(entity.getLoadKey())).thenReturn(entity); + } + return data; + } + + private static ContentGate gate(PackCompatReport report) { + return new ContentGate(null, Map.of(), report); + } + + @Test + public void evaluateCompatDropsSpawnsWhoseEntityIsExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisEntity zombie = entity("zombie"); + IrisEntity camel = excludeEntity(entity("camel")); + IrisData data = dataWith(report, zombie, camel); + IrisSpawner spawner = new IrisSpawner(); + spawner.setLoadKey("desert"); + spawner.setLoader(data); + spawner.setSpawns(new KList<>( + new IrisEntitySpawn().setEntity("zombie"), + new IrisEntitySpawn().setEntity("camel"))); + + CompatStatus status = spawner.evaluateCompat(gate(report)); + + assertFalse(status.excluded()); + assertEquals(1, spawner.getSpawns().size()); + assertEquals("zombie", spawner.getSpawns().get(0).getEntity()); + CompatFinding dropped = find(report, CompatAction.DROPPED, "spawner", "desert"); + assertNotNull(dropped); + assertEquals(CompatRegistry.ENTITY, dropped.registry()); + assertEquals(MISSING_ENTITY, dropped.key()); + assertEquals("spawns[1] camel", dropped.detail()); + } + + @Test + public void evaluateCompatExcludesSpawnerWithNoSpawnsLeft() { + PackCompatReport report = new PackCompatReport(); + IrisEntity camel = excludeEntity(entity("camel")); + IrisData data = dataWith(report, camel); + IrisSpawner spawner = new IrisSpawner(); + spawner.setLoadKey("desert"); + spawner.setLoader(data); + spawner.setSpawns(new KList<>(new IrisEntitySpawn().setEntity("camel"))); + spawner.setInitialSpawns(new KList<>(new IrisEntitySpawn().setEntity("camel"))); + + CompatStatus status = spawner.evaluateCompat(gate(report)); + + assertTrue(status.excluded()); + assertTrue(spawner.getSpawns().isEmpty()); + assertTrue(spawner.getInitialSpawns().isEmpty()); + CompatFinding cascade = find(report, CompatAction.EXCLUDED, "spawner", "desert"); + assertNotNull(cascade); + assertEquals(MISSING_ENTITY, cascade.key()); + assertEquals("no entity spawns remain", cascade.detail()); + } + + @Test + public void evaluateCompatKeepsSpawnerThatNeverDeclaredSpawns() { + PackCompatReport report = new PackCompatReport(); + IrisData data = dataWith(report); + IrisSpawner spawner = new IrisSpawner(); + spawner.setLoadKey("empty"); + spawner.setLoader(data); + + assertFalse(spawner.evaluateCompat(gate(report)).excluded()); + assertTrue(report.isEmpty()); + } + + @Test + public void entitySpawnResolvesNoEntityWhenExcluded() { + PackCompatReport report = new PackCompatReport(); + IrisEntity camel = excludeEntity(entity("camel")); + IrisData data = dataWith(report, camel); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + + assertNull(new IrisEntitySpawn().setEntity("camel").getRealEntity(engine)); + } + + @Test + public void entitySpawnResolvesPresentEntity() { + PackCompatReport report = new PackCompatReport(); + IrisEntity zombie = entity("zombie"); + IrisData data = dataWith(report, zombie); + Engine engine = mock(Engine.class); + when(engine.getData()).thenReturn(data); + + assertSame(zombie, new IrisEntitySpawn().setEntity("zombie").getRealEntity(engine)); + } +} From 0b543bf7ee97da5313832d5a62600d8e52a3040a Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Thu, 3 Sep 2026 04:54:38 -0400 Subject: [PATCH 3/3] Compat report, validation and commands - Force-load every registrant and placement during pack validation and persist the findings with the Minecraft version in the validation cache - Turn an excluded dimension into a blocking validation error and suppress content-key warnings the gate already reported - Print the capped per-key listing after the validation lines on Bukkit and modded, ordered excluded, dropped, substituted - Log one summary line per world engine from the published validation result - Add /iris pack compat on Bukkit and modded with help and locale keys for every shipped language --- .../iris/core/IrisWorldGeneratorResolver.java | 18 +- .../iris/core/commands/CommandPack.java | 74 +++++++ .../core/commands/CommandPackCompatTest.java | 94 ++++++++ .../art/arcane/iris/modded/ModdedStartup.java | 12 +- .../modded/command/ModdedCommandHelp.java | 3 +- .../modded/command/ModdedPackCommands.java | 81 +++++++ .../localization/BukkitRuntimeMessages.java | 16 ++ .../localization/DirectorCommandMessages.java | 10 + .../localization/ModdedCommandMessages.java | 16 ++ .../core/localization/ModdedHelpMessages.java | 5 + .../iris/core/pack/PackValidationCache.java | 68 +++++- .../iris/core/pack/PackValidationResult.java | 26 +++ .../arcane/iris/core/pack/PackValidator.java | 208 +++++++++++++++++- .../art/arcane/iris/engine/IrisEngine.java | 43 +++- core/src/main/resources/languages/de_DE.json | 11 +- core/src/main/resources/languages/es_ES.json | 11 +- core/src/main/resources/languages/fi_FI.json | 11 +- core/src/main/resources/languages/fr_FR.json | 11 +- core/src/main/resources/languages/he_IL.json | 11 +- core/src/main/resources/languages/it_IT.json | 11 +- core/src/main/resources/languages/ja-JP.json | 11 +- core/src/main/resources/languages/ko_KR.json | 11 +- core/src/main/resources/languages/lt_LT.json | 11 +- core/src/main/resources/languages/nl_NL.json | 11 +- core/src/main/resources/languages/pl_PL.json | 11 +- core/src/main/resources/languages/pt_PT.json | 11 +- core/src/main/resources/languages/ru_RU.json | 11 +- core/src/main/resources/languages/tr_TR.json | 11 +- core/src/main/resources/languages/vi_VI.json | 11 +- core/src/main/resources/languages/zh_CN.json | 11 +- core/src/main/resources/languages/zh_TW.json | 11 +- .../core/pack/PackValidationCacheTest.java | 55 ++++- .../core/pack/PackValidatorCompatTest.java | 130 +++++++++++ 33 files changed, 1018 insertions(+), 28 deletions(-) create mode 100644 adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackCompatTest.java create mode 100644 core/src/test/java/art/arcane/iris/core/pack/PackValidatorCompatTest.java diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java index a6bcff211..7d1cdab1a 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/IrisWorldGeneratorResolver.java @@ -60,6 +60,7 @@ import java.util.function.Supplier; */ public final class IrisWorldGeneratorResolver { private static final int VALIDATION_STABILITY_ATTEMPTS = 2; + private static final int COMPAT_BOOT_KEY_CAP = 3; private static final Object SNAPSHOT_VALIDATION_LOCK = new Object(); private static final String IRIS_DIMENSION_NAMESPACE = "iris"; private static final String PLOT_SQUARED_DISCOVERY_WORLD = "CheckingPlotSquaredGenerator"; @@ -123,6 +124,9 @@ public final class IrisWorldGeneratorResolver { if (packDirectory != null && packFingerprint != null && !packFingerprint.isBlank()) { PackValidationRegistry.publish(packDirectory.toPath(), result, packFingerprint); } + String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; + String compatSummary = PackValidator.compatSummary(result, minecraftVersion); + String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary; if (!result.isLoadable()) { Iris.error("Pack '" + result.getPackName() + "' FAILED validation - world and Studio creation with this pack will be refused. Reasons:"); @@ -131,12 +135,22 @@ public final class IrisWorldGeneratorResolver { } } else if (!result.getWarnings().isEmpty()) { Iris.info("Pack '" + result.getPackName() + "' validated (" - + result.getWarnings().size() + " warning(s))."); + + result.getWarnings().size() + " warning(s))." + compatSuffix); for (String warning : result.getWarnings()) { Iris.warn(" [" + result.getPackName() + "] " + warning); } } else if (cached.isEmpty()) { - Iris.success("Pack '" + result.getPackName() + "' validated."); + if (compatSummary.isEmpty()) { + Iris.success("Pack '" + result.getPackName() + "' validated."); + } else { + Iris.info("Pack '" + result.getPackName() + "' validated." + compatSuffix); + } + } else if (!compatSummary.isEmpty()) { + Iris.info("Pack '" + result.getPackName() + "' validated." + compatSuffix); + } + // Cache hits carry the persisted findings, so the listing prints on every boot, not only after a miss. + for (String line : PackValidator.compatBootLines(result, minecraftVersion, COMPAT_BOOT_KEY_CAP)) { + Iris.warn(line); } } IrisStartupValidation.markPacksReady(); diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java index d1ba14c18..3eb1348c0 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java @@ -19,6 +19,7 @@ package art.arcane.iris.core.commands; import art.arcane.iris.Iris; +import art.arcane.iris.core.compat.PackCompatReport; import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackResourceCleanup; import art.arcane.iris.core.pack.PackValidationCache; @@ -34,10 +35,13 @@ import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.volmlib.util.localization.TextKey; +import art.arcane.iris.spi.IrisPlatforms; + import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Map; import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.core.localization.BukkitCommandMessages; @@ -250,6 +254,76 @@ public class CommandPack implements DirectorExecutor { reportResult(s, result); } + @Director(description = "Show pack content that cannot generate on this Minecraft version", descriptionKey = "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version", aliases = {"cp"}) + public void compat( + @Param(description = "The pack folder name to report on, or * for every pack", descriptionKey = "iris.director.commandpack.param.pack_folder_name_compat", defaultValue = "*") + String pack + ) { + VolmitSender s = sender(); + if (wantsAllPacks(pack)) { + Map snapshot = PackValidationRegistry.snapshot(); + if (snapshot.isEmpty()) { + s.sendMessage(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_NO_VALIDATION_RESULTS_RECORDED_RUN_IRIS_PACK_VALIDATE_FIRST)); + return; + } + for (Map.Entry entry : snapshot.entrySet()) { + for (String line : compatOutput(entry.getKey(), entry.getValue())) { + s.sendMessage(line); + } + } + return; + } + for (String line : compatOutput(pack, PackValidationRegistry.get(pack))) { + s.sendMessage(line); + } + } + + /** + * The compat listing for one pack, straight from the persisted validation so no pack is reloaded. Every key is + * printed with every subject; the boot listing is the capped form of the same data. + */ + static List compatOutput(String pack, PackValidationResult result) { + List lines = new ArrayList<>(); + if (result == null) { + lines.add(IrisLanguage.text( + BukkitRuntimeMessages.COMMAND_PACK_NO_VALIDATION_RESULT_RUN_IRIS_PACK_VALIDATE, + MessageArgument.untrusted("pack", String.valueOf(pack)), + MessageArgument.untrusted("pack2", String.valueOf(pack)))); + return lines; + } + String version = compatVersion(result); + if (result.getCompatFindings().isEmpty()) { + lines.add(IrisLanguage.text( + BukkitRuntimeMessages.COMMAND_PACK_COMPAT_NONE, + MessageArgument.untrusted("pack", pack), + MessageArgument.untrusted("version", version))); + return lines; + } + lines.add(IrisLanguage.text( + BukkitRuntimeMessages.COMMAND_PACK_COMPAT_HEADER, + MessageArgument.untrusted("pack", pack), + MessageArgument.untrusted("version", version))); + for (String line : PackCompatReport.of(result.getCompatFindings()).keyLines(0)) { + lines.add(IrisLanguage.text( + BukkitRuntimeMessages.COMMAND_PACK_MESSAGE_2, + MessageArgument.untrusted("value", line))); + } + lines.add(IrisLanguage.text(BukkitRuntimeMessages.COMMAND_PACK_COMPAT_REMEDY)); + return lines; + } + + /** The live Minecraft version, falling back to the one the validation ran against. */ + static String compatVersion(PackValidationResult result) { + if (IrisPlatforms.isBound()) { + String live = IrisPlatforms.get().minecraftVersion(); + if (live != null && !live.isBlank()) { + return live; + } + } + String persisted = result.getMinecraftVersion(); + return persisted == null || persisted.isBlank() ? "unknown" : persisted; + } + private PackValidationResult runValidate(VolmitSender s, File packFolder) { try { PackValidationResult result = PackValidator.validate(packFolder); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackCompatTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackCompatTest.java new file mode 100644 index 000000000..c85d1ed66 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackCompatTest.java @@ -0,0 +1,94 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility; +import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor; +import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CommandPackCompatTest { + private IrisPlatform previousPlatform; + + /** The persisted Minecraft version is only used when no platform is bound, so pin that here. */ + @Before + public void unbindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + IrisPlatforms.unbind(); + } + + @After + public void rebindPlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + private static CompatFinding block(CompatAction action, String subjectKey) { + return new CompatFinding( + CompatRegistry.BLOCK, "minecraft:sulfur", action, "Biome", subjectKey, "layers[0].palette[1]"); + } + + private static PackValidationResult result(List findings) { + return new PackValidationResult("overworld", List.of(), List.of(), 1L, findings, "26.1.2"); + } + + @Test + public void unknownPackIsReportedWithoutAnyCompatLines() { + List lines = CommandPack.compatOutput("nope", null); + + assertEquals(1, lines.size()); + assertTrue(lines.getFirst(), lines.getFirst().contains("No validation result for 'nope'")); + } + + @Test + public void packWithoutFindingsSaysSoAndNamesTheVersion() { + List lines = CommandPack.compatOutput("overworld", result(List.of())); + + assertEquals(1, lines.size()); + assertTrue(lines.getFirst(), lines.getFirst().contains("Pack 'overworld' uses no content")); + assertTrue(lines.getFirst(), lines.getFirst().contains("26.1.2")); + } + + @Test + public void findingsArePrintedUncappedWithTheFallbackRemedy() { + List lines = CommandPack.compatOutput("overworld", result(List.of( + block(CompatAction.EXCLUDED, "cave/a"), + block(CompatAction.EXCLUDED, "cave/b"), + block(CompatAction.EXCLUDED, "cave/c"), + block(CompatAction.EXCLUDED, "cave/d")))); + + assertEquals(3, lines.size()); + assertTrue(lines.get(0), lines.get(0).contains("Pack 'overworld': content unavailable on Minecraft 26.1.2")); + assertTrue(lines.get(1), lines.get(1).contains("minecraft:sulfur (block)")); + assertFalse("the command prints every subject", lines.get(1).contains("more")); + assertTrue(lines.get(1), lines.get(1).contains("cave/d")); + assertTrue(lines.get(2), lines.get(2).contains("blockFallbacks")); + } + + @Test + public void compatIsInvocableWithoutAPackArgument() throws Exception { + DirectorNodeDescriptor node = DirectorAnnotationCompatibility + .fromMethod(CommandPack.class.getDeclaredMethod("compat", String.class)) + .orElseThrow(); + DirectorParameterDescriptor pack = node.getParameters().getFirst(); + + assertEquals("pack", pack.getName()); + assertFalse("compat must not require a pack", pack.isRequired()); + assertFalse("Director needs a non-blank default to treat the parameter as optional", + pack.getDefaultValue().isBlank()); + } +} diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java index 13194ef1a..cd4ce78e8 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedStartup.java @@ -25,6 +25,7 @@ import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.modded.command.ModdedPackCommands; +import art.arcane.iris.spi.IrisPlatforms; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; import net.minecraft.server.MinecraftServer; @@ -41,6 +42,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; public final class ModdedStartup { + private static final int COMPAT_BOOT_KEY_CAP = 3; private static final AtomicBoolean PREPARED = new AtomicBoolean(false); private static final AtomicBoolean STARTED = new AtomicBoolean(false); @@ -138,17 +140,23 @@ public final class ModdedStartup { try { PackValidationResult result = PackValidator.validate(packDir); PackValidationRegistry.publish(result); + String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; + String compatSummary = PackValidator.compatSummary(result, minecraftVersion); + String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary; if (!result.isLoadable()) { ModdedIrisLog.error("Iris pack '{}' FAILED validation with {} blocking error(s); world/studio creation will be refused. First error: {}", result.getPackName(), result.getBlockingErrors().size(), result.getBlockingErrors().getFirst()); } else if (!result.getWarnings().isEmpty()) { - ModdedIrisLog.info("Iris pack '{}' validated ({} warning(s)).", result.getPackName(), result.getWarnings().size()); + ModdedIrisLog.info("Iris pack '{}' validated ({} warning(s)).{}", result.getPackName(), result.getWarnings().size(), compatSuffix); for (String warning : result.getWarnings()) { ModdedIrisLog.warn(" [{}] {}", result.getPackName(), warning); } } else { - ModdedIrisLog.info("Iris pack '{}' validated.", result.getPackName()); + ModdedIrisLog.info("Iris pack '{}' validated.{}", result.getPackName(), compatSuffix); + } + for (String line : PackValidator.compatBootLines(result, minecraftVersion, COMPAT_BOOT_KEY_CAP)) { + ModdedIrisLog.warn("{}", line); } } catch (Throwable e) { ModdedIrisLog.error("Iris pack validation failed for '{}'", packDir.getName(), e); 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 719c2b0c8..f29523a17 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 @@ -164,7 +164,8 @@ final class ModdedCommandHelp { Entry.command("validate", "[pack]", ModdedHelpMessages.COMMAND_VALIDATE_VALIDATE_A_PACK_OR_EVERY_PACK, "v"), Entry.command("cleanup", " [apply]", ModdedHelpMessages.COMMAND_CLEANUP_PREVIEW_OR_QUARANTINE_UNUSED_RESOURCE_CANDIDATES, "c"), Entry.command("restore", " [apply]", ModdedHelpMessages.COMMAND_RESTORE_PREVIEW_OR_RESTORE_THE_LATEST_QUARANTINE, "r"), - Entry.command("status", "[pack]", ModdedHelpMessages.COMMAND_STATUS_SHOW_CACHED_VALIDATION_STATUS, "s") + Entry.command("status", "[pack]", ModdedHelpMessages.COMMAND_STATUS_SHOW_CACHED_VALIDATION_STATUS, "s"), + Entry.command("compat", "[pack]", ModdedHelpMessages.COMMAND_COMPAT_SHOW_CONTENT_THAT_CANNOT_GENERATE_ON_THIS_MINECRAFT_VERSION, "cp") )); SECTIONS.put("pk", SECTIONS.get("pack")); SECTIONS.put("world", List.of( diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java index 10a7dd589..fc76cdcbf 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPackCommands.java @@ -19,6 +19,7 @@ package art.arcane.iris.modded.command; import art.arcane.iris.modded.ModdedIrisLog; +import art.arcane.iris.core.compat.PackCompatReport; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.core.pack.PackDirectoryResolver; import art.arcane.iris.core.pack.PackResourceCleanup; @@ -93,6 +94,15 @@ public final class ModdedPackCommands { .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) .executes((CommandContext context) -> status(context.getSource(), StringArgumentType.getString(context, "pack"))))); + root.then(Commands.literal("compat") + .executes((CommandContext context) -> compat(context.getSource(), null)) + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> compat(context.getSource(), StringArgumentType.getString(context, "pack"))))); + root.then(Commands.literal("cp") + .executes((CommandContext context) -> compat(context.getSource(), null)) + .then(Commands.argument("pack", StringArgumentType.word()).suggests(IrisModdedCommands.PACK_NAMES) + .executes((CommandContext context) -> compat(context.getSource(), StringArgumentType.getString(context, "pack"))))); + return root; } @@ -215,6 +225,77 @@ public final class ModdedPackCommands { return 1; } + private static int compat(CommandSourceStack source, String pack) { + if (pack == null || pack.isBlank()) { + Map snapshot = PackValidationRegistry.snapshot(); + if (snapshot.isEmpty()) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_NO_VALIDATION_RESULTS_RECORDED_RUN_IRIS_PACK_VALIDATE_FIRST)); + return 0; + } + for (Map.Entry entry : snapshot.entrySet()) { + for (String line : compatOutput(entry.getKey(), entry.getValue())) { + IrisModdedCommands.ok(source, line); + } + } + return 1; + } + PackValidationResult result = PackValidationRegistry.get(pack); + for (String line : compatOutput(pack, result)) { + if (result == null) { + IrisModdedCommands.fail(source, line); + } else { + IrisModdedCommands.ok(source, line); + } + } + return result == null ? 0 : 1; + } + + /** + * The compat listing for one pack, straight from the persisted validation so no pack is reloaded. Every key is + * printed with every subject; the boot listing is the capped form of the same data. + */ + static List compatOutput(String pack, PackValidationResult result) { + List lines = new ArrayList<>(); + if (result == null) { + lines.add(IrisLanguage.plain( + ModdedCommandMessages.MODDED_PACK_COMMANDS_NO_VALIDATION_RESULT_RUN_IRIS_PACK_VALIDATE, + MessageArgument.untrusted("pack", pack), + MessageArgument.untrusted("pack2", pack))); + return lines; + } + String version = compatVersion(result); + if (result.getCompatFindings().isEmpty()) { + lines.add(IrisLanguage.plain( + ModdedCommandMessages.MODDED_PACK_COMMANDS_COMPAT_NONE, + MessageArgument.untrusted("pack", pack), + MessageArgument.untrusted("version", version))); + return lines; + } + lines.add(IrisLanguage.plain( + ModdedCommandMessages.MODDED_PACK_COMMANDS_COMPAT_HEADER, + MessageArgument.untrusted("pack", pack), + MessageArgument.untrusted("version", version))); + for (String line : PackCompatReport.of(result.getCompatFindings()).keyLines(0)) { + lines.add(IrisLanguage.plain( + ModdedCommandMessages.MODDED_PACK_COMMANDS_MESSAGE_2, + MessageArgument.untrusted("value", line))); + } + lines.add(IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_COMPAT_REMEDY)); + return lines; + } + + /** The live Minecraft version, falling back to the one the validation ran against. */ + static String compatVersion(PackValidationResult result) { + if (IrisPlatforms.isBound()) { + String live = IrisPlatforms.get().minecraftVersion(); + if (live != null && !live.isBlank()) { + return live; + } + } + String persisted = result.getMinecraftVersion(); + return persisted == null || persisted.isBlank() ? "unknown" : persisted; + } + private static void report(CommandSourceStack source, PackValidationResult result) { if (result.isLoadable()) { IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_PACK_COMMANDS_PACK_IS_LOADABLE_WARNINGS, MessageArgument.untrusted("value", result.getPackName()), MessageArgument.untrusted("value2", result.getWarnings().size()))); diff --git a/core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java b/core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java index a73e5cb73..3e2d17ff9 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/BukkitRuntimeMessages.java @@ -158,6 +158,19 @@ public final class BukkitRuntimeMessages { "other", C.GRAY + " ... and {count} more warnings." ) ); + public static final TextKey COMMAND_PACK_COMPAT_HEADER = TextKey.of( + "iris.bukkit.runtime.commandpack.compat_header", + C.YELLOW + "Pack '" + "{pack}" + "': content unavailable on Minecraft " + "{version}" + ); + public static final TextKey COMMAND_PACK_COMPAT_NONE = TextKey.of( + "iris.bukkit.runtime.commandpack.compat_none", + C.GREEN + "Pack '" + "{pack}" + "' uses no content that is unavailable on Minecraft " + "{version}" + "." + ); + public static final TextKey COMMAND_PACK_COMPAT_REMEDY = TextKey.of( + "iris.bukkit.runtime.commandpack.compat_remedy", + C.GRAY + "Update the server to a newer Minecraft to restore this content, or declare fallbacks" + + " (dimension blockFallbacks, block backup)." + ); public static final LinesKey COMMAND_DEVELOPER_UPDATE_WORLD_WARNING = LinesKey.of( "iris.bukkit.runtime.commanddeveloper.update_world_warning", C.RED + "You should always make a backup before using this", @@ -644,6 +657,9 @@ public final class BukkitRuntimeMessages { COMMAND_PACK_MESSAGE, COMMAND_PACK_MESSAGE_2, COMMAND_PACK_MORE_WARNING_S, + COMMAND_PACK_COMPAT_HEADER, + COMMAND_PACK_COMPAT_NONE, + COMMAND_PACK_COMPAT_REMEDY, COMMAND_DEVELOPER_UPDATE_WORLD_WARNING, BULK_STRUCTURE_IMPORTER_IMPORTING_VANILLA_DATAPACK_STRUCTURES_MODE_INCLUDENONJIGSAW, BULK_STRUCTURE_IMPORTER_FAIL_INVALID_KEY, diff --git a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java index 08a2f8f6d..a16f93012 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/DirectorCommandMessages.java @@ -570,6 +570,14 @@ public final class DirectorCommandMessages { "iris.director.commandpack.param.pack_folder_name", "The pack folder name" ); + public static final TextKey COMMAND_PACK_DIRECTOR_SHOW_CONTENT_CANNOT_GENERATE_THIS_MINECRAFT_VERSION = TextKey.of( + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version", + "Show pack content that cannot generate on this Minecraft version" + ); + public static final TextKey COMMAND_PACK_PARAM_PACK_FOLDER_NAME_COMPAT = TextKey.of( + "iris.director.commandpack.param.pack_folder_name_compat", + "The pack folder name to report on" + ); public static final TextKey COMMAND_PREGEN_DIRECTOR_PREGENERATE_YOUR_IRIS_WORLDS = TextKey.of( "iris.director.commandpregen.director.pregenerate_your_iris_worlds", "Pregenerate your Iris worlds!" @@ -1005,6 +1013,8 @@ public final class DirectorCommandMessages { COMMAND_PACK_PARAM_PREVIEW_APPLY_2, COMMAND_PACK_DIRECTOR_SHOW_CACHED_VALIDATION_STATUS_PACK, COMMAND_PACK_PARAM_PACK_FOLDER_NAME, + COMMAND_PACK_DIRECTOR_SHOW_CONTENT_CANNOT_GENERATE_THIS_MINECRAFT_VERSION, + COMMAND_PACK_PARAM_PACK_FOLDER_NAME_COMPAT, COMMAND_PREGEN_DIRECTOR_PREGENERATE_YOUR_IRIS_WORLDS, COMMAND_PREGEN_DIRECTOR_PREGENERATE_WORLD, COMMAND_PREGEN_PARAM_RADIUS_PREGEN_BLOCKS, diff --git a/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java b/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java index caadca06f..465c74939 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/ModdedCommandMessages.java @@ -771,6 +771,19 @@ public final class ModdedCommandMessages { "iris.modded.moddedpackcommands.message_2", " ! " + "{value}" ); + public static final TextKey MODDED_PACK_COMMANDS_COMPAT_HEADER = TextKey.of( + "iris.modded.moddedpackcommands.compat_header", + "Pack '" + "{pack}" + "': content unavailable on Minecraft " + "{version}" + ); + public static final TextKey MODDED_PACK_COMMANDS_COMPAT_NONE = TextKey.of( + "iris.modded.moddedpackcommands.compat_none", + "Pack '" + "{pack}" + "' uses no content that is unavailable on Minecraft " + "{version}" + "." + ); + public static final TextKey MODDED_PACK_COMMANDS_COMPAT_REMEDY = TextKey.of( + "iris.modded.moddedpackcommands.compat_remedy", + "Update the server to a newer Minecraft to restore this content, or declare fallbacks" + + " (dimension blockFallbacks, block backup)." + ); public static final TextKey MODDED_PACK_COMMANDS_MORE_WARNING_S = TextKey.of( "iris.modded.moddedpackcommands.more_warning_s", " ... and " + "{value}" + " more warning(s)." @@ -1432,6 +1445,9 @@ public final class ModdedCommandMessages { MODDED_PACK_COMMANDS_PACK_IS_BROKEN, MODDED_PACK_COMMANDS_MESSAGE, MODDED_PACK_COMMANDS_MESSAGE_2, + MODDED_PACK_COMMANDS_COMPAT_HEADER, + MODDED_PACK_COMMANDS_COMPAT_NONE, + MODDED_PACK_COMMANDS_COMPAT_REMEDY, MODDED_PACK_COMMANDS_MORE_WARNING_S, MODDED_PACK_COMMANDS_NO_CLEANUP_CANDIDATES_FOUND_PACK, MODDED_PACK_COMMANDS_CLEANUP_PREVIEW_PACK_CANDIDATE_S_NO_FILES_WERE_CHANGED, diff --git a/core/src/main/java/art/arcane/iris/core/localization/ModdedHelpMessages.java b/core/src/main/java/art/arcane/iris/core/localization/ModdedHelpMessages.java index 070203132..658bed468 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/ModdedHelpMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/ModdedHelpMessages.java @@ -278,6 +278,10 @@ public final class ModdedHelpMessages { "iris.modded.help.entry.command.status_validation", "Show cached validation status" ); + public static final TextKey COMMAND_COMPAT_SHOW_CONTENT_THAT_CANNOT_GENERATE_ON_THIS_MINECRAFT_VERSION = TextKey.of( + "iris.modded.help.entry.command.compat_content", + "Show content that cannot generate on this Minecraft version" + ); public static final TextKey COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK = TextKey.of( "iris.modded.help.entry.command.enable", "Create and inject a persistent Iris dimension at runtime; refuses if the pack is missing, quote pack:dimensionKey to pick a specific pack dimension" @@ -484,6 +488,7 @@ public final class ModdedHelpMessages { COMMAND_CLEANUP_PREVIEW_OR_QUARANTINE_UNUSED_RESOURCE_CANDIDATES, COMMAND_RESTORE_PREVIEW_OR_RESTORE_THE_LATEST_QUARANTINE, COMMAND_STATUS_SHOW_CACHED_VALIDATION_STATUS, + COMMAND_COMPAT_SHOW_CONTENT_THAT_CANNOT_GENERATE_ON_THIS_MINECRAFT_VERSION, COMMAND_ENABLE_CREATE_AND_INJECT_A_PERSISTENT_IRIS_DIMENSION_AT_RUNTIME_DOWNLOADS_THE_PACK, COMMAND_REPLACE_OVERWORLD_INJECT_AN_IRIS_PRIMARY_WORLD_AND_ROUTE_PLAYERS_THERE_INSTEAD_OF_THE, COMMAND_DISABLE_EVACUATE_AND_UNLOAD_AN_IRIS_DIMENSION_WORLD_DATA_ON_DISK_IS_KEPT, diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java index 522196acc..a49c7ca6b 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidationCache.java @@ -1,6 +1,9 @@ package art.arcane.iris.core.pack; import art.arcane.iris.core.ServerConfigurator; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformRegistries; @@ -28,7 +31,7 @@ import java.util.Optional; import java.util.Set; public final class PackValidationCache { - private static final int SCHEMA_VERSION = 2; + private static final int SCHEMA_VERSION = 3; private static final long MAX_CACHE_BYTES = 16L * 1024L * 1024L; private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); @@ -105,11 +108,17 @@ public final class PackValidationCache { || !actual.add(cached.packName)) { return Optional.empty(); } + List findings = readFindings(cached.compatFindings); + if (findings == null) { + return Optional.empty(); + } results.add(new PackValidationResult( cached.packName, cached.blockingErrors, cached.warnings, - cached.validatedAtMillis)); + cached.validatedAtMillis, + findings, + cached.minecraftVersion)); } if (!expected.equals(actual)) { return Optional.empty(); @@ -144,6 +153,8 @@ public final class PackValidationCache { cached.blockingErrors = List.copyOf(result.getBlockingErrors()); cached.warnings = List.copyOf(result.getWarnings()); cached.validatedAtMillis = result.getValidatedAtMillis(); + cached.minecraftVersion = result.getMinecraftVersion(); + cached.compatFindings = writeFindings(result.getCompatFindings()); state.results.add(cached); } @@ -167,6 +178,48 @@ public final class PackValidationCache { } } + /** Null when a persisted finding is malformed; the caller discards the whole cache file. */ + private static List readFindings(List cached) { + if (cached == null) { + return List.of(); + } + List findings = new ArrayList<>(cached.size()); + for (CachedFinding finding : cached) { + if (finding == null || finding.key == null || finding.registry == null || finding.action == null) { + return null; + } + CompatRegistry registry; + CompatAction action; + try { + registry = CompatRegistry.valueOf(finding.registry); + action = CompatAction.valueOf(finding.action); + } catch (IllegalArgumentException unknownEnum) { + return null; + } + findings.add(new CompatFinding( + registry, finding.key, action, finding.subjectType, finding.subjectKey, finding.detail)); + } + return List.copyOf(findings); + } + + private static List writeFindings(List findings) { + if (findings == null || findings.isEmpty()) { + return List.of(); + } + List cached = new ArrayList<>(findings.size()); + for (CompatFinding finding : findings) { + CachedFinding entry = new CachedFinding(); + entry.registry = finding.registry().name(); + entry.key = finding.key(); + entry.action = finding.action().name(); + entry.subjectType = finding.subjectType(); + entry.subjectKey = finding.subjectKey(); + entry.detail = finding.detail(); + cached.add(entry); + } + return cached; + } + private static void updateSorted(MessageDigest digest, List values) { List sorted = new ArrayList<>(Objects.requireNonNull(values, "Pack validation registry keys")); sorted.sort(String::compareTo); @@ -197,5 +250,16 @@ public final class PackValidationCache { private List blockingErrors; private List warnings; private long validatedAtMillis; + private String minecraftVersion; + private List compatFindings; + } + + private static final class CachedFinding { + private String registry; + private String key; + private String action; + private String subjectType; + private String subjectKey; + private String detail; } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java index b00e2e938..a9971a69d 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidationResult.java @@ -18,6 +18,8 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.core.compat.CompatFinding; + import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -27,15 +29,29 @@ public final class PackValidationResult { private final List blockingErrors; private final List warnings; private final long validatedAtMillis; + private final List compatFindings; + private final String minecraftVersion; + /** Result without version-content gating data; used where the pack was never loaded through the gate. */ public PackValidationResult(String packName, List blockingErrors, List warnings, long validatedAtMillis) { + this(packName, blockingErrors, warnings, validatedAtMillis, List.of(), null); + } + + public PackValidationResult(String packName, + List blockingErrors, + List warnings, + long validatedAtMillis, + List compatFindings, + String minecraftVersion) { this.packName = packName; this.blockingErrors = blockingErrors == null ? new ArrayList<>() : new ArrayList<>(blockingErrors); this.warnings = warnings == null ? new ArrayList<>() : new ArrayList<>(warnings); this.validatedAtMillis = validatedAtMillis; + this.compatFindings = compatFindings == null ? List.of() : List.copyOf(compatFindings); + this.minecraftVersion = minecraftVersion; } public String getPackName() { @@ -57,4 +73,14 @@ public final class PackValidationResult { public long getValidatedAtMillis() { return validatedAtMillis; } + + /** Every version-content gating decision recorded while this pack was validated. */ + public List getCompatFindings() { + return compatFindings; + } + + /** The raw {@code IrisPlatform.minecraftVersion()} the gate ran against, or null when it did not run. */ + public String getMinecraftVersion() { + return minecraftVersion; + } } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 0a70e0061..eb35842ee 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -18,9 +18,30 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisImage; +import art.arcane.iris.engine.object.IrisObject; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.matter.IrisMatterObject; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; + import java.io.File; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.regex.Pattern; public final class PackValidator { @@ -40,6 +61,14 @@ public final class PackValidator { static final List REMOVED_WORLDGEN_FIELDS = List.of("fluidBodies"); static final List UNSUPPORTED_STRUCTURE_TRANSFORM_FIELDS = List.of("rotation", "translate", "scale"); static final Pattern RESOURCE_KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+:[a-z0-9/._-]+"); + private static final int BLOCKING_REASON_LIMIT = 3; + /** + * Binary assets, not key-bearing registrants: an object is gated through the placements that can place it + * (palette header only) and images carry no registry keys, so loading every one of them here would parse the + * whole object library for nothing. + */ + private static final Set> UNGATED_LOADERS = + Set.of(IrisObject.class, IrisMatterObject.class, IrisImage.class); private PackValidator() { } @@ -124,7 +153,184 @@ public final class PackValidator { addDistinct(ContentKeyValidator.strictContent() ? blockingErrors : warnings, contentKeys.strict()); addDistinct(warnings, contentKeys.advisory()); - return new PackValidationResult(packName, blockingErrors, warnings, validatedAt); + String minecraftVersion = validateLiveRegistries && IrisPlatforms.isBound() + ? IrisPlatforms.get().minecraftVersion() + : null; + List compatFindings = validateLiveRegistries + ? gateContent(packFolder, dimensionFiles, minecraftVersion, blockingErrors, warnings) + : List.of(); + + return new PackValidationResult( + packName, blockingErrors, warnings, validatedAt, compatFindings, minecraftVersion); + } + + /** + * Forces a complete load of the pack through {@link IrisData} so every registrant and every reachable + * {@link IrisObjectPlacement} is evaluated by the version-content gate, then folds the resulting findings into + * this validation. Runs on a detached loader that is closed again, and only on a cache miss (the caller's cache + * fingerprint already covers the Minecraft version and the registry key sets). + */ + private static List gateContent( + File packFolder, + File[] dimensionFiles, + String minecraftVersion, + List blockingErrors, + List warnings + ) { + if (!IrisPlatforms.isBound()) { + return List.of(); + } + IrisData data = null; + try { + data = IrisData.openRuntime(packFolder); + if (!data.getContentGate().ready()) { + return List.of(); + } + boolean dimensionExcluded = loadEverything(data, dimensionFiles); + List findings = data.getCompatReport().findings(); + applyCompatFindings(minecraftVersion, dimensionExcluded, findings, blockingErrors, warnings); + return findings; + } catch (Throwable e) { + IrisLogging.reportError("Version-content gating failed for pack '" + packFolder.getName() + "'", e); + return List.of(); + } finally { + if (data != null) { + try { + data.close(); + } catch (Throwable ignored) { + } + } + } + } + + /** + * Loads every registrant of every loader type (dimensions first, so dimension-level fallbacks are in place before + * anything else resolves) and evaluates every placement reachable from the dimensions, regions and biomes. + * + * @return true when any dimension in the pack cannot generate on this server + */ + private static boolean loadEverything(IrisData data, File[] dimensionFiles) { + boolean dimensionExcluded = false; + for (File dimensionFile : dimensionFiles) { + IrisDimension dimension = data.getDimensionLoader().load( + PackValidationIo.stripExtension(dimensionFile.getName()), false); + if (dimension != null && dimension.isCompatExcluded()) { + dimensionExcluded = true; + } + } + + Set placements = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Map.Entry, ResourceLoader> entry + : data.getLoaders().entrySet()) { + if (UNGATED_LOADERS.contains(entry.getKey())) { + continue; + } + for (String key : entry.getValue().getPossibleKeys()) { + collectPlacements(entry.getValue().load(key, false), placements); + } + } + for (IrisObjectPlacement placement : placements) { + placement.evaluateCompat(data); + } + return dimensionExcluded; + } + + private static void collectPlacements(IrisRegistrant registrant, Set out) { + if (registrant instanceof IrisBiome biome) { + addPlacements(biome.getObjects(), out); + return; + } + if (registrant instanceof IrisRegion region) { + addPlacements(region.getObjects(), out); + } + } + + private static void addPlacements(Collection placements, Set out) { + if (placements == null) { + return; + } + for (IrisObjectPlacement placement : placements) { + if (placement != null) { + out.add(placement); + } + } + } + + /** + * Turns gate findings into validation output: an excluded dimension is blocking, and any + * {@link ContentKeyValidator} warning about a key the gate already reported is dropped so the operator does not + * read the same missing key twice. + */ + static void applyCompatFindings( + String minecraftVersion, + boolean dimensionExcluded, + List findings, + List blockingErrors, + List warnings + ) { + if (dimensionExcluded) { + addDistinct(blockingErrors, List.of(excludedDimensionError(minecraftVersion, findings))); + } + if (findings == null || findings.isEmpty() || warnings == null) { + return; + } + Set reported = new LinkedHashSet<>(); + for (CompatFinding finding : findings) { + reported.add("'" + finding.key() + "'"); + } + warnings.removeIf(warning -> { + for (String quoted : reported) { + if (warning.contains(quoted)) { + return true; + } + } + return false; + }); + } + + /** {@code Pack cannot generate on Minecraft 26.1.2: minecraft:sulfur (block), minecraft:camel (entity)} */ + static String excludedDimensionError(String minecraftVersion, List findings) { + StringBuilder sb = new StringBuilder(96); + sb.append("Pack cannot generate on Minecraft ") + .append(minecraftVersion == null || minecraftVersion.isBlank() ? "unknown" : minecraftVersion) + .append(": "); + Set reasons = new LinkedHashSet<>(); + if (findings != null) { + for (CompatFinding finding : findings) { + if (reasons.size() == BLOCKING_REASON_LIMIT) { + break; + } + reasons.add(finding.key() + " (" + finding.registry().label() + ")"); + } + } + sb.append(reasons.isEmpty() ? "the dimension composes content this server does not have" + : String.join(", ", reasons)); + return sb.toString(); + } + + /** Boot listing for a validated pack; empty when the gate found nothing. */ + public static List compatBootLines(PackValidationResult result, String minecraftVersion, int perKeyCap) { + if (result == null || result.getCompatFindings().isEmpty()) { + return List.of(); + } + return PackCompatReport.of(result.getCompatFindings()) + .bootLines(result.getPackName(), versionOf(result, minecraftVersion), perKeyCap); + } + + /** One-line compat summary appended to the {@code Pack 'x' validated (...)} line; empty when nothing to say. */ + public static String compatSummary(PackValidationResult result, String minecraftVersion) { + if (result == null || result.getCompatFindings().isEmpty()) { + return ""; + } + return PackCompatReport.of(result.getCompatFindings()) + .summaryLine(versionOf(result, minecraftVersion)); + } + + private static String versionOf(PackValidationResult result, String minecraftVersion) { + if (minecraftVersion != null && !minecraftVersion.isBlank()) { + return minecraftVersion; + } + return result.getMinecraftVersion(); } private static void addDistinct(List destination, List additions) { diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java index 94ce8cc04..486f431b1 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngine.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngine.java @@ -39,6 +39,9 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisEngineData; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.core.compat.PackCompatReport; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.spi.IrisLogging; import com.google.common.util.concurrent.AtomicDouble; import art.arcane.iris.spi.IrisPlatforms; @@ -66,6 +69,7 @@ import lombok.Data; import lombok.Getter; import lombok.Setter; +import java.io.File; import java.util.HashSet; import java.util.Locale; import java.util.Objects; @@ -234,6 +238,7 @@ public class IrisEngine implements Engine { EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(); runtimeBuilder.publishRuntime(initialRuntime, null); IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms"); + logPackCompatSummary(); logStudioInitializationPhase("build_runtime", phaseStartedAt, false); phaseStartedAt = System.nanoTime(); if (requiredMode.warmGenerationCaches()) { @@ -254,6 +259,38 @@ public class IrisEngine implements Engine { IrisLogging.debug("Engine Initialized " + getCacheID()); } + /** + * One line per engine naming what this pack cannot generate on the running Minecraft version. The published + * validation result is complete (validation force-loads the whole pack), so it is preferred; an unvalidated pack + * falls back to what this engine has gated while building its runtime (dimension, regions, biomes). Never throws: + * a report failure must not take an otherwise working world down with it. + */ + private void logPackCompatSummary() { + try { + File folder = getData().getDataFolder(); + // A world engine reads its snapshot copy under /iris/pack, so the dimension key is the pack's name. + String pack = getDimension().getLoadKey(); + String world = target.getWorld().name(); + String version = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null; + PackValidationResult published = PackValidationRegistry.get(folder.toPath()); + if (published == null) { + published = PackValidationRegistry.get(folder.getName()); + } + PackCompatReport report = published != null && !published.getCompatFindings().isEmpty() + ? PackCompatReport.of(published.getCompatFindings()) + : getData().getCompatReport(); + if (!report.isEmpty()) { + IrisLogging.info("World '" + world + "' pack '" + pack + "' " + report.summaryLine(version)); + } + if (getDimension().isCompatExcluded()) { + IrisLogging.error("World '" + world + "' pack '" + pack + "' cannot generate on Minecraft " + + (version == null || version.isBlank() ? "unknown" : version)); + } + } catch (Throwable e) { + IrisLogging.debug("Pack compat summary failed: " + e.getMessage()); + } + } + public void awaitGenerationCacheWarm() { if (generationCacheWarm.isDone() && !generationCacheWarm.isCompletedExceptionally()) { return; @@ -682,7 +719,8 @@ public class IrisEngine implements Engine { return null; } - return getData().getBiomeLoader().load(getDimension().getFocus()); + IrisBiome focus = getData().getBiomeLoader().load(getDimension().getFocus()); + return focus == null || focus.isCompatExcluded() ? null : focus; } @Override @@ -691,7 +729,8 @@ public class IrisEngine implements Engine { return null; } - return getData().getRegionLoader().load(getDimension().getFocusRegion()); + IrisRegion focus = getData().getRegionLoader().load(getDimension().getFocusRegion()); + return focus == null || focus.isCompatExcluded() ? null : focus; } @Override diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 60e762b83..765831e3a 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Pack-Inhalte anzeigen, die in dieser Minecraft-Version nicht generiert werden können", + "iris.director.commandpack.param.pack_folder_name_compat": "Der Pack-Ordnername für den Bericht", + "iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': Inhalte in Minecraft {version} nicht verfügbar", + "iris.bukkit.runtime.commandpack.compat_none": "§aPack '{pack}' verwendet keine Inhalte, die in Minecraft {version} fehlen.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Aktualisiere den Server auf ein neueres Minecraft, um diese Inhalte wiederherzustellen, oder deklariere Ersatzwerte (Dimension blockFallbacks, Block backup).", + "iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': Inhalte in Minecraft {version} nicht verfügbar", + "iris.modded.moddedpackcommands.compat_none": "Pack '{pack}' verwendet keine Inhalte, die in Minecraft {version} fehlen.", + "iris.modded.moddedpackcommands.compat_remedy": "Aktualisiere den Server auf ein neueres Minecraft, um diese Inhalte wiederherzustellen, oder deklariere Ersatzwerte (Dimension blockFallbacks, Block backup).", + "iris.modded.help.entry.command.compat_content": "Inhalte anzeigen, die in dieser Minecraft-Version nicht generiert werden können" } } diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 1594a39d9..2cfd47f6a 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Muestra el contenido del pack que no puede generarse en esta versión de Minecraft", + "iris.director.commandpack.param.pack_folder_name_compat": "El nombre de la carpeta del pack sobre el que informar", + "iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': contenido no disponible en Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aEl pack '{pack}' no usa contenido que falte en Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Actualiza el servidor a un Minecraft más reciente para recuperar este contenido, o declara alternativas (blockFallbacks de la dimensión, backup del bloque).", + "iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': contenido no disponible en Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "El pack '{pack}' no usa contenido que falte en Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Actualiza el servidor a un Minecraft más reciente para recuperar este contenido, o declara alternativas (blockFallbacks de la dimensión, backup del bloque).", + "iris.modded.help.entry.command.compat_content": "Muestra el contenido que no puede generarse en esta versión de Minecraft" } } diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index b26961ff5..2d566047b 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Näytä paketin sisältö, jota ei voi luoda tässä Minecraft-versiossa", + "iris.director.commandpack.param.pack_folder_name_compat": "Raportoitavan pakettikansion nimi", + "iris.bukkit.runtime.commandpack.compat_header": "§ePaketti '{pack}': sisältö ei ole käytettävissä Minecraft {version} -versiossa", + "iris.bukkit.runtime.commandpack.compat_none": "§aPaketti '{pack}' ei käytä sisältöä, joka puuttuisi Minecraft {version} -versiosta.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Päivitä palvelin uudempaan Minecraft-versioon palauttaaksesi tämän sisällön tai määritä varasisältö (ulottuvuuden blockFallbacks, lohkon backup).", + "iris.modded.moddedpackcommands.compat_header": "Paketti '{pack}': sisältö ei ole käytettävissä Minecraft {version} -versiossa", + "iris.modded.moddedpackcommands.compat_none": "Paketti '{pack}' ei käytä sisältöä, joka puuttuisi Minecraft {version} -versiosta.", + "iris.modded.moddedpackcommands.compat_remedy": "Päivitä palvelin uudempaan Minecraft-versioon palauttaaksesi tämän sisällön tai määritä varasisältö (ulottuvuuden blockFallbacks, lohkon backup).", + "iris.modded.help.entry.command.compat_content": "Näytä sisältö, jota ei voi luoda tässä Minecraft-versiossa" } } diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index b017acdb3..d219a836d 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Afficher le contenu du pack qui ne peut pas être généré sur cette version de Minecraft", + "iris.director.commandpack.param.pack_folder_name_compat": "Le nom du dossier du pack à analyser", + "iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}' : contenu indisponible sur Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aLe pack '{pack}' n'utilise aucun contenu absent de Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Mettez le serveur à jour vers un Minecraft plus récent pour restaurer ce contenu, ou déclarez des replis (blockFallbacks de la dimension, backup du bloc).", + "iris.modded.moddedpackcommands.compat_header": "Pack '{pack}' : contenu indisponible sur Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "Le pack '{pack}' n'utilise aucun contenu absent de Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Mettez le serveur à jour vers un Minecraft plus récent pour restaurer ce contenu, ou déclarez des replis (blockFallbacks de la dimension, backup du bloc).", + "iris.modded.help.entry.command.compat_content": "Afficher le contenu qui ne peut pas être généré sur cette version de Minecraft" } } diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index cf3124630..19f1dc928 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "הצג תוכן חבילה שלא ניתן ליצור בגרסת Minecraft הזו", + "iris.director.commandpack.param.pack_folder_name_compat": "שם תיקיית החבילה לדיווח", + "iris.bukkit.runtime.commandpack.compat_header": "§eחבילה '{pack}': תוכן לא זמין ב-Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aהחבילה '{pack}' אינה משתמשת בתוכן שחסר ב-Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7עדכן את השרת ל-Minecraft חדש יותר כדי לשחזר את התוכן הזה, או הגדר חלופות (blockFallbacks של הממד, backup של הבלוק).", + "iris.modded.moddedpackcommands.compat_header": "חבילה '{pack}': תוכן לא זמין ב-Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "החבילה '{pack}' אינה משתמשת בתוכן שחסר ב-Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "עדכן את השרת ל-Minecraft חדש יותר כדי לשחזר את התוכן הזה, או הגדר חלופות (blockFallbacks של הממד, backup של הבלוק).", + "iris.modded.help.entry.command.compat_content": "הצג תוכן שלא ניתן ליצור בגרסת Minecraft הזו" } } diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index aec0c8fec..63d57a658 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Mostra i contenuti del pack che non possono essere generati su questa versione di Minecraft", + "iris.director.commandpack.param.pack_folder_name_compat": "Il nome della cartella del pack da analizzare", + "iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': contenuti non disponibili su Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aIl pack '{pack}' non usa contenuti assenti da Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Aggiorna il server a un Minecraft più recente per ripristinare questi contenuti, oppure dichiara delle alternative (blockFallbacks della dimensione, backup del blocco).", + "iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': contenuti non disponibili su Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "Il pack '{pack}' non usa contenuti assenti da Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Aggiorna il server a un Minecraft più recente per ripristinare questi contenuti, oppure dichiara delle alternative (blockFallbacks della dimensione, backup del blocco).", + "iris.modded.help.entry.command.compat_content": "Mostra i contenuti che non possono essere generati su questa versione di Minecraft" } } diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index 75ac9c0cb..9421d64d1 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "この Minecraft バージョンでは生成できないパック内容を表示します", + "iris.director.commandpack.param.pack_folder_name_compat": "対象とするパックフォルダー名", + "iris.bukkit.runtime.commandpack.compat_header": "§eパック '{pack}': Minecraft {version} では利用できない内容があります", + "iris.bukkit.runtime.commandpack.compat_none": "§aパック '{pack}' に Minecraft {version} で欠けている内容はありません。", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7この内容を復元するにはサーバーを新しい Minecraft に更新するか、代替を宣言してください (ディメンションの blockFallbacks、ブロックの backup)。", + "iris.modded.moddedpackcommands.compat_header": "パック '{pack}': Minecraft {version} では利用できない内容があります", + "iris.modded.moddedpackcommands.compat_none": "パック '{pack}' に Minecraft {version} で欠けている内容はありません。", + "iris.modded.moddedpackcommands.compat_remedy": "この内容を復元するにはサーバーを新しい Minecraft に更新するか、代替を宣言してください (ディメンションの blockFallbacks、ブロックの backup)。", + "iris.modded.help.entry.command.compat_content": "この Minecraft バージョンでは生成できない内容を表示します" } } diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index f8601297a..ed8a1ab0a 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "이 Minecraft 버전에서 생성할 수 없는 팩 콘텐츠를 표시합니다", + "iris.director.commandpack.param.pack_folder_name_compat": "보고할 팩 폴더 이름", + "iris.bukkit.runtime.commandpack.compat_header": "§e팩 '{pack}': Minecraft {version} 에서 사용할 수 없는 콘텐츠", + "iris.bukkit.runtime.commandpack.compat_none": "§a팩 '{pack}' 은 Minecraft {version} 에 없는 콘텐츠를 사용하지 않습니다.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7이 콘텐츠를 복원하려면 서버를 최신 Minecraft 로 업데이트하거나 대체를 선언하세요 (차원의 blockFallbacks, 블록의 backup).", + "iris.modded.moddedpackcommands.compat_header": "팩 '{pack}': Minecraft {version} 에서 사용할 수 없는 콘텐츠", + "iris.modded.moddedpackcommands.compat_none": "팩 '{pack}' 은 Minecraft {version} 에 없는 콘텐츠를 사용하지 않습니다.", + "iris.modded.moddedpackcommands.compat_remedy": "이 콘텐츠를 복원하려면 서버를 최신 Minecraft 로 업데이트하거나 대체를 선언하세요 (차원의 blockFallbacks, 블록의 backup).", + "iris.modded.help.entry.command.compat_content": "이 Minecraft 버전에서 생성할 수 없는 콘텐츠를 표시합니다" } } diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index a7940d8bf..3739fdbe7 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Rodyti paketo turinį, kurio negalima sugeneruoti šioje Minecraft versijoje", + "iris.director.commandpack.param.pack_folder_name_compat": "Paketo aplanko pavadinimas ataskaitai", + "iris.bukkit.runtime.commandpack.compat_header": "§ePaketas '{pack}': turinys nepasiekiamas Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aPaketas '{pack}' nenaudoja turinio, kurio trūksta Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Atnaujinkite serverį į naujesnį Minecraft, kad atkurtumėte šį turinį, arba paskelbkite atsargas (dimensijos blockFallbacks, bloko backup).", + "iris.modded.moddedpackcommands.compat_header": "Paketas '{pack}': turinys nepasiekiamas Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "Paketas '{pack}' nenaudoja turinio, kurio trūksta Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Atnaujinkite serverį į naujesnį Minecraft, kad atkurtumėte šį turinį, arba paskelbkite atsargas (dimensijos blockFallbacks, bloko backup).", + "iris.modded.help.entry.command.compat_content": "Rodyti turinį, kurio negalima sugeneruoti šioje Minecraft versijoje" } } diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 18034346c..40bd18137 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Toon pack-inhoud die niet kan worden gegenereerd op deze Minecraft-versie", + "iris.director.commandpack.param.pack_folder_name_compat": "De naam van de packmap om te rapporteren", + "iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': inhoud niet beschikbaar op Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aPack '{pack}' gebruikt geen inhoud die ontbreekt in Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Werk de server bij naar een nieuwere Minecraft om deze inhoud te herstellen, of declareer terugvallen (blockFallbacks van de dimensie, backup van het blok).", + "iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': inhoud niet beschikbaar op Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "Pack '{pack}' gebruikt geen inhoud die ontbreekt in Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Werk de server bij naar een nieuwere Minecraft om deze inhoud te herstellen, of declareer terugvallen (blockFallbacks van de dimensie, backup van het blok).", + "iris.modded.help.entry.command.compat_content": "Toon inhoud die niet kan worden gegenereerd op deze Minecraft-versie" } } diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 8b30423eb..65d084b45 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Pokaż zawartość paczki, której nie można wygenerować w tej wersji Minecraft", + "iris.director.commandpack.param.pack_folder_name_compat": "Nazwa folderu paczki do raportu", + "iris.bukkit.runtime.commandpack.compat_header": "§ePaczka '{pack}': zawartość niedostępna w Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aPaczka '{pack}' nie używa zawartości brakującej w Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Zaktualizuj serwer do nowszego Minecraft, aby przywrócić tę zawartość, albo zadeklaruj zamienniki (blockFallbacks wymiaru, backup bloku).", + "iris.modded.moddedpackcommands.compat_header": "Paczka '{pack}': zawartość niedostępna w Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "Paczka '{pack}' nie używa zawartości brakującej w Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Zaktualizuj serwer do nowszego Minecraft, aby przywrócić tę zawartość, albo zadeklaruj zamienniki (blockFallbacks wymiaru, backup bloku).", + "iris.modded.help.entry.command.compat_content": "Pokaż zawartość, której nie można wygenerować w tej wersji Minecraft" } } diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index e6cf58cf5..1aef5e1d9 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Mostrar o conteúdo do pack que não pode ser gerado nesta versão do Minecraft", + "iris.director.commandpack.param.pack_folder_name_compat": "O nome da pasta do pack a reportar", + "iris.bukkit.runtime.commandpack.compat_header": "§ePack '{pack}': conteúdo indisponível no Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aO pack '{pack}' não usa conteúdo em falta no Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Atualize o servidor para um Minecraft mais recente para restaurar este conteúdo, ou declare alternativas (blockFallbacks da dimensão, backup do bloco).", + "iris.modded.moddedpackcommands.compat_header": "Pack '{pack}': conteúdo indisponível no Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "O pack '{pack}' não usa conteúdo em falta no Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Atualize o servidor para um Minecraft mais recente para restaurar este conteúdo, ou declare alternativas (blockFallbacks da dimensão, backup do bloco).", + "iris.modded.help.entry.command.compat_content": "Mostrar o conteúdo que não pode ser gerado nesta versão do Minecraft" } } diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index a56d969a4..d0da17f1c 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Показать содержимое пака, которое нельзя сгенерировать в этой версии Minecraft", + "iris.director.commandpack.param.pack_folder_name_compat": "Имя папки пака для отчёта", + "iris.bukkit.runtime.commandpack.compat_header": "§eПак '{pack}': содержимое недоступно в Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aПак '{pack}' не использует содержимое, которого нет в Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Обновите сервер до более новой версии Minecraft, чтобы вернуть это содержимое, или объявите замены (blockFallbacks измерения, backup блока).", + "iris.modded.moddedpackcommands.compat_header": "Пак '{pack}': содержимое недоступно в Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "Пак '{pack}' не использует содержимое, которого нет в Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Обновите сервер до более новой версии Minecraft, чтобы вернуть это содержимое, или объявите замены (blockFallbacks измерения, backup блока).", + "iris.modded.help.entry.command.compat_content": "Показать содержимое, которое нельзя сгенерировать в этой версии Minecraft" } } diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 8d264d1e3..c4dc9ff54 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Bu Minecraft sürümünde üretilemeyen paket içeriğini göster", + "iris.director.commandpack.param.pack_folder_name_compat": "Raporlanacak paket klasörü adı", + "iris.bukkit.runtime.commandpack.compat_header": "§ePaket '{pack}': içerik Minecraft {version} sürümünde yok", + "iris.bukkit.runtime.commandpack.compat_none": "§aPaket '{pack}' Minecraft {version} sürümünde eksik olan içerik kullanmıyor.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Bu içeriği geri kazanmak için sunucuyu daha yeni bir Minecraft sürümüne güncelleyin veya yedekler tanımlayın (boyutun blockFallbacks, bloğun backup).", + "iris.modded.moddedpackcommands.compat_header": "Paket '{pack}': içerik Minecraft {version} sürümünde yok", + "iris.modded.moddedpackcommands.compat_none": "Paket '{pack}' Minecraft {version} sürümünde eksik olan içerik kullanmıyor.", + "iris.modded.moddedpackcommands.compat_remedy": "Bu içeriği geri kazanmak için sunucuyu daha yeni bir Minecraft sürümüne güncelleyin veya yedekler tanımlayın (boyutun blockFallbacks, bloğun backup).", + "iris.modded.help.entry.command.compat_content": "Bu Minecraft sürümünde üretilemeyen içeriği göster" } } diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index dfa29fa2c..22be9db60 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "Hiển thị nội dung gói không thể tạo trên phiên bản Minecraft này", + "iris.director.commandpack.param.pack_folder_name_compat": "Tên thư mục gói cần báo cáo", + "iris.bukkit.runtime.commandpack.compat_header": "§eGói '{pack}': nội dung không có trên Minecraft {version}", + "iris.bukkit.runtime.commandpack.compat_none": "§aGói '{pack}' không dùng nội dung nào thiếu trên Minecraft {version}.", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7Cập nhật máy chủ lên Minecraft mới hơn để khôi phục nội dung này, hoặc khai báo phương án dự phòng (blockFallbacks của chiều, backup của khối).", + "iris.modded.moddedpackcommands.compat_header": "Gói '{pack}': nội dung không có trên Minecraft {version}", + "iris.modded.moddedpackcommands.compat_none": "Gói '{pack}' không dùng nội dung nào thiếu trên Minecraft {version}.", + "iris.modded.moddedpackcommands.compat_remedy": "Cập nhật máy chủ lên Minecraft mới hơn để khôi phục nội dung này, hoặc khai báo phương án dự phòng (blockFallbacks của chiều, backup của khối).", + "iris.modded.help.entry.command.compat_content": "Hiển thị nội dung không thể tạo trên phiên bản Minecraft này" } } diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 72588fc7c..2839af2d8 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "显示在当前 Minecraft 版本中无法生成的资源包内容", + "iris.director.commandpack.param.pack_folder_name_compat": "要报告的资源包文件夹名称", + "iris.bukkit.runtime.commandpack.compat_header": "§e资源包 '{pack}': 内容在 Minecraft {version} 中不可用", + "iris.bukkit.runtime.commandpack.compat_none": "§a资源包 '{pack}' 未使用 Minecraft {version} 中缺失的内容。", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7将服务器升级到更新的 Minecraft 以恢复这些内容,或声明回退 (维度的 blockFallbacks、方块的 backup)。", + "iris.modded.moddedpackcommands.compat_header": "资源包 '{pack}': 内容在 Minecraft {version} 中不可用", + "iris.modded.moddedpackcommands.compat_none": "资源包 '{pack}' 未使用 Minecraft {version} 中缺失的内容。", + "iris.modded.moddedpackcommands.compat_remedy": "将服务器升级到更新的 Minecraft 以恢复这些内容,或声明回退 (维度的 blockFallbacks、方块的 backup)。", + "iris.modded.help.entry.command.compat_content": "显示在当前 Minecraft 版本中无法生成的内容" } } diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index e27df451c..bcefc41fb 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1550,6 +1550,15 @@ "iris.desktop.imagemap.preview_status": "X {x} Z {z} | {value} | {scale} blocks/pixel", "iris.bukkit.commandstudio.opening_image_map_studio": "§aOpening the Image Map Studio.", "iris.modded.moddedstudiocommands.image_map_requires_iris_dimension": "Stand in an active Iris or Studio dimension before opening the Image Map Studio.", - "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display." + "iris.modded.moddedstudiocommands.opening_image_map_studio_on_server_display": "Opening the Image Map Studio for {value} on the server display.", + "iris.director.commandpack.director.show_content_cannot_generate_this_minecraft_version": "顯示在目前 Minecraft 版本中無法生成的資源包內容", + "iris.director.commandpack.param.pack_folder_name_compat": "要報告的資源包資料夾名稱", + "iris.bukkit.runtime.commandpack.compat_header": "§e資源包 '{pack}': 內容在 Minecraft {version} 中無法使用", + "iris.bukkit.runtime.commandpack.compat_none": "§a資源包 '{pack}' 未使用 Minecraft {version} 中缺少的內容。", + "iris.bukkit.runtime.commandpack.compat_remedy": "§7將伺服器升級到較新的 Minecraft 以還原這些內容,或宣告備援 (維度的 blockFallbacks、方塊的 backup)。", + "iris.modded.moddedpackcommands.compat_header": "資源包 '{pack}': 內容在 Minecraft {version} 中無法使用", + "iris.modded.moddedpackcommands.compat_none": "資源包 '{pack}' 未使用 Minecraft {version} 中缺少的內容。", + "iris.modded.moddedpackcommands.compat_remedy": "將伺服器升級到較新的 Minecraft 以還原這些內容,或宣告備援 (維度的 blockFallbacks、方塊的 backup)。", + "iris.modded.help.entry.command.compat_content": "顯示在目前 Minecraft 版本中無法生成的內容" } } diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java index b8d7d46ae..5232eb2c9 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidationCacheTest.java @@ -1,5 +1,8 @@ package art.arcane.iris.core.pack; +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; import org.junit.Rule; import org.junit.Test; import org.junit.Assume; @@ -16,6 +19,7 @@ import java.util.Optional; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -61,6 +65,55 @@ public class PackValidationCacheTest { cache, "content", "context", List.of("other")).isEmpty()); } + @Test + public void cacheRoundTripsCompatFindingsAndMinecraftVersion() throws Exception { + Path cache = new File(temporaryFolder.newFolder("compat-round-trip"), "validation.json").toPath(); + CompatFinding excluded = new CompatFinding( + CompatRegistry.BLOCK, "minecraft:sulfur", CompatAction.EXCLUDED, + "Biome", "cave/sulfur-grotto", "layers[0].palette[1]"); + CompatFinding dropped = new CompatFinding( + CompatRegistry.ENTITY, "minecraft:camel", CompatAction.DROPPED, "Spawner", "desert", "spawns[0]"); + PackValidationCache.save(cache, "content", "context", List.of(new PackValidationResult( + "overworld", List.of(), List.of("warning"), 7L, List.of(excluded, dropped), "26.1.2"))); + + PackValidationResult loaded = PackValidationCache.load( + cache, "content", "context", List.of("overworld")).orElseThrow().getFirst(); + + assertEquals("26.1.2", loaded.getMinecraftVersion()); + assertEquals(List.of(excluded, dropped), loaded.getCompatFindings()); + } + + @Test + public void resultsWithoutGatingRoundTripAsEmptyFindings() throws Exception { + Path cache = new File(temporaryFolder.newFolder("compat-empty"), "validation.json").toPath(); + PackValidationCache.save(cache, "content", "context", List.of( + new PackValidationResult("overworld", List.of(), List.of(), 1L))); + + PackValidationResult loaded = PackValidationCache.load( + cache, "content", "context", List.of("overworld")).orElseThrow().getFirst(); + + assertTrue(loaded.getCompatFindings().isEmpty()); + assertNull(loaded.getMinecraftVersion()); + } + + @Test + public void cacheWithAnUnreadableFindingIsIgnored() throws Exception { + Path cache = new File(temporaryFolder.newFolder("compat-corrupt"), "validation.json").toPath(); + Files.writeString(cache, """ + { + "schemaVersion": 3, + "contentFingerprint": "content", + "contextFingerprint": "context", + "results": [ + {"packName":"overworld","blockingErrors":[],"warnings":[],"validatedAtMillis":1, + "compatFindings":[{"registry":"NOT_A_REGISTRY","key":"minecraft:sulfur","action":"EXCLUDED"}]} + ] + } + """, StandardCharsets.UTF_8); + + assertTrue(PackValidationCache.load(cache, "content", "context", List.of("overworld")).isEmpty()); + } + @Test public void corruptCacheIsIgnored() throws Exception { Path cache = new File(temporaryFolder.newFolder("corrupt"), "validation.json").toPath(); @@ -77,7 +130,7 @@ public class PackValidationCacheTest { Path cache = new File(temporaryFolder.newFolder("duplicate"), "validation.json").toPath(); Files.writeString(cache, """ { - "schemaVersion": 2, + "schemaVersion": 3, "contentFingerprint": "content", "contextFingerprint": "context", "results": [ diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackValidatorCompatTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorCompatTest.java new file mode 100644 index 000000000..0ed5151a2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackValidatorCompatTest.java @@ -0,0 +1,130 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.core.compat.CompatAction; +import art.arcane.iris.core.compat.CompatFinding; +import art.arcane.iris.core.compat.CompatRegistry; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackValidatorCompatTest { + private static CompatFinding block(CompatAction action, String subjectKey) { + return new CompatFinding( + CompatRegistry.BLOCK, "minecraft:sulfur", action, "Biome", subjectKey, "layers[0].palette[1]"); + } + + private static CompatFinding entity(CompatAction action, String subjectKey) { + return new CompatFinding( + CompatRegistry.ENTITY, "minecraft:camel", action, "Spawner", subjectKey, "spawns[0]"); + } + + @Test + public void excludedDimensionBecomesABlockingError() { + List blockingErrors = new ArrayList<>(); + List warnings = new ArrayList<>(); + + PackValidator.applyCompatFindings( + "26.1.2", + true, + List.of(block(CompatAction.EXCLUDED, "cave/sulfur"), entity(CompatAction.DROPPED, "desert")), + blockingErrors, + warnings); + + assertEquals(1, blockingErrors.size()); + assertEquals( + "Pack cannot generate on Minecraft 26.1.2: minecraft:sulfur (block), minecraft:camel (entity)", + blockingErrors.getFirst()); + } + + @Test + public void unexcludedDimensionAddsNoBlockingError() { + List blockingErrors = new ArrayList<>(); + List warnings = new ArrayList<>(); + + PackValidator.applyCompatFindings( + "26.1.2", false, List.of(block(CompatAction.DROPPED, "cave/sulfur")), blockingErrors, warnings); + + assertTrue(blockingErrors.toString(), blockingErrors.isEmpty()); + } + + @Test + public void excludedDimensionWithoutFindingsStillNamesTheVersion() { + List blockingErrors = new ArrayList<>(); + + PackValidator.applyCompatFindings("26.1.2", true, List.of(), blockingErrors, new ArrayList<>()); + + assertEquals( + "Pack cannot generate on Minecraft 26.1.2: the dimension composes content this server does not have", + blockingErrors.getFirst()); + assertTrue(PackValidator.excludedDimensionError(null, List.of()).contains("Minecraft unknown")); + } + + @Test + public void contentKeyWarningsForGatedKeysAreSuppressed() { + List warnings = new ArrayList<>(List.of( + "Unknown block key 'minecraft:sulfur' (missing from the block registry)", + "Unknown entity key 'minecraft:camel' (missing from the entity registry)", + "Unknown block key 'minecraft:cheese' (missing from the block registry)", + "Duplicate generator 'overworld'")); + + PackValidator.applyCompatFindings( + "26.1.2", + false, + List.of(block(CompatAction.EXCLUDED, "cave/sulfur"), entity(CompatAction.DROPPED, "desert")), + new ArrayList<>(), + warnings); + + assertEquals(List.of( + "Unknown block key 'minecraft:cheese' (missing from the block registry)", + "Duplicate generator 'overworld'"), warnings); + } + + @Test + public void bootLinesCapSubjectsPerKeyAndSummaryCountsActions() { + PackValidationResult result = new PackValidationResult( + "overworld", + List.of(), + List.of(), + 1L, + List.of( + block(CompatAction.EXCLUDED, "cave/sulfur-grotto"), + block(CompatAction.EXCLUDED, "desert/sulfur-flats"), + block(CompatAction.DROPPED, "cave/sulfur-grotto"), + block(CompatAction.SUBSTITUTED, "desert/dunes"), + entity(CompatAction.EXCLUDED, "camel")), + "26.1.2"); + + List lines = PackValidator.compatBootLines(result, null, 3); + + assertEquals(4, lines.size()); + assertEquals("Pack 'overworld': content unavailable on Minecraft 26.1.2", lines.get(0)); + assertTrue(lines.get(1), lines.get(1).startsWith(" minecraft:sulfur (block): ")); + assertTrue(lines.get(1), lines.get(1).endsWith("; +1 more")); + assertEquals(3, lines.get(1).split("; ").length - 1); + assertTrue(lines.get(2), lines.get(2).startsWith(" minecraft:camel (entity): ")); + assertFalse(lines.get(2), lines.get(2).contains("more")); + assertTrue(lines.get(3), lines.get(3).contains("/iris pack compat overworld")); + + assertEquals( + "2 content keys unavailable on Minecraft 26.1.2: 3 excluded, 1 dropped, 1 substituted.", + PackValidator.compatSummary(result, null)); + assertEquals( + "2 content keys unavailable on Minecraft 26.2: 3 excluded, 1 dropped, 1 substituted.", + PackValidator.compatSummary(result, "26.2")); + } + + @Test + public void bootLinesAndSummaryAreEmptyWithoutFindings() { + PackValidationResult result = new PackValidationResult("overworld", List.of(), List.of(), 1L); + + assertTrue(PackValidator.compatBootLines(result, "26.1.2", 3).isEmpty()); + assertEquals("", PackValidator.compatSummary(result, "26.1.2")); + assertTrue(PackValidator.compatBootLines(null, "26.1.2", 3).isEmpty()); + assertEquals("", PackValidator.compatSummary(null, "26.1.2")); + } +}