diff --git a/CHANGELOG-1.21.1.md b/CHANGELOG-1.21.1.md index 3a7a6f3f6..0548b8436 100644 --- a/CHANGELOG-1.21.1.md +++ b/CHANGELOG-1.21.1.md @@ -1,6 +1,21 @@ # Changelog for Minecraft 1.21.1 All notable changes to this project will be documented in this file. + +## Unreleased + + +### Added +* Allow individual recipes to be enabled or disabled in the Attuned Crafting Interface, Closes #162, Closes #219 + The Attuned Crafting Interface now has a gui that shows all recipes of its target machine + in a grid of their output icons, with a green or red border for their state. + Recipes can be toggled by clicking them, and bulk actions apply to the current search filter. + Hovering a recipe shows its output and the inputs it requires. + Disabled recipes are not registered in the crafting network, so no crafting job will use them. + +### Changed +* Bump CommonCapabilities to 2.11.5, which is required for identifying recipes by their recipe id + ## [1.21.1-1.5.0](https://github.com/CyclopsMC/IntegratedCrafting/compare/1.21.1-1.4.7...1.21.1-1.5.0) - 2026-08-24 21:07:04 diff --git a/gradle.properties b/gradle.properties index d4b6fe77b..6dc57613d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -33,5 +33,5 @@ org.gradle.caching=true # Dependencies cyclopscore_version=1.26.2-808 integrateddynamics_version=1.32.0-1630 -commoncapabilities_version=2.9.12-263 +commoncapabilities_version=2.11.5-363 integratedtunnels_version=1.8.44-484 diff --git a/src/main/java/org/cyclops/integratedcrafting/IntegratedCrafting.java b/src/main/java/org/cyclops/integratedcrafting/IntegratedCrafting.java index a979077ef..20eaef875 100644 --- a/src/main/java/org/cyclops/integratedcrafting/IntegratedCrafting.java +++ b/src/main/java/org/cyclops/integratedcrafting/IntegratedCrafting.java @@ -26,6 +26,7 @@ import org.cyclops.integratedcrafting.capability.network.NetworkCraftingHandlerCraftingNetwork; import org.cyclops.integratedcrafting.core.CraftingProcessOverrideRegistry; import org.cyclops.integratedcrafting.core.CraftingProcessOverrides; +import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingAttunedRecipesConfig; import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingConfig; import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingSettingsConfig; import org.cyclops.integratedcrafting.part.PartTypes; @@ -131,6 +132,7 @@ public void onConfigsRegister(ConfigHandler configHandler) { configHandler.addConfigurable(new ContainerPartInterfaceCraftingConfig()); configHandler.addConfigurable(new ContainerPartInterfaceCraftingSettingsConfig()); + configHandler.addConfigurable(new ContainerPartInterfaceCraftingAttunedRecipesConfig()); configHandler.addConfigurable(new RecipeSerializerDeadBushConfig()); // This one is only used in game tests. } diff --git a/src/main/java/org/cyclops/integratedcrafting/RegistryEntries.java b/src/main/java/org/cyclops/integratedcrafting/RegistryEntries.java index a93c7746d..5771af798 100644 --- a/src/main/java/org/cyclops/integratedcrafting/RegistryEntries.java +++ b/src/main/java/org/cyclops/integratedcrafting/RegistryEntries.java @@ -7,6 +7,7 @@ import net.minecraft.world.item.crafting.RecipeSerializer; import net.neoforged.neoforge.registries.DeferredHolder; import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCrafting; +import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingAttunedRecipes; import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingSettings; import org.cyclops.integratedcrafting.recipe.type.RecipeDeadBush; import org.cyclops.integrateddynamics.item.ItemVariable; @@ -22,6 +23,7 @@ public class RegistryEntries { public static final DeferredHolder, MenuType> CONTAINER_INTERFACE_CRAFTING = DeferredHolder.create(Registries.MENU, ResourceLocation.parse("integratedcrafting:part_interface_crafting")); public static final DeferredHolder, MenuType> CONTAINER_INTERFACE_CRAFTING_SETTINGS = DeferredHolder.create(Registries.MENU, ResourceLocation.parse("integratedcrafting:part_interface_crafting_settings")); + public static final DeferredHolder, MenuType> CONTAINER_INTERFACE_CRAFTING_ATTUNED_RECIPES = DeferredHolder.create(Registries.MENU, ResourceLocation.parse("integratedcrafting:part_interface_crafting_attuned_recipes")); public static final DeferredHolder, RecipeSerializer> RECIPESERIALIZER_DEAD_BUSH = DeferredHolder.create(Registries.RECIPE_SERIALIZER, ResourceLocation.parse("integratedcrafting:crafting_special_dead_bush")); diff --git a/src/main/java/org/cyclops/integratedcrafting/api/recipe/RecipeKey.java b/src/main/java/org/cyclops/integratedcrafting/api/recipe/RecipeKey.java new file mode 100644 index 000000000..0ee57613b --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/api/recipe/RecipeKey.java @@ -0,0 +1,129 @@ +package org.cyclops.integratedcrafting.api.recipe; + +import net.minecraft.core.HolderLookup; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.Tag; +import net.minecraft.resources.ResourceLocation; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; + +import javax.annotation.Nullable; +import java.util.Objects; + +/** + * An opaque and persistable identifier of a recipe. + * + * Recipes that originate from a built-in recipe are identified by their recipe id, + * which keeps this key small, even for machines that expose thousands of recipes. + * All other recipes are identified by their full structural serialization. + * + * Keys are deliberately never resolved back into recipes, + * as {@link org.cyclops.commoncapabilities.api.capability.recipehandler.RecipeDefinition#fromRecipeId(HolderLookup.Provider, ResourceLocation)} + * throws once the recipe no longer exists. + * Keeping unknown keys opaque makes sure that a pack update can not silently + * change the meaning of a key that was stored before. + * + * @author rubensworks + */ +public final class RecipeKey { + + /** + * The NBT key under which {@link IRecipeDefinition#serialize(HolderLookup.Provider, IRecipeDefinition)} + * stores the id of built-in recipes. + */ + private static final String NBT_RECIPE_ID = "recipeId"; + + @Nullable + private final String recipeId; + @Nullable + private final CompoundTag structure; + + private RecipeKey(@Nullable String recipeId, @Nullable CompoundTag structure) { + this.recipeId = recipeId; + this.structure = structure; + } + + /** + * Create a key for the given built-in recipe id. + * @param recipeId A recipe id. + * @return A key. + */ + public static RecipeKey ofRecipeId(String recipeId) { + return new RecipeKey(Objects.requireNonNull(recipeId), null); + } + + /** + * Create a key for the given recipe. + * @param lookupProvider A lookup provider, + * only used for recipes that are not backed by a built-in recipe. + * @param recipe A recipe. + * @return A key. + */ + public static RecipeKey of(HolderLookup.Provider lookupProvider, IRecipeDefinition recipe) { + ResourceLocation recipeId = recipe.getRecipeId(); + if (recipeId != null) { + return new RecipeKey(recipeId.toString(), null); + } + return new RecipeKey(null, IRecipeDefinition.serialize(lookupProvider, recipe)); + } + + /** + * Read a key from NBT. + * @param tag An NBT tag, as produced by {@link #serialize()}. + * @return A key. + */ + public static RecipeKey deserialize(CompoundTag tag) { + if (tag.contains(NBT_RECIPE_ID, Tag.TAG_STRING)) { + return new RecipeKey(tag.getString(NBT_RECIPE_ID), null); + } + return new RecipeKey(null, tag.copy()); + } + + /** + * @return An NBT representation of this key. + */ + public CompoundTag serialize() { + if (this.recipeId != null) { + CompoundTag tag = new CompoundTag(); + tag.putString(NBT_RECIPE_ID, this.recipeId); + return tag; + } + return this.structure.copy(); + } + + /** + * @return The id of the built-in recipe this key refers to, or null if this key is structural. + */ + @Nullable + public String getRecipeId() { + return this.recipeId; + } + + /** + * @return If this key identifies a recipe by its full structure instead of by a recipe id. + */ + public boolean isStructural() { + return this.recipeId == null; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof RecipeKey that)) { + return false; + } + return Objects.equals(this.recipeId, that.recipeId) && Objects.equals(this.structure, that.structure); + } + + @Override + public int hashCode() { + return this.recipeId != null ? this.recipeId.hashCode() : this.structure.hashCode(); + } + + @Override + public String toString() { + return "[RecipeKey " + (this.recipeId != null ? this.recipeId : this.structure) + "]"; + } + +} diff --git a/src/main/java/org/cyclops/integratedcrafting/client/gui/ContainerScreenPartInterfaceCraftingAttunedRecipes.java b/src/main/java/org/cyclops/integratedcrafting/client/gui/ContainerScreenPartInterfaceCraftingAttunedRecipes.java new file mode 100644 index 000000000..5905d51ff --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/client/gui/ContainerScreenPartInterfaceCraftingAttunedRecipes.java @@ -0,0 +1,327 @@ +package org.cyclops.integratedcrafting.client.gui; + +import com.google.common.collect.Lists; +import com.mojang.blaze3d.systems.RenderSystem; +import com.mojang.datafixers.util.Either; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.util.Mth; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.tooltip.TooltipComponent; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; +import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; +import org.cyclops.cyclopscore.client.gui.component.button.ButtonImage; +import org.cyclops.cyclopscore.client.gui.component.button.ButtonText; +import org.cyclops.cyclopscore.client.gui.container.ContainerScreenScrolling; +import org.cyclops.cyclopscore.client.gui.image.IImage; +import org.cyclops.cyclopscore.helper.GuiHelpers; +import org.cyclops.cyclopscore.helper.RenderHelpers; +import org.cyclops.integratedcrafting.Reference; +import org.cyclops.integratedcrafting.client.gui.tooltip.RecipeInputs; +import org.cyclops.integratedcrafting.client.gui.tooltip.RecipeInputsTooltip; +import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingAttunedRecipes; + +import java.awt.Rectangle; +import java.util.List; + +/** + * Gui that shows all recipes of an attuned crafting interface in a grid. + * @author rubensworks + */ +public class ContainerScreenPartInterfaceCraftingAttunedRecipes extends ContainerScreenScrolling { + + private static final int GRID_X = 9; + private static final int GRID_Y = 18; + private static final int BUTTONS_Y = 132; + private static final int BUTTON_WIDTH = 52; + private static final int BUTTON_HEIGHT = 14; + /** + * The bulk action buttons are spread out over the full width of the grid. + */ + private static final int BUTTON_OFFSET = 55; + private static final String[] BULK_ACTION_NAMES = {"enableall", "disableall", "invert"}; + + /** + * The white overlay that highlights the cell under the mouse. + */ + private static final int COLOR_HOVER = 0x80FFFFFF; + /** + * The overlay that greys out a disabled recipe. + */ + private static final int COLOR_DISABLED = 0x80303030; + private static final int COLOR_BORDER_ENABLED = 0xFF44BB44; + private static final int COLOR_BORDER_DISABLED = 0xFFBB4444; + + public ContainerScreenPartInterfaceCraftingAttunedRecipes(ContainerPartInterfaceCraftingAttunedRecipes container, + Inventory inventory, Component title) { + super(container, inventory, title); + } + + @Override + protected ResourceLocation constructGuiTexture() { + return ResourceLocation.fromNamespaceAndPath(Reference.MOD_ID, "textures/gui/part_interface_crafting_attuned_recipes.png"); + } + + @Override + protected int getBaseXSize() { + return 195; + } + + @Override + protected int getBaseYSize() { + return 231; + } + + protected int getGridWidth() { + return getMenu().getColumns() * GuiHelpers.SLOT_SIZE; + } + + protected int getGridHeight() { + return getMenu().getPageSize() * GuiHelpers.SLOT_SIZE; + } + + @Override + protected int getScrollHeight() { + return getGridHeight() + 4; + } + + @Override + protected Rectangle getScrollRegion() { + return new Rectangle(this.leftPos + GRID_X, this.topPos + GRID_Y, getGridWidth(), getGridHeight()); + } + + @Override + public void init() { + clearWidgets(); + super.init(); + + addRenderableWidget(new ButtonImage(this.leftPos - 20, this.topPos, 18, 18, + Component.translatable("gui.integrateddynamics.part_settings"), + createServerPressable(ContainerPartInterfaceCraftingAttunedRecipes.BUTTON_SETTINGS, (button) -> {}), + new IImage[]{ + org.cyclops.integrateddynamics.client.gui.image.Images.BUTTON_BACKGROUND_INACTIVE, + org.cyclops.integrateddynamics.client.gui.image.Images.BUTTON_MIDDLE_SETTINGS + }, + false, 0, 0)); + if (getMenu().getPartType().supportsOffsets()) { + addRenderableWidget(new ButtonImage(this.leftPos - 20, this.topPos + 20, 18, 18, + Component.translatable("gui.integrateddynamics.part_offsets"), + createServerPressable(ContainerPartInterfaceCraftingAttunedRecipes.BUTTON_OFFSETS, (button) -> {}), + new IImage[]{ + org.cyclops.integrateddynamics.client.gui.image.Images.BUTTON_BACKGROUND_INACTIVE, + org.cyclops.integrateddynamics.client.gui.image.Images.BUTTON_MIDDLE_OFFSET + }, + false, 0, 0)); + } + + // The bulk actions apply to the recipes that match the current search filter, + // as toggling thousands of recipes one by one is not workable. + addBulkActionButton(0, ContainerPartInterfaceCraftingAttunedRecipes.BULK_ACTION_ENABLE); + addBulkActionButton(1, ContainerPartInterfaceCraftingAttunedRecipes.BULK_ACTION_DISABLE); + addBulkActionButton(2, ContainerPartInterfaceCraftingAttunedRecipes.BULK_ACTION_INVERT); + + getScrollbar().setTotalRows(getTotalGridRows()); + } + + protected void addBulkActionButton(int index, int action) { + Component label = Component.translatable(getBulkActionKey(index)); + addRenderableWidget(new ButtonText(this.leftPos + getBulkActionX(index), this.topPos + BUTTONS_Y, + BUTTON_WIDTH, BUTTON_HEIGHT, label, label, + (button) -> getMenu().applyBulkAction(action), true)); + } + + protected static String getBulkActionKey(int index) { + return "gui.integratedcrafting.partinterface.recipes." + BULK_ACTION_NAMES[index]; + } + + protected int getBulkActionX(int index) { + return GRID_X + index * BUTTON_OFFSET; + } + + /** + * The scrollbar rows are rounded up here, + * as a last row that is only partially filled must still be reachable. + * {@link ContainerScreenScrolling} rounds down, which would hide it. + */ + protected int getTotalGridRows() { + return Mth.ceil((double) getMenu().getFilteredItemCount() / getMenu().getColumns()); + } + + @Override + protected void updateSearch(String searchString) { + super.updateSearch(searchString); + getScrollbar().setTotalRows(getTotalGridRows()); + getScrollbar().scrollTo(0); + } + + /** + * @param mouseX The absolute mouse x position. + * @param mouseY The absolute mouse y position. + * @return The index of the grid cell under the mouse, or -1 if the mouse is not over a cell. + */ + protected int getCellIndexAt(double mouseX, double mouseY) { + int relativeX = (int) (mouseX - this.leftPos - this.offsetX - GRID_X); + int relativeY = (int) (mouseY - this.topPos - this.offsetY - GRID_Y); + if (relativeX < 0 || relativeY < 0 || relativeX >= getGridWidth() || relativeY >= getGridHeight()) { + return -1; + } + // Ignore the border between two cells + if (relativeX % GuiHelpers.SLOT_SIZE >= GuiHelpers.SLOT_SIZE_INNER + || relativeY % GuiHelpers.SLOT_SIZE >= GuiHelpers.SLOT_SIZE_INNER) { + return -1; + } + return relativeX / GuiHelpers.SLOT_SIZE + + (relativeY / GuiHelpers.SLOT_SIZE) * getMenu().getColumns(); + } + + protected int getCellX(int index) { + return this.leftPos + this.offsetX + GRID_X + (index % getMenu().getColumns()) * GuiHelpers.SLOT_SIZE; + } + + protected int getCellY(int index) { + return this.topPos + this.offsetY + GRID_Y + (index / getMenu().getColumns()) * GuiHelpers.SLOT_SIZE; + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int mouseButton) { + if (mouseButton == 0) { + int index = getCellIndexAt(mouseX, mouseY); + if (index >= 0) { + IRecipeDefinition recipe = getMenu().getVisibleElement(index); + if (recipe != null) { + getMenu().setRecipeEnabled(recipe, !getMenu().isRecipeEnabled(recipe)); + Minecraft.getInstance().getSoundManager() + .play(SimpleSoundInstance.forUI(SoundEvents.UI_BUTTON_CLICK, 1.0F)); + return true; + } + } + } + return super.mouseClicked(mouseX, mouseY, mouseButton); + } + + @Override + protected void renderBg(GuiGraphics guiGraphics, float partialTicks, int mouseX, int mouseY) { + super.renderBg(guiGraphics, partialTicks, mouseX, mouseY); + + RenderHelpers.drawScaledCenteredString(guiGraphics.pose(), guiGraphics.bufferSource(), font, + this.title.getString(), this.leftPos + this.offsetX + 6, this.topPos + this.offsetY + 10, 70, + 4210752, false, Font.DisplayMode.NORMAL); + + ContainerPartInterfaceCraftingAttunedRecipes container = getMenu(); + int cells = container.getPageSize() * container.getColumns(); + for (int i = 0; i < cells; i++) { + IRecipeDefinition recipe = container.getVisibleElement(i); + if (recipe == null) { + continue; + } + int x = getCellX(i); + int y = getCellY(i); + + boolean enabled = container.isRecipeEnabled(recipe); + + // The state border is inset by a pixel, so that the slot's own border stays visible, + // and is drawn before the output icon and its count so that it does not cut through them. + guiGraphics.renderOutline(x, y, GuiHelpers.SLOT_SIZE_INNER, GuiHelpers.SLOT_SIZE_INNER, + enabled ? COLOR_BORDER_ENABLED : COLOR_BORDER_DISABLED); + if (RenderHelpers.isPointInRegion(x, y, GuiHelpers.SLOT_SIZE_INNER, GuiHelpers.SLOT_SIZE_INNER, + mouseX, mouseY)) { + guiGraphics.fill(x, y, x + GuiHelpers.SLOT_SIZE_INNER, y + GuiHelpers.SLOT_SIZE_INNER, COLOR_HOVER); + } + + ItemStack outputItem = ContainerPartInterfaceCraftingAttunedRecipes.getOutputItem(recipe); + if (!outputItem.isEmpty()) { + guiGraphics.renderItem(outputItem, x, y); + guiGraphics.renderItemDecorations(font, outputItem, x, y); + } + + if (!enabled) { + // Draw in front of the output, which is rendered as a 3D item + guiGraphics.pose().pushPose(); + guiGraphics.pose().translate(0, 0, 300); + guiGraphics.fill(x, y, x + GuiHelpers.SLOT_SIZE_INNER, y + GuiHelpers.SLOT_SIZE_INNER, COLOR_DISABLED); + guiGraphics.pose().popPose(); + } + } + } + + @Override + protected void renderLabels(GuiGraphics guiGraphics, int mouseX, int mouseY) { + int index = getCellIndexAt(mouseX, mouseY); + if (index >= 0) { + IRecipeDefinition recipe = getMenu().getVisibleElement(index); + if (recipe != null) { + renderRecipeTooltip(guiGraphics, recipe, mouseX, mouseY); + } + } + + // The button labels are kept short enough to fit, so what they act on is in their tooltip + for (int i = 0; i < BULK_ACTION_NAMES.length; i++) { + if (isHovering(getBulkActionX(i), BUTTONS_Y, BUTTON_WIDTH, BUTTON_HEIGHT, mouseX, mouseY)) { + drawTooltip(Lists.newArrayList(Component.translatable(getBulkActionKey(i) + ".info")), + guiGraphics.pose(), mouseX - this.leftPos, mouseY - this.topPos); + } + } + + if (isHovering(-20, 0, 18, 18, mouseX, mouseY)) { + drawTooltip(Lists.newArrayList(Component.translatable("gui.integrateddynamics.part_settings")), + guiGraphics.pose(), mouseX - this.leftPos, mouseY - this.topPos); + } + if (getMenu().getPartType().supportsOffsets() && isHovering(-20, 20, 18, 18, mouseX, mouseY)) { + drawTooltip(Lists.newArrayList(Component.translatable("gui.integrateddynamics.part_offsets")), + guiGraphics.pose(), mouseX - this.leftPos, mouseY - this.topPos); + } + } + + protected void renderRecipeTooltip(GuiGraphics guiGraphics, IRecipeDefinition recipe, int mouseX, int mouseY) { + ContainerPartInterfaceCraftingAttunedRecipes.RecipeEntry entry = getMenu().getEntry(recipe); + Minecraft minecraft = Minecraft.getInstance(); + + List> elements = Lists.newArrayList(); + + ItemStack outputItem = ContainerPartInterfaceCraftingAttunedRecipes.getOutputItem(recipe); + if (outputItem.isEmpty()) { + elements.add(Either.left(entry.displayName())); + } else { + for (Component line : outputItem.getTooltipLines( + Item.TooltipContext.of(minecraft.level.registryAccess()), minecraft.player, + minecraft.options.advancedItemTooltips ? TooltipFlag.Default.ADVANCED : TooltipFlag.Default.NORMAL)) { + elements.add(Either.left(line)); + } + } + elements.add(Either.left(Component.literal(entry.identifier()).withStyle(ChatFormatting.DARK_GRAY))); + + // The inputs are only determined for the recipe that is actually hovered, + // as doing this for every shown recipe on every frame would be far too slow. + List>> inputs = RecipeInputs.getGroupedInputs(recipe); + if (!inputs.isEmpty()) { + elements.add(Either.left(Component.translatable("gui.integratedcrafting.partinterface.recipes.inputs") + .withStyle(ChatFormatting.YELLOW))); + elements.add(Either.right(new RecipeInputsTooltip(inputs))); + } + + elements.add(Either.left(Component.translatable(getMenu().isRecipeEnabled(recipe) + ? "gui.integratedcrafting.partinterface.recipes.click_disable" + : "gui.integratedcrafting.partinterface.recipes.click_enable") + .withStyle(ChatFormatting.AQUA))); + + // Don't write to the depth buffer, so that anything drawn after this tooltip is not occluded by it. + RenderSystem.disableDepthTest(); + // Tooltips are positioned in screen space, while this layer is translated to the position of the gui. + guiGraphics.pose().pushPose(); + guiGraphics.pose().translate(-this.leftPos, -this.topPos, 0); + guiGraphics.renderComponentTooltipFromElements(font, elements, mouseX, mouseY, ItemStack.EMPTY); + guiGraphics.pose().popPose(); + RenderSystem.enableDepthTest(); + } + +} diff --git a/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/ClientRecipeInputsTooltip.java b/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/ClientRecipeInputsTooltip.java new file mode 100644 index 000000000..92a3c22dd --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/ClientRecipeInputsTooltip.java @@ -0,0 +1,92 @@ +package org.cyclops.integratedcrafting.client.gui.tooltip; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.inventory.tooltip.ClientTooltipComponent; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; +import org.cyclops.cyclopscore.client.gui.GuiGraphicsExtended; +import org.cyclops.cyclopscore.helper.GuiHelpers; + +import java.util.List; + +/** + * Draws the inputs of a recipe as a grid of slots inside a tooltip. + * + * Inputs that accept multiple alternatives, such as tag-based inputs, + * cycle over their alternatives. + * + * @author rubensworks + */ +@OnlyIn(Dist.CLIENT) +public class ClientRecipeInputsTooltip implements ClientTooltipComponent { + + private static final ResourceLocation SLOT_SPRITE = ResourceLocation.withDefaultNamespace("container/bundle/slot"); + private static final int SLOT_WIDTH = 18; + private static final int SLOT_HEIGHT = 20; + private static final int MAX_COLUMNS = 9; + private static final int MARGIN_Y = 2; + + /** + * The number of ticks an alternative is shown before cycling to the next one. + */ + private static final int TICK_DELAY = 30; + + private final List>> inputs; + private final int columns; + private final int rows; + + public ClientRecipeInputsTooltip(RecipeInputsTooltip tooltip) { + this.inputs = tooltip.inputs(); + // Spread the inputs evenly over as few rows as possible + this.rows = Math.max(1, (int) Math.ceil((double) this.inputs.size() / MAX_COLUMNS)); + this.columns = Math.max(1, (int) Math.ceil((double) this.inputs.size() / this.rows)); + } + + @Override + public int getHeight() { + return this.rows * SLOT_HEIGHT + MARGIN_Y; + } + + @Override + public int getWidth(Font font) { + return this.columns * SLOT_WIDTH; + } + + @Override + public void renderImage(Font font, int x, int y, GuiGraphics guiGraphics) { + int tick = getTick(); + for (int i = 0; i < this.inputs.size(); i++) { + List> alternatives = this.inputs.get(i); + int slotX = x + (i % this.columns) * SLOT_WIDTH; + int slotY = y + (i / this.columns) * SLOT_HEIGHT; + guiGraphics.blitSprite(SLOT_SPRITE, slotX, slotY, SLOT_WIDTH, SLOT_HEIGHT); + // Cycle over the alternatives of this input + drawIngredient(guiGraphics, font, alternatives.get(tick % alternatives.size()), slotX + 1, slotY + 1); + } + } + + protected static void drawIngredient(GuiGraphics guiGraphics, Font font, + IPrototypedIngredient ingredient, int x, int y) { + T prototype = ingredient.getPrototype(); + if (prototype instanceof ItemStack itemStack) { + guiGraphics.renderItem(itemStack, x, y); + guiGraphics.renderItemDecorations(font, itemStack, x, y); + } else { + // Other ingredient types have no icon here, so only their quantity is shown + long quantity = ingredient.getComponent().getMatcher().getQuantity(prototype); + new GuiGraphicsExtended(guiGraphics).drawSlotText(font, + GuiHelpers.quantityToScaledString(quantity), x, y); + } + } + + protected static int getTick() { + Minecraft minecraft = Minecraft.getInstance(); + return minecraft.level == null ? 0 : (int) (minecraft.level.getGameTime() / TICK_DELAY); + } + +} diff --git a/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/RecipeInputs.java b/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/RecipeInputs.java new file mode 100644 index 000000000..3b50be2d3 --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/RecipeInputs.java @@ -0,0 +1,102 @@ +package org.cyclops.integratedcrafting.client.gui.tooltip; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IPrototypedIngredientAlternatives; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; +import org.cyclops.commoncapabilities.api.ingredient.IIngredientMatcher; +import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.commoncapabilities.api.ingredient.PrototypedIngredient; + +import java.util.List; +import java.util.Map; + +/** + * Collects the inputs that a recipe requires, for showing them in a gui. + * @author rubensworks + */ +public class RecipeInputs { + + /** + * Determine the inputs that the given recipe requires. + * + * Inputs that only differ in their quantity are grouped into a single entry + * whose quantity is the sum of theirs, + * so that a recipe that takes the same ingredient multiple times + * is shown as one entry with a higher quantity. + * + * @param recipe A recipe. + * @return The required inputs, each with all the alternatives that satisfy it. + */ + public static List>> getGroupedInputs(IRecipeDefinition recipe) { + List>> inputs = Lists.newArrayList(); + for (IngredientComponent inputComponent : recipe.getInputComponents()) { + addInputs(recipe, inputComponent, inputs); + } + return group(inputs); + } + + protected static void addInputs(IRecipeDefinition recipe, IngredientComponent inputComponent, + List>> inputs) { + IIngredientMatcher matcher = inputComponent.getMatcher(); + for (IPrototypedIngredientAlternatives alternatives : recipe.getInputs(inputComponent)) { + List> nonEmptyAlternatives = Lists.newArrayList(); + for (IPrototypedIngredient alternative : alternatives.getAlternatives()) { + if (!matcher.isEmpty(alternative.getPrototype())) { + nonEmptyAlternatives.add(alternative); + } + } + if (!nonEmptyAlternatives.isEmpty()) { + inputs.add(nonEmptyAlternatives); + } + } + } + + protected static List>> group(List>> inputs) { + List>> groupedInputs = Lists.newArrayList(); + // Inputs are keyed on their alternatives without quantities, so that only their quantities may differ. + Map>, Integer> groupIndexes = Maps.newHashMap(); + for (List> alternatives : inputs) { + List> groupKey = withoutQuantities(alternatives); + Integer groupIndex = groupIndexes.get(groupKey); + if (groupIndex == null) { + groupIndexes.put(groupKey, groupedInputs.size()); + groupedInputs.add(alternatives); + } else { + groupedInputs.set(groupIndex, addQuantities(groupedInputs.get(groupIndex), alternatives)); + } + } + return groupedInputs; + } + + protected static List> withoutQuantities(List> alternatives) { + List> withoutQuantities = Lists.newArrayListWithCapacity(alternatives.size()); + for (IPrototypedIngredient alternative : alternatives) { + withoutQuantities.add(withQuantity(alternative, 1)); + } + return withoutQuantities; + } + + protected static List> addQuantities(List> alternatives, + List> addedAlternatives) { + List> summed = Lists.newArrayListWithCapacity(alternatives.size()); + for (int i = 0; i < alternatives.size(); i++) { + IPrototypedIngredient alternative = alternatives.get(i); + summed.add(withQuantity(alternative, + getQuantity(alternative) + getQuantity(addedAlternatives.get(i)))); + } + return summed; + } + + protected static long getQuantity(IPrototypedIngredient ingredient) { + return ingredient.getComponent().getMatcher().getQuantity(ingredient.getPrototype()); + } + + protected static IPrototypedIngredient withQuantity(IPrototypedIngredient ingredient, long quantity) { + IngredientComponent component = ingredient.getComponent(); + return new PrototypedIngredient<>(component, + component.getMatcher().withQuantity(ingredient.getPrototype(), quantity), ingredient.getCondition()); + } + +} diff --git a/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/RecipeInputsTooltip.java b/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/RecipeInputsTooltip.java new file mode 100644 index 000000000..9c9b1e786 --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/client/gui/tooltip/RecipeInputsTooltip.java @@ -0,0 +1,18 @@ +package org.cyclops.integratedcrafting.client.gui.tooltip; + +import net.minecraft.world.inventory.tooltip.TooltipComponent; +import org.cyclops.commoncapabilities.api.ingredient.IPrototypedIngredient; + +import java.util.List; + +/** + * A tooltip component holding the inputs that a recipe requires. + * + * Every entry in {@link #inputs()} represents a single required input, + * which holds all ingredients that are valid alternatives for that input. + * + * @param inputs The required inputs, with their alternatives. + * @author rubensworks + */ +public record RecipeInputsTooltip(List>> inputs) implements TooltipComponent { +} diff --git a/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsAttunedRecipes.java b/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsAttunedRecipes.java new file mode 100644 index 000000000..ffd5fbd58 --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/gametest/GameTestsAttunedRecipes.java @@ -0,0 +1,482 @@ +package org.cyclops.integratedcrafting.gametest; + +import io.netty.buffer.Unpooled; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntArrayTag; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.MenuProvider; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; +import org.cyclops.commoncapabilities.IngredientComponents; +import org.cyclops.commoncapabilities.api.capability.itemhandler.ItemMatch; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; +import org.cyclops.integratedcrafting.Reference; +import org.cyclops.integratedcrafting.api.crafting.CraftingJob; +import org.cyclops.integratedcrafting.api.crafting.CraftingJobDependencyGraph; +import org.cyclops.integratedcrafting.api.crafting.RecursiveCraftingRecipeException; +import org.cyclops.integratedcrafting.api.crafting.UnknownCraftingRecipeException; +import org.cyclops.integratedcrafting.api.network.ICraftingNetwork; +import org.cyclops.integratedcrafting.api.recipe.RecipeKey; +import org.cyclops.cyclopscore.helper.ValueNotifierHelpers; +import org.cyclops.integratedcrafting.core.CraftingHelpers; +import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingAttunedRecipes; +import org.cyclops.integratedcrafting.part.PartTypeInterfaceCraftingAttuned; +import org.cyclops.integratedcrafting.part.PartTypes; +import org.cyclops.integrateddynamics.api.evaluate.variable.ValueDeseralizationContext; +import org.cyclops.integrateddynamics.api.network.INetwork; +import org.cyclops.integrateddynamics.api.network.IPositionedAddonsNetwork; +import org.cyclops.integrateddynamics.api.part.PartPos; +import org.cyclops.integrateddynamics.core.helper.NetworkHelpers; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static org.cyclops.integratedcrafting.gametest.GameTestHelpersIntegratedCrafting.createBasicNetwork; +import static org.cyclops.integratedcrafting.gametest.GameTestHelpersIntegratedCrafting.enableRecipeInWriter; + +/** + * Game tests for enabling and disabling individual recipes of an attuned crafting interface. + * + * @author rubensworks + */ +@GameTestHolder(Reference.MOD_ID) +@PrefixGameTestTemplate(false) +public class GameTestsAttunedRecipes { + + public static final String TEMPLATE_EMPTY = "empty10"; + public static final int TIMEOUT = 2000; + public static final BlockPos POS = BlockPos.ZERO.offset(2, 0, 2); + + public static final String RECIPE_CHEST = "minecraft:chest"; + + protected static boolean containsRecipeId(Collection recipes, String recipeId) { + return recipes.stream() + .anyMatch(recipe -> recipe.getRecipeId() != null && recipe.getRecipeId().toString().equals(recipeId)); + } + + protected static int indexOfRecipeId(List recipes, String recipeId) { + for (int i = 0; i < recipes.size(); i++) { + IRecipeDefinition recipe = recipes.get(i); + if (recipe.getRecipeId() != null && recipe.getRecipeId().toString().equals(recipeId)) { + return i; + } + } + return -1; + } + + /** + * Wrap a gui payload the way {@link ValueNotifierHelpers} does, so that it can be fed to a container. + */ + protected static CompoundTag wrapValue(CompoundTag payload) { + CompoundTag tag = new CompoundTag(); + tag.put(ValueNotifierHelpers.KEY, payload); + return tag; + } + + protected static INetwork getNetwork(GameTestHelper helper, PartPos partPos) { + return NetworkHelpers.getNetworkChecked(partPos.getPos().getLevel(true), + partPos.getPos().getBlockPos(), partPos.getSide()); + } + + /** + * Disabling a recipe must remove it from the crafting network's recipe index, + * and enabling it again must put it back. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testAttunedDisabledRecipeIsNotIndexed(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + createBasicNetwork(helper, POS, true); + PartTypeInterfaceCraftingAttuned.State partState = positions.interfaceStates().get(0); + RecipeKey chestKey = RecipeKey.ofRecipeId(RECIPE_CHEST); + + helper.startSequence() + .thenWaitUntil(() -> { + ICraftingNetwork craftingNetwork = partState.getCraftingNetwork(); + helper.assertTrue(craftingNetwork != null, "The interface has no crafting network"); + helper.assertTrue(containsRecipeId(craftingNetwork.getRecipeIndex(0).getRecipes(), RECIPE_CHEST), + "The chest recipe is not indexed yet"); + }) + .thenExecute(() -> { + ICraftingNetwork craftingNetwork = partState.getCraftingNetwork(); + int indexedBefore = craftingNetwork.getRecipeIndex(0).getRecipes().size(); + int exposedBefore = partState.getRecipes().size(); + helper.assertValueEqual(partState.getAllRecipes().size(), exposedBefore, + "Not all recipes are exposed initially"); + + partState.setRecipesEnabled(Collections.singleton(chestKey), false); + + helper.assertFalse(partState.isRecipeEnabled(chestKey), "The chest recipe is still enabled"); + helper.assertValueEqual(partState.getRecipes().size(), exposedBefore - 1, + "The interface still exposes the disabled recipe"); + helper.assertFalse(containsRecipeId(partState.getRecipes(), RECIPE_CHEST), + "The interface still exposes the chest recipe"); + helper.assertValueEqual(craftingNetwork.getRecipeIndex(0).getRecipes().size(), indexedBefore - 1, + "The network recipe index did not shrink"); + helper.assertFalse(containsRecipeId(craftingNetwork.getRecipeIndex(0).getRecipes(), RECIPE_CHEST), + "The chest recipe is still indexed after disabling it"); + + // All recipes stay listed for the gui, only the exposed ones shrink + helper.assertTrue(containsRecipeId(partState.getAllRecipes(), RECIPE_CHEST), + "The chest recipe disappeared from the full recipe list"); + + partState.setRecipesEnabled(Collections.singleton(chestKey), true); + + helper.assertTrue(partState.isRecipeEnabled(chestKey), "The chest recipe is still disabled"); + helper.assertValueEqual(partState.getRecipes().size(), exposedBefore, + "The interface did not expose the re-enabled recipe"); + helper.assertValueEqual(craftingNetwork.getRecipeIndex(0).getRecipes().size(), indexedBefore, + "The network recipe index did not grow back"); + helper.assertTrue(containsRecipeId(craftingNetwork.getRecipeIndex(0).getRecipes(), RECIPE_CHEST), + "The chest recipe was not indexed again after enabling it"); + }) + .thenSucceed(); + } + + /** + * The crafting job planner must not select a disabled recipe. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testAttunedDisabledRecipeIsNotPlanned(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + createBasicNetwork(helper, POS, true); + PartTypeInterfaceCraftingAttuned.State partState = positions.interfaceStates().get(0); + RecipeKey chestKey = RecipeKey.ofRecipeId(RECIPE_CHEST); + + // Insert items in interface chest, so that the planner has ingredients to work with + ChestBlockEntity chestIn = helper.getBlockEntity(POS.east()); + chestIn.setItem(0, new ItemStack(Items.OAK_PLANKS, 64)); + + helper.startSequence() + .thenWaitUntil(() -> { + helper.assertTrue(partState.getCraftingNetwork() != null, "The interface has no crafting network"); + helper.assertTrue(containsRecipeId(partState.getCraftingNetwork().getRecipeIndex(0).getRecipes(), RECIPE_CHEST), + "The chest recipe is not indexed yet"); + }) + .thenExecute(() -> { + INetwork network = getNetwork(helper, positions.interfaces().get(0)); + + helper.assertTrue(planChest(network) != null, "The planner did not find the chest recipe"); + + partState.setRecipesEnabled(Collections.singleton(chestKey), false); + helper.assertTrue(planChest(network) == null, "The planner still selected the disabled chest recipe"); + + partState.setRecipesEnabled(Collections.singleton(chestKey), true); + helper.assertTrue(planChest(network) != null, "The planner did not find the re-enabled chest recipe"); + }) + .thenSucceed(); + } + + protected CraftingJob planChest(INetwork network) { + try { + return CraftingHelpers.calculateCraftingJobs(network, 0, IngredientComponents.ITEMSTACK, + new ItemStack(Items.CHEST), ItemMatch.ITEM, true, + CraftingHelpers.getGlobalCraftingJobIdentifier(), new CraftingJobDependencyGraph(), false); + } catch (UnknownCraftingRecipeException | RecursiveCraftingRecipeException e) { + return null; + } + } + + /** + * The crafting network unregisters an interface by iterating over the recipes it exposes, + * so the exposed recipes must never change without the network being told about it. + * Otherwise, disabled recipes would leak into the network's recipe index forever. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testAttunedDisabledRecipeDoesNotLeakOnUnregister(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + createBasicNetwork(helper, POS, true); + PartTypeInterfaceCraftingAttuned.State partState = positions.interfaceStates().get(0); + RecipeKey chestKey = RecipeKey.ofRecipeId(RECIPE_CHEST); + + helper.startSequence() + .thenWaitUntil(() -> { + ICraftingNetwork craftingNetwork = partState.getCraftingNetwork(); + helper.assertTrue(craftingNetwork != null, "The interface has no crafting network"); + helper.assertTrue(containsRecipeId(craftingNetwork.getRecipeIndex(0).getRecipes(), RECIPE_CHEST), + "The chest recipe is not indexed yet"); + }) + .thenExecute(() -> { + ICraftingNetwork craftingNetwork = partState.getCraftingNetwork(); + + partState.setRecipesEnabled(Collections.singleton(chestKey), false); + + // Moving the interface to another channel unregisters and re-registers it, + // which is the code path that iterates over the exposed recipes. + partState.setChannelCrafting(1); + + // The channel-independent index is never cleaned up per channel, + // so it is where recipes would leak into. + Collection allIndexed = craftingNetwork + .getRecipeIndex(IPositionedAddonsNetwork.WILDCARD_CHANNEL).getRecipes(); + helper.assertFalse(containsRecipeId(allIndexed, RECIPE_CHEST), + "The disabled chest recipe leaked into the network recipe index"); + helper.assertValueEqual(allIndexed.size(), partState.getRecipes().size(), + "The network recipe index does not match the exposed recipes"); + helper.assertTrue(containsRecipeId(craftingNetwork.getRecipeIndex(1).getRecipes(), "minecraft:stick"), + "The interface did not re-register its recipes on the new channel"); + helper.assertFalse(containsRecipeId(craftingNetwork.getRecipeIndex(1).getRecipes(), RECIPE_CHEST), + "The disabled chest recipe was registered on the new channel"); + }) + .thenSucceed(); + } + + /** + * Disabled recipes must survive a save and load of the part state. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testAttunedDisabledRecipesArePersisted(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + createBasicNetwork(helper, POS, true); + PartTypeInterfaceCraftingAttuned.State partState = positions.interfaceStates().get(0); + RecipeKey chestKey = RecipeKey.ofRecipeId(RECIPE_CHEST); + RecipeKey removedKey = RecipeKey.ofRecipeId("somemod:removed_recipe"); + + helper.startSequence() + .thenWaitUntil(() -> helper.assertTrue(partState.getCraftingNetwork() != null, + "The interface has no crafting network")) + .thenExecute(() -> { + ValueDeseralizationContext context = ValueDeseralizationContext.of(helper.getLevel()); + partState.setRecipesEnabled(java.util.List.of(chestKey, removedKey), false); + + CompoundTag tag = new CompoundTag(); + partState.writeToNBT(context, tag); + + PartTypeInterfaceCraftingAttuned.State loadedState = new PartTypeInterfaceCraftingAttuned.State(); + loadedState.readFromNBT(context, tag); + + helper.assertFalse(loadedState.isRecipeEnabled(chestKey), + "The disabled chest recipe was not persisted"); + // Keys of recipes that no longer exist must stay opaque, + // so that a pack update can not silently re-enable them. + helper.assertFalse(loadedState.isRecipeEnabled(removedKey), + "The key of a recipe that no longer exists was not persisted"); + helper.assertValueEqual(loadedState.getDisabledRecipes().size(), 2, + "The wrong number of disabled recipes was persisted"); + }) + .thenSucceed(); + } + + /** + * All recipes must survive the trip to the client through the gui data buffer, + * together with which of them are disabled. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testAttunedRecipesReachTheGui(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + createBasicNetwork(helper, POS, true); + PartTypeInterfaceCraftingAttuned.State partState = positions.interfaceStates().get(0); + RecipeKey chestKey = RecipeKey.ofRecipeId(RECIPE_CHEST); + + helper.startSequence() + .thenWaitUntil(() -> { + helper.assertTrue(partState.getCraftingNetwork() != null, "The interface has no crafting network"); + helper.assertTrue(containsRecipeId(partState.getAllRecipes(), RECIPE_CHEST), + "The chest recipe is not read yet"); + }) + .thenExecute(() -> { + partState.setRecipesEnabled(Collections.singleton(chestKey), false); + + ContainerPartInterfaceCraftingAttunedRecipes container = openGuiContainer(helper, positions); + + helper.assertValueEqual(container.getUnfilteredItemCount(), partState.getAllRecipes().size(), + "Not all recipes reached the gui"); + + // The search matches recipe ids, so this isolates the chest recipe + container.updateFilter(RECIPE_CHEST); + IRecipeDefinition chestRecipe = null; + for (int i = 0; i < container.getPageSize() * container.getColumns(); i++) { + IRecipeDefinition recipe = container.getVisibleElement(i); + if (recipe != null && RECIPE_CHEST.equals(container.getEntry(recipe).identifier())) { + chestRecipe = recipe; + } + } + helper.assertTrue(chestRecipe != null, "The chest recipe was not found through the gui search"); + helper.assertFalse(container.isRecipeEnabled(chestRecipe), + "The gui does not show the chest recipe as disabled"); + helper.assertValueEqual(container.getEntry(chestRecipe).serverIndex(), + indexOfRecipeId(partState.getAllRecipes(), RECIPE_CHEST), + "The gui has the wrong server index for the chest recipe"); + + // The whole grid is filled when there are more recipes than fit on one page + container.updateFilter(""); + int cells = container.getPageSize() * container.getColumns(); + helper.assertTrue(container.getFilteredItemCount() > cells, + "The target does not expose enough recipes to fill the gui grid"); + for (int i = 0; i < cells; i++) { + helper.assertTrue(container.getVisibleElement(i) != null, + "The gui grid has a hole at cell " + i); + } + + // Recipes that do not match the search must not be shown + container.updateFilter("this recipe does not exist"); + helper.assertValueEqual(container.getFilteredItemCount(), 0, + "The gui shows recipes that do not match the search"); + helper.assertTrue(container.getVisibleElement(0) == null, + "The gui grid still shows a recipe that does not match the search"); + + // The part must also hand out the same container to a player opening it + ServerPlayer player = helper.makeMockServerPlayerInLevel(); + MenuProvider menuProvider = PartTypes.INTERFACE_CRAFTING_ATTUNED + .getContainerProvider(positions.interfaces().get(0)).orElse(null); + helper.assertTrue(menuProvider != null, "The part has no gui"); + helper.assertValueEqual(menuProvider.getDisplayName().getString(), + Component.translatable(PartTypes.INTERFACE_CRAFTING_ATTUNED.getTranslationKey()).getString(), + "The gui has the wrong title"); + AbstractContainerMenu menu = menuProvider.createMenu(2, player.getInventory(), player); + helper.assertTrue(menu instanceof ContainerPartInterfaceCraftingAttunedRecipes, + "The part opened the wrong gui"); + helper.assertValueEqual(((ContainerPartInterfaceCraftingAttunedRecipes) menu).getUnfilteredItemCount(), + partState.getAllRecipes().size(), "The opened gui has the wrong number of recipes"); + }) + .thenSucceed(); + } + + /** + * The gui toggles recipes by sending the recipe key, and applies bulk actions + * by sending the server-side indexes of the recipes that match its search. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testAttunedRecipesGuiActionsAreApplied(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + createBasicNetwork(helper, POS, true); + PartTypeInterfaceCraftingAttuned.State partState = positions.interfaceStates().get(0); + RecipeKey chestKey = RecipeKey.ofRecipeId(RECIPE_CHEST); + + helper.startSequence() + .thenWaitUntil(() -> { + helper.assertTrue(partState.getCraftingNetwork() != null, "The interface has no crafting network"); + helper.assertTrue(containsRecipeId(partState.getAllRecipes(), RECIPE_CHEST), + "The chest recipe is not read yet"); + }) + .thenExecute(() -> { + ContainerPartInterfaceCraftingAttunedRecipes container = openGuiContainer(helper, positions); + int chestIndex = indexOfRecipeId(partState.getAllRecipes(), RECIPE_CHEST); + + // Single toggle, keyed by recipe key + container.onUpdate(container.getToggleRecipeValueId(), + wrapValue(toggleTag(chestKey, false, 0))); + helper.assertFalse(partState.isRecipeEnabled(chestKey), + "The gui toggle did not disable the chest recipe"); + + // Repeating the same toggle after re-enabling it must not be swallowed, + // which is what the sequence number in the payload is for. + container.onUpdate(container.getToggleRecipeValueId(), + wrapValue(toggleTag(chestKey, true, 1))); + helper.assertTrue(partState.isRecipeEnabled(chestKey), + "The gui toggle did not enable the chest recipe"); + container.onUpdate(container.getToggleRecipeValueId(), + wrapValue(toggleTag(chestKey, false, 2))); + helper.assertFalse(partState.isRecipeEnabled(chestKey), + "The repeated gui toggle was swallowed"); + + // Bulk actions + container.onUpdate(container.getBulkActionValueId(), wrapValue(bulkTag( + ContainerPartInterfaceCraftingAttunedRecipes.BULK_ACTION_ENABLE, + partState.getRecipesVersion(), new int[]{chestIndex}, 3))); + helper.assertTrue(partState.isRecipeEnabled(chestKey), "The bulk enable was not applied"); + + container.onUpdate(container.getBulkActionValueId(), wrapValue(bulkTag( + ContainerPartInterfaceCraftingAttunedRecipes.BULK_ACTION_INVERT, + partState.getRecipesVersion(), new int[]{chestIndex}, 4))); + helper.assertFalse(partState.isRecipeEnabled(chestKey), "The bulk invert was not applied"); + + // Indexes from a recipe list the server no longer has must be ignored + container.onUpdate(container.getBulkActionValueId(), wrapValue(bulkTag( + ContainerPartInterfaceCraftingAttunedRecipes.BULK_ACTION_ENABLE, + partState.getRecipesVersion() + 1, new int[]{chestIndex}, 5))); + helper.assertFalse(partState.isRecipeEnabled(chestKey), + "A bulk action with stale indexes was applied"); + + // Out-of-range indexes must be ignored instead of throwing + container.onUpdate(container.getBulkActionValueId(), wrapValue(bulkTag( + ContainerPartInterfaceCraftingAttunedRecipes.BULK_ACTION_ENABLE, + partState.getRecipesVersion(), + new int[]{-1, partState.getAllRecipes().size(), chestIndex}, 6))); + helper.assertTrue(partState.isRecipeEnabled(chestKey), + "The bulk enable with out-of-range indexes was not applied"); + }) + .thenSucceed(); + } + + /** + * Open the part's gui container the way a player would, by writing its gui data + * and constructing the container from it. + */ + protected ContainerPartInterfaceCraftingAttunedRecipes openGuiContainer( + GameTestHelper helper, + GameTestHelpersIntegratedCrafting.INetworkPositions positions) { + ServerPlayer player = helper.makeMockServerPlayerInLevel(); + RegistryFriendlyByteBuf packetBuffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), + helper.getLevel().registryAccess()); + PartTypes.INTERFACE_CRAFTING_ATTUNED.writeExtraGuiData(packetBuffer, positions.interfaces().get(0), player); + return new ContainerPartInterfaceCraftingAttunedRecipes(1, player.getInventory(), packetBuffer); + } + + protected static CompoundTag toggleTag(RecipeKey key, boolean enabled, int sequence) { + CompoundTag tag = new CompoundTag(); + tag.put("key", key.serialize()); + tag.putBoolean("enabled", enabled); + tag.putInt("seq", sequence); + return tag; + } + + protected static CompoundTag bulkTag(int action, int version, int[] indexes, int sequence) { + CompoundTag tag = new CompoundTag(); + tag.putInt("action", action); + tag.putInt("version", version); + tag.put("indexes", new IntArrayTag(indexes)); + tag.putInt("seq", sequence); + return tag; + } + + /** + * A disabled recipe must not be crafted, and must be craftable again after enabling it. + */ + @GameTest(template = TEMPLATE_EMPTY, timeoutTicks = TIMEOUT) + public void testAttunedDisabledRecipeIsNotCrafted(GameTestHelper helper) { + GameTestHelpersIntegratedCrafting.INetworkPositions positions = + createBasicNetwork(helper, POS, true); + PartTypeInterfaceCraftingAttuned.State partState = positions.interfaceStates().get(0); + RecipeKey chestKey = RecipeKey.ofRecipeId(RECIPE_CHEST); + + // Insert items in interface chest + ChestBlockEntity chestIn = helper.getBlockEntity(POS.east()); + chestIn.setItem(0, new ItemStack(Items.OAK_PLANKS, 64)); + + helper.startSequence() + .thenWaitUntil(() -> { + ICraftingNetwork craftingNetwork = partState.getCraftingNetwork(); + helper.assertTrue(craftingNetwork != null, "The interface has no crafting network"); + helper.assertTrue(containsRecipeId(craftingNetwork.getRecipeIndex(0).getRecipes(), RECIPE_CHEST), + "The chest recipe is not indexed yet"); + }) + .thenExecute(() -> { + partState.setRecipesEnabled(Collections.singleton(chestKey), false); + enableRecipeInWriter(helper, positions.writer(), new ItemStack(Items.CHEST)); + }) + .thenExecuteAfter(200, () -> { + helper.assertValueEqual(chestIn.getItem(0).getItem(), Items.OAK_PLANKS, "Slot 0 item is incorrect"); + helper.assertValueEqual(chestIn.getItem(0).getCount(), 64, + "Planks were consumed while the chest recipe was disabled"); + helper.assertTrue(chestIn.getItem(1).isEmpty(), "A chest was crafted while its recipe was disabled"); + + partState.setRecipesEnabled(Collections.singleton(chestKey), true); + }) + .thenWaitUntil(() -> { + helper.assertValueEqual(chestIn.getItem(1).getItem(), Items.CHEST, "Slot 1 item is incorrect"); + helper.assertValueEqual(chestIn.getItem(1).getCount(), 1, "Slot 1 amount is incorrect"); + }) + .thenSucceed(); + } + +} diff --git a/src/main/java/org/cyclops/integratedcrafting/inventory/container/ContainerPartInterfaceCraftingAttunedRecipes.java b/src/main/java/org/cyclops/integratedcrafting/inventory/container/ContainerPartInterfaceCraftingAttunedRecipes.java new file mode 100644 index 000000000..0612918d5 --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/inventory/container/ContainerPartInterfaceCraftingAttunedRecipes.java @@ -0,0 +1,518 @@ +package org.cyclops.integratedcrafting.inventory.container; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import net.minecraft.core.HolderLookup; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.IntArrayTag; +import net.minecraft.nbt.Tag; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.Container; +import net.minecraft.world.SimpleContainer; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.logging.log4j.Level; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; +import org.cyclops.commoncapabilities.api.capability.recipehandler.RecipeDefinition; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.cyclopscore.helper.ValueNotifierHelpers; +import org.cyclops.cyclopscore.inventory.container.ScrollingInventoryContainer; +import org.cyclops.integratedcrafting.IntegratedCrafting; +import org.cyclops.integratedcrafting.RegistryEntries; +import org.cyclops.integratedcrafting.api.recipe.RecipeKey; +import org.cyclops.integratedcrafting.part.PartTypeInterfaceCraftingAttuned; +import org.cyclops.integrateddynamics.api.part.IPartContainer; +import org.cyclops.integrateddynamics.api.part.IPartType; +import org.cyclops.integrateddynamics.api.part.PartTarget; +import org.cyclops.integrateddynamics.core.helper.PartHelpers; + +import javax.annotation.Nullable; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Container that lists all recipes of an attuned crafting interface, + * and that allows the player to enable or disable each of them. + * + * Recipes are identified by their {@link RecipeKey}, and never by their position in this list, + * because the client sorts its copy of the list by display name, + * and because the server may re-read its recipes while this gui is open. + * + * @author rubensworks + */ +public class ContainerPartInterfaceCraftingAttunedRecipes extends ScrollingInventoryContainer { + + public static final String BUTTON_SETTINGS = "button_settings"; + public static final String BUTTON_OFFSETS = "button_offsets"; + + /** + * The number of recipes that are shown next to each other. + * This matches the player inventory below it, so that both grids line up. + */ + public static final int COLUMNS = 9; + public static final int ROWS = 6; + + public static final int BULK_ACTION_ENABLE = 0; + public static final int BULK_ACTION_DISABLE = 1; + public static final int BULK_ACTION_INVERT = 2; + + private final PartTarget target; + private final IPartContainer partContainer; + private final IPartType partType; + + private final Map entries; + private final Set disabledRecipes; + private final int recipesVersion; + + private final int toggleRecipeValueId; + private final int bulkActionValueId; + + private int sequence = 0; + + public ContainerPartInterfaceCraftingAttunedRecipes(int id, Inventory playerInventory, RegistryFriendlyByteBuf packetBuffer) { + this(id, playerInventory, new SimpleContainer(0), + PartHelpers.readPartTarget(packetBuffer), Optional.empty(), PartHelpers.readPart(packetBuffer), + readRecipes(packetBuffer)); + } + + public ContainerPartInterfaceCraftingAttunedRecipes(int id, Inventory playerInventory, Container inventory, + PartTarget target, Optional partContainer, + IPartType partType, List recipes, + Set disabledRecipes, int recipesVersion) { + this(id, playerInventory, inventory, target, partContainer, partType, + new GuiRecipes(createServerEntries(recipes), disabledRecipes, recipesVersion)); + } + + private ContainerPartInterfaceCraftingAttunedRecipes(int id, Inventory playerInventory, Container inventory, + PartTarget target, Optional partContainer, + IPartType partType, GuiRecipes guiRecipes) { + super(RegistryEntries.CONTAINER_INTERFACE_CRAFTING_ATTUNED_RECIPES.get(), id, playerInventory, inventory, + guiRecipes.getRecipes(), (recipe, pattern) -> { + RecipeEntry entry = guiRecipes.getEntries().get(recipe); + return entry == null || pattern.matcher(entry.searchString()).matches(); + }); + this.target = target; + this.partContainer = partContainer.orElseGet(() -> PartHelpers.getPartContainerChecked(target.getCenter())); + this.partType = partType; + + this.entries = guiRecipes.getEntries(); + this.disabledRecipes = Sets.newHashSet(guiRecipes.getDisabledRecipes()); + this.recipesVersion = guiRecipes.getRecipesVersion(); + + this.toggleRecipeValueId = getNextValueId(); + this.bulkActionValueId = getNextValueId(); + + addPlayerInventory(player.getInventory(), 9, 149); + + putButtonAction(BUTTON_SETTINGS, (s, containerExtended) -> { + if (!player.level().isClientSide()) { + PartHelpers.openContainerPartSettings((ServerPlayer) player, getTarget().getCenter(), getPartType()); + } + }); + putButtonAction(BUTTON_OFFSETS, (s, containerExtended) -> { + if (!player.level().isClientSide()) { + PartHelpers.openContainerPartOffsets((ServerPlayer) player, getTarget().getCenter(), getPartType()); + } + }); + } + + public IPartType getPartType() { + return this.partType; + } + + public PartTarget getTarget() { + return this.target; + } + + public PartTypeInterfaceCraftingAttuned.State getPartState() { + return (PartTypeInterfaceCraftingAttuned.State) this.partContainer.getPartState(getTarget().getCenter().getSide()); + } + + /** + * @return The value id under which single recipe toggles are sent to the server. + */ + public int getToggleRecipeValueId() { + return this.toggleRecipeValueId; + } + + /** + * @return The value id under which bulk actions are sent to the server. + */ + public int getBulkActionValueId() { + return this.bulkActionValueId; + } + + @Override + public int getPageSize() { + return ROWS; + } + + @Override + public int getColumns() { + return COLUMNS; + } + + @Override + protected int getSizeInventory() { + return 0; + } + + @Override + public boolean stillValid(Player player) { + return PartHelpers.canInteractWith(getTarget(), player, this.partContainer); + } + + /** + * @param recipe One of the recipes in this container. + * @return The gui data of the given recipe, or null if it is unknown. + */ + @Nullable + public RecipeEntry getEntry(IRecipeDefinition recipe) { + return this.entries.get(recipe); + } + + /** + * @param recipe One of the recipes in this container. + * @return If the given recipe is exposed to the crafting network. + */ + public boolean isRecipeEnabled(IRecipeDefinition recipe) { + RecipeEntry entry = getEntry(recipe); + return entry == null || !this.disabledRecipes.contains(entry.key()); + } + + /** + * Enable or disable a single recipe. + * + * The change is applied locally right away, and sent to the server keyed by recipe key. + * + * @param recipe One of the recipes in this container. + * @param enabled If the recipe should be exposed to the crafting network. + */ + public void setRecipeEnabled(IRecipeDefinition recipe, boolean enabled) { + RecipeEntry entry = getEntry(recipe); + if (entry == null) { + return; + } + setRecipeEnabledLocally(entry, enabled); + + CompoundTag tag = new CompoundTag(); + tag.put("key", entry.key().serialize()); + tag.putBoolean("enabled", enabled); + // The value notifier drops values that are equal to the previously sent one, + // so a monotonic sequence number is needed to make repeated identical toggles arrive. + tag.putInt("seq", this.sequence++); + ValueNotifierHelpers.setValue(this, this.toggleRecipeValueId, tag); + } + + /** + * Apply a bulk action to all recipes that match the current search filter. + * + * The affected recipes are sent as indexes in the server's recipe list, + * as sending thousands of recipe keys would be needlessly large. + * The server ignores the action if it re-read its recipes in the meantime, + * which is detected via the recipe list version. + * + * @param action One of {@link #BULK_ACTION_ENABLE}, {@link #BULK_ACTION_DISABLE} and {@link #BULK_ACTION_INVERT}. + */ + public void applyBulkAction(int action) { + List> filteredItems = getFilteredItems(); + int[] indexes = new int[filteredItems.size()]; + int i = 0; + for (Pair filteredItem : filteredItems) { + RecipeEntry entry = getEntry(filteredItem.getRight()); + if (entry == null) { + continue; + } + indexes[i++] = entry.serverIndex(); + setRecipeEnabledLocally(entry, isEnabledAfterBulkAction(action, this.disabledRecipes.contains(entry.key()))); + } + if (i < indexes.length) { + indexes = Arrays.copyOf(indexes, i); + } + + CompoundTag tag = new CompoundTag(); + tag.putInt("action", action); + tag.putInt("version", this.recipesVersion); + tag.put("indexes", new IntArrayTag(indexes)); + // See setRecipeEnabled: the value notifier drops repeated identical values. + tag.putInt("seq", this.sequence++); + ValueNotifierHelpers.setValue(this, this.bulkActionValueId, tag); + } + + protected void setRecipeEnabledLocally(RecipeEntry entry, boolean enabled) { + if (enabled) { + this.disabledRecipes.remove(entry.key()); + } else { + this.disabledRecipes.add(entry.key()); + } + } + + protected static boolean isEnabledAfterBulkAction(int action, boolean wasDisabled) { + return switch (action) { + case BULK_ACTION_ENABLE -> true; + case BULK_ACTION_DISABLE -> false; + default -> wasDisabled; + }; + } + + @Override + public void onUpdate(int valueId, CompoundTag value) { + super.onUpdate(valueId, value); + if (player.level().isClientSide()) { + return; + } + try { + Tag rawValue = ValueNotifierHelpers.getValueNbt(this, valueId); + if (!(rawValue instanceof CompoundTag payload)) { + return; + } + if (valueId == this.toggleRecipeValueId) { + getPartState().setRecipesEnabled( + Collections.singleton(RecipeKey.deserialize(payload.getCompound("key"))), + payload.getBoolean("enabled")); + } else if (valueId == this.bulkActionValueId) { + applyBulkActionServer(payload); + } + } catch (RuntimeException e) { + IntegratedCrafting.clog(Level.WARN, + "Could not apply a recipe change to an attuned crafting interface: " + e.getMessage()); + } + } + + protected void applyBulkActionServer(CompoundTag payload) { + PartTypeInterfaceCraftingAttuned.State partState = getPartState(); + if (payload.getInt("version") != partState.getRecipesVersion()) { + // The recipes were re-read since the client built its list, so its indexes are stale. + return; + } + + int action = payload.getInt("action"); + List allRecipes = partState.getAllRecipes(); + List toEnable = Lists.newArrayList(); + List toDisable = Lists.newArrayList(); + for (int index : payload.getIntArray("indexes")) { + if (index < 0 || index >= allRecipes.size()) { + continue; + } + RecipeKey key = partState.getRecipeKey(allRecipes.get(index)); + if (key == null) { + continue; + } + (isEnabledAfterBulkAction(action, !partState.isRecipeEnabled(key)) ? toEnable : toDisable).add(key); + } + partState.setRecipesEnabled(toEnable, true); + partState.setRecipesEnabled(toDisable, false); + } + + /** + * Write all recipes and disabled recipe keys of the given part state to the gui data buffer. + * @param packetBuffer A packet buffer. + * @param partState An attuned crafting interface part state. + */ + public static void writeRecipes(RegistryFriendlyByteBuf packetBuffer, PartTypeInterfaceCraftingAttuned.State partState) { + HolderLookup.Provider lookupProvider = packetBuffer.registryAccess(); + + packetBuffer.writeVarInt(partState.getRecipesVersion()); + + List recipes = partState.getAllRecipes(); + packetBuffer.writeVarInt(recipes.size()); + for (IRecipeDefinition recipe : recipes) { + ResourceLocation recipeId = recipe.getRecipeId(); + if (recipeId != null) { + // Built-in recipes are sent by id only, the client resolves them via its own recipe manager. + packetBuffer.writeBoolean(true); + packetBuffer.writeResourceLocation(recipeId); + } else { + packetBuffer.writeBoolean(false); + packetBuffer.writeNbt(IRecipeDefinition.serialize(lookupProvider, recipe)); + } + } + + Set disabledRecipes = partState.getDisabledRecipes(); + packetBuffer.writeVarInt(disabledRecipes.size()); + for (RecipeKey disabledRecipe : disabledRecipes) { + String recipeId = disabledRecipe.getRecipeId(); + if (recipeId != null) { + packetBuffer.writeBoolean(true); + packetBuffer.writeUtf(recipeId); + } else { + packetBuffer.writeBoolean(false); + packetBuffer.writeNbt(disabledRecipe.serialize()); + } + } + } + + public static GuiRecipes readRecipes(RegistryFriendlyByteBuf packetBuffer) { + HolderLookup.Provider lookupProvider = packetBuffer.registryAccess(); + + int recipesVersion = packetBuffer.readVarInt(); + + int recipeCount = packetBuffer.readVarInt(); + List entryList = Lists.newArrayListWithCapacity(recipeCount); + for (int i = 0; i < recipeCount; i++) { + IRecipeDefinition recipe = null; + RecipeKey key; + if (packetBuffer.readBoolean()) { + ResourceLocation recipeId = packetBuffer.readResourceLocation(); + key = RecipeKey.ofRecipeId(recipeId.toString()); + try { + recipe = RecipeDefinition.fromRecipeId(lookupProvider, recipeId); + } catch (RuntimeException e) { + // The client does not know this recipe, so it can not be shown. + } + } else { + CompoundTag tag = packetBuffer.readNbt(); + key = RecipeKey.deserialize(tag); + try { + recipe = IRecipeDefinition.deserialize(lookupProvider, tag); + } catch (RuntimeException e) { + // The recipe could not be reconstructed, so it can not be shown. + } + } + if (recipe != null) { + entryList.add(RecipeEntry.of(i, key, recipe)); + } + } + + int disabledCount = packetBuffer.readVarInt(); + Set disabledRecipes = Sets.newHashSetWithExpectedSize(disabledCount); + for (int i = 0; i < disabledCount; i++) { + if (packetBuffer.readBoolean()) { + disabledRecipes.add(RecipeKey.ofRecipeId(packetBuffer.readUtf())); + } else { + disabledRecipes.add(RecipeKey.deserialize(packetBuffer.readNbt())); + } + } + + // Sorting happens client-side, so that the shown order follows the client's language. + // The server keeps its recipes in the order in which the target exposes them. + entryList.sort(RecipeEntry.COMPARATOR); + + return new GuiRecipes(entryList, disabledRecipes, recipesVersion); + } + + protected static List createServerEntries(List recipes) { + List entryList = Lists.newArrayListWithCapacity(recipes.size()); + int i = 0; + for (IRecipeDefinition recipe : recipes) { + entryList.add(RecipeEntry.ofServer(i++, recipe)); + } + return entryList; + } + + /** + * @param recipe A recipe. + * @return The first item output of the given recipe, or an empty stack if it has none. + */ + public static ItemStack getOutputItem(IRecipeDefinition recipe) { + List outputs = recipe.getOutput().getInstances(IngredientComponent.ITEMSTACK); + return outputs.isEmpty() ? ItemStack.EMPTY : outputs.get(0); + } + + /** + * The gui-side data of a single recipe. + * + * @param serverIndex The index of this recipe in the server's recipe list. + * @param key The key that identifies this recipe. + * @param recipe The recipe. + * @param displayName The name that is shown for this recipe. + * @param sortName The lowercased name that this entry is sorted by. + * @param identifier A stable identifier that breaks sorting ties. + * @param searchString The lowercased string that the search field matches against. + */ + public static record RecipeEntry(int serverIndex, RecipeKey key, IRecipeDefinition recipe, + Component displayName, String sortName, String identifier, String searchString) { + + /** + * Sorts entries by their output name, and breaks ties on their identifier, + * so that recipes that share an output name keep a stable order across sessions. + */ + public static final Comparator COMPARATOR = (a, b) -> { + int comparison = a.sortName().compareTo(b.sortName()); + return comparison != 0 ? comparison : a.identifier().compareTo(b.identifier()); + }; + + public static RecipeEntry of(int serverIndex, RecipeKey key, IRecipeDefinition recipe) { + String identifier = key.getRecipeId() != null ? key.getRecipeId() : key.serialize().toString(); + ItemStack outputItem = getOutputItem(recipe); + Component displayName = outputItem.isEmpty() + ? Component.literal(identifier) : outputItem.getHoverName(); + String name = displayName.getString(); + + // The search strings are precomputed once, + // as localizing thousands of recipes on every keystroke would be far too slow. + StringBuilder searchString = new StringBuilder(name).append(' ').append(identifier); + if (!outputItem.isEmpty()) { + searchString.append(' ') + .append(BuiltInRegistries.ITEM.getKey(outputItem.getItem()).getNamespace()); + } + + return new RecipeEntry(serverIndex, key, recipe, displayName, name.toLowerCase(Locale.ENGLISH), + identifier, searchString.toString().toLowerCase(Locale.ENGLISH)); + } + + /** + * Construct an entry without any gui data, for use on the server. + * @param serverIndex The index of this recipe in the server's recipe list. + * @param recipe The recipe. + * @return An entry. + */ + public static RecipeEntry ofServer(int serverIndex, IRecipeDefinition recipe) { + return new RecipeEntry(serverIndex, null, recipe, Component.empty(), "", "", ""); + } + + } + + /** + * All recipe data that an instance of this container is constructed from. + */ + public static class GuiRecipes { + + private final List recipes; + private final Map entries; + private final Set disabledRecipes; + private final int recipesVersion; + + public GuiRecipes(List entryList, Set disabledRecipes, int recipesVersion) { + this.recipes = Lists.newArrayListWithCapacity(entryList.size()); + this.entries = Maps.newIdentityHashMap(); + for (RecipeEntry entry : entryList) { + this.recipes.add(entry.recipe()); + this.entries.put(entry.recipe(), entry); + } + this.disabledRecipes = disabledRecipes; + this.recipesVersion = recipesVersion; + } + + public List getRecipes() { + return this.recipes; + } + + public Map getEntries() { + return this.entries; + } + + public Set getDisabledRecipes() { + return this.disabledRecipes; + } + + public int getRecipesVersion() { + return this.recipesVersion; + } + + } + +} diff --git a/src/main/java/org/cyclops/integratedcrafting/inventory/container/ContainerPartInterfaceCraftingAttunedRecipesConfig.java b/src/main/java/org/cyclops/integratedcrafting/inventory/container/ContainerPartInterfaceCraftingAttunedRecipesConfig.java new file mode 100644 index 000000000..262ffceb3 --- /dev/null +++ b/src/main/java/org/cyclops/integratedcrafting/inventory/container/ContainerPartInterfaceCraftingAttunedRecipesConfig.java @@ -0,0 +1,33 @@ +package org.cyclops.integratedcrafting.inventory.container; + +import net.minecraft.client.gui.screens.MenuScreens; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.screens.inventory.MenuAccess; +import net.minecraft.world.flag.FeatureFlags; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; +import org.cyclops.cyclopscore.client.gui.ScreenFactorySafe; +import org.cyclops.cyclopscore.config.extendedconfig.GuiConfig; +import org.cyclops.cyclopscore.inventory.container.ContainerTypeData; +import org.cyclops.integratedcrafting.IntegratedCrafting; +import org.cyclops.integratedcrafting.client.gui.ContainerScreenPartInterfaceCraftingAttunedRecipes; + +/** + * Config for {@link ContainerPartInterfaceCraftingAttunedRecipes}. + * @author rubensworks + */ +public class ContainerPartInterfaceCraftingAttunedRecipesConfig extends GuiConfig { + + public ContainerPartInterfaceCraftingAttunedRecipesConfig() { + super(IntegratedCrafting._instance, + "part_interface_crafting_attuned_recipes", + eConfig -> new ContainerTypeData<>(ContainerPartInterfaceCraftingAttunedRecipes::new, FeatureFlags.VANILLA_SET)); + } + + @OnlyIn(Dist.CLIENT) + @Override + public > MenuScreens.ScreenConstructor getScreenFactory() { + return new ScreenFactorySafe<>(ContainerScreenPartInterfaceCraftingAttunedRecipes::new); + } + +} diff --git a/src/main/java/org/cyclops/integratedcrafting/part/PartTypeInterfaceCraftingAttuned.java b/src/main/java/org/cyclops/integratedcrafting/part/PartTypeInterfaceCraftingAttuned.java index 09e95d47a..7e2944840 100644 --- a/src/main/java/org/cyclops/integratedcrafting/part/PartTypeInterfaceCraftingAttuned.java +++ b/src/main/java/org/cyclops/integratedcrafting/part/PartTypeInterfaceCraftingAttuned.java @@ -1,9 +1,15 @@ package org.cyclops.integratedcrafting.part; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; import net.minecraft.ChatFormatting; import net.minecraft.core.BlockPos; import net.minecraft.core.Direction; +import net.minecraft.core.HolderLookup; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; @@ -25,7 +31,9 @@ import org.cyclops.cyclopscore.helper.IModHelpersNeoForge; import org.cyclops.integratedcrafting.GeneralConfig; import org.cyclops.integratedcrafting.api.network.ICraftingNetwork; +import org.cyclops.integratedcrafting.api.recipe.RecipeKey; import org.cyclops.integratedcrafting.core.part.PartTypeInterfaceCraftingBase; +import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingAttunedRecipes; import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingSettings; import org.cyclops.integrateddynamics.api.evaluate.variable.ValueDeseralizationContext; import org.cyclops.integrateddynamics.api.network.INetwork; @@ -41,7 +49,9 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; /** * Interface for auto crafting that reads out all available target machine recipes. @@ -70,20 +80,37 @@ public MutableComponent getDisplayName() { @Override public AbstractContainerMenu createMenu(int id, Inventory playerInventory, Player playerEntity) { Triple data = PartHelpers.getContainerPartConstructionData(pos); - return new ContainerPartInterfaceCraftingSettings(id, playerInventory, new SimpleContainer(0), - data.getRight(), Optional.of(data.getLeft()), data.getMiddle()); + PartTypeInterfaceCraftingAttuned.State partState = (PartTypeInterfaceCraftingAttuned.State) data.getLeft().getPartState(pos.getSide()); + return new ContainerPartInterfaceCraftingAttunedRecipes(id, playerInventory, new SimpleContainer(0), + data.getRight(), Optional.of(data.getLeft()), data.getMiddle(), + partState.getAllRecipes(), partState.getDisabledRecipes(), partState.getRecipesVersion()); } }); } @Override public void writeExtraGuiData(RegistryFriendlyByteBuf packetBuffer, PartPos pos, ServerPlayer player) { - super.writeExtraGuiDataSettings(packetBuffer, pos, player); // We show the settings directly. + super.writeExtraGuiDataSettings(packetBuffer, pos, player); // Writes the part position and part type + ContainerPartInterfaceCraftingAttunedRecipes.writeRecipes(packetBuffer, + (PartTypeInterfaceCraftingAttuned.State) PartHelpers.getPartContainerChecked(pos).getPartState(pos.getSide())); } @Override public Optional getContainerProviderSettings(PartPos pos) { - return Optional.empty(); + return Optional.of(new MenuProvider() { + + @Override + public MutableComponent getDisplayName() { + return Component.translatable(getTranslationKey()); + } + + @Override + public AbstractContainerMenu createMenu(int id, Inventory playerInventory, Player playerEntity) { + Triple data = PartHelpers.getContainerPartConstructionData(pos); + return new ContainerPartInterfaceCraftingSettings(id, playerInventory, new SimpleContainer(0), + data.getRight(), Optional.of(data.getLeft()), data.getMiddle()); + } + }); } @Override @@ -156,7 +183,24 @@ public void onBlockNeighborChange(INetwork network, IPartNetwork partNetwork, Pa public static class State extends PartTypeInterfaceCraftingBase.State { protected boolean hasValidTarget = false; - private Collection recipes; + /** + * All recipes that the target exposes, in the order the target exposes them. + */ + private List recipes = Collections.emptyList(); + /** + * The subset of {@link #recipes} that is exposed to the crafting network. + * This must always be kept in sync with the recipes that are registered in the crafting network, + * as the network unregisters a crafting interface by iterating over {@link #getRecipes()}. + */ + private List recipesEnabled = Collections.emptyList(); + private Map recipeKeys = Maps.newIdentityHashMap(); + /** + * The keys of all recipes that the player has disabled. + * Unknown keys are retained, so that recipes stay disabled across pack updates + * that temporarily remove them. + */ + private final Set disabledRecipes = Sets.newLinkedHashSet(); + private int recipesVersion = 0; protected Optional getTargetRecipeHandler() { PartPos target = getTarget().getTarget(); @@ -168,12 +212,53 @@ public void setNetworks(@org.jetbrains.annotations.Nullable INetwork network, @o super.setNetworks(network, craftingNetwork, partNetwork, channel, valueDeseralizationContext, initialize); this.hasValidTarget = getTargetRecipeHandler().isPresent(); - this.recipes = getTargetRecipeHandler() - .map(IRecipeHandler::getRecipes) - .orElse(Collections.emptyList()); + reloadRecipeIndex(valueDeseralizationContext); markDirty(); } + /** + * Re-read all recipes from the target, and re-apply the disabled recipes filter on them. + * + * This is a no-op without a lookup provider, + * as recipes that are not backed by a built-in recipe can not be keyed without one. + * The previously read recipes are then retained, + * which keeps {@link #getRecipes()} in sync with what is registered in the crafting network. + * + * @param valueDeseralizationContext The deserialization context, may be null. + */ + protected void reloadRecipeIndex(@Nullable ValueDeseralizationContext valueDeseralizationContext) { + if (valueDeseralizationContext == null) { + return; + } + HolderLookup.Provider lookupProvider = valueDeseralizationContext.holderLookupProvider(); + + List recipes = Lists.newArrayList(getTargetRecipeHandler() + .map(IRecipeHandler::getRecipes) + .orElse(Collections.emptyList())); + Map recipeKeys = Maps.newIdentityHashMap(); + for (IRecipeDefinition recipe : recipes) { + recipeKeys.put(recipe, RecipeKey.of(lookupProvider, recipe)); + } + + this.recipes = recipes; + this.recipeKeys = recipeKeys; + this.recipesEnabled = filterEnabledRecipes(); + this.recipesVersion++; + } + + protected List filterEnabledRecipes() { + if (this.disabledRecipes.isEmpty()) { + return this.recipes; + } + List enabled = Lists.newArrayListWithCapacity(this.recipes.size()); + for (IRecipeDefinition recipe : this.recipes) { + if (!this.disabledRecipes.contains(this.recipeKeys.get(recipe))) { + enabled.add(recipe); + } + } + return enabled; + } + public boolean hasValidTarget() { return this.hasValidTarget; } @@ -182,17 +267,122 @@ public boolean hasValidTarget() { public void writeToNBT(ValueDeseralizationContext valueDeseralizationContext, CompoundTag tag) { super.writeToNBT(valueDeseralizationContext, tag); tag.putBoolean("hasValidTarget", hasValidTarget); + if (!this.disabledRecipes.isEmpty()) { + ListTag disabledRecipesTag = new ListTag(); + for (RecipeKey disabledRecipe : this.disabledRecipes) { + disabledRecipesTag.add(disabledRecipe.serialize()); + } + tag.put("disabledRecipes", disabledRecipesTag); + } } @Override public void readFromNBT(ValueDeseralizationContext valueDeseralizationContext, CompoundTag tag) { super.readFromNBT(valueDeseralizationContext, tag); this.hasValidTarget = tag.getBoolean("hasValidTarget"); + // The exposed recipes are deliberately not re-filtered here: + // they are recomputed by setNetworks, which is what keeps them in sync + // with the recipes that are registered in the crafting network. + this.disabledRecipes.clear(); + for (Tag disabledRecipeTag : tag.getList("disabledRecipes", Tag.TAG_COMPOUND)) { + this.disabledRecipes.add(RecipeKey.deserialize((CompoundTag) disabledRecipeTag)); + } } @Override public Collection getRecipes() { - return this.recipes; + return this.recipesEnabled; + } + + /** + * @return All recipes that the target exposes, including the disabled ones. + */ + public List getAllRecipes() { + return Collections.unmodifiableList(this.recipes); + } + + /** + * @return The keys of all recipes that are currently disabled. + */ + public Set getDisabledRecipes() { + return Collections.unmodifiableSet(this.disabledRecipes); + } + + /** + * @param recipe One of {@link #getAllRecipes()}. + * @return The key of the given recipe, or null if it is not exposed by the target. + */ + @Nullable + public RecipeKey getRecipeKey(IRecipeDefinition recipe) { + return this.recipeKeys.get(recipe); + } + + /** + * A counter that is incremented every time the recipes are re-read from the target. + * + * This allows guis to detect that the recipe list they are showing has become stale. + * + * @return The current recipe list version. + */ + public int getRecipesVersion() { + return this.recipesVersion; + } + + /** + * @param key A recipe key. + * @return If the recipe with the given key is exposed to the crafting network. + */ + public boolean isRecipeEnabled(RecipeKey key) { + return !this.disabledRecipes.contains(key); + } + + /** + * Enable or disable the recipes with the given keys. + * + * Keys that do not correspond to a recipe of the current target are still stored, + * so that the player's choice survives a temporary disappearance of the recipe. + * + * @param keys The keys of the recipes to update. + * @param enabled If the recipes should be exposed to the crafting network. + * @return If anything changed. + */ + public boolean setRecipesEnabled(Collection keys, boolean enabled) { + Set changedKeys = Sets.newHashSet(); + for (RecipeKey key : keys) { + if (enabled ? this.disabledRecipes.remove(key) : this.disabledRecipes.add(key)) { + changedKeys.add(key); + } + } + if (changedKeys.isEmpty()) { + return false; + } + + List changedRecipes = Lists.newArrayList(); + for (IRecipeDefinition recipe : this.recipes) { + if (changedKeys.contains(this.recipeKeys.get(recipe))) { + changedRecipes.add(recipe); + } + } + + // The exposed recipes must be updated before the network is notified, + // as the network unregisters this interface by iterating over them. + this.recipesEnabled = filterEnabledRecipes(); + + // Apply the change to the network incrementally, + // so that the network's recipe index stays in sync with our exposed recipes. + ICraftingNetwork craftingNetwork = getCraftingNetwork(); + if (craftingNetwork != null && !shouldAddToCraftingNetwork()) { + for (IRecipeDefinition changedRecipe : changedRecipes) { + if (enabled) { + craftingNetwork.addCraftingInterfaceRecipe(getChannelCrafting(), this, changedRecipe); + } else { + craftingNetwork.removeCraftingInterfaceRecipe(getChannelCrafting(), this, changedRecipe); + } + } + } + + markDirty(); + return true; } } diff --git a/src/main/java/org/cyclops/integratedcrafting/proxy/ClientProxy.java b/src/main/java/org/cyclops/integratedcrafting/proxy/ClientProxy.java index 320baa8ac..e1bc47272 100644 --- a/src/main/java/org/cyclops/integratedcrafting/proxy/ClientProxy.java +++ b/src/main/java/org/cyclops/integratedcrafting/proxy/ClientProxy.java @@ -1,8 +1,11 @@ package org.cyclops.integratedcrafting.proxy; +import net.neoforged.neoforge.client.event.RegisterClientTooltipComponentFactoriesEvent; import org.cyclops.cyclopscore.init.ModBase; import org.cyclops.cyclopscore.proxy.ClientProxyComponent; import org.cyclops.integratedcrafting.IntegratedCrafting; +import org.cyclops.integratedcrafting.client.gui.tooltip.ClientRecipeInputsTooltip; +import org.cyclops.integratedcrafting.client.gui.tooltip.RecipeInputsTooltip; /** * Proxy for the client side. @@ -14,6 +17,12 @@ public class ClientProxy extends ClientProxyComponent { public ClientProxy() { super(new CommonProxy()); + + getMod().getModEventBus().addListener(this::registerClientTooltipComponentFactories); + } + + public void registerClientTooltipComponentFactories(RegisterClientTooltipComponentFactoriesEvent event) { + event.register(RecipeInputsTooltip.class, ClientRecipeInputsTooltip::new); } @Override diff --git a/src/main/resources/assets/integratedcrafting/lang/en_us.json b/src/main/resources/assets/integratedcrafting/lang/en_us.json index 6a6ca54ef..a16b7c5ca 100644 --- a/src/main/resources/assets/integratedcrafting/lang/en_us.json +++ b/src/main/resources/assets/integratedcrafting/lang/en_us.json @@ -9,6 +9,16 @@ "gui.integratedcrafting.partsettings.craftingcheckdisabled": "Disable Craft Check", "gui.integratedcrafting.partsettings.blockingmode": "Blocking Mode", + "gui.integratedcrafting.partinterface.recipes.enableall": "Enable", + "gui.integratedcrafting.partinterface.recipes.enableall.info": "Enable all recipes that match the search", + "gui.integratedcrafting.partinterface.recipes.disableall": "Disable", + "gui.integratedcrafting.partinterface.recipes.disableall.info": "Disable all recipes that match the search", + "gui.integratedcrafting.partinterface.recipes.invert": "Invert", + "gui.integratedcrafting.partinterface.recipes.invert.info": "Invert all recipes that match the search", + "gui.integratedcrafting.partinterface.recipes.inputs": "Inputs:", + "gui.integratedcrafting.partinterface.recipes.click_enable": "Click to enable this recipe", + "gui.integratedcrafting.partinterface.recipes.click_disable": "Click to disable this recipe", + "gui.integratedcrafting.partinterface.slot.message.valid": "Recipe is valid for the target.", "gui.integratedcrafting.partinterface.slot.message.invalid": "Recipe is not acceptable by the target.", "gui.integratedcrafting.partinterface.slot.message.norecipe": "The variable does not contain a recipe.", diff --git a/src/main/resources/assets/integratedcrafting/textures/gui/part_interface_crafting_attuned_recipes.png b/src/main/resources/assets/integratedcrafting/textures/gui/part_interface_crafting_attuned_recipes.png new file mode 100644 index 000000000..a9247471d Binary files /dev/null and b/src/main/resources/assets/integratedcrafting/textures/gui/part_interface_crafting_attuned_recipes.png differ diff --git a/src/test/java/org/cyclops/integratedcrafting/api/recipe/TestRecipeKey.java b/src/test/java/org/cyclops/integratedcrafting/api/recipe/TestRecipeKey.java new file mode 100644 index 000000000..caf583ce3 --- /dev/null +++ b/src/test/java/org/cyclops/integratedcrafting/api/recipe/TestRecipeKey.java @@ -0,0 +1,125 @@ +package org.cyclops.integratedcrafting.api.recipe; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.resources.ResourceLocation; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IPrototypedIngredientAlternatives; +import org.cyclops.commoncapabilities.api.capability.recipehandler.IRecipeDefinition; +import org.cyclops.commoncapabilities.api.capability.recipehandler.PrototypedIngredientAlternativesList; +import org.cyclops.commoncapabilities.api.capability.recipehandler.RecipeDefinition; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.commoncapabilities.api.ingredient.MixedIngredients; +import org.cyclops.commoncapabilities.api.ingredient.PrototypedIngredient; +import org.cyclops.integratedcrafting.ingredient.IngredientComponentStubs; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; + +/** + * @author rubensworks + */ +public class TestRecipeKey { + + protected static IRecipeDefinition newRecipe(long input, long output, ResourceLocation recipeId) { + Map, List>> inputs = Maps.newIdentityHashMap(); + inputs.put(IngredientComponentStubs.SIMPLE, Lists.newArrayList( + new PrototypedIngredientAlternativesList<>(Lists.newArrayList( + new PrototypedIngredient<>(IngredientComponentStubs.SIMPLE, input, true))))); + Map, List> outputs = Maps.newIdentityHashMap(); + outputs.put(IngredientComponentStubs.SIMPLE, Lists.newArrayList(output)); + return new RecipeDefinition(inputs, new MixedIngredients(outputs), recipeId); + } + + /** + * A structural tag as {@link IRecipeDefinition#serialize} produces it for recipes without a recipe id. + */ + protected static CompoundTag newStructureTag(long output) { + CompoundTag tag = new CompoundTag(); + CompoundTag input = new CompoundTag(); + input.put("cyclopscore:simple", new ListTag()); + tag.put("input", input); + tag.put("inputReusable", new CompoundTag()); + CompoundTag outputTag = new CompoundTag(); + outputTag.putLong("cyclopscore:simple", output); + tag.put("output", outputTag); + return tag; + } + + @Test + public void testRecipeIdKeyRoundTrip() { + RecipeKey key = RecipeKey.of(null, newRecipe(1, 2, ResourceLocation.parse("minecraft:chest"))); + assertThat(key.getRecipeId(), is("minecraft:chest")); + assertThat(key.isStructural(), is(false)); + + RecipeKey restored = RecipeKey.deserialize(key.serialize()); + assertThat(restored, is(key)); + assertThat(restored.hashCode(), is(key.hashCode())); + assertThat(restored.getRecipeId(), is("minecraft:chest")); + assertThat(restored.isStructural(), is(false)); + } + + @Test + public void testRecipeIdKeysAreCompared() { + RecipeKey key = RecipeKey.of(null, newRecipe(1, 2, ResourceLocation.parse("minecraft:chest"))); + + // Recipes with the same id are the same key, even if their contents differ. + assertThat(RecipeKey.of(null, newRecipe(3, 4, ResourceLocation.parse("minecraft:chest"))), is(key)); + + assertThat(RecipeKey.of(null, newRecipe(1, 2, ResourceLocation.parse("minecraft:trapped_chest"))), is(not(key))); + } + + @Test + public void testStructuralKeyRoundTrip() { + RecipeKey key = RecipeKey.deserialize(newStructureTag(2)); + assertThat(key.getRecipeId(), is(nullValue())); + assertThat(key.isStructural(), is(true)); + + RecipeKey restored = RecipeKey.deserialize(key.serialize()); + assertThat(restored, is(key)); + assertThat(restored.hashCode(), is(key.hashCode())); + assertThat(restored.isStructural(), is(true)); + } + + @Test + public void testStructuralKeysAreCompared() { + assertThat(RecipeKey.deserialize(newStructureTag(2)), is(RecipeKey.deserialize(newStructureTag(2)))); + assertThat(RecipeKey.deserialize(newStructureTag(2)), is(not(RecipeKey.deserialize(newStructureTag(3))))); + } + + @Test + public void testStructuralKeyIsNeverEqualToRecipeIdKey() { + assertThat(RecipeKey.deserialize(newStructureTag(2)), is(not(RecipeKey.ofRecipeId("minecraft:chest")))); + assertThat(RecipeKey.ofRecipeId("minecraft:chest"), is(not(RecipeKey.deserialize(newStructureTag(2))))); + } + + /** + * Keys of recipes that no longer exist must stay usable, + * as resolving them would throw, and losing them would silently re-enable a disabled recipe. + */ + @Test + public void testMissingRecipeKeyRoundTrip() { + RecipeKey key = RecipeKey.ofRecipeId("somemod:removed_recipe"); + + RecipeKey restored = RecipeKey.deserialize(key.serialize()); + assertThat(restored, is(key)); + assertThat(restored.getRecipeId(), is("somemod:removed_recipe")); + assertThat(restored, is(not(RecipeKey.ofRecipeId("somemod:other_recipe")))); + } + + @Test + public void testSerializedKeyIsIndependentOfTheKey() { + RecipeKey key = RecipeKey.deserialize(newStructureTag(2)); + CompoundTag serialized = key.serialize(); + serialized.putString("injected", "value"); + assertThat(RecipeKey.deserialize(key.serialize()), is(key)); + } + +} diff --git a/src/test/java/org/cyclops/integratedcrafting/inventory/container/TestRecipeEntryComparator.java b/src/test/java/org/cyclops/integratedcrafting/inventory/container/TestRecipeEntryComparator.java new file mode 100644 index 000000000..b2acda2d9 --- /dev/null +++ b/src/test/java/org/cyclops/integratedcrafting/inventory/container/TestRecipeEntryComparator.java @@ -0,0 +1,69 @@ +package org.cyclops.integratedcrafting.inventory.container; + +import com.google.common.collect.Lists; +import org.cyclops.integratedcrafting.api.recipe.RecipeKey; +import org.cyclops.integratedcrafting.inventory.container.ContainerPartInterfaceCraftingAttunedRecipes.RecipeEntry; +import org.junit.Test; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.CoreMatchers.is; +import static org.junit.Assert.assertThat; + +/** + * @author rubensworks + */ +public class TestRecipeEntryComparator { + + protected static RecipeEntry newEntry(int serverIndex, String sortName, String identifier) { + return new RecipeEntry(serverIndex, RecipeKey.ofRecipeId(identifier), null, null, + sortName, identifier, sortName + " " + identifier); + } + + protected static List sortedIdentifiers(List entries) { + List sorted = Lists.newArrayList(entries); + sorted.sort(RecipeEntry.COMPARATOR); + return sorted.stream().map(RecipeEntry::identifier).collect(Collectors.toList()); + } + + @Test + public void testSortsByOutputName() { + List entries = Lists.newArrayList( + newEntry(0, "stick", "minecraft:stick"), + newEntry(1, "chest", "minecraft:chest"), + newEntry(2, "oak planks", "minecraft:oak_planks") + ); + assertThat(sortedIdentifiers(entries), + is(Lists.newArrayList("minecraft:chest", "minecraft:oak_planks", "minecraft:stick"))); + } + + /** + * Recipes that share an output name must keep a stable order, + * independent of the order in which the server sent them. + */ + @Test + public void testTiebreakOnIdentifierIsStable() { + List entries = Lists.newArrayList( + newEntry(0, "oak planks", "othermod:oak_planks_from_wood"), + newEntry(1, "oak planks", "minecraft:oak_planks"), + newEntry(2, "oak planks", "anothermod:oak_planks") + ); + List expected = Lists.newArrayList( + "anothermod:oak_planks", "minecraft:oak_planks", "othermod:oak_planks_from_wood"); + assertThat(sortedIdentifiers(entries), is(expected)); + + // The same entries in a different receive order must sort identically + List reversed = Lists.newArrayList(entries); + java.util.Collections.reverse(reversed); + assertThat(sortedIdentifiers(reversed), is(expected)); + } + + @Test + public void testEqualNamesAndIdentifiersAreEqual() { + assertThat(RecipeEntry.COMPARATOR.compare( + newEntry(0, "chest", "minecraft:chest"), + newEntry(1, "chest", "minecraft:chest")), is(0)); + } + +}