From ad66538d7ad81c1ac48fb44183d68dfce8e0f7d6 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Wed, 14 Oct 2026 22:44:16 -0400 Subject: [PATCH] Kept mod trees on plantable ground - A mod biome that brings its own vegetation pays a template penalty scaled by the share of the template's top surface layer that cannot hold plants, so snow, ice, sand, and stone templates no longer swallow its trees - The top layer palette is resolved through biome-palette snippets and weighted by palette entry weight - Mod biomes without their own vegetation keep the closest climate template --- .../iris/pack/ingest/IngestPackModel.java | 41 ++++++ .../iris/pack/ingest/ModBiomeAdopter.java | 9 +- .../ingest/IngestPlantableSurfaceTest.java | 124 ++++++++++++++++++ 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 core/src/test/java/art/arcane/iris/pack/ingest/IngestPlantableSurfaceTest.java diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/IngestPackModel.java b/core/src/main/java/art/arcane/iris/pack/ingest/IngestPackModel.java index ab47fc9c2..ea22ae19d 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/IngestPackModel.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/IngestPackModel.java @@ -29,6 +29,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; import java.util.EnumMap; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -40,6 +41,9 @@ final class IngestPackModel { private static final int DEFAULT_MIN_Y = -64; private static final int DEFAULT_MAX_Y = 320; private static final String DEFAULT_DERIVATIVE = "minecraft:the_void"; + private static final Set PLANTABLE_BLOCKS = Set.of("minecraft:grass_block", "minecraft:dirt", "minecraft:coarse_dirt", + "minecraft:podzol", "minecraft:rooted_dirt", "minecraft:mycelium", "minecraft:moss_block", "minecraft:pale_moss_block", + "minecraft:mud", "minecraft:muddy_mangrove_roots", "minecraft:farmland"); private final IngestPack pack; private final WorldgenCatalogIndex index; @@ -50,6 +54,7 @@ final class IngestPackModel { private final int packMaxY; private final List regions; private final Map reachableBiomes; + private final Map plantableShares = new HashMap<>(); private IngestPackModel(IngestPack pack, WorldgenCatalogIndex index, String dimensionPath, JsonObject dimension, int[] heightRange, List regions, Map reachableBiomes) { @@ -115,6 +120,42 @@ final class IngestPackModel { return climateReference(index, reachableBiomes.get(biomeKey)).map(IngestClimate::of).orElse(null); } + double plantableShare(String biomeKey) throws IOException { + Double cached = plantableShares.get(biomeKey); + if (cached != null) { + return cached; + } + double share = topLayerPlantableShare(biomeKey); + plantableShares.put(biomeKey, share); + return share; + } + + private double topLayerPlantableShare(String biomeKey) throws IOException { + Optional biome = pack.find(IngestPack.biomePath(biomeKey)); + JsonElement layers = biome.map((JsonObject value) -> value.get("layers")).orElse(null); + if (layers == null || !layers.isJsonArray() || layers.getAsJsonArray().isEmpty()) { + return 1D; + } + JsonObject top = pack.snippet(layers.getAsJsonArray().get(0), "biome-palette"); + JsonElement palette = top == null ? null : top.get("palette"); + if (palette == null || !palette.isJsonArray() || palette.getAsJsonArray().isEmpty()) { + return 1D; + } + double total = 0D; + double plantable = 0D; + for (JsonElement entry : palette.getAsJsonArray()) { + if (!entry.isJsonObject() || !entry.getAsJsonObject().has("block")) { + continue; + } + double weight = Math.max(0D, number(entry.getAsJsonObject().get("weight"), 1D)); + total += weight; + if (PLANTABLE_BLOCKS.contains(VanillaJson.namespaced(entry.getAsJsonObject().get("block").getAsString()))) { + plantable += weight; + } + } + return total <= 0D ? 1D : plantable / total; + } + Set biomeCategories(String biomeKey) { return climateReference(index, reachableBiomes.get(biomeKey)).map((CatalogBiome biome) -> BiomeCategories.of(biome.tags())).orElse(Set.of()); } diff --git a/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeAdopter.java b/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeAdopter.java index 8edf1a4c2..00dfdd468 100644 --- a/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeAdopter.java +++ b/core/src/main/java/art/arcane/iris/pack/ingest/ModBiomeAdopter.java @@ -44,6 +44,7 @@ final class ModBiomeAdopter { private static final double REGION_CLIMATE_WEIGHT = 0.25D; private static final double REUSE_WEIGHT = 0.05D; private static final double THEMED_REGION_PENALTY = 0.6D; + private static final double BARE_SURFACE_PENALTY = 3D; private static final int THEMED_RARITY_FACTOR = 5; private final IngestPackModel model; @@ -71,7 +72,8 @@ final class ModBiomeAdopter { continue; } BiomeRole role = classification.role(); - Placement placement = place(role, IngestClimate.of(biome), BiomeCategories.of(biome.tags())); + boolean ownsVegetation = ownsVegetation(biome, namespace); + Placement placement = place(role, IngestClimate.of(biome), BiomeCategories.of(biome.tags()), ownsVegetation); if (placement == null) { items.add(new ModIngestItem(namespace, biome.key(), "biome", ModIngestTier.SKIPPED, "", "no region in this dimension accepts a " + role.label() + " biome")); @@ -82,7 +84,7 @@ final class ModBiomeAdopter { templateUses.merge(template, 1, Integer::sum); String irisKey = irisKey(biome.key()); JsonObject json = model.pack().require(IngestPack.biomePath(template)).deepCopy(); - transform(json, biome, ownsVegetation(biome, namespace)); + transform(json, biome, ownsVegetation); model.pack().create(IngestPack.biomePath(irisKey), json); appendToPool(region.key(), role, irisKey); adopted.put(biome.key(), new AdoptedBiome(biome.key(), irisKey, role, region.key())); @@ -98,7 +100,7 @@ final class ModBiomeAdopter { return INGEST_BIOME_FOLDER + namespace + "/" + modBiomeKey.substring(colon + 1); } - private Placement place(BiomeRole role, IngestClimate climate, Set categories) { + private Placement place(BiomeRole role, IngestClimate climate, Set categories, boolean ownsVegetation) throws IOException { Placement best = null; double bestScore = Double.POSITIVE_INFINITY; for (IngestPackModel.RegionProfile region : model.regions()) { @@ -112,6 +114,7 @@ final class ModBiomeAdopter { double score = climate.distance(templateClimate == null ? region.climate() : templateClimate) + CATEGORY_WEIGHT * BiomeCategories.distance(categories, model.biomeCategories(template)) + REUSE_WEIGHT * templateUses.getOrDefault(template, 0) + + (ownsVegetation ? BARE_SURFACE_PENALTY * (1D - model.plantableShare(template)) : 0D) + regionPenalty; if (best == null || score < bestScore) { best = new Placement(region, template); diff --git a/core/src/test/java/art/arcane/iris/pack/ingest/IngestPlantableSurfaceTest.java b/core/src/test/java/art/arcane/iris/pack/ingest/IngestPlantableSurfaceTest.java new file mode 100644 index 000000000..71edd4ff2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/pack/ingest/IngestPlantableSurfaceTest.java @@ -0,0 +1,124 @@ +/* + * 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.pack.ingest.catalog.CatalogBiome; +import art.arcane.iris.pack.ingest.catalog.CatalogDimensionType; +import art.arcane.iris.pack.ingest.catalog.CatalogNamespace; +import art.arcane.iris.pack.ingest.catalog.CatalogPlacedFeature; +import art.arcane.iris.pack.ingest.catalog.WorldgenCatalog; +import com.google.gson.JsonParser; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertTrue; + +public class IngestPlantableSurfaceTest { + private static final String MOD = "modtrees"; + private static final int VEGETAL_STEP = ModIngestFixture.VEGETAL_STEP; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + @Test + public void modBiomesWithTheirOwnTreesAvoidSnowCoveredTemplates() throws Exception { + ModIngestReport report = ingest(); + + assertTrue(reason(report, "snowy_maple_woods"), reason(report, "snowy_maple_woods").contains("in region tundra from template tundra/")); + assertTrue(reason(report, "scarlet_peaks"), reason(report, "scarlet_peaks").contains("in region tundra from template tundra/")); + } + + @Test + public void modBiomesWithoutVegetationKeepTheBestClimateTemplate() throws Exception { + ModIngestReport report = ingest(); + + assertTrue(reason(report, "frost_flats"), reason(report, "frost_flats").contains("from template frozen/plains")); + } + + private ModIngestReport ingest() throws IOException { + Path root = folder.newFolder().toPath(); + Path source = root.resolve("source"); + ModIngestFixture.write(source, "dimensions/overworld.json", """ + {"name": "Overworld", "environment": "NORMAL", "regions": ["frozen", "tundra"], "dimensionHeight": {"min": -64, "max": 320}} + """); + ModIngestFixture.write(source, "regions/frozen.json", """ + {"name": "Frozen", "landBiomes": ["frozen/plains", "frozen/slopes"]} + """); + ModIngestFixture.write(source, "regions/tundra.json", """ + {"name": "Tundra", "landBiomes": ["tundra/taiga", "tundra/mountain-forest"]} + """); + ModIngestFixture.write(source, "snippet/biome-palette/snow/top.json", """ + {"palette": [{"block": "minecraft:snow_block"}], "minHeight": 1, "maxHeight": 1} + """); + biome(source, "frozen/plains", "minecraft:snowy_plains", "\"snippet/biome-palette/snow/top\""); + biome(source, "frozen/slopes", "minecraft:snowy_slopes", "\"snippet/biome-palette/snow/top\""); + biome(source, "tundra/taiga", "minecraft:taiga", "{\"palette\": [{\"block\": \"grass_block\"}]}"); + biome(source, "tundra/mountain-forest", "minecraft:windswept_forest", "{\"palette\": [{\"block\": \"minecraft:grass_block\", \"weight\": 3}, {\"block\": \"minecraft:snow_block\"}]}"); + Path target = root.resolve("packs").resolve("trees"); + return ModIngester.ingest(new ModIngestRequest(source, target, "overworld", catalog(), Set.of(MOD), Set.of(), false)); + } + + private static void biome(Path pack, String key, String derivative, String topLayer) throws IOException { + ModIngestFixture.write(pack, "biomes/" + key + ".json", "{\"name\": \"" + key + "\", \"derivative\": \"" + derivative + + "\", \"layers\": [" + topLayer + ", {\"palette\": [{\"block\": \"minecraft:dirt\"}]}]}"); + } + + private static WorldgenCatalog catalog() { + List biomes = new ArrayList<>(); + biomes.add(biome("minecraft:snowy_plains", 0.0F, 0.5F, List.of("c:is_snowy", "c:is_plains"), List.of())); + biomes.add(biome("minecraft:snowy_slopes", -0.3F, 0.9F, List.of("c:is_snowy", "minecraft:is_mountain"), List.of())); + biomes.add(biome("minecraft:taiga", 0.25F, 0.8F, List.of("minecraft:is_taiga"), List.of())); + biomes.add(biome("minecraft:windswept_forest", 0.2F, 0.3F, List.of("minecraft:is_mountain", "minecraft:is_forest"), List.of())); + biomes.add(biome(MOD + ":snowy_maple_woods", 0.0F, 0.5F, List.of("c:is_snowy"), List.of(MOD + ":maple_trees"))); + biomes.add(biome(MOD + ":scarlet_peaks", -0.3F, 0.9F, List.of("c:is_snowy", "minecraft:is_mountain"), List.of(MOD + ":scarlet_trees"))); + biomes.add(biome(MOD + ":frost_flats", 0.0F, 0.5F, List.of("c:is_snowy", "c:is_plains"), List.of())); + List features = List.of(tree("maple_trees"), tree("scarlet_trees")); + return new WorldgenCatalog(WorldgenCatalog.FORMAT, "26.3", "fabric", 1L, "plantable", + List.of(new CatalogNamespace(MOD, "Mod Trees", "1")), biomes, features, List.of(), + List.of(new CatalogDimensionType("minecraft:overworld", -64, 384, 384, null)), List.of(), Map.of()); + } + + private static CatalogPlacedFeature tree(String path) { + String key = MOD + ":" + path; + return new CatalogPlacedFeature(key, key, "minecraft:tree", JsonParser.parseString("{}"), + JsonParser.parseString("[{\"type\": \"minecraft:count\", \"count\": 4}]")); + } + + private static CatalogBiome biome(String key, float temperature, float downfall, List tags, List vegetal) { + List> steps = new ArrayList<>(11); + for (int index = 0; index < 11; index++) { + steps.add(index == VEGETAL_STEP ? vegetal : List.of()); + } + List allTags = new ArrayList<>(tags); + allTags.add("minecraft:is_overworld"); + return new CatalogBiome(key, temperature, downfall, true, allTags, steps, List.of(), null); + } + + private static String reason(ModIngestReport report, String biome) { + return ModIngestFixture.item(report, MOD + ":" + biome).reason(); + } +}