diff --git a/gradle.properties b/gradle.properties index 11506e7..5fea858 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.6.7.2 +mod_version=0.6.7.3 immersive_portals_version=6.0.7 terrablender_version=4.1.0.8 serene_seasons_version_id=pHEgQQUE 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 09c8cbd..116ab25 100644 --- a/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java +++ b/src/main/java/net/dnorberg/rivencraft/compat/jei/RivencraftJeiPlugin.java @@ -25,12 +25,14 @@ import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.book.LinkProperties; import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.registry.ModCreativeTabs; import net.dnorberg.rivencraft.writing.InkModifierIngredients; import net.dnorberg.rivencraft.writing.PageContent; import net.dnorberg.rivencraft.writing.SymbolReference; import net.dnorberg.rivencraft.registry.ModItems; import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.core.component.DataComponents; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; import net.minecraft.resources.ResourceLocation; @@ -63,6 +65,11 @@ public final class RivencraftJeiPlugin implements IModPlugin { case UNRESOLVED_SYMBOL -> "unresolved:" + content.symbol().orElseThrow().id(); }; }); + registration.registerSubtypeInterpreter(ModItems.SYMBOL_PORTFOLIO.get(), + (IIngredientSubtypeInterpreter) (stack, context) -> { + Component name = stack.get(DataComponents.CUSTOM_NAME); + return name == null ? "empty" : name.getString(); + }); } @Override @@ -72,6 +79,7 @@ public final class RivencraftJeiPlugin implements IModPlugin { var connection = Minecraft.getInstance().getConnection(); if (connection != null) { pages.addAll(PageItem.creativeSymbolPages(connection.registryAccess())); + pages.addAll(ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess())); } else { PageItem.bundledCreativeSymbols().stream().map(PageItem::createBundledCreativePage) .forEach(pages::add); @@ -84,6 +92,8 @@ public final class RivencraftJeiPlugin implements IModPlugin { // 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)); + runtime.getIngredientVisibility().hideIngredients(VanillaTypes.ITEM_STACK, + List.of(new ItemStack(ModItems.SYMBOL_PORTFOLIO.get())), List.of(UidContext.Ingredient)); var connection = Minecraft.getInstance().getConnection(); if (connection == null) return; Set known = runtime.getIngredientManager().getAllItemStacks().stream() @@ -98,6 +108,19 @@ public final class RivencraftJeiPlugin implements IModPlugin { if (!missing.isEmpty()) { runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missing); } + Set knownPortfolios = runtime.getIngredientManager().getAllItemStacks().stream() + .filter(stack -> stack.is(ModItems.SYMBOL_PORTFOLIO.get())) + .map(stack -> stack.get(DataComponents.CUSTOM_NAME)) + .filter(java.util.Objects::nonNull) + .map(Component::getString) + .collect(Collectors.toSet()); + List missingPortfolios = ModCreativeTabs.creativeSymbolPortfolios(connection.registryAccess()) + .stream() + .filter(stack -> !knownPortfolios.contains(stack.getHoverName().getString())) + .toList(); + if (!missingPortfolios.isEmpty()) { + runtime.getIngredientManager().addIngredientsAtRuntime(VanillaTypes.ITEM_STACK, missingPortfolios); + } } @Override diff --git a/src/main/java/net/dnorberg/rivencraft/gametest/SymbolPortfolioGameTests.java b/src/main/java/net/dnorberg/rivencraft/gametest/SymbolPortfolioGameTests.java new file mode 100644 index 0000000..67eaa7a --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/gametest/SymbolPortfolioGameTests.java @@ -0,0 +1,59 @@ +package net.dnorberg.rivencraft.gametest; + +import java.util.ArrayList; +import java.util.List; +import net.dnorberg.rivencraft.Rivencraft; +import net.dnorberg.rivencraft.item.CollationFolderItem; +import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.item.SymbolPortfolioItem; +import net.dnorberg.rivencraft.registry.ModCreativeTabs; +import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.writing.NotebookContents; +import net.dnorberg.rivencraft.writing.PageContent; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +@GameTestHolder(Rivencraft.MOD_ID) +@PrefixGameTestTemplate(false) +public final class SymbolPortfolioGameTests { + private SymbolPortfolioGameTests() {} + + @GameTest(template = "empty") + public static void portfoliosCoverEverySymbolCategoryWithoutTruncation(GameTestHelper helper) { + var registries = helper.getLevel().registryAccess(); + List creative = ModCreativeTabs.creativeSymbolPortfolios(registries); + helper.assertTrue(!creative.isEmpty(), "Creative tab must include prefilled portfolios"); + for (ResourceLocation category : PageItem.bundledCreativeCategories()) { + List expected = PageItem.creativeSymbolPages(registries, category); + List volumes = SymbolPortfolioItem.createVolumes(category, expected); + helper.assertTrue(!volumes.isEmpty(), "Missing portfolio for " + category); + List actual = new ArrayList<>(); + for (ItemStack volume : volumes) { + helper.assertTrue(volume.is(ModItems.SYMBOL_PORTFOLIO.get()) + && CollationFolderItem.isCollection(volume) + && creative.stream().anyMatch(candidate -> + candidate.getHoverName().getString().equals(volume.getHoverName().getString())), + "Portfolio must be an editable creative collection for " + category); + NotebookContents contents = volume.get(ModDataComponents.NOTEBOOK_CONTENTS.get()); + helper.assertTrue(contents != null && contents.pageCount() > 0 + && contents.pageCount() <= NotebookContents.MAX_PAGES, + "Each portfolio volume must contain valid pages"); + helper.assertTrue(contents.pages().stream().allMatch(page -> + PageItem.content(page).kind() == PageContent.Kind.SYMBOL), + "Portfolios must contain symbol pages, not Link Panels"); + actual.addAll(contents.pages()); + } + helper.assertTrue(actual.size() == expected.size(), "Portfolio omitted pages in " + category); + for (int index = 0; index < expected.size(); index++) { + helper.assertTrue(ItemStack.isSameItemSameComponents(actual.get(index), expected.get(index)), + "Portfolio pages must match the category tab in " + category); + } + } + helper.succeed(); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/item/CollationFolderItem.java b/src/main/java/net/dnorberg/rivencraft/item/CollationFolderItem.java index ab0ca5f..e7f27cf 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/CollationFolderItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/CollationFolderItem.java @@ -13,11 +13,13 @@ public final class CollationFolderItem extends NotebookItem { } public static boolean isCollection(ItemStack stack) { - return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get()); + return stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.COLLATION_FOLDER.get()) + || stack.is(ModItems.SYMBOL_PORTFOLIO.get()); } public static int capacity(ItemStack stack) { return stack.is(ModItems.COLLATION_FOLDER.get()) ? MAX_PAGES - : stack.is(ModItems.NOTEBOOK.get()) ? NotebookContents.MAX_PAGES : 0; + : stack.is(ModItems.NOTEBOOK.get()) || stack.is(ModItems.SYMBOL_PORTFOLIO.get()) + ? NotebookContents.MAX_PAGES : 0; } } diff --git a/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java b/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java index f4f9bf5..e718482 100644 --- a/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java +++ b/src/main/java/net/dnorberg/rivencraft/item/NotebookItem.java @@ -39,8 +39,7 @@ public class NotebookItem extends Item { NotebookContents contents = stack.getOrDefault( ModDataComponents.NOTEBOOK_CONTENTS.get(), NotebookContents.EMPTY); tooltip.add(Component.translatable("item.rivencraft.notebook.pages", - contents.pageCount(), stack.is(net.dnorberg.rivencraft.registry.ModItems.COLLATION_FOLDER.get()) - ? CollationFolderItem.MAX_PAGES : NotebookContents.MAX_PAGES) + contents.pageCount(), CollationFolderItem.capacity(stack)) .withStyle(ChatFormatting.GRAY)); if (contents.unresolvedCount() > 0) { tooltip.add(Component.translatable("item.rivencraft.notebook.unresolved", diff --git a/src/main/java/net/dnorberg/rivencraft/item/SymbolPortfolioItem.java b/src/main/java/net/dnorberg/rivencraft/item/SymbolPortfolioItem.java new file mode 100644 index 0000000..0064b09 --- /dev/null +++ b/src/main/java/net/dnorberg/rivencraft/item/SymbolPortfolioItem.java @@ -0,0 +1,40 @@ +package net.dnorberg.rivencraft.item; + +import java.util.ArrayList; +import java.util.List; +import net.dnorberg.rivencraft.registry.ModDataComponents; +import net.dnorberg.rivencraft.registry.ModItems; +import net.dnorberg.rivencraft.writing.NotebookContents; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; + +/** Non-craftable, prefilled Legacy-style collections of one symbol-page category. */ +public final class SymbolPortfolioItem extends NotebookItem { + public SymbolPortfolioItem(Properties properties) { + super(properties); + } + + public static List createVolumes(ResourceLocation category, List pages) { + if (pages.isEmpty()) return List.of(); + int capacity = NotebookContents.MAX_PAGES; + int volumeCount = (pages.size() + capacity - 1) / capacity; + List volumes = new ArrayList<>(volumeCount); + for (int volume = 0; volume < volumeCount; volume++) { + ItemStack portfolio = new ItemStack(ModItems.SYMBOL_PORTFOLIO.get()); + int first = volume * capacity; + int last = Math.min(first + capacity, pages.size()); + portfolio.set(ModDataComponents.NOTEBOOK_CONTENTS.get(), + new NotebookContents(pages.subList(first, last))); + Component categoryName = Component.translatableWithFallback( + "itemGroup.rivencraft.pages." + category.getPath(), category.getPath()); + portfolio.set(DataComponents.CUSTOM_NAME, volumeCount == 1 + ? Component.translatable("item.rivencraft.symbol_portfolio.named", categoryName) + : Component.translatable("item.rivencraft.symbol_portfolio.volume", + categoryName, volume + 1, volumeCount)); + volumes.add(portfolio); + } + return List.copyOf(volumes); + } +} diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java index 52ca125..8be925c 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModCreativeTabs.java @@ -7,6 +7,9 @@ import java.util.List; import java.util.Map; import net.dnorberg.rivencraft.Rivencraft; import net.dnorberg.rivencraft.item.PageItem; +import net.dnorberg.rivencraft.item.SymbolPortfolioItem; +import net.minecraft.core.HolderLookup; +import net.minecraft.world.item.ItemStack; import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; @@ -28,6 +31,7 @@ public final class ModCreativeTabs { output.accept(ModItems.LINKING_BOOK.get()); output.accept(ModItems.NOTEBOOK.get()); output.accept(ModItems.COLLATION_FOLDER.get()); + creativeSymbolPortfolios(parameters.holders()).forEach(output::accept); output.accept(ModItems.SEALED_NOTEBOOK.get()); output.accept(ModItems.INK_VIAL.get()); output.accept(ModItems.WRITING_DESK.get()); @@ -59,14 +63,28 @@ public final class ModCreativeTabs { public static final Map> SYMBOL_PAGES = registerSymbolPageTabs(); - private static Map> registerSymbolPageTabs() { + /** Prefilled portfolios mirror the symbol-page tabs, including data-pack and modded pages. */ + public static List creativeSymbolPortfolios(HolderLookup.Provider registries) { + List portfolios = new ArrayList<>(); + for (ResourceLocation category : orderedSymbolCategories()) { + portfolios.addAll(SymbolPortfolioItem.createVolumes(category, + PageItem.creativeSymbolPages(registries, category))); + } + return List.copyOf(portfolios); + } + + private static List orderedSymbolCategories() { 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)); + return List.copyOf(categories); + } + + private static Map> registerSymbolPageTabs() { Map> tabs = new LinkedHashMap<>(); - for (ResourceLocation category : categories) { + for (ResourceLocation category : orderedSymbolCategories()) { 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(), diff --git a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java index 3292920..d933268 100644 --- a/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java +++ b/src/main/java/net/dnorberg/rivencraft/registry/ModItems.java @@ -6,6 +6,7 @@ import net.dnorberg.rivencraft.item.LinkingBookItem; import net.dnorberg.rivencraft.item.PageItem; import net.dnorberg.rivencraft.item.NotebookItem; import net.dnorberg.rivencraft.item.CollationFolderItem; +import net.dnorberg.rivencraft.item.SymbolPortfolioItem; import net.dnorberg.rivencraft.item.SealedNotebookItem; import net.dnorberg.rivencraft.item.InkVialItem; import net.minecraft.world.item.Item; @@ -39,6 +40,8 @@ public final class ModItems { ); public static final DeferredItem COLLATION_FOLDER = ITEMS.registerItem( "collation_folder", CollationFolderItem::new, new Item.Properties().stacksTo(1)); + public static final DeferredItem SYMBOL_PORTFOLIO = ITEMS.registerItem( + "symbol_portfolio", SymbolPortfolioItem::new, new Item.Properties().stacksTo(1)); public static final DeferredItem SEALED_NOTEBOOK = ITEMS.registerItem( "sealed_notebook", SealedNotebookItem::new, new Item.Properties().stacksTo(16)); public static final DeferredItem INK_VIAL = ITEMS.registerItem( diff --git a/src/main/resources/assets/rivencraft/lang/en_us.json b/src/main/resources/assets/rivencraft/lang/en_us.json index daffe73..a16aa72 100644 --- a/src/main/resources/assets/rivencraft/lang/en_us.json +++ b/src/main/resources/assets/rivencraft/lang/en_us.json @@ -22,6 +22,9 @@ "item.rivencraft.page.unresolved": "Unresolved Symbol Page", "item.rivencraft.notebook": "Notebook", "item.rivencraft.collation_folder": "Collation Folder", + "item.rivencraft.symbol_portfolio": "Symbol Portfolio", + "item.rivencraft.symbol_portfolio.named": "%s Portfolio", + "item.rivencraft.symbol_portfolio.volume": "%s Portfolio %s/%s", "item.rivencraft.sealed_notebook": "Sealed Notebook", "block.rivencraft.link_modifier": "Link Modifier", "container.rivencraft.link_modifier": "Link Modifier", diff --git a/src/main/resources/assets/rivencraft/models/item/symbol_portfolio.json b/src/main/resources/assets/rivencraft/models/item/symbol_portfolio.json new file mode 100644 index 0000000..1304aff --- /dev/null +++ b/src/main/resources/assets/rivencraft/models/item/symbol_portfolio.json @@ -0,0 +1,4 @@ +{ + "parent": "minecraft:item/generated", + "textures": { "layer0": "rivencraft:item/symbol_portfolio" } +} diff --git a/src/main/resources/assets/rivencraft/textures/item/symbol_portfolio.png b/src/main/resources/assets/rivencraft/textures/item/symbol_portfolio.png new file mode 100644 index 0000000..0cc028d Binary files /dev/null and b/src/main/resources/assets/rivencraft/textures/item/symbol_portfolio.png differ