Scoped mod trees per biome

- importedFeatures gains biomeRules, which deny placed feature families or exact keys only in registry biomes matched by key, namespace, or tag; Iris custom biomes match through their vanilla derivative
- Mod ingest classifies tree-like passthrough features from the catalog feature JSON, including trees nested in random selectors, fallen trees, and huge mushrooms
- Tree-like mod features that a mod injects into vanilla biomes the pack already uses are denied in minecraft biomes, so they no longer double the pack's own trees while adopted mod biomes keep them
- Studio schema digest updated for the new rule shape
This commit is contained in:
Brian Neumann-Fopiano committed 2026-10-14 21:35:38 -04:00
1 parent 0bc235afb6
commit 7767d6d30a
10 files changed
+454 -6

No files matched your search

@@ -19,9 +19,11 @@
package art.arcane.iris.pack.ingest;
import art.arcane.iris.generation.decoration.IrisDecorationStep;
import art.arcane.iris.structure.nativegen.IrisImportedFeatureBiomeRule;
import art.arcane.iris.structure.nativegen.IrisImportedFeatureControl;
import art.arcane.iris.structure.nativegen.NativeFeatureGenerationStatus;
import art.arcane.volmlib.util.collection.KList;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
@@ -37,7 +39,8 @@ final class ImportedFeatureWriter {
private ImportedFeatureWriter() {
}
static IrisImportedFeatureControl write(JsonObject dimension, Set<String> nativeKeys, Set<String> unselectedNamespaces) {
static IrisImportedFeatureControl write(JsonObject dimension, Set<String> nativeKeys, Set<String> unselectedNamespaces,
Set<String> vanillaScopedKeys) {
JsonElement existing = dimension.get("importedFeatures");
JsonObject imported = existing != null && existing.isJsonObject() ? existing.getAsJsonObject() : new JsonObject();
Set<String> disabled = new LinkedHashSet<>(IngestJson.strings(imported.get("disabled")));
@@ -52,11 +55,23 @@ final class ImportedFeatureWriter {
imported.addProperty("enabled", true);
imported.add("disabled", IngestJson.stringArray(disabled));
imported.add("disabledExact", IngestJson.stringArray(disabledExact));
JsonElement existingRules = imported.get("biomeRules");
JsonArray rules = existingRules != null && existingRules.isJsonArray() ? existingRules.getAsJsonArray() : new JsonArray();
if (!vanillaScopedKeys.isEmpty()) {
JsonObject rule = new JsonObject();
rule.add("biomes", IngestJson.stringArray(List.of(VANILLA_PREFIX)));
rule.add("disabledExact", IngestJson.stringArray(vanillaScopedKeys));
rules.add(rule);
}
if (!rules.isEmpty()) {
imported.add("biomeRules", rules);
}
dimension.add("importedFeatures", imported);
IrisImportedFeatureControl control = new IrisImportedFeatureControl();
control.setEnabled(true);
control.setDisabled(new KList<>(disabled));
control.setDisabledExact(new KList<>(disabledExact));
control.setBiomeRules(biomeRules(rules));
control.setSteps(steps(imported.get("steps")));
control.setDisabledSteps(steps(imported.get("disabledSteps")));
return control;
@@ -106,6 +121,21 @@ final class ImportedFeatureWriter {
return "suppressed by importedFeatures";
}
private static KList<IrisImportedFeatureBiomeRule> biomeRules(JsonArray rules) {
KList<IrisImportedFeatureBiomeRule> parsed = new KList<>();
for (JsonElement entry : rules) {
if (!entry.isJsonObject()) {
continue;
}
JsonObject rule = entry.getAsJsonObject();
parsed.add(new IrisImportedFeatureBiomeRule()
.setBiomes(new KList<>(IngestJson.strings(rule.get("biomes"))))
.setDisabled(new KList<>(IngestJson.strings(rule.get("disabled"))))
.setDisabledExact(new KList<>(IngestJson.strings(rule.get("disabledExact")))));
}
return parsed;
}
private static boolean enabled(JsonObject imported) {
JsonElement value = imported.get("enabled");
return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isBoolean() && value.getAsBoolean();
@@ -36,14 +36,19 @@ import java.util.TreeMap;
import java.util.TreeSet;
final class ModFeatureIngester {
private static final String VANILLA_NAMESPACE = "minecraft";
private final IngestPackModel model;
private final WorldgenCatalogIndex index;
private final TreeFeatures trees;
private final Map<String, FeaturePlan> plans = new TreeMap<>();
private final Set<String> nativeKeys = new TreeSet<>();
private final Set<String> vanillaScopedKeys = new TreeSet<>();
ModFeatureIngester(IngestPackModel model, WorldgenCatalogIndex index, Set<String> namespaces) {
this.model = model;
this.index = index;
this.trees = new TreeFeatures(index);
Map<String, Set<String>> declaring = new TreeMap<>();
Map<String, Set<Integer>> steps = new TreeMap<>();
for (CatalogBiome biome : index.catalog().biomes()) {
@@ -76,6 +81,10 @@ final class ModFeatureIngester {
return nativeKeys;
}
Set<String> vanillaScopedKeys() {
return vanillaScopedKeys;
}
Map<String, FeaturePlan> plans() {
return plans;
}
@@ -106,11 +115,30 @@ final class ModFeatureIngester {
nativeKeys.add(plan.key());
continue;
}
if (treeInPackVanillaBiomes(plan)) {
vanillaScopedKeys.add(plan.key());
items.add(new ModIngestItem(namespace, plan.key(), "feature", ModIngestTier.PASSTHROUGH, "importedFeatures",
plan.reason() + "; denied in minecraft biomes the pack already decorates, so its own trees are not doubled"));
continue;
}
items.add(new ModIngestItem(namespace, plan.key(), "feature", ModIngestTier.PASSTHROUGH, "importedFeatures", plan.reason()));
}
return items;
}
private boolean treeInPackVanillaBiomes(FeaturePlan plan) {
Optional<CatalogPlacedFeature> feature = index.placedFeature(plan.key());
if (feature.isEmpty() || !trees.isTree(feature.get())) {
return false;
}
for (String biome : plan.declaring()) {
if (WorldgenCatalogIndex.namespaceOf(biome).equals(VANILLA_NAMESPACE) && model.reachableBiomes().containsValue(biome)) {
return true;
}
}
return false;
}
private FeaturePlan plan(String key, Set<String> declaring, Set<Integer> steps) {
Optional<CatalogPlacedFeature> feature = index.placedFeature(key);
if (feature.isEmpty()) {
@@ -89,7 +89,7 @@ public final class ModIngester {
List<ModIngestItem> featureItems = features.apply(adopted);
JsonObject dimension = pack.edit(model.dimensionPath());
IrisImportedFeatureControl control = ImportedFeatureWriter.write(dimension, features.nativeKeys(),
unselectedFeatureNamespaces(request.catalog(), namespaces));
unselectedFeatureNamespaces(request.catalog(), namespaces), features.vanillaScopedKeys());
featureItems = ImportedFeatureWriter.applySuppression(featureItems, features.plans(), control);
Set<String> hostBiomes = new TreeSet<>(adopted.keySet());
hostBiomes.addAll(model.reachableBiomes().values());
@@ -0,0 +1,120 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.pack.ingest;
import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature;
import art.arcane.iris.pack.ingest.catalog.WorldgenCatalogIndex;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
final class TreeFeatures {
private static final Set<String> TREE_TYPES = Set.of("minecraft:tree", "minecraft:fallen_tree", "minecraft:huge_brown_mushroom",
"minecraft:huge_red_mushroom", "minecraft:huge_fungus");
private static final List<String> NESTED_FIELDS = List.of("feature", "features", "default", "feature_true", "feature_false");
private static final String TAG_PREFIX = "#";
private static final int MAX_DEPTH = 8;
private final WorldgenCatalogIndex index;
private final Map<String, CatalogPlacedFeature> byConfigured;
TreeFeatures(WorldgenCatalogIndex index) {
this.index = index;
this.byConfigured = new HashMap<>();
for (CatalogPlacedFeature feature : index.catalog().placedFeatures()) {
if (feature.configuredFeature() != null) {
byConfigured.putIfAbsent(VanillaJson.namespaced(feature.configuredFeature()), feature);
}
}
}
boolean isTree(CatalogPlacedFeature feature) {
return configured(feature.featureType(), feature.config(), 0);
}
static boolean treeType(String type) {
String namespaced = VanillaJson.namespaced(type);
String path = namespaced.substring(namespaced.indexOf(':') + 1);
return TREE_TYPES.contains(namespaced) || path.endsWith("tree") || path.startsWith("huge_");
}
private boolean configured(String type, JsonElement config, int depth) {
if (type != null && treeType(type)) {
return true;
}
if (depth >= MAX_DEPTH || config == null || !config.isJsonObject()) {
return false;
}
JsonObject object = config.getAsJsonObject();
for (String field : NESTED_FIELDS) {
if (placed(object.get(field), depth + 1)) {
return true;
}
}
return false;
}
private boolean placed(JsonElement holder, int depth) {
if (holder == null || holder.isJsonNull() || depth > MAX_DEPTH) {
return false;
}
if (holder.isJsonArray()) {
for (JsonElement entry : holder.getAsJsonArray()) {
if (placed(entry, depth)) {
return true;
}
}
return false;
}
if (holder.isJsonPrimitive()) {
String key = holder.getAsString();
if (key.startsWith(TAG_PREFIX)) {
return false;
}
Optional<CatalogPlacedFeature> feature = index.placedFeature(VanillaJson.namespaced(key));
return feature.isPresent() && configured(feature.get().featureType(), feature.get().config(), depth);
}
JsonObject object = holder.getAsJsonObject();
if (object.has("chance")) {
return placed(object.get("feature"), depth);
}
return configuredHolder(object.get("feature"), depth);
}
private boolean configuredHolder(JsonElement holder, int depth) {
if (holder == null || holder.isJsonNull()) {
return false;
}
if (holder.isJsonPrimitive()) {
CatalogPlacedFeature feature = byConfigured.get(VanillaJson.namespaced(holder.getAsString()));
return feature != null && configured(feature.featureType(), feature.config(), depth);
}
if (!holder.isJsonObject()) {
return false;
}
JsonObject object = holder.getAsJsonObject();
JsonElement type = object.get("type");
return configured(type != null && type.isJsonPrimitive() ? type.getAsString() : null, object.get("config"), depth);
}
}
@@ -0,0 +1,91 @@
/*
* Iris is a World Generator for Minecraft Servers
* Copyright (c) 2026 Arcane Arts (Volmit Software)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package art.arcane.iris.structure.nativegen;
import art.arcane.iris.pack.schema.annotation.ArrayType;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.documentation.Description;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@Description("Denies native placed features in specific registry biomes only. Iris custom biomes match through the vanilla derivative that supplies their features, so a rule for 'minecraft:forest' also covers Iris biomes derived from it.")
@Data
public class IrisImportedFeatureBiomeRule {
private static final String TAG_PREFIX = "#";
@ArrayType(type = String.class, min = 1)
@Description("Registry biomes this rule applies to: an exact key such as 'minecraft:forest', a namespace such as 'minecraft:', or a biome tag such as '#minecraft:is_forest'.")
private KList<String> biomes = new KList<>();
@ArrayType(type = String.class, min = 1)
@Description("Placed feature families denied in the matched biomes, matched like importedFeatures.disabled on namespace, slash, or underscore boundaries.")
private KList<String> disabled = new KList<>();
@ArrayType(type = String.class, min = 1)
@Description("Exact placed feature keys denied in the matched biomes after trimming and case normalization.")
private KList<String> disabledExact = new KList<>();
public boolean appliesTo(String biomeKey, Set<String> biomeTags) {
String normalizedBiome = normalize(biomeKey);
for (String entry : Objects.requireNonNull(biomes, "importedFeatures.biomeRules.biomes must not be null")) {
String normalizedEntry = normalize(entry);
if (normalizedEntry.isEmpty()) {
continue;
}
if (normalizedEntry.startsWith(TAG_PREFIX)) {
if (biomeTags.contains(normalizedEntry.substring(TAG_PREFIX.length()))) {
return true;
}
continue;
}
if (normalizedEntry.endsWith(":") ? normalizedBiome.startsWith(normalizedEntry) : normalizedBiome.equals(normalizedEntry)) {
return true;
}
}
return false;
}
public boolean denies(String featureKey) {
for (String entry : Objects.requireNonNull(disabled, "importedFeatures.biomeRules.disabled must not be null")) {
if (IrisImportedStructureControl.matchesKey(entry, featureKey)) {
return true;
}
}
String normalizedFeature = normalize(featureKey);
for (String entry : Objects.requireNonNull(disabledExact, "importedFeatures.biomeRules.disabledExact must not be null")) {
if (!normalize(entry).isEmpty() && normalize(entry).equals(normalizedFeature)) {
return true;
}
}
return false;
}
private static String normalize(String value) {
return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
}
}
@@ -31,6 +31,7 @@ import lombok.experimental.Accessors;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@Accessors(chain = true)
@NoArgsConstructor
@@ -49,6 +50,10 @@ public class IrisImportedFeatureControl implements NativeImportedFeatureControl
@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<String> disabledExact = new KList<>();
@ArrayType(type = IrisImportedFeatureBiomeRule.class, min = 1)
@Description("Per-biome denials applied on top of 'disabled' and 'disabledExact'. Each rule names registry biomes and the placed features it denies in those biomes only, so a mod tree injected into vanilla forests can be denied there while the mod's own biomes keep it.")
private KList<IrisImportedFeatureBiomeRule> biomeRules = 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<IrisDecorationStep> steps = new KList<>();
@@ -124,6 +129,22 @@ public class IrisImportedFeatureControl implements NativeImportedFeatureControl
return shouldGenerateStep(IrisDecorationStep.byOrdinal(step));
}
@Override
public boolean hasBiomeFeatureRules() {
return biomeRules != null && !biomeRules.isEmpty();
}
@Override
public boolean shouldGenerateInBiome(String biomeKey, Set<String> biomeTags, String featureKey) {
for (IrisImportedFeatureBiomeRule rule : Objects.requireNonNull(
biomeRules, "importedFeatures.biomeRules must not be null")) {
if (rule != null && rule.appliesTo(biomeKey, biomeTags) && rule.denies(featureKey)) {
return false;
}
}
return true;
}
@Override
public boolean hasFeatureFilter() {
return (disabled != null && !disabled.isEmpty()) || (disabledExact != null && !disabledExact.isEmpty());