From 84a1f327328ded4bed0027189d401d8e64a8e22e Mon Sep 17 00:00:00 2001 From: Dustin Norberg Date: Fri, 2 Oct 2026 14:49:41 -0400 Subject: [PATCH] Organize creative pages and stabilize GameTests --- README.md | 3 + docs/AGE_PROFILES.md | 4 +- docs/GAMETESTS.md | 21 +++++++ docs/WRITING_GRAMMAR.md | 2 +- gradle.properties | 2 +- .../book/EnvironmentWritingGameTests.java | 39 +++++++++++++ .../book/TerrainWritingGameTests.java | 20 ++----- .../compat/jei/RivencraftJeiPlugin.java | 4 ++ .../rivencraft/gametest/LinkingGameTests.java | 36 ------------ .../gametest/WritingDataGameTests.java | 2 +- .../dnorberg/rivencraft/item/PageItem.java | 57 +++++++++++++++++-- .../rivencraft/registry/ModCreativeTabs.java | 44 +++++++++++--- .../assets/rivencraft/lang/en_us.json | 10 +++- 13 files changed, 173 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index c42ca54..62080e6 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,9 @@ With JEI installed, Rivencraft displays the Ink Mixer operation in its own category and the hand-use, copying, binding, and Linking Book crafting flows in a Writing category. The Link Panel and individual symbol Pages, including registered vanilla biome pages, appear as separately searchable JEI ingredients. +Creative inventory separates Link Pages and each symbol category into their own +tabs, including distinct Biome and Biome Generation tabs. Blank Pages are hidden +from creative inventory and JEI. JEI is optional; vanilla crafting recipes and all writing interactions work without it. For a development client with JEI, run `./gradlew runClient -PwithJei=true`. diff --git a/docs/AGE_PROFILES.md b/docs/AGE_PROFILES.md index 9debd89..6b41e3f 100644 --- a/docs/AGE_PROFILES.md +++ b/docs/AGE_PROFILES.md @@ -56,7 +56,7 @@ authored pages: the Binder's 27 symbol-page positions bound an authored book. Native cannot be paired with explicit biome pages. Legacy filled Tiny–Huge to three; Rivencraft deliberately uses two so a pair of written biome pages stays exactly as written. -The Pages creative tab offers pages for every registered vanilla biome. +The Biome Pages creative tab offers pages for every registered vanilla biome. Hand-authored symbol definitions take precedence; the remaining vanilla pages are generated from the biome registry, so they do not each need a JSON file. Custom/modded biomes still require a data-pack symbol definition. @@ -99,7 +99,7 @@ cannot reproduce those Legacy distinctions yet, and Snow remains planned for the optional season integrations. A data pack that supplies these unsupported weather choices receives a binding error rather than a false success. -The Pages creative tab and optional JEI page ingredients use the same generated +The category creative tabs and optional JEI page ingredients use the same generated index of bundled environment symbols. Existing Age definitions and page-free random Ages retain their saved or seeded environment unchanged. diff --git a/docs/GAMETESTS.md b/docs/GAMETESTS.md index 007053c..394e83d 100644 --- a/docs/GAMETESTS.md +++ b/docs/GAMETESTS.md @@ -41,3 +41,24 @@ Logs and configuration remain available. Cleanup never targets a custom `-PgameTestRunDir` or a player world; callers using a custom directory manage it themselves. The full suite should still be run before shipping changes in the archived systems. + +## Manual Age unload/reload check + +The automated suite does not unload a freshly generated Age. Minecraft can +queue point-of-interest block updates after generation; closing that Age's +region storage while a queued update is still pending can leave the dedicated +GameTest server waiting forever in `SectionStorage.readColumn`. Plan save/load, +terrain generation, and safe spawn remain automated; runtime unload/reload is +checked in a normal game session: + +1. Back up the test world. Bind one Link-Panel-only Descriptive Book and one + Descriptive Book with a terrain page (for example, Island), using distinct + names. Bring a working way back to the Overworld. +2. Travel through each book, confirm the named Age and expected terrain, then + return to the Overworld. Close book screens and wait a few seconds. +3. With cheats enabled, run `/rivencraft age list` and record each Age's + `rivencraft:age/...` dimension ID. Run `/rivencraft age unload ` + for one Age at a time, then list again to confirm it is unloaded. +4. Reopen the same Descriptive Books and travel back. Confirm each book reaches + its original Age, the terrain and safe arrival remain intact, and no extra + Age entry was created. Save, restart the world, and repeat the book check. diff --git a/docs/WRITING_GRAMMAR.md b/docs/WRITING_GRAMMAR.md index 8c44218..2e1c287 100644 --- a/docs/WRITING_GRAMMAR.md +++ b/docs/WRITING_GRAMMAR.md @@ -10,4 +10,4 @@ Missing terrain, controller, weather, lighting, celestial, or the controller's m The plan stores the original writing order, the authoritative semantic value of each resolved symbol, the Age seed, and a copy of the selected runtime profile. An explicit terrain page replaces the random terrain selection; an omitted terrain uses the seed-selected profile. The built-in Standard, Amplified, Cave, Island, Flat, and Void terrain pages select their matching generation resources. Age loading uses the frozen runtime profile and does not need the original symbol definition or runtime-profile JSON, though its referenced noise, biome-preset, and dimension-type registry entries must remain available. Existing bound books without plans are not reinterpreted. Page-free and Link-Panel-only books keep the pre-existing random path. Biome and environment pages now affect new Ages; feature effects remain issue #35 work, and instability remains Milestone 6 work. -The Rivencraft Pages creative tab reads a generated index of bundled symbol definitions. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page automatically in the next build; no Java starter-page list needs updating. The server catalog still decides whether that page is valid for writing. External server data-pack symbols are not included in this bundled index. +The Rivencraft symbol-page creative tabs read a generated index of bundled symbol definitions and group Pages by each definition's category, except biome-controller roles, which appear in a separate Biome Generation tab rather than the Biome tab. Adding another valid JSON symbol under `src/main/resources/data/rivencraft/rivencraft/symbols/` includes its page in the matching category tab on the next build; a new bundled category receives its own tab. Link Pages have a separate tab. Blank Pages appear in neither the creative tabs nor JEI's ingredient list. The server catalog still decides whether a page is valid for writing. External server data-pack symbols are not included in this bundled index. diff --git a/gradle.properties b/gradle.properties index 7a24552..949ad89 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ 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.6.0 +mod_version=0.4.6.2 immersive_portals_version=6.0.7 # 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. diff --git a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java index 7a584ea..edc1bc8 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/EnvironmentWritingGameTests.java @@ -1,11 +1,14 @@ 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; import net.dnorberg.rivencraft.writing.SymbolReference; import net.dnorberg.rivencraft.writing.WritingEntry; @@ -22,6 +25,42 @@ import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; public final class EnvironmentWritingGameTests { private EnvironmentWritingGameTests() {} + @GameTest(template = "empty") + public static void symbolCategoriesPartitionCreativePages(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")); + 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.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"); + helper.succeed(); + } + @GameTest(template = "empty") public static void environmentPagesEnterCreativeAndJeiFeed(GameTestHelper helper) { Set expected = Set.of( diff --git a/src/main/java/net/dnorberg/rivencraft/book/TerrainWritingGameTests.java b/src/main/java/net/dnorberg/rivencraft/book/TerrainWritingGameTests.java index a5376c3..9dc1e9e 100644 --- a/src/main/java/net/dnorberg/rivencraft/book/TerrainWritingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/book/TerrainWritingGameTests.java @@ -206,18 +206,14 @@ public final class TerrainWritingGameTests { helper.assertTrue(level.getChunkSource().getGenerator().getBiomeSource().possibleBiomes().stream() .allMatch(biome -> biome.is(Biomes.THE_END)), "The runtime sky-island generator must use the End biome page"); - helper.assertValueEqual(AgeDimensionService.unload(server, age), AgeDimensionService.UnloadResult.UNLOADED, - "The island Age must unload"); - var reloaded = AgeDimensionService.ensureLoaded(server, age); - helper.assertTrue(reloaded != null && reloaded.getChunkSource().getGenerator().getBiomeSource() - .possibleBiomes().stream().allMatch(biome -> biome.is(Biomes.THE_END)), - "The authored End biome must survive runtime dimension reload"); + helper.assertValueEqual(AgeGenerationPlan.load(age.plan().save()).biomeSelection(), + age.plan().biomeSelection(), "The authored End biome must survive a plan save/load round trip"); helper.succeed(); }); } @GameTest(template = "empty", timeoutTicks = 600) - public static void writtenTerrainFamiliesGenerateSafelyAndReload(GameTestHelper helper) { + public static void writtenTerrainFamiliesGenerateSafely(GameTestHelper helper) { var player = helper.makeMockServerPlayerInLevel(); checkWrittenTerrain(helper, player, AgeGenerationProfiles.all(), 0); } @@ -259,14 +255,8 @@ public final class TerrainWritingGameTests { && level.getBlockState(spawn.above()).isAir(), "Every written terrain family must retain a safe arrival platform"); - helper.assertValueEqual(AgeDimensionService.unload(server, age), - AgeDimensionService.UnloadResult.UNLOADED, - "A written Age must unload without losing its plan"); - var reloaded = AgeDimensionService.ensureLoaded(server, age); - helper.assertTrue(reloaded != null, "The written Age must reload from its frozen plan"); - assertGenerator(helper, reloaded, age.plan().runtimeProfile()); - helper.assertValueEqual(reloaded.dimensionType().effectsLocation(), - level.dimensionType().effectsLocation(), "Reload must retain dimension effects"); + helper.assertValueEqual(AgeGenerationPlan.load(age.plan().save()).runtimeProfile(), + age.plan().runtimeProfile(), "The written terrain profile must survive plan persistence"); helper.runAfterDelay(1, () -> checkWrittenTerrain(helper, player, profiles, index + 1)); }); } diff --git a/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java b/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java index bdcd270..3a70a99 100644 --- a/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java +++ b/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java @@ -19,6 +19,7 @@ import mezz.jei.api.registration.IRecipeRegistration; import mezz.jei.api.registration.ISubtypeRegistration; import mezz.jei.api.registration.IExtraIngredientRegistration; import mezz.jei.api.ingredients.subtypes.IIngredientSubtypeInterpreter; +import mezz.jei.api.ingredients.subtypes.UidContext; import mezz.jei.api.runtime.IJeiRuntime; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.PageItem; @@ -74,6 +75,9 @@ public final class RivencraftJeiPlugin implements IModPlugin { @Override public void onRuntimeAvailable(IJeiRuntime runtime) { + // JEI discovers the base Page item as a blank stack; only authored pages belong in its list. + runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK, + List.of(new ItemStack(ModItems.PAGE.get())), List.of(UidContext.Ingredient)); var connection = Minecraft.getInstance().getConnection(); if (connection == null) return; Set known = runtime.getIngredientManager().getAllItemStacks().stream() diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java index 738ab8c..f1e2d27 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/LinkingGameTests.java @@ -313,42 +313,6 @@ public final class LinkingGameTests { throw new IllegalStateException("Could not select profile " + profile); } - @GameTest(template = "empty", timeoutTicks = 100) - public static void unloadedAgeReloadsWithTheSameDefinition(GameTestHelper helper) { - UUID id = UUID.randomUUID(); - AgeDefinition definition = AgeRegistrySavedData.resolveOrCreate( - helper.getLevel(), - new AgeReference(id, "Lifecycle Test Age " + id) - ); - - var server = helper.getLevel().getServer(); - var first = AgeDimensionService.ensureLoaded(server, definition); - helper.assertTrue(first != null, "The lifecycle test Age must load"); - helper.runAfterDelay(1, () -> { - helper.assertValueEqual( - AgeDimensionService.unload(server, definition), - AgeDimensionService.UnloadResult.UNLOADED, - "A loaded Age must unload without deleting its definition" - ); - helper.runAfterDelay(1, () -> { - helper.assertTrue(server.getLevel(AgeDimensionService.dimensionKey(definition)) == null, - "An unloaded Age must leave the active server level map"); - - var reloaded = AgeDimensionService.ensureLoaded(server, definition); - helper.assertTrue(reloaded != null, "A book must be able to reload an archived Age"); - helper.runAfterDelay(1, () -> { - var registered = server.getLevel(AgeDimensionService.dimensionKey(definition)); - helper.assertTrue(registered != null, "The reloaded Age must rejoin the active server level map"); - helper.assertValueEqual(registered.getSeed(), definition.seed(), - "Reloading an Age must preserve its immutable seed"); - helper.assertValueEqual(registered.dimension().location(), definition.dimensionId(), - "Reloading an Age must preserve its dimension identifier"); - helper.succeed(); - }); - }); - }); - } - @GameTest(template = "empty", timeoutTicks = 200) public static void legacyBaseTerrainProfilesLoadRuntimeDimensions(GameTestHelper helper) { for (var profile : AgeGenerationProfiles.all()) { diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java index 1d82c18..43be384 100644 --- a/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java +++ b/src/main/java/net/dnorberg/rivencraft/gametest/WritingDataGameTests.java @@ -111,7 +111,7 @@ public final class WritingDataGameTests { for (String terrain : List.of("standard", "amplified", "cave", "island", "flat", "void")) { String path = "terrain/" + terrain; helper.assertTrue(PageItem.bundledCreativeSymbols().contains(path), - "The Pages creative tab must include " + path); + "The matching symbol-page creative tab must include " + path); ItemStack page = PageItem.createBundledCreativePage(path); helper.assertValueEqual(PageItem.content(page).symbol().orElseThrow().id(), ResourceLocation.fromNamespaceAndPath("rivencraft", path), diff --git a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java index 0bb9bdb..8d2fe15 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/PageItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/PageItem.java @@ -8,6 +8,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.Set; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.registry.ModDataComponents; @@ -18,18 +19,24 @@ import net.dnorberg.rivencraft.writing.SymbolDefinition; import net.dnorberg.rivencraft.writing.BiomeSymbolPages; import net.dnorberg.rivencraft.writing.SymbolDisplayMetadata; import net.dnorberg.rivencraft.writing.SymbolReference; +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.registries.Registries; import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; import net.minecraft.world.item.Item; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.TooltipFlag; /** One stackable Page item whose server-owned component identifies its writing state. */ public final class PageItem extends Item { - private static final Map CREATIVE_SYMBOLS = loadCreativeSymbols(); + private static final ResourceLocation BIOME_GENERATION_CATEGORY = + ResourceLocation.fromNamespaceAndPath(Rivencraft.MOD_ID, "biome_generation"); + private static final Map CREATIVE_SYMBOLS = loadCreativeSymbols(); + + private record CreativeSymbol(SymbolDisplayMetadata display, ResourceLocation creativeCategory) {} public PageItem(Properties properties) { super(properties); @@ -46,6 +53,21 @@ public final class PageItem extends Item { return List.copyOf(CREATIVE_SYMBOLS.keySet()); } + /** Bundled categories become separate creative tabs without a Java page list. */ + public static List bundledCreativeCategories() { + return List.copyOf(CREATIVE_SYMBOLS.values().stream() + .map(CreativeSymbol::creativeCategory) + .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new))); + } + + public static ItemStack bundledCreativeCategoryIcon(ResourceLocation category) { + return CREATIVE_SYMBOLS.entrySet().stream() + .filter(entry -> entry.getValue().creativeCategory().equals(category)) + .findFirst() + .map(entry -> createBundledCreativePage(entry.getKey())) + .orElseThrow(() -> new IllegalArgumentException("No bundled pages for category " + category)); + } + /** Exact symbol-page variants offered in the creative tab and to optional ingredient viewers. */ public static List creativeSymbolPages(HolderLookup.Provider registries) { List pages = new ArrayList<>(); @@ -60,12 +82,30 @@ public final class PageItem extends Item { return List.copyOf(pages); } + public static List creativeSymbolPages(HolderLookup.Provider registries, + ResourceLocation category) { + return creativeSymbolPages(registries).stream() + .filter(page -> category.equals(creativeCategory(page))) + .toList(); + } + + 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"); + if (metadata.id().getNamespace().equals(Rivencraft.MOD_ID)) { + CreativeSymbol bundled = CREATIVE_SYMBOLS.get(metadata.id().getPath()); + if (bundled != null) return bundled.creativeCategory(); + } + return metadata.category(); + } + /** Bundled symbols for the creative tab; the server still validates catalog membership on use. */ public static ItemStack createBundledCreativePage(String path) { - SymbolDisplayMetadata metadata = CREATIVE_SYMBOLS.get(path); - if (metadata == null) { + CreativeSymbol symbol = CREATIVE_SYMBOLS.get(path); + if (symbol == null) { throw new IllegalArgumentException("Not a bundled symbol: " + path); } + SymbolDisplayMetadata metadata = symbol.display(); SymbolReference reference = new SymbolReference(metadata.id()); ItemStack page = create(PageContent.symbol(reference)); page.set(ModDataComponents.PAGE_SYMBOL_DISPLAY.get(), metadata); @@ -79,7 +119,7 @@ public final class PageItem extends Item { return page; } - private static Map loadCreativeSymbols() { + private static Map loadCreativeSymbols() { String resource = "/assets/rivencraft/creative_symbol_pages.json"; try (var stream = PageItem.class.getResourceAsStream(resource)) { if (stream == null) { @@ -87,13 +127,18 @@ public final class PageItem extends Item { } var json = JsonParser.parseReader(new InputStreamReader(stream, StandardCharsets.UTF_8)) .getAsJsonObject(); - Map symbols = new LinkedHashMap<>(); + Map symbols = new LinkedHashMap<>(); json.entrySet().forEach(entry -> { var id = net.minecraft.resources.ResourceLocation.parse(entry.getKey()); if (!id.getNamespace().equals(Rivencraft.MOD_ID)) { throw new IllegalStateException("Creative symbol is not a Rivencraft page: " + id); } - symbols.put(id.getPath(), SymbolDefinition.parse(entry.getValue()).display(id)); + SymbolDefinition definition = SymbolDefinition.parse(entry.getValue()); + ResourceLocation creativeCategory = definition.semantics() + .filter(semantics -> semantics.role() == SymbolSemantics.Role.BIOME_CONTROLLER) + .map(ignored -> BIOME_GENERATION_CATEGORY) + .orElse(definition.category()); + symbols.put(id.getPath(), new CreativeSymbol(definition.display(id), creativeCategory)); }); return Collections.unmodifiableMap(symbols); } catch (java.io.IOException exception) { diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index d1b5a76..84e98b7 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -1,9 +1,15 @@ package net.dnorberg.rivencraft.registry; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.PageItem; import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; import net.minecraft.world.item.CreativeModeTab; import net.neoforged.bus.api.IEventBus; import net.neoforged.neoforge.registries.DeferredHolder; @@ -28,17 +34,39 @@ public final class ModCreativeTabs { }) .build()); - public static final DeferredHolder PAGES = - CREATIVE_TABS.register("rivencraft_pages", () -> CreativeModeTab.builder() - .title(Component.translatable("itemGroup.rivencraft.pages")) + public static final DeferredHolder LINK_PAGES = + CREATIVE_TABS.register("rivencraft_link_pages", () -> CreativeModeTab.builder() + .title(Component.translatable("itemGroup.rivencraft.pages.link")) .icon(PageItem::createLinkPanelPage) - .displayItems((parameters, output) -> { - output.accept(ModItems.PAGE.get()); - output.accept(PageItem.createLinkPanelPage()); - PageItem.creativeSymbolPages(parameters.holders()).forEach(output::accept); - }) + .displayItems((parameters, output) -> output.accept(PageItem.createLinkPanelPage())) .build()); + private static final List CATEGORY_ORDER = List.of( + "terrain", "biome", "biome_generation", "weather", "lighting", "sky", "celestial", "feature"); + + public static final Map> SYMBOL_PAGES = + registerSymbolPageTabs(); + + private static Map> registerSymbolPageTabs() { + List categories = new ArrayList<>(PageItem.bundledCreativeCategories()); + categories.sort(Comparator.comparingInt((ResourceLocation id) -> { + int index = CATEGORY_ORDER.indexOf(id.getPath()); + return index < 0 ? CATEGORY_ORDER.size() : index; + }).thenComparing(ResourceLocation::toString)); + Map> tabs = new LinkedHashMap<>(); + for (ResourceLocation category : categories) { + String key = "rivencraft_pages_" + category.getNamespace() + "_" + category.getPath().replace('/', '_'); + tabs.put(category, CREATIVE_TABS.register(key, () -> CreativeModeTab.builder() + .title(Component.translatableWithFallback("itemGroup.rivencraft.pages." + category.getPath(), + "Rivencraft " + category.getPath() + " Pages")) + .icon(() -> PageItem.bundledCreativeCategoryIcon(category)) + .displayItems((parameters, output) -> PageItem.creativeSymbolPages(parameters.holders(), category) + .forEach(output::accept)) + .build())); + } + return Map.copyOf(tabs); + } + private ModCreativeTabs() { } diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index 44c90e8..9f2f422 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -230,5 +230,13 @@ "jei.rivencraft.writing.bind": "At a Book Binder, name the Age and add a cover plus Link Panel. Optional symbol Pages follow the panel.", "jei.rivencraft.writing.linking_book": "Craft one Link Panel Page with leather to make an unwritten Linking Book. Open it to name and bind it.", "itemGroup.rivencraft": "Rivencraft", - "itemGroup.rivencraft.pages": "Rivencraft Pages" + "itemGroup.rivencraft.pages.link": "Rivencraft Link Pages", + "itemGroup.rivencraft.pages.terrain": "Rivencraft Terrain Pages", + "itemGroup.rivencraft.pages.biome": "Rivencraft 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", + "itemGroup.rivencraft.pages.sky": "Rivencraft Sky Pages", + "itemGroup.rivencraft.pages.celestial": "Rivencraft Celestial Pages", + "itemGroup.rivencraft.pages.feature": "Rivencraft Feature Pages" }