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));
+ }
+
+}