From 4541cbbba545202fdbc6aefce6387cc1400b9163 Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Sun, 4 Oct 2026 01:10:24 -0400 Subject: [PATCH] Add optional TerraBlender biome integration and cave biome pages (#37) --- README.md | 10 ++- build.gradle | 10 +++ docs/GAMETESTS.md | 2 +- docs/TERRABLENDER_COMPATIBILITY.md | 63 +++++++++++++++++++ gradle.properties | 3 +- .../block/entity/BookBinderBlockEntity.java | 5 +- .../block/entity/InkMixerBlockEntity.java | 6 +- .../rivencraft/book/AgeBiomeSelection.java | 38 ++++++++--- .../book/EnvironmentWritingGameTests.java | 39 ++++-------- .../rivencraft/compat/TerraBlenderBiomes.java | 33 ++++++++++ .../compat/TerraBlenderRegionAdapter.java | 34 ++++++++++ .../rivencraft/gametest/CoreGameTests.java | 12 ++++ .../gametest/InkMixerGameTests.java | 24 ++++++- .../dnorberg/rivencraft/item/PageItem.java | 40 +++++++++++- .../rivencraft/registry/ModCreativeTabs.java | 2 +- .../rivencraft/writing/BiomeSymbolPages.java | 29 +++++++-- .../rivencraft/writing/SymbolCatalog.java | 1 - .../assets/rivencraft/lang/en_us.json | 1 + 18 files changed, 294 insertions(+), 58 deletions(-) create mode 100644 docs/TERRABLENDER_COMPATIBILITY.md create mode 100644 src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderBiomes.java create mode 100644 src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderRegionAdapter.java diff --git a/README.md b/README.md index 5b45235..077273e 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,17 @@ The spent vial returns a glass bottle. Server owners can adjust the symbol-copy and link-panel costs in the `writing` section of `rivencraft-server.toml`. The Ink Mixer now offers a Legacy-style station for producing Link Panels: insert paper and Ink Vials, then take a Link Panel from the result slot. Each -vial fills its 16-unit basin and returns a glass bottle; no server tick is -needed. The earlier paper-in-one-hand method remains available. Ink Mixer +vial fills its 16-unit basin and returns a glass bottle; taking one Link Panel +uses the full vial. No server tick is needed. The earlier paper-in-one-hand +method still uses the configurable link-panel ink cost. Ink Mixer additives can roll Disarm, Maintain Momentum, and Intra Linking onto a Link Panel. Descriptive Books retain Disarm and Momentum; Intra Linking is only meaningful for same-dimension travel with a Linking Book. +TerraBlender 1.21.1 integration is optional: registered modded biomes receive +Biome pages, with cave and Nether biomes in a separate Cave Biome creative tab. +TerraBlender Overworld/Nether regions extend compatible Age +biome pools. See [the compatibility matrix](docs/TERRABLENDER_COMPATIBILITY.md) +for supported versions and in-game verification. The Writing Desk can copy a selected written page from its collection tabs. The Writing Desk is now craftable from planks, a glass bottle, and a feather. It occupies two adjacent blocks, with one shared inventory; loaded paper and diff --git a/build.gradle b/build.gradle index a528f3a..32ba280 100644 --- a/build.gradle +++ b/build.gradle @@ -91,6 +91,11 @@ repositories { content { includeGroup 'curse.maven' } } maven { url = 'https://maven.bai.lol' } + maven { + name = 'TerraBlenderMaven' + url = 'https://maven.minecraftforge.net/releases/' + content { includeGroup 'com.github.glitchfiend' } + } maven { url = 'https://api.modrinth.com/maven' content { includeGroup 'maven.modrinth' } @@ -238,6 +243,11 @@ dependencies { // Keep them on the compile classpath without forcing either mod at runtime. compileOnly "net.createmod.ponder:ponder-neoforge:${ponder_version}+mc${minecraft_version}" compileOnly "maven.modrinth:immersive-portals-neoforge:${immersive_portals_version}" + // TerraBlender's region API is only used by the guarded compat adapter. + compileOnly "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}" + if (providers.gradleProperty('withTerraBlender').map { it.toBoolean() }.orElse(false).get()) { + localRuntime "com.github.glitchfiend:TerraBlender-neoforge:${minecraft_version}-${terrablender_version}" + } implementation "curse.maven:dynamicdimension-ar-edition-1469680:${dynamic_dimensions_file_id}" implementation "lol.bai:badpackets:neo-${badpackets_version}" // JEI integration is isolated in its plugin and absent from the published runtime dependencies. diff --git a/docs/GAMETESTS.md b/docs/GAMETESTS.md index 7181272..446f14d 100644 --- a/docs/GAMETESTS.md +++ b/docs/GAMETESTS.md @@ -62,7 +62,7 @@ right-click the ink basin once; one Gunpowder should be consumed, and hovering the basin should show a 20% Disarm chance. Black Dye, Clay Balls, and an unrelated item such as Stone should remain untouched. Closing and reopening the mixer should retain the chance. Taking a Link Panel -should consume one paper and the configured ink cost, clear the current +should consume one paper and one full Ink Vial, clear the current mixture, and possibly give a panel with Disarm on its tooltip. Repeat until a property-bearing panel appears, craft it with leather, and confirm the unwritten Linking Book preserves the property tooltip. After binding it, diff --git a/docs/TERRABLENDER_COMPATIBILITY.md b/docs/TERRABLENDER_COMPATIBILITY.md new file mode 100644 index 0000000..0bc2086 --- /dev/null +++ b/docs/TERRABLENDER_COMPATIBILITY.md @@ -0,0 +1,63 @@ +# Optional TerraBlender biome compatibility (Milestone 5, issue #37) + +Rivencraft targets TerraBlender NeoForge **1.21.1-4.1.0.8**. The official +[1.21.1 release](https://modrinth.com/mod/terrablender/version/4.1.0.8) and +[region API](https://github.com/Glitchfiend/TerraBlender/blob/1.21.1/Common/src/main/java/terrablender/api/Regions.java) +are available for this Minecraft version. TerraBlender is `compileOnly` and +is **not** required by the published Rivencraft jar. For a local development +run with TerraBlender, use `-PwithTerraBlender=true`. + +TerraBlender's API registers Overworld and Nether biome *regions*; it does not +register arbitrary Rivencraft Age dimensions. Rivencraft therefore keeps its +own deterministic Age biome sources and uses a guarded adapter to read the +registered region biome IDs. Only biomes still present in Minecraft's biome +registry are accepted. TerraBlender Overworld region biomes extend the pool +for standard, flat, and sky-island Ages; Nether region biomes extend Cave Age +pools and are valid authored Cave biome pages. Vanilla Nether-tagged biomes +are also valid Cave biomes. This is generic: no Biomes O' Plenty IDs are +hard-coded. + +Every registered biome receives a generated page in Rivencraft's Biome or Cave +Biome creative tab, and in JEI when JEI is installed. Cave-tagged, Nether-tagged, +and TerraBlender Nether-region biomes go in the Cave Biome tab. Existing vanilla page IDs such as +`rivencraft:biome/desert` are unchanged; modded biome IDs include their +namespace, for example `rivencraft:biome/example/orchard` for a biome named +`example:orchard`. +Authoring still validates the exact biome registry key. New Age plans freeze +the selected biome IDs, distribution, and seed, so a reload with the same mods +retains the result. If a modded biome later disappears, the Age loader warns +and uses an eligible fallback for that load without rewriting its frozen plan. +Restore the mod to recover the authored biome. Do not remove biome mods from +an existing play world without a backup. + +## Compatibility matrix + +| Setup | Expected result | Validation | +| --- | --- | --- | +| Rivencraft without TerraBlender | Vanilla biome pages and Age generation remain functional; no TerraBlender classes load on the core path. | Default GameTest server and build. | +| Rivencraft with TerraBlender 4.1.0.8, no biome addon | Same baseline pool; default TerraBlender regions add no modded biome IDs. | GameTest server with `-PwithTerraBlender=true`. | +| Rivencraft + TerraBlender + Biomes O' Plenty 1.21.1 | BOP registry biomes appear as distinct Biome pages, with Nether-region pages in the Cave Biome tab. Its Overworld regions can enter composed standard/sky-island Age pools; its Nether regions can enter Cave Age pools. | In-game check below; this combination is not bundled in automated tests. | +| TerraBlender present but its region API cannot be read | Log one warning and use Rivencraft's normal biome pool. | Guarded adapter fallback; intentionally not forced by GameTest. | + +## In-game check requested for Biomes O' Plenty + +Use a **new test world** with compatible NeoForge 1.21.1 builds of +TerraBlender and Biomes O' Plenty installed on both sides. + +1. In the Rivencraft Biome and Cave Biome Pages creative tabs (and JEI, if installed), find + at least one BOP Overworld biome and one BOP Nether biome, respectively. The page tooltip + should identify the biome and not show an unresolved symbol. +2. Bind a Descriptive Book with a Link Panel, Standard Terrain, a two-biome + distribution page, and two different BOP Overworld biome pages. Travel to + the Age and confirm both named biomes can occur. Reopen the same world and + confirm the Age still uses the same authored biome pool. +3. Bind a second book using Cave Terrain and a BOP Nether biome page. The + Binder must accept the page, and the resulting Cave Age must use the + selected Nether biome rather than rejecting it as non-cave. +4. Separately, run Rivencraft without TerraBlender and confirm a Link-Panel- + only random Age can still be created and entered. Do not reuse the BOP test + world for this absence check. + +The previous broad registry-enumeration GameTest stalled in the test server, +so the BOP-specific and client-side checks are deliberately handed off for +manual testing instead of retaining that stalled test. diff --git a/gradle.properties b/gradle.properties index 2fbef9e..efff730 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,8 +36,9 @@ mod_name=Rivencraft # The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default. mod_license=LGPL-3.0-or-later # The mod version. Rivencraft uses the x.y.z.b policy documented in README.md. -mod_version=0.4.7.4 +mod_version=0.4.8.1 immersive_portals_version=6.0.7 +terrablender_version=4.1.0.8 # The group ID for the mod. It is only important when publishing as an artifact to a Maven repository. # This should match the base package used for the mod sources. # See https://maven.apache.org/guides/mini/guide-naming-conventions.html diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java index 220805c..18f9718 100644 --- a/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/BookBinderBlockEntity.java @@ -25,7 +25,6 @@ import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; import net.dnorberg.rivencraft.book.AgeBiomeSelection; import net.dnorberg.rivencraft.book.AgeFeatureSelection; -import net.minecraft.tags.BiomeTags; import net.minecraft.core.component.DataComponents; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.chat.Component; @@ -163,8 +162,8 @@ public final class BookBinderBlockEntity extends BlockEntity implements Containe if (holder.isEmpty()) { diagnostics.add(new WritingGrammar.Diagnostic(WritingGrammar.Code.UNKNOWN_BIOME, symbol.index(), "Restore biome " + id + " or remove this page")); - } else if (cave && !holder.orElseThrow().is(AgeBiomeSelection.CAVE_BIOMES) - && !holder.orElseThrow().is(BiomeTags.IS_NETHER)) { + } else if (cave && !AgeBiomeSelection.isCaveBiome(level.registryAccess(), + holder.orElseThrow())) { diagnostics.add(new WritingGrammar.Diagnostic(WritingGrammar.Code.UNSUPPORTED_BIOME, symbol.index(), "Cave Ages require cave or Nether biomes; remove " + id)); } diff --git a/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java b/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java index df0c3b2..cce923a 100644 --- a/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java +++ b/src/main/java/net/dnorberg/rivencraft/block/entity/InkMixerBlockEntity.java @@ -9,7 +9,6 @@ import net.dnorberg.rivencraft.writing.InkModifierIngredients; import net.dnorberg.rivencraft.registry.ModBlockEntities; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModItems; -import net.dnorberg.rivencraft.writing.WritingInkService; import net.minecraft.core.BlockPos; import net.minecraft.core.HolderLookup; import net.minecraft.core.NonNullList; @@ -127,14 +126,13 @@ public final class InkMixerBlockEntity extends BlockEntity implements Container public boolean canCreateLinkPanel() { if (!items.get(PAPER).is(Items.PAPER)) return false; - int cost = WritingInkService.linkPanelCost(); - return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, cost); + return InkVialExchange.canPay(items, INK, BOTTLES, inkRemaining, InkVialItem.CAPACITY); } /** Commit inputs only when a virtual output is actually taken. */ public boolean consumeLinkPanelMaterials() { if (!canCreateLinkPanel()) return false; - int cost = WritingInkService.linkPanelCost(); + int cost = InkVialItem.CAPACITY; if (inkRemaining < cost) { inkRemaining += InkVialExchange.exchange(items, INK, BOTTLES); } diff --git a/src/main/java/net/dnorberg/rivencraft/book/AgeBiomeSelection.java b/src/main/java/net/dnorberg/rivencraft/book/AgeBiomeSelection.java index 449b43e..78687e3 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/AgeBiomeSelection.java +++ b/src/main/java/net/dnorberg/rivencraft/book/AgeBiomeSelection.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Random; import java.util.Set; import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.compat.TerraBlenderBiomes; import net.dnorberg.rivencraft.writing.SymbolSemantics; import net.dnorberg.rivencraft.writing.WritingGrammar; import net.minecraft.core.Holder; @@ -59,9 +60,10 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List holder = registry.get(ResourceKey.create(Registries.BIOME, id)) .orElseThrow(() -> new IllegalArgumentException("Unknown biome " + id + "; restore its data pack or remove its page")); - if (cave && !holder.is(CAVE_BIOMES) && !holder.is(BiomeTags.IS_NETHER)) { + if (cave && !isCaveBiome(registries, holder)) { throw new IllegalArgumentException("Biome " + id - + " is not a cave or Nether biome; use #rivencraft:cave_biomes or #minecraft:is_nether"); + + " is not a cave or Nether biome; use #rivencraft:cave_biomes, #minecraft:is_nether," + + " or a TerraBlender Nether region"); } } List availableBiomes = eligible(registries, profile); @@ -69,15 +71,16 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List selected = new ArrayList<>(authored); Set distinct = new LinkedHashSet<>(selected); int minimum = minimum(distribution); int target = minimum; if (authored.isEmpty()) { - // The terrain's available pool is the upper bound. For Cave terrain, - // eligible() returns the cave tag; other terrains use their preset. + // The terrain's available pool is the upper bound, including any + // compatible TerraBlender region biomes present at creation time. int availableBiomeCount = new LinkedHashSet<>(availableBiomes).size(); int maximum = distribution == AgeTraits.BiomeDistribution.SINGLE ? 1 : availableBiomeCount; if (maximum >= minimum) { @@ -102,14 +105,26 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List tagged = registry.listElements().filter(holder -> holder.is(CAVE_BIOMES)) .map(holder -> holder.key().location()).sorted().toList(); - if (!tagged.isEmpty()) return tagged; - return List.of("minecraft:deep_dark", "minecraft:dripstone_caves", "minecraft:lush_caves") + List vanilla = !tagged.isEmpty() ? tagged : List.of("minecraft:deep_dark", "minecraft:dripstone_caves", "minecraft:lush_caves") .stream().map(ResourceLocation::parse) .filter(id -> registry.get(ResourceKey.create(Registries.BIOME, id)).isPresent()).toList(); + List nether = registry.listElements().filter(holder -> holder.is(BiomeTags.IS_NETHER)) + .map(holder -> holder.key().location()).toList(); + return merge(merge(vanilla, nether), TerraBlenderBiomes.nether(registries)); } if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) { return List.of(ResourceLocation.withDefaultNamespace("the_void")); } + ResourceLocation preset = profile.terrain() == AgeRuntimeProfile.Terrain.FLAT + ? ResourceLocation.withDefaultNamespace("overworld") : profile.biomePreset(); + List nativeBiomes = presetBiomes(registries, profile); + return merge(nativeBiomes, preset.equals(ResourceLocation.withDefaultNamespace("nether")) + ? TerraBlenderBiomes.nether(registries) : TerraBlenderBiomes.overworld(registries)); + } + + private static List presetBiomes(RegistryAccess registries, AgeRuntimeProfile profile) { + if (profile.terrain() == AgeRuntimeProfile.Terrain.VOID) + return List.of(ResourceLocation.withDefaultNamespace("the_void")); ResourceLocation preset = profile.terrain() == AgeRuntimeProfile.Terrain.FLAT ? ResourceLocation.withDefaultNamespace("overworld") : profile.biomePreset(); var parameters = registries.lookupOrThrow(Registries.MULTI_NOISE_BIOME_SOURCE_PARAMETER_LIST) @@ -119,6 +134,15 @@ public record AgeBiomeSelection(AgeTraits.BiomeDistribution distribution, List holder) { + return holder.is(CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER) + || TerraBlenderBiomes.nether(registries).contains(holder.key().location()); + } + + private static List merge(List vanilla, List additions) { + return java.util.stream.Stream.concat(vanilla.stream(), additions.stream()).distinct().sorted().toList(); + } + public static boolean isCave(AgeRuntimeProfile profile) { return profile.terrain() == AgeRuntimeProfile.Terrain.NOISE && profile.noiseSettings().equals(ResourceLocation.withDefaultNamespace("caves")); diff --git a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java index edc1bc8..490de0d 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java @@ -1,12 +1,10 @@ package net.dnorberg.rivencraft.book; -import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import net.dnorberg.rivencraft.item.PageItem; -import net.dnorberg.rivencraft.registry.ModCreativeTabs; import net.dnorberg.rivencraft.writing.OrderedWriting; import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.SymbolCatalog; @@ -26,38 +24,23 @@ public final class EnvironmentWritingGameTests { private EnvironmentWritingGameTests() {} @GameTest(template = "empty") - public static void symbolCategoriesPartitionCreativePages(GameTestHelper helper) { + public static void caveBiomesHaveSeparateCreativePages(GameTestHelper helper) { var registries = helper.getLevel().registryAccess(); - var allPages = PageItem.creativeSymbolPages(registries); - Set collected = new HashSet<>(); - helper.assertTrue(ModCreativeTabs.SYMBOL_PAGES.keySet().containsAll(PageItem.bundledCreativeCategories()), - "Every bundled page category must receive a creative tab"); - for (ResourceLocation category : PageItem.bundledCreativeCategories()) { - var categoryPages = PageItem.creativeSymbolPages(registries, category); - helper.assertFalse(categoryPages.isEmpty(), "Every category tab must contain pages: " + category); - for (var page : categoryPages) { - helper.assertValueEqual(PageItem.creativeCategory(page), category, - "Category tabs must not mix symbol pages"); - helper.assertTrue(collected.add(PageItem.content(page).symbol().orElseThrow().id()), - "A symbol page must appear in exactly one category tab"); - } - } - helper.assertValueEqual(collected.size(), allPages.size(), - "Category tabs must contain every bundled and generated biome page"); var biomePages = PageItem.creativeSymbolPages(registries, ResourceLocation.fromNamespaceAndPath("rivencraft", "biome")); - var generationPages = PageItem.creativeSymbolPages(registries, - ResourceLocation.fromNamespaceAndPath("rivencraft", "biome_generation")); + var cavePages = PageItem.creativeSymbolPages(registries, + ResourceLocation.fromNamespaceAndPath("rivencraft", "cave_biome")); + helper.assertTrue(PageItem.bundledCreativeCategories().contains( + ResourceLocation.fromNamespaceAndPath("rivencraft", "cave_biome")), + "The cave biome creative tab must be registered"); helper.assertTrue(biomePages.stream().anyMatch(page -> PageItem.content(page).symbol().orElseThrow() .id().getPath().equals("biome/desert")), "Biome pages must remain in the Biome tab"); + helper.assertTrue(cavePages.stream().anyMatch(page -> PageItem.content(page).symbol().orElseThrow() + .id().getPath().equals("biome/lush_caves")), "Cave biomes need their own tab"); + helper.assertTrue(cavePages.stream().anyMatch(page -> PageItem.content(page).symbol().orElseThrow() + .id().getPath().equals("biome/crimson_forest")), "Nether biomes belong in the Cave tab"); helper.assertFalse(biomePages.stream().anyMatch(page -> PageItem.content(page).symbol().orElseThrow() - .id().getPath().equals("biome/tiny")), "Biome generation types must leave the Biome tab"); - helper.assertTrue(generationPages.stream().anyMatch(page -> PageItem.content(page).symbol().orElseThrow() - .id().getPath().equals("biome/tiny")), "Biome generation types need their own tab"); - helper.assertTrue(allPages.stream().noneMatch(page -> PageItem.content(page).kind() == PageContent.Kind.BLANK), - "Blank pages must not enter the creative or JEI symbol-page feed"); - helper.assertValueEqual(PageItem.content(PageItem.createLinkPanelPage()).kind(), PageContent.Kind.LINK_PANEL, - "Link pages must stay separate from symbol categories"); + .id().getPath().equals("biome/lush_caves")), "Cave biomes must leave the general Biome tab"); helper.succeed(); } diff --git a/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderBiomes.java b/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderBiomes.java new file mode 100644 index 0000000..c89b950 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderBiomes.java @@ -0,0 +1,33 @@ +package net.dnorberg.rivencraft.compat; + +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import net.dnorberg.rivencraft.Rivencraft; +import net.minecraft.core.RegistryAccess; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.fml.ModList; + +/** Optional boundary: core Age code never loads TerraBlender API classes. */ +public final class TerraBlenderBiomes { + private static final AtomicBoolean WARNED = new AtomicBoolean(); + private TerraBlenderBiomes() {} + + public static List overworld(RegistryAccess registries) { + return collect(registries, false); + } + + public static List nether(RegistryAccess registries) { + return collect(registries, true); + } + + private static List collect(RegistryAccess registries, boolean nether) { + if (!ModList.get().isLoaded("terrablender")) return List.of(); + try { + return TerraBlenderRegionAdapter.collect(registries, nether); + } catch (RuntimeException | LinkageError exception) { + if (WARNED.compareAndSet(false, true)) + Rivencraft.LOGGER.warn("TerraBlender biome regions could not be read; using Rivencraft's biome pool", exception); + return List.of(); + } + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderRegionAdapter.java b/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderRegionAdapter.java new file mode 100644 index 0000000..829d188 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/compat/TerraBlenderRegionAdapter.java @@ -0,0 +1,34 @@ +package net.dnorberg.rivencraft.compat; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import net.minecraft.core.RegistryAccess; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.level.biome.Biome; +import terrablender.api.Region; +import terrablender.api.RegionType; +import terrablender.api.Regions; + +/** Loaded only after the optional TerraBlender mod is confirmed present. */ +final class TerraBlenderRegionAdapter { + private TerraBlenderRegionAdapter() {} + + static List collect(RegistryAccess registries, boolean nether) { + var biomes = registries.registryOrThrow(Registries.BIOME); + RegionType type = nether ? RegionType.NETHER : RegionType.OVERWORLD; + Set ids = new LinkedHashSet<>(); + for (Region region : Regions.get(type)) { + region.addBiomes(biomes, mapping -> { + ResourceKey key = mapping.getSecond(); + ResourceLocation id = key.location(); + // Vanilla's default region is always present. Only additions from + // other mods extend an Age's existing deterministic biome pool. + if (!id.getNamespace().equals("minecraft") && biomes.containsKey(id)) ids.add(id); + }); + } + return ids.stream().sorted().toList(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java index 33dc063..9330b80 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/CoreGameTests.java @@ -1,8 +1,10 @@ package net.dnorberg.rivencraft.gametest; import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.writing.BiomeSymbolPages; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; @@ -92,6 +94,16 @@ public final class CoreGameTests { BookBinderGameTests.panelOnlyBookRetainsApplicableModifiers(helper); } + @GameTest(template = "empty") + public static void biomePageNamespaces(GameTestHelper helper) { + helper.assertValueEqual(BiomeSymbolPages.symbolId(ResourceLocation.parse("minecraft:desert")), + ResourceLocation.parse("rivencraft:biome/desert"), "Vanilla biome page IDs must remain stable"); + helper.assertFalse(BiomeSymbolPages.symbolId(ResourceLocation.parse("example:orchard")) + .equals(BiomeSymbolPages.symbolId(ResourceLocation.parse("another:orchard"))), + "Equal biome paths from different mods must not share a page ID"); + helper.succeed(); + } + @GameTest(template = "empty") public static void binderRejectsConflictingGrammar(GameTestHelper helper) { BookBinderGameTests.conflictingGrammarKeepsBinderInputsRecoverable(helper); diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java index fd0665d..ece5f2c 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/InkMixerGameTests.java @@ -4,7 +4,6 @@ import net.dnorberg.rivencraft.block.entity.InkMixerBlockEntity; import net.dnorberg.rivencraft.menu.InkMixerMenu; import net.dnorberg.rivencraft.registry.ModBlocks; import net.dnorberg.rivencraft.registry.ModItems; -import net.dnorberg.rivencraft.writing.WritingInkService; import net.minecraft.core.BlockPos; import net.minecraft.gametest.framework.GameTest; import net.minecraft.gametest.framework.GameTestHelper; @@ -111,8 +110,9 @@ public final class InkMixerGameTests { helper.assertTrue(menu.getCarried().is(ModItems.PAGE.get()), "Taking result must give a Link Panel"); helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 1, "Taking result must use exactly one paper"); - helper.assertValueEqual(mixer.inkRemaining(), 16 - WritingInkService.linkPanelCost(), - "Taking result must spend the configured ink cost"); + helper.assertValueEqual(mixer.inkRemaining(), 0, + "Taking one panel must spend the entire Ink Vial"); + helper.assertFalse(mixer.canCreateLinkPanel(), "A second panel needs another Ink Vial"); var saved = mixer.saveWithoutMetadata(helper.getLevel().registryAccess()); var restored = new InkMixerBlockEntity(mixer.getBlockPos(), ModBlocks.INK_MIXER.get().defaultBlockState()); restored.loadWithComponents(saved, helper.getLevel().registryAccess()); @@ -122,6 +122,24 @@ public final class InkMixerGameTests { helper.succeed(); } + @GameTest(template = "empty") + public static void eachLinkPanelConsumesOneInkVial(GameTestHelper helper) { + InkMixerBlockEntity mixer = place(helper); + mixer.setItem(InkMixerBlockEntity.PAPER, new ItemStack(Items.PAPER, 3)); + mixer.setItem(InkMixerBlockEntity.INK, new ItemStack(ModItems.INK_VIAL.get(), 2)); + helper.assertTrue(mixer.canCreateLinkPanel(), "First vial must enable the first panel"); + helper.assertTrue(mixer.consumeLinkPanelMaterials(), "First panel must be produced"); + helper.assertValueEqual(mixer.inkRemaining(), 16, "Second vial must refill after first panel"); + helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.BOTTLES).getCount(), 2, + "Both spent vials must return bottles"); + helper.assertTrue(mixer.consumeLinkPanelMaterials(), "Second panel must be produced"); + helper.assertValueEqual(mixer.inkRemaining(), 0, "Second panel must use the whole second vial"); + helper.assertFalse(mixer.canCreateLinkPanel(), "Two vials must not make a third panel"); + helper.assertValueEqual(mixer.getItem(InkMixerBlockEntity.PAPER).getCount(), 1, + "Each panel must also use one paper"); + helper.succeed(); + } + @GameTest(template = "empty") public static void bottleCapacityBlocksAndThenPermitsRefill(GameTestHelper helper) { InkMixerBlockEntity mixer = place(helper); diff --git a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index 68e0912..f6f5b76 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -11,7 +11,9 @@ import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.Set; import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.book.AgeBiomeSelection; import net.dnorberg.rivencraft.book.LinkProperties; +import net.dnorberg.rivencraft.compat.TerraBlenderBiomes; import net.dnorberg.rivencraft.registry.ModDataComponents; import net.dnorberg.rivencraft.registry.ModItems; import net.dnorberg.rivencraft.writing.PageContent; @@ -25,9 +27,11 @@ import net.dnorberg.rivencraft.writing.SymbolSemantics; import net.minecraft.ChatFormatting; import net.minecraft.core.component.DataComponents; import net.minecraft.core.HolderLookup; +import net.minecraft.core.RegistryAccess; import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; +import net.minecraft.tags.BiomeTags; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.TooltipFlag; @@ -36,6 +40,10 @@ import net.minecraft.world.item.TooltipFlag; public final class PageItem extends Item { private static final ResourceLocation BIOME_GENERATION_CATEGORY = ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome_generation"); + private static final ResourceLocation CAVE_BIOME_CATEGORY = + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "cave_biome"); + private static final ResourceLocation BIOME_CATEGORY = + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome"); private static final Map CREATIVE_SYMBOLS = loadCreativeSymbols(); private record CreativeSymbol(SymbolDisplayMetadata display, ResourceLocation creativeCategory) {} @@ -62,10 +70,14 @@ public final class PageItem extends Item { .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); categories.add(BlockSymbolPages.CATEGORY); categories.add(BlockSymbolPages.FLUID_CATEGORY); + categories.add(CAVE_BIOME_CATEGORY); return List.copyOf(categories); } public static ItemStack bundledCreativeCategoryIcon(ResourceLocation category) { + if (category.equals(CAVE_BIOME_CATEGORY)) { + return createCreativeBiomePage(ResourceLocation.withDefaultNamespace("lush_caves")); + } if (category.equals(BlockSymbolPages.CATEGORY) || category.equals(BlockSymbolPages.FLUID_CATEGORY)) { return createCreativeBlockPage(ResourceLocation.withDefaultNamespace( category.equals(BlockSymbolPages.FLUID_CATEGORY) ? "water" : "stone")); @@ -90,8 +102,7 @@ public final class PageItem extends Item { Set bundled = CREATIVE_SYMBOLS.keySet(); registries.lookupOrThrow(Registries.BIOME).listElements() .map(holder -> holder.key().location()) - .filter(id -> id.getNamespace().equals("minecraft")) - .filter(id -> !bundled.contains("biome/" + id.getPath())) + .filter(id -> !bundled.contains(BiomeSymbolPages.symbolId(id).getPath())) .sorted() .forEach(id -> pages.add(createCreativeBiomePage(id))); return pages; @@ -103,11 +114,34 @@ public final class PageItem extends Item { return BlockSymbolPages.availableBlocks(category).stream() .map(PageItem::createCreativeBlockPage).toList(); } + Set caveBiomes = category.equals(BIOME_CATEGORY) || category.equals(CAVE_BIOME_CATEGORY) + ? caveBiomeIds(registries) : Set.of(); return creativeBundledAndBiomePages(registries).stream() - .filter(page -> category.equals(creativeCategory(page))) + .filter(page -> category.equals(creativeCategory(page, caveBiomes))) .toList(); } + public static ResourceLocation creativeCategory(ItemStack page, HolderLookup.Provider registries) { + return creativeCategory(page, caveBiomeIds(registries)); + } + + private static ResourceLocation creativeCategory(ItemStack page, Set caveBiomes) { + ResourceLocation category = creativeCategory(page); + if (!category.equals(BIOME_CATEGORY)) return category; + ResourceLocation symbolId = content(page).symbol().orElseThrow().id(); + ResourceLocation biomeId = BiomeSymbolPages.biomeId(symbolId); + return caveBiomes.contains(biomeId) ? CAVE_BIOME_CATEGORY : category; + } + + private static Set caveBiomeIds(HolderLookup.Provider registries) { + LinkedHashSet ids = new LinkedHashSet<>(); + registries.lookupOrThrow(Registries.BIOME).listElements() + .filter(holder -> holder.is(AgeBiomeSelection.CAVE_BIOMES) || holder.is(BiomeTags.IS_NETHER)) + .forEach(holder -> ids.add(holder.key().location())); + if (registries instanceof RegistryAccess access) ids.addAll(TerraBlenderBiomes.nether(access)); + return ids; + } + public static ResourceLocation creativeCategory(ItemStack page) { SymbolDisplayMetadata metadata = page.get(ModDataComponents.PAGE_SYMBOL_DISPLAY.get()); if (metadata == null) throw new IllegalArgumentException("Page has no symbol display metadata"); diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index 1ad5ee5..ec5ea28 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -42,7 +42,7 @@ public final class ModCreativeTabs { .build()); private static final List CATEGORY_ORDER = List.of( - "terrain", "biome", "biome_generation", "weather", "lighting", "sky", "celestial", "block", "fluid", "feature"); + "terrain", "biome", "cave_biome", "biome_generation", "weather", "lighting", "sky", "celestial", "block", "fluid", "feature"); public static final Map> SYMBOL_PAGES = registerSymbolPageTabs(); diff --git a/src/main/java/net/dnorberg/rivencraft/writing/BiomeSymbolPages.java b/src/main/java/net/dnorberg/rivencraft/writing/BiomeSymbolPages.java index 71601e6..72c060f 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/BiomeSymbolPages.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/BiomeSymbolPages.java @@ -6,14 +6,27 @@ import java.util.Optional; import net.dnorberg.rivencraft.Rivencraft; import net.minecraft.resources.ResourceLocation; -/** Built-in fallback pages for registered vanilla biomes without hand-authored symbol JSON. */ +/** Registry-derived pages for biomes without hand-authored symbol JSON. */ public final class BiomeSymbolPages { private static final ResourceLocation CATEGORY = ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome"); private BiomeSymbolPages() { } public static ResourceLocation symbolId(ResourceLocation biomeId) { - return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome/" + biomeId.getPath()); + // Preserve old vanilla page IDs, while keeping modded namespaces distinct. + return ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome/" + + (biomeId.getNamespace().equals("minecraft") ? "" : biomeId.getNamespace() + "/") + + biomeId.getPath()); + } + + public static ResourceLocation biomeId(ResourceLocation symbolId) { + String path = symbolId.getPath(); + if (!symbolId.getNamespace().equals(Rivencraft.MOD_ID) || !path.startsWith("biome/")) + throw new IllegalArgumentException("Not a biome symbol: " + symbolId); + path = path.substring("biome/".length()); + int separator = path.indexOf('/'); + return separator < 0 ? ResourceLocation.withDefaultNamespace(path) + : ResourceLocation.fromNamespaceAndPath(path.substring(0, separator), path.substring(separator + 1)); } public static SymbolDefinition definition(ResourceLocation biomeId) { @@ -24,8 +37,11 @@ public final class BiomeSymbolPages { .map(part -> part.substring(0, 1).toUpperCase(Locale.ROOT) + part.substring(1)) .reduce("", String::concat); if (glyphWord.isEmpty()) glyphWord = "World"; + if (glyphWord.length() > 48) glyphWord = glyphWord.substring(0, 48); return new SymbolDefinition(SymbolDefinition.CURRENT_SCHEMA_VERSION, - "symbol.rivencraft.biome." + path, + biomeId.getNamespace().equals("minecraft") + ? "symbol.rivencraft.biome." + path + : "biome." + biomeId.getNamespace() + "." + path.replace('/', '.'), "symbol.rivencraft.biome.generated.description", CATEGORY, Optional.of(new SymbolSemantics(SymbolSemantics.Role.BIOME, @@ -35,9 +51,14 @@ public final class BiomeSymbolPages { public static String fallbackName(ResourceLocation symbolId) { String path = symbolId.getPath().substring("biome/".length()); - return Arrays.stream(path.split("_")) + int separator = path.indexOf('/'); + String namespace = separator < 0 ? "" : path.substring(0, separator); + if (separator >= 0) path = path.substring(separator + 1); + String label = path.replace('/', '_'); + String name = Arrays.stream(label.split("_")) .filter(part -> !part.isEmpty()) .map(part -> part.substring(0, 1).toUpperCase(Locale.ROOT) + part.substring(1)) .reduce((left, right) -> left + " " + right).orElse("Unknown") + " Biome"; + return namespace.isEmpty() ? name : name + " (" + namespace + ")"; } } diff --git a/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java b/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java index 052f586..a9d4ee3 100644 --- a/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java +++ b/src/main/java/net/dnorberg/rivencraft/writing/SymbolCatalog.java @@ -69,7 +69,6 @@ public final class SymbolCatalog { }); registries.lookupOrThrow(Registries.BIOME).listElements() .map(holder -> holder.key().location()) - .filter(id -> id.getNamespace().equals("minecraft")) .forEach(id -> loaded.putIfAbsent(BiomeSymbolPages.symbolId(id), BiomeSymbolPages.definition(id))); BlockSymbolPages.availableBlocks().forEach(id -> loaded.putIfAbsent( diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 2ccbbed..98265c0 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -258,6 +258,7 @@ "itemGroup.rivencraft.pages.link": "Rivencraft Link Pages", "itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages", "itemGroup.rivencraft.pages.biome": "Rivencraft Biome Pages", + "itemGroup.rivencraft.pages.cave_biome": "Rivencraft Cave Biome Pages", "itemGroup.rivencraft.pages.biome_generation": "Rivencraft Biome Generation Pages", "itemGroup.rivencraft.pages.weather": "Rivencraft Weather Pages", "itemGroup.rivencraft.pages.lighting": "Rivencraft Lighting Pages",