diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ImportedFeatureWriter.java b/core/src/main/java/art/arcane/iris/pack/ingest/ImportedFeatureWriter.java index dcf6f9a5d..24c5ddb5a 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/ImportedFeatureWriter.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/ImportedFeatureWriter.java @@ -37,18 +37,26 @@ final class ImportedFeatureWriter { private ImportedFeatureWriter() { } - static IrisImportedFeatureControl write(JsonObject dimension, Set nativeKeys) { + static IrisImportedFeatureControl write(JsonObject dimension, Set nativeKeys, Set unselectedNamespaces) { JsonElement existing = dimension.get("importedFeatures"); JsonObject imported = existing != null && existing.isJsonObject() ? existing.getAsJsonObject() : new JsonObject(); Set disabled = new LinkedHashSet<>(IngestJson.strings(imported.get("disabled"))); - disabled.add(VANILLA_PREFIX); - disabled.addAll(nativeKeys); + if (!enabled(imported)) { + disabled.add(VANILLA_PREFIX); + for (String namespace : unselectedNamespaces) { + disabled.add(namespace + ":"); + } + } + Set disabledExact = new LinkedHashSet<>(IngestJson.strings(imported.get("disabledExact"))); + disabledExact.addAll(nativeKeys); imported.addProperty("enabled", true); imported.add("disabled", IngestJson.stringArray(disabled)); + imported.add("disabledExact", IngestJson.stringArray(disabledExact)); dimension.add("importedFeatures", imported); IrisImportedFeatureControl control = new IrisImportedFeatureControl(); control.setEnabled(true); control.setDisabled(new KList<>(disabled)); + control.setDisabledExact(new KList<>(disabledExact)); control.setSteps(steps(imported.get("steps"))); control.setDisabledSteps(steps(imported.get("disabledSteps"))); return control; @@ -90,9 +98,19 @@ final class ImportedFeatureWriter { return "suppressed by importedFeatures.disabled entry " + entry; } } + for (String entry : control.getDisabledExact()) { + if (entry.trim().equalsIgnoreCase(key.trim())) { + return "suppressed by importedFeatures.disabledExact entry " + entry; + } + } return "suppressed by importedFeatures"; } + private static boolean enabled(JsonObject imported) { + JsonElement value = imported.get("enabled"); + return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isBoolean() && value.getAsBoolean(); + } + private static KList steps(JsonElement value) { KList steps = new KList<>(); for (String name : IngestJson.strings(value)) { diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java index 1ca092beb..f3ce3aa2a 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModIngester.java @@ -83,7 +83,8 @@ public final class ModIngester { .adopt(modBiomes(index, namespaces), biomeItems); List featureItems = features.apply(adopted); JsonObject dimension = pack.edit(model.dimensionPath()); - IrisImportedFeatureControl control = ImportedFeatureWriter.write(dimension, features.nativeKeys()); + IrisImportedFeatureControl control = ImportedFeatureWriter.write(dimension, features.nativeKeys(), + unselectedFeatureNamespaces(request.catalog(), namespaces)); featureItems = ImportedFeatureWriter.applySuppression(featureItems, features.plans(), control); Set hostBiomes = new TreeSet<>(adopted.keySet()); hostBiomes.addAll(model.reachableBiomes().values()); @@ -124,6 +125,16 @@ public final class ModIngester { return selected; } + private static Set unselectedFeatureNamespaces(WorldgenCatalog catalog, Set selected) { + Set unselected = new TreeSet<>(); + for (CatalogPlacedFeature feature : catalog.placedFeatures()) { + unselected.add(WorldgenCatalogIndex.namespaceOf(feature.key())); + } + unselected.removeAll(selected); + unselected.remove(ImportedFeatureWriter.VANILLA_PREFIX.substring(0, ImportedFeatureWriter.VANILLA_PREFIX.length() - 1)); + return unselected; + } + private static List modBiomes(WorldgenCatalogIndex index, Set namespaces) { List biomes = new ArrayList<>(); for (String namespace : namespaces) { diff --git a/core/src/main/java/art/arcane/iris/structure/nativegen/IrisImportedFeatureControl.java b/core/src/main/java/art/arcane/iris/structure/nativegen/IrisImportedFeatureControl.java index 5cc403dd6..5f3ad666d 100644 --- a/core/src/main/java/art/arcane/iris/structure/nativegen/IrisImportedFeatureControl.java +++ b/core/src/main/java/art/arcane/iris/structure/nativegen/IrisImportedFeatureControl.java @@ -29,12 +29,13 @@ import lombok.Data; import lombok.NoArgsConstructor; import lombok.experimental.Accessors; +import java.util.Locale; import java.util.Objects; @Accessors(chain = true) @NoArgsConstructor @AllArgsConstructor -@Description("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation for this dimension (set as the dimension's 'importedFeatures' field). Placed features are ores, trees, plants, springs, geodes and every other decoration entry a biome declares. This is OFF by default: with 'enabled' false Iris generates exactly the terrain it always has and no native feature runs. With it true, every placed feature the biome's vanilla derivative declares runs over Iris terrain, in vanilla step order, on the same worldgen thread vanilla uses. Carvers are never imported - Iris has no NoiseGeneratorSettings, so there is nothing for a carver to carve against. Family matching on 'disabled' uses namespace, slash, or underscore boundaries, so 'minecraft:ore' covers every vanilla ore feature without matching unrelated names.") +@Description("Controls native vanilla, mod, and ingested datapack PLACED FEATURE generation for this dimension (set as the dimension's 'importedFeatures' field). Placed features are ores, trees, plants, springs, geodes and every other decoration entry a biome declares. This is OFF by default: with 'enabled' false Iris generates exactly the terrain it always has and no native feature runs. With it true, every placed feature the biome's vanilla derivative declares runs over Iris terrain, in vanilla step order, on the same worldgen thread vanilla uses. Carvers are never imported - Iris has no NoiseGeneratorSettings, so there is nothing for a carver to carve against. Family matching on 'disabled' uses namespace, slash, or underscore boundaries, so 'minecraft:ore' covers every vanilla ore feature without matching unrelated names, while 'disabledExact' compares normalized complete keys only.") @Data public class IrisImportedFeatureControl implements NativeImportedFeatureControl { @Description("Master switch. False (the default) means no native placed feature generates and chunk output is identical to a pack without this block at all. True runs the vanilla decoration feature pass over Iris terrain.") @@ -44,6 +45,10 @@ public class IrisImportedFeatureControl implements NativeImportedFeatureControl @Description("Placed feature keys to deny explicitly, e.g. 'minecraft:ore_diamond'. A namespace:path prefix also matches, so 'minecraft:ore' denies every vanilla ore and 'minecraft:trees' denies every tree placement. Every key not matched here generates while 'enabled' is true.") private KList disabled = new KList<>(); + @ArrayType(type = String.class, min = 1) + @Description("Exact placed feature keys to deny after trimming and case normalization. Unlike 'disabled', entries never match key families, so 'minecraft:ore_iron' does not disable 'minecraft:ore_iron_small'.") + private KList disabledExact = new KList<>(); + @ArrayType(type = IrisDecorationStep.class, min = 1) @Description("Restrict feature generation to these decoration steps only. Empty (the default) means every step is eligible. Use this to import ores without importing vegetation: set it to UNDERGROUND_ORES.") private KList steps = new KList<>(); @@ -104,6 +109,14 @@ public class IrisImportedFeatureControl implements NativeImportedFeatureControl return NativeFeatureGenerationStatus.DISABLED_BY_PACK; } } + KList deniedExactKeys = Objects.requireNonNull( + disabledExact, "importedFeatures.disabledExact must not be null"); + String normalizedKey = placedFeatureKey.trim().toLowerCase(Locale.ROOT); + for (String entry : deniedExactKeys) { + if (entry != null && entry.trim().toLowerCase(Locale.ROOT).equals(normalizedKey)) { + return NativeFeatureGenerationStatus.DISABLED_BY_PACK; + } + } return NativeFeatureGenerationStatus.GENERATE_NATIVE; } @Override @@ -113,7 +126,7 @@ public class IrisImportedFeatureControl implements NativeImportedFeatureControl @Override public boolean hasFeatureFilter() { - return disabled != null && !disabled.isEmpty(); + return (disabled != null && !disabled.isEmpty()) || (disabledExact != null && !disabledExact.isEmpty()); } } diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ImportedFeatureWriterTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ImportedFeatureWriterTest.java new file mode 100644 index 000000000..bb77d5296 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/ingest/ImportedFeatureWriterTest.java @@ -0,0 +1,78 @@ +/* + * Iris is a World Generator for Minecraft Bukkit Servers + * Copyright (c) 2022 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.pack.ingest; + +import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ImportedFeatureWriterTest { + @Test + public void sourceThatAlreadyRanImportedFeaturesKeepsVanillaAndOtherModsGenerating() { + JsonObject dimension = dimension("{\"importedFeatures\": {\"enabled\": true, \"disabled\": [\"minecraft:ore_diamond\"]}}"); + + IrisImportedFeatureControl control = ImportedFeatureWriter.write(dimension, Set.of("examplemod:ore_tin"), Set.of("othermod")); + + JsonObject imported = dimension.getAsJsonObject("importedFeatures"); + assertEquals(List.of("minecraft:ore_diamond"), ModIngestFixture.strings(imported.get("disabled"))); + assertEquals(List.of("examplemod:ore_tin"), ModIngestFixture.strings(imported.get("disabledExact"))); + assertTrue(control.shouldGenerate("minecraft:ore_iron")); + assertTrue(control.shouldGenerate("othermod:daisies")); + assertFalse(control.shouldGenerate("minecraft:ore_diamond")); + assertFalse(control.shouldGenerate("examplemod:ore_tin")); + } + + @Test + public void sourceWithoutImportedFeaturesOnlyRunsTheSelectedModsPassthrough() { + JsonObject dimension = dimension("{}"); + + IrisImportedFeatureControl control = ImportedFeatureWriter.write(dimension, Set.of("examplemod:ore_tin"), Set.of("othermod")); + + JsonObject imported = dimension.getAsJsonObject("importedFeatures"); + assertTrue(imported.get("enabled").getAsBoolean()); + assertEquals(List.of("minecraft:", "othermod:"), ModIngestFixture.strings(imported.get("disabled"))); + assertFalse(control.shouldGenerate("minecraft:ore_iron")); + assertFalse(control.shouldGenerate("othermod:daisies")); + assertFalse(control.shouldGenerate("examplemod:ore_tin")); + assertTrue(control.shouldGenerate("examplemod:ore_tin_large")); + assertTrue(control.shouldGenerate("examplemod:maple_trees")); + } + + @Test + public void existingExactEntriesAreKept() { + JsonObject dimension = dimension("{\"importedFeatures\": {\"enabled\": false, \"disabledExact\": [\"examplemod:old\"]}}"); + + ImportedFeatureWriter.write(dimension, Set.of("examplemod:ore_tin"), Set.of()); + + assertEquals(List.of("examplemod:old", "examplemod:ore_tin"), + ModIngestFixture.strings(dimension.getAsJsonObject("importedFeatures").get("disabledExact"))); + } + + private static JsonObject dimension(String json) { + return JsonParser.parseString(json).getAsJsonObject(); + } +} diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java index af26c131f..05cfba906 100644 --- a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterFeatureTest.java @@ -155,20 +155,21 @@ public class ModIngesterFeatureTest { assertEquals(ModIngestTier.PASSTHROUGH, weird.tier()); assertTrue(weird.reason(), weird.reason().contains("examplemod:custom_stone")); assertTrue(imported.get("enabled").getAsBoolean()); - assertEquals(List.of("minecraft:ore_diamond", "minecraft:", "examplemod:bluebell_patch", "examplemod:glow_flowers", - "examplemod:ore_ruby", "examplemod:ore_sapphire", "examplemod:ore_tin"), + assertEquals(List.of("minecraft:ore_diamond", "minecraft:", "othermod:"), ModIngestFixture.strings(imported.get("disabled"))); + assertEquals(List.of("examplemod:bluebell_patch", "examplemod:glow_flowers", + "examplemod:ore_ruby", "examplemod:ore_sapphire", "examplemod:ore_tin"), + ModIngestFixture.strings(imported.get("disabledExact"))); assertEquals(List.of("LAKES"), ModIngestFixture.strings(imported.get("disabledSteps"))); } @Test - public void featuresWithoutHostsOrShadowedByDisabledPrefixesAreSkipped() { + public void featuresWithoutHostsAreSkippedAndConvertedKeysNeverShadowTheirFamily() { ModIngestItem nether = ModIngestFixture.item(report, "examplemod:nether_ore"); ModIngestItem buried = ModIngestFixture.item(report, "examplemod:ore_tin_buried"); assertEquals(ModIngestTier.SKIPPED, nether.tier()); - assertEquals(ModIngestTier.SKIPPED, buried.tier()); - assertTrue(buried.reason(), buried.reason().contains("examplemod:ore_tin")); + assertEquals(ModIngestTier.PASSTHROUGH, buried.tier()); } @Test diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java index 2d40db8e0..f5d6d0f87 100644 --- a/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java +++ b/core/src/test/java/art/arcane/iris/pack/ingest/ModIngesterPackTest.java @@ -85,7 +85,7 @@ public class ModIngesterPackTest { assertFalse(modified.contains("biomes/unused/orphan.json")); assertEquals(report.items().size(), manifest.getAsJsonArray("items").size()); assertTrue(report.summaryLines().toString(), - report.summaryLines().contains("examplemod: 5 native, 6 adopted, 2 passthrough, 7 skipped")); + report.summaryLines().contains("examplemod: 5 native, 6 adopted, 3 passthrough, 6 skipped")); } @Test diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 763041586..6908389af 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "2db2ba19e3466b7c686adca580943c1cc832bf0b44a0568e8db86a920116cfc0"; + private static final String EXPECTED_SCHEMA_DIGEST = "5841ff4a1ad2813e4ad45f941fd40e5083a6b1b29a61c522df9efa12eb04f8c0"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, diff --git a/core/src/test/java/art/arcane/iris/structure/nativegen/IrisImportedFeatureControlTest.java b/core/src/test/java/art/arcane/iris/structure/nativegen/IrisImportedFeatureControlTest.java index 5a8eda2d0..efb594c06 100644 --- a/core/src/test/java/art/arcane/iris/structure/nativegen/IrisImportedFeatureControlTest.java +++ b/core/src/test/java/art/arcane/iris/structure/nativegen/IrisImportedFeatureControlTest.java @@ -86,6 +86,31 @@ public class IrisImportedFeatureControlTest { control.resolve("minecraft:ore_diamond", IrisDecorationStep.UNDERGROUND_ORES)); } + @Test + public void disabledExactDeniesOnlyTheCompleteKey() { + IrisImportedFeatureControl control = new IrisImportedFeatureControl() + .setEnabled(true) + .setDisabledExact(keys(" ExampleMod:Ore_Tin ")); + + assertFalse(control.shouldGenerate("examplemod:ore_tin")); + assertTrue(control.shouldGenerate("examplemod:ore_tin_large")); + assertTrue(control.shouldGenerate("examplemod:ore_tin/deep")); + assertTrue(control.hasFeatureFilter()); + assertEquals(NativeFeatureGenerationStatus.DISABLED_BY_PACK, + control.resolve("examplemod:ore_tin", IrisDecorationStep.UNDERGROUND_ORES)); + assertEquals(NativeFeatureGenerationStatus.GENERATE_NATIVE, + control.resolve("examplemod:ore_tin_large", IrisDecorationStep.UNDERGROUND_ORES)); + } + + @Test + public void nullDisabledExactIsRejected() { + IrisImportedFeatureControl control = new IrisImportedFeatureControl() + .setEnabled(true) + .setDisabledExact(null); + + assertThrows(NullPointerException.class, () -> control.shouldGenerate("examplemod:ore_tin")); + } + @Test public void stepAllowListNarrowsGenerationToListedStepsOnly() { IrisImportedFeatureControl control = new IrisImportedFeatureControl()