diff --git a/docs/adr/0002-recipe-rewrite.md b/docs/adr/0002-recipe-rewrite.md new file mode 100644 index 0000000000..be830df516 --- /dev/null +++ b/docs/adr/0002-recipe-rewrite.md @@ -0,0 +1,186 @@ +# 2. Recipe Rewrite + +Date: 2024-01-02 +Last update: 2023-01-04 + +## Status + +WIP + +## Context and Definitions + +Unlike vanilla Minecraft, the recipe system of Slimefun is very basic and +rigid. The most notable issues include: + +- It is impossible for an item to have more than one crafting recipe in + certain workstations (Enhanced Crafting Table, Magic Workbench, etc). +- Recipes cannot be shifted. For example, Copper Wire can only be crafted + in the middle row, and not in the top nor bottom. +- Items with more than one recipe (e.g. Sulfate) can have only one + displayed when its entry is viewed in the guide. + +### Managing Recipes + +Another less noticeable issue is all the different sources of recipes. +Some recipes come from `SlimefunItems`, others are defined by the workstations +themselves. + +Other machines have completely unique processes that cannot be written +down succinctly in terms of input and output items (Auto Anvil, for example +takes a Duct Tape and a damaged item, and returns a less damaged item; +Gold Pans take Gravel/Soul Sand and spit out randomly determined items). + +This rewrite will aim to tackle all such recipes. + +### Machines + +We cannot attempt to redesign recipes without mentioning the machines they are +crafted in. So in this ADR, a machine will be some mechanic where a recipe's +input is converted into its output. This includes both manual workstations and +automatic machines. + +## Solution + +### Categorizing Recipes + +So far, all we have is a lump of recipes and a lump of machines which can +craft some of those recipes. Thankfully, most machines have no overlap with +each other, and we can simplify this mess a little with categories. Many +of the `RecipeType`s are already categories, but there will be many more +automatic machines who don't have a `RecipeType` that need a category too. + +#### Smeltery and Improvised Smeltery + +There is a small hiccup with these two machines, in that the Improvised +Smeltery can only craft a subset of the Smeltery recipes. To fix this, we +have a 'Dust Smelting' category and an 'Alloy Smelting' Category. The Improvised +Smeltery will only be able to craft recipes in the 'Dust Smelting' category, +but the Smeltery will be able to craft both. + +### Recipe Structures + +The last defining feature of a recipe is its structure (or lack thereof) of +its input items. The four structures a recipe can have are: + +- **Identical**: The items in the input grid/zone/area must be **exactly** + as defined in the recipe, in the **exact** same spot as defined in the + recipe. +- **Shaped**: The items in the input grid/zone/area must be **exactly** as + defined in the recipe, but only the relative positions of the items to + each other must be the same as in the recipe. i.e., the input items can + be **shifted** +- **Shapeless**: The items in the input grid/zone/area must be **exactly** as + defined in the recipe, but their order/position does not matter +- **Subset**: The recipe's inputs must be a **subset** (not necessarily proper) + of the items in the input grid/zone/area, and the order/position does not + matter. The majority of recipes in Slimefun are Subset recipes (Smeltery, + Ore Grinder, Compressor, etc...) + +Currently, there are no Shaped or Shapeless recipes in Slimefun, however most +Identical recipes should be Shaped/Shapeless instead (Copper Wire -> Shaped, +Monster Jerky -> Shapeless, etc...) + +#### Ancient Altar + +This is a rather unique case, since the inputs can be rotated, but we only +need to check each of the up to 8 rotations against the recipe. + +### Searching and Matching Recipes + +The problem we have now is that given an ordered set of input items, how +do we know what it should craft? Searching through the recipes will be done +with a simple linear search, but matching a recipe is a bit more involved + +#### Identical + +Identical recipes are the easiest, but also the least common. To match them, +we simply iterate over each index (left-right, top-down) and match the two +items in that index. + +#### Shaped + +Similar to Identical, but with start by finding the first non-empty element +in each list of items before continuing. + +One small caveat is that if two non-empty items match, their row-difference +cannot be different from that of the first non-empty elements. This makes +sure that (1) and (2) match, but (3) doesn't match either + +```txt ++-+-+ +-+-+ +-+-+ +|a|b| | | | | |a| ++-+-+ +-+-+ +-+-+ +| | | |a|b| |b| | ++-+-+ +-+-+ +-+-+ + (1) (2) (3) +``` + +#### Shapeless and Subset + +For both Shapeless and Subset recipes, we look for an injective map from the +recipe inputs to the given inputs, where a recipe input item is mapped to +an given input item if the latter can be used as the former in the recipe +being matched. For Shapeless recipes, we additionally require that the size +of the recipe inputs is the same as the size of the actual inputs. + +#### Optimization + +To optimize this process a little bit, whenever a given set of input items +can craft more than one output, we hash the inputs and put it + the recipe +into an LRU cache so that next craft, we can instantly retrieve the recipe. + +### The Guide Display + +If available, an item's guide page will be paginated, with each recipe +taking up a page. Inputs that can be multiple items will cycle through +them, similar to the guide page for vanilla items. + +The recipes displayed in the bottom two rows will only show recipes +that have one input. + +## Implementation + +### The `Recipe` class + +This is a pretty straightforward data class that contains a recipes input, +output, and structure. + +Items in a `Recipe`'s input are `RecipeComponent`s rather than plain +`ItemStack`s, which allows for 'tagged' components (e.g. all types of Wood +Log/Coloured Wool, etc...). This also allows both vanilla and Slimefun copper +to be used as one item + +### `SlimefunRecipeService` + +This is essentially a multimap of `RecipeCategory`s to `Recipe`s, along with +an LRU cache + +### Registering Recipes + +The preferred constructors for `SlimefunItem`s have switched from using +`RecipeType` to `RecipeCategory`, but the recipes themselves have not been +changed, so little migration is necessary for items with only 1 recipe. + +For Slimefun items with more than one recipe, call +`SlimefunItem.addRecipe(recipe)` before registering the item. + +For recipes that craft vanilla items, call +`RecipeCategory.registerRecipe(recipe)` + +### Searching Recipes + +Machines can implement the `RecipeCrafter` interface, which comes with +searching utilities already. Otherwise, call `Slimefun.searchRecipes()`. +This performs a linear seach as described in the 'Searching and Matching +Recipes' section + +## Other Notes + +- There are no mirrored recipes. To achieve something similar, manually add + the normal and flipped versions + +## Progress + +- New recipe system / API: Awaiting Review +- Migration of crafting machines to new API: WIP +- Guide recipe pagination / item cycling: Not Started diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/Recipe.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/Recipe.java new file mode 100644 index 0000000000..60ca64184e --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/Recipe.java @@ -0,0 +1,119 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.input.RecipeInputs; +import io.github.thebusybiscuit.slimefun4.api.recipes.output.ItemOutput; +import io.github.thebusybiscuit.slimefun4.api.recipes.output.RecipeOutput; + +/** + * A simple interface that associates a set of input items to an + * output item(s). Contains methods to match its input items + * to another set of items + * + * @author SchnTgaiSpock + */ +public interface Recipe { + + public static Recipe EMPTY = new SlimefunRecipe(RecipeInputs.EMPTY, RecipeOutput.EMPTY); + + /** + * Construct a simple recipe + * + * @param structure The {@link RecipeStructure} of the recipe + * @param inputs The inputs of the recipe + * @param output The output of the recipe + * @return The constructed Recipe + */ + @Nonnull + @ParametersAreNonnullByDefault + public static Recipe of(RecipeStructure structure, ItemStack[] inputs, ItemStack output) { + return new SlimefunRecipe(RecipeInputs.of(structure, inputs), new ItemOutput(output)); + } + + /** + * Construct a simpler recipe + * + * @param structure The {@link RecipeStructure} of the recipe + * @param input The inputs of the recipe + * @param output The output of the recipe + * @return The constructed Recipe + */ + @Nonnull + @ParametersAreNonnullByDefault + public static Recipe of(RecipeStructure structure, ItemStack input, ItemStack output) { + return new SlimefunRecipe(RecipeInputs.of(structure, input), new ItemOutput(output)); + } + + /** + * Sets the inputs of this recipe + * @param inputs The new inputs + */ + public void setInputs(RecipeInputs inputs); + + /** + * Sets the output of this recipe + * @param output The new output + */ + public void setOutputs(RecipeOutput output); + + /** + * Sets the structure of this recipe + * @param structure The new structure + */ + public default void setStructure(RecipeStructure structure) { + getInputs().setStructure(structure); + } + + /** + * @return The inputs of this recipe + */ + public @Nonnull RecipeInputs getInputs(); + + /** + * @return The outputs of this recipe + */ + public @Nonnull RecipeOutput getOutput(); + + /** + * @return The structure of this recipe + */ + public default @Nonnull RecipeStructure getStructure() { + return getInputs().getStructure(); + } + + /** + * If this recipe is disabled. Disabled recipes cannot be registered + * @return + */ + public default boolean isDisabled() { + return getInputs().isDisabled() || getOutput().isDisabled(); + } + + /** + * Matches the givenItems against this recipe's inputs + * + * @param givenItems The items to match + * @return The result of the match. See {@link RecipeMatchResult} + */ + public default @Nonnull RecipeMatchResult match(@Nonnull ItemStack[] givenItems) { + return getInputs().match(getStructure(), givenItems); + } + + /** + * Matches the givenItems against this recipe's inputs + * using some other structure. + * + * @param otherStructure The alternate structure + * @param givenItems The items to match + * @return The result of the match. See {@link RecipeMatchResult} + */ + @ParametersAreNonnullByDefault + public default @Nonnull RecipeMatchResult match(RecipeStructure otherStructure, ItemStack[] givenItems) { + return getInputs().match(otherStructure, givenItems); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeCategory.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeCategory.java new file mode 100644 index 0000000000..032c2a37d7 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeCategory.java @@ -0,0 +1,231 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +import javax.annotation.Nonnull; + +import org.bukkit.ChatColor; +import org.bukkit.Keyed; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import io.github.bakedlibs.dough.items.CustomItemStack; +import io.github.bakedlibs.dough.recipes.MinecraftRecipe; +import io.github.thebusybiscuit.slimefun4.api.recipes.components.ItemComponent; +import io.github.thebusybiscuit.slimefun4.api.recipes.components.RecipeComponent; +import io.github.thebusybiscuit.slimefun4.api.recipes.output.ItemOutput; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.implementation.SlimefunItems; +import io.github.thebusybiscuit.slimefun4.implementation.items.altar.AltarRecipe; +import io.github.thebusybiscuit.slimefun4.implementation.items.altar.AncientAltar; + +public class RecipeCategory implements Keyed { + + public static final RecipeCategory MULTIBLOCK = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "multiblock"), new CustomItemStack(Material.BRICKS, "&bMultiBlock", "", "&a&oBuild it in the World")); + public static final RecipeCategory ARMOR_FORGE = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "armor_forge"), new CustomItemStack(SlimefunItems.ARMOR_FORGE, "", "&a&oCraft it in an Armor Forge")); + public static final RecipeCategory GRIND_STONE = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "grind_stone"), new CustomItemStack(SlimefunItems.GRIND_STONE, "", "&a&oGrind it using the Grind Stone"), RecipeStructure.SUBSET); + public static final RecipeCategory ORE_CRUSHER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "ore_crusher"), new CustomItemStack(SlimefunItems.ORE_CRUSHER, "", "&a&oCrush it using the Ore Crusher"), RecipeStructure.SUBSET); + public static final RecipeCategory GOLD_PAN = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "gold_pan"), new CustomItemStack(SlimefunItems.GOLD_PAN, "", "&a&oUse a Gold Pan on Gravel to obtain this Item"), RecipeStructure.SUBSET); + public static final RecipeCategory NETHER_GOLD_PAN = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "nether_gold_pan"), new CustomItemStack(SlimefunItems.NETHER_GOLD_PAN, "", "&a&oUse a Nether Gold Pan on Soul Sand or Soul Soil to obtain this Item"), RecipeStructure.SUBSET); + public static final RecipeCategory COMPRESSOR = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "compressor"), new CustomItemStack(SlimefunItems.COMPRESSOR, "", "&a&oCompress it using the Compressor"), RecipeStructure.SUBSET); + public static final RecipeCategory PRESSURE_CHAMBER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "pressure_chamber"), new CustomItemStack(SlimefunItems.PRESSURE_CHAMBER, "", "&a&oCompress it using the Pressure Chamber"), RecipeStructure.SUBSET); + public static final RecipeCategory MAGIC_WORKBENCH = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "magic_workbench"), new CustomItemStack(SlimefunItems.MAGIC_WORKBENCH, "", "&a&oCraft it in a Magic Workbench")); + public static final RecipeCategory ORE_WASHER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "ore_washer"), new CustomItemStack(SlimefunItems.ORE_WASHER, "", "&a&oWash it in an Ore Washer"), RecipeStructure.SUBSET); + public static final RecipeCategory ENHANCED_CRAFTING_TABLE = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "enhanced_crafting_table"), new CustomItemStack(SlimefunItems.ENHANCED_CRAFTING_TABLE, "", "&a&oA regular Crafting Table cannot", "&a&ohold this massive Amount of Power...")); + public static final RecipeCategory JUICER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "juicer"), new CustomItemStack(SlimefunItems.JUICER, "", "&a&oUsed for Juice Creation"), RecipeStructure.SUBSET); + public static final RecipeCategory TABLE_SAW = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "table-saw"), new CustomItemStack(Material.STONECUTTER, "&bTable Saw", "", "&a&oCut it in a Table Saw"), RecipeStructure.SUBSET); +/** + * @deprecated Smeltery recipes have moved to {@code DUST_SMELTING} and {@code ALLOY_SMELTING} + */ + @Deprecated + public static final RecipeCategory SMELTERY = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "smeltery"), new CustomItemStack(SlimefunItems.SMELTERY, "", "&a&oSmelt it using a Smeltery"), RecipeStructure.SUBSET) { + @Override + public void onRegisterRecipe(Recipe recipe) { + int dusts = 0; + int nonEmpty = 0; + for (final RecipeComponent comp : recipe.getInputs().getComponents()) { + if (!comp.isAir()) { + if (comp.getSlimefunItemIDs().size() > 0 && comp.getSlimefunItemIDs().get(0).endsWith("_DUST")) { + dusts++; + } + nonEmpty++; + } + } + if (dusts == 1 && nonEmpty == 1) { + DUST_SMELTING.registerRecipe(recipe); + } else { + ALLOY_SMELTING.registerRecipe(recipe); + } + } + }; + + public static final RecipeCategory ALLOY_SMELTING = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "alloy_smelting"), new CustomItemStack(SlimefunItems.SMELTERY, "", "&a&oSmelt it using a Smeltery"), RecipeStructure.SUBSET) { + @Override + @Deprecated + public RecipeType asRecipeType() { + return RecipeType.SMELTERY; + } + @Override + public String getTranslationKey() { + return "slimefun.smeltery"; + } + }; + public static final RecipeCategory DUST_SMELTING = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "dust_smelting"), new CustomItemStack(SlimefunItems.SMELTERY, "", "&a&oSmelt it using a Smeltery"), RecipeStructure.SUBSET) { + @Override + @Deprecated + public RecipeType asRecipeType() { + return RecipeType.SMELTERY; + } + @Override + public String getTranslationKey() { + return "slimefun.smeltery"; + } + @Override + public void onRegisterRecipe(Recipe recipe) { + // Add the inverse of this recipe (if applicable) to the ingot pulverizer category + Optional dust = recipe.getInputs().getComponents().stream().filter(comp -> !comp.isAir()).findFirst(); + if (dust.isPresent() && dust.get() instanceof final ItemComponent singleDust && recipe.getOutput() instanceof final ItemOutput itemOutput) { + INGOT_PULVERIZER.registerRecipe( + Recipe.of(RecipeStructure.SUBSET, new ItemStack[] { itemOutput.getOutputTemplate() }, singleDust.getComponent()) + ); + } + } + }; + public static final RecipeCategory ANCIENT_ALTAR = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "ancient_altar"), SlimefunItems.ANCIENT_ALTAR, RecipeStructure.IDENTICAL) { + @Override + public void onRegisterRecipe(Recipe recipe) { + AltarRecipe altarRecipe = new AltarRecipe(Arrays.asList(recipe.getInputs().asDisplayGrid()), recipe.getOutput().generateOutput()); + AncientAltar altar = ((AncientAltar) SlimefunItems.ANCIENT_ALTAR.getItem()); + altar.getRecipes().add(altarRecipe); + } + }; + + public static final RecipeCategory MOB_DROP = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "mob_drop"), new CustomItemStack(Material.IRON_SWORD, "&bMob Drop", "", "&rKill the specified Mob to obtain this Item")) { + @Override + public void onRegisterRecipe(Recipe recipe) { + String mob = ChatColor.stripColor(recipe.getInputs().getComponents().get(4).getDisplayItems().get(0).getItemMeta().getDisplayName()).toUpperCase(Locale.ROOT) + .replace(' ', '_'); + EntityType entity = EntityType.valueOf(mob); + Set dropping = Slimefun.getRegistry().getMobDrops().getOrDefault(entity, new HashSet<>()); + dropping.add(recipe.getOutput().generateOutput()); + Slimefun.getRegistry().getMobDrops().put(entity, dropping); + } + }; + public static final RecipeCategory BARTER_DROP = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "barter_drop"), new CustomItemStack(Material.GOLD_INGOT, "&bBarter Drop", "&aBarter with piglins for a chance", "&ato obtain this item")) { + @Override + public void onRegisterRecipe(Recipe recipe) { + Slimefun.getRegistry().getBarteringDrops().add(recipe.getOutput().generateOutput()); + } + }; + + public static final RecipeCategory INTERACT = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "interact"), new CustomItemStack(Material.PLAYER_HEAD, "&bInteract", "", "&a&oRight click with this item")); + + public static final RecipeCategory HEATED_PRESSURE_CHAMBER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "heated_pressure_chamber"), SlimefunItems.HEATED_PRESSURE_CHAMBER, RecipeStructure.SUBSET); + public static final RecipeCategory FOOD_FABRICATOR = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "food_fabricator"), SlimefunItems.FOOD_FABRICATOR, RecipeStructure.SUBSET); + public static final RecipeCategory FOOD_COMPOSTER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "food_composter"), SlimefunItems.FOOD_COMPOSTER, RecipeStructure.SUBSET); + public static final RecipeCategory FREEZER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "freezer"), SlimefunItems.FREEZER, RecipeStructure.SUBSET); + public static final RecipeCategory REFINERY = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "refinery"), SlimefunItems.REFINERY, RecipeStructure.SUBSET); + public static final RecipeCategory INGOT_PULVERIZER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "ingot_pulverizer"), SlimefunItems.ELECTRIC_INGOT_PULVERIZER, RecipeStructure.SUBSET); + + public static final RecipeCategory OIL_PUMP = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "oil_pump"), SlimefunItems.OIL_PUMP); + public static final RecipeCategory GEO_MINER = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "geo_miner"), SlimefunItems.GEO_MINER); + public static final RecipeCategory NUCLEAR_REACTOR = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "nuclear_reactor"), SlimefunItems.NUCLEAR_REACTOR, RecipeStructure.SUBSET); + + public static final RecipeCategory PICKAXE_OF_CONTAINMENT = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "pickaxe_of_containment"), SlimefunItems.PICKAXE_OF_CONTAINMENT, RecipeStructure.SUBSET); + + public static final RecipeCategory NULL = new RecipeCategory(new NamespacedKey(Slimefun.instance(), "null"), new ItemStack(Material.AIR)); + + private final ItemStack displayItem; + private final NamespacedKey key; + private final RecipeStructure defaultStructure; + + public RecipeCategory(NamespacedKey key, ItemStack displayItem, RecipeStructure defaultStructure) { + this.displayItem = displayItem; + this.key = key; + this.defaultStructure = defaultStructure; + } + + public RecipeCategory(NamespacedKey key, ItemStack displayItem) { + this(key, displayItem, RecipeStructure.SHAPED); + } + + public RecipeCategory(MinecraftRecipe recipe) { + this.displayItem = new ItemStack(recipe.getMachine()); + this.defaultStructure = RecipeStructure.NULL; // This is for the guide display only, nothing is crafted with this + this.key = NamespacedKey.minecraft(recipe.getRecipeClass().getSimpleName().toLowerCase(Locale.ROOT).replace("recipe", "")); + } + + public void registerRecipes(@Nonnull List recipes) { + Slimefun.getSlimefunRecipeService().registerRecipes(this, recipes); + } + + public void registerRecipe(@Nonnull Recipe recipe) { + Slimefun.getSlimefunRecipeService().registerRecipes(this, List.of(recipe)); + } + + /** + * This can be overriden if a specific category should require it + * + * @param recipe The recipe being registered using + * {@code Slimefun.registerRecipes()} + */ + public void onRegisterRecipe(Recipe recipe) {} + + /** + * For backwards compat (namely SlimefunItem.getRecipeType()). + * To be removed when RecipeType is removed + */ + @Deprecated + public RecipeType asRecipeType() { + return new RecipeType(key, displayItem); + } + + public ItemStack getDisplayItem() { + return displayItem; + } + + public ItemStack getLocalizedItem(Player p) { + return Slimefun.getLocalization().getRecipeCategoryItem(p, this); + } + + @Override + public NamespacedKey getKey() { + return key; + } + + @Override + public String toString() { + return key.toString(); + } + + public String getTranslationKey() { + return getKey().getNamespace() + "." + getKey().getKey(); + } + + public RecipeStructure getDefaultStructure() { + return defaultStructure; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof final RecipeCategory other) { + return other.getKey().equals(getKey()); + } + + return false; + } + + @Override + public int hashCode() { + return getKey().hashCode(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeCrafter.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeCrafter.java new file mode 100644 index 0000000000..69b241d277 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeCrafter.java @@ -0,0 +1,141 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; + +import javax.annotation.Nonnull; + +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.components.RecipeComponent; +import io.github.thebusybiscuit.slimefun4.api.recipes.output.RecipeOutput; +import io.github.thebusybiscuit.slimefun4.core.services.SlimefunRecipeService.CachingStrategy; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +/** + * Workstations and Machines should implement this interface if + * they craft items. Simply provide the category(ies) of items they + * craft, and getting/searching recipes is provided for free. + * + * @author SchnTgaiSpock + */ +@FunctionalInterface +public interface RecipeCrafter { + + /** + * @return The {@link RecipeCategory}s that this crafter can craft + */ + public Collection getCraftedCategories(); + + /** + * @return All recipes in the categories that this crafter can craft + */ + public default Collection getRecipes() { + List recipes = new ArrayList<>(); + + for (final RecipeCategory category : getCraftedCategories()) { + recipes.addAll(Slimefun.getSlimefunRecipeService().getRecipes(category)); + } + + return recipes; + } + + /** + * Searches the recipes that this crafter can craft + * + * @param givenItems The items to craft + * @return (The recipe if found, The result of the match) + */ + public default RecipeSearchResult searchRecipes(@Nonnull ItemStack[] givenItems) { + for (RecipeCategory category : getCraftedCategories()) { + RecipeSearchResult result = Slimefun.searchRecipes( + category, givenItems, CachingStrategy.IF_MULTIPLE_CRAFTABLE); + + if (result.isMatch()) { + return result; + } + } + + return RecipeSearchResult.NO_MATCH; + } + + /** + * Searches the recipes that this crafter can craft + * + * @param givenItems The items to craft + * @param onRecipeFound To be called when a matching recipe is found + * @return (The recipe if found, The result of the match) + */ + public default RecipeSearchResult searchRecipes( + @Nonnull ItemStack[] givenItems, + BiConsumer onRecipeFound) { + for (RecipeCategory category : getCraftedCategories()) { + RecipeSearchResult result = Slimefun.searchRecipes( + category, givenItems, CachingStrategy.IF_MULTIPLE_CRAFTABLE, onRecipeFound); + + if (result.isMatch()) { + return result; + } + } + + return RecipeSearchResult.NO_MATCH; + } + + /** + * Searches the recipes that this crafter can craft + * + * @param givenItems The items to craft + * @param onRecipeFound To be called when a matching recipe is found. If it + * returns true, consumes the input items according to + * the recipe + * @return (The recipe if found, The result of the match) + */ + public default RecipeSearchResult searchRecipes( + @Nonnull ItemStack[] givenItems, + BiPredicate onRecipeFound) { + for (RecipeCategory category : getCraftedCategories()) { + RecipeSearchResult result = Slimefun.searchRecipes( + category, givenItems, CachingStrategy.IF_MULTIPLE_CRAFTABLE, onRecipeFound); + + if (result.isMatch()) { + return result; + } + } + + return RecipeSearchResult.NO_MATCH; + } + + /** + * @return All recipes that only have 1 input item + */ + public default Map getSingleInputRecipes() { + Map singleInputRecipes = new HashMap<>(); + + recipeLoop: for (Recipe recipe : getRecipes()) { + int nonEmptyComponents = 0; + RecipeComponent nonEmptyComponent = RecipeComponent.AIR; + + for (RecipeComponent component : recipe.getInputs().getComponents()) { + if (!component.isAir()) { + nonEmptyComponents++; + if (nonEmptyComponents > 1) { + continue recipeLoop; + } + nonEmptyComponent = component; + } + } + + if (nonEmptyComponents > 0) { + singleInputRecipes.put(nonEmptyComponent, recipe.getOutput()); + } + } + + return singleInputRecipes; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeMatchResult.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeMatchResult.java new file mode 100644 index 0000000000..57f771ed21 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeMatchResult.java @@ -0,0 +1,69 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.Collections; +import java.util.Map; + +import javax.annotation.ParametersAreNonnullByDefault; + +import com.google.common.base.Preconditions; + +import io.github.thebusybiscuit.slimefun4.api.recipes.components.RecipeComponent; + +/** + * Stores detailed information about the result of matching a recipe against + * some given inputs + */ +public class RecipeMatchResult { + + public static final RecipeMatchResult NO_MATCH = new RecipeMatchResult(false, Collections.emptyMap()); + + @ParametersAreNonnullByDefault + public static RecipeMatchResult match(Map consumption, String message) { + return new RecipeMatchResult(true, consumption, message); + } + + @ParametersAreNonnullByDefault + public static RecipeMatchResult match(Map consumption) { + return match(consumption, ""); + } + + private final boolean isMatch; + private Map consumption; + private final String message; + + @ParametersAreNonnullByDefault + public RecipeMatchResult(boolean isMatch, Map consumption, String message) { + Preconditions.checkNotNull(consumption, "The 'consumption' map cannot be null!"); + + this.isMatch = isMatch; + this.consumption = consumption; + this.message = message == null ? "" : message; + } + + @ParametersAreNonnullByDefault + public RecipeMatchResult(boolean isMatch, Map consumption) { + this(isMatch, consumption, ""); + } + + /** + * Whether or not the recipe be crafted (barring research, etc...) + * @return + */ + public boolean isMatch() { + return isMatch; + } + + public Map getConsumption() { + return consumption; + } + + public String getMessage() { + return message; + } + + @Override + public String toString() { + return "RecipeMatchResult(match = " + isMatch + ", consumption map = " + consumption + ", message = " + message + ")"; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeSearchResult.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeSearchResult.java new file mode 100644 index 0000000000..db7349947d --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeSearchResult.java @@ -0,0 +1,57 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import javax.annotation.Nullable; +import javax.annotation.ParametersAreNonnullByDefault; + +import com.google.common.base.Preconditions; + +public class RecipeSearchResult { + + public static final RecipeSearchResult NO_MATCH = new RecipeSearchResult(null, null, RecipeMatchResult.NO_MATCH); + + private final @Nullable Recipe recipe; + private final @Nullable RecipeCategory searchCategory; + private final RecipeMatchResult matchResult; + + @ParametersAreNonnullByDefault + public RecipeSearchResult(Recipe recipe, RecipeCategory searchCategory, RecipeMatchResult matchResult) { + Preconditions.checkArgument(matchResult != null, "'matchResult' cannot be null!"); + Preconditions.checkArgument(!matchResult.isMatch() || recipe != null, "'recipe' cannot be null when there is a match!"); + Preconditions.checkArgument(!matchResult.isMatch() || searchCategory != null, "'searchCategory' cannot be null when there is a match!"); + + this.recipe = recipe; + this.searchCategory = searchCategory; + this.matchResult = matchResult; + } + + /** + * @return The matched recipe, or null if {@code RecipeSearchResult.isMatch()} is false + */ + public @Nullable Recipe getRecipe() { + return recipe; + } + + /** + * @return The category this recipe was found in, or null if {@code isMatch()} is false + */ + public @Nullable RecipeCategory getSearchCategory() { + return searchCategory; + } + + /** + * @return The match result + */ + public RecipeMatchResult getMatchResult() { + return matchResult; + } + + public boolean isMatch() { + return matchResult.isMatch(); + } + + @Override + public String toString() { + return "RecipeSearchResult{match = " + matchResult + ", category = " + searchCategory + ", recipe = " + recipe + ")"; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeStructure.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeStructure.java new file mode 100644 index 0000000000..37924d4ae6 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeStructure.java @@ -0,0 +1,214 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.components.RecipeComponent; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +public abstract class RecipeStructure implements Keyed { + + /** + * Note: This structure assumes the recipe inputs and the given + * inputs have the same dimension + */ + public static final RecipeStructure IDENTICAL = new RecipeStructure("identical") { + @Override + public RecipeMatchResult match(ItemStack[] givenItems, List components) { + if (givenItems.length != components.size()) { + return RecipeMatchResult.NO_MATCH; + } + + Map consumption = new HashMap<>(); + for (int i = 0; i < givenItems.length; i++) { + RecipeComponent component = components.get(i); + + if (!component.matches(givenItems[i])) { + return RecipeMatchResult.NO_MATCH; + } + + if (!component.isAir()) { + consumption.put(i, component); + } + } + + return RecipeMatchResult.match(consumption); + } + }; + + /** + * Note: This structure assumes the recipe is 3x3 + */ + public static final RecipeStructure SHAPED = new RecipeStructure("shaped") { + @Override + public RecipeMatchResult match(ItemStack[] givenItems, List components) { + if (givenItems.length != components.size() || givenItems.length != 9) { + return RecipeMatchResult.NO_MATCH; + } + + int height = 3; + int numSlots = 9; + + // Get the first non-empty slot of the recipe + int recipeFirstNonnull = 0; + for (int i = 0; i < numSlots; i++) { + if (!components.get(i).isAir()) { + recipeFirstNonnull = i; + break; + } + } + + // Get the first non-empty slot of the given items + int givenFirstNonnull = 0; + for (int i = 0; i < numSlots; i++) { + if (givenItems[i] != null && !givenItems[i].getType().isAir()) { + givenFirstNonnull = i; + break; + } + } + + // Same procedure as IDENTICAL, with the additional caveat that two + // matching (non-empty) items cannot be on different rows (ignoring + // the initial row difference) + int rowOffset = (givenFirstNonnull - recipeFirstNonnull) / height; + + Map consumption = new HashMap<>(); + + // Check the remaining slots + for (int i = 0; i < numSlots - recipeFirstNonnull; i++) { + int recipeIndex = recipeFirstNonnull + i; + int givenIndex = givenFirstNonnull + i; + RecipeComponent component = components.get(recipeIndex); + ItemStack item = givenIndex < givenItems.length ? givenItems[givenIndex] : null; + + if (!component.matches(item)) { + return RecipeMatchResult.NO_MATCH; + } else if (!component.isAir()) { + // Different relative rows + if (recipeIndex / height + rowOffset != givenIndex / height) { + return RecipeMatchResult.NO_MATCH; + } + consumption.put(givenIndex, component); + } + } + + return RecipeMatchResult.match(consumption); + } + }; + + public static final RecipeStructure SHAPELESS = new RecipeStructure("shapeless") { + @Override + public RecipeMatchResult match(ItemStack[] givenItems, List components) { + if (countNonEmpty(givenItems) != countNonEmpty(components)) { + return RecipeMatchResult.NO_MATCH; + } + + return checkSubset(givenItems, components); + } + }; + + public static final RecipeStructure SUBSET = new RecipeStructure("subset") { + @Override + public RecipeMatchResult match(ItemStack[] givenItems, List components) { + if (countNonEmpty(givenItems) < countNonEmpty(components)) { + return RecipeMatchResult.NO_MATCH; + } + + return checkSubset(givenItems, components); + } + }; + + public static final RecipeStructure NULL = new RecipeStructure("null") { + @Override + public RecipeMatchResult match(ItemStack[] givenItems, List components) { + return RecipeMatchResult.NO_MATCH; + } + }; + + /** + * This may give a false negative on certain recpies, such as + * [b, a, c] against [(a|b|c), b, c] which could be solved by + * using a bipartite matching algorithm, however that would + * most likely be too slow, so it will be up to the recipe + * creator to not make bad recipes such as the example above. + * @param givenItems The items to match + * @param components The recipe components + * @return If the two match + */ + protected static final RecipeMatchResult checkSubset(ItemStack[] givenItems, List components) { + Map consumption = new HashMap<>(); + componentLoop: for (final RecipeComponent component : components) { + if (component.isAir()) { + continue; + } + + for (int i = 0; i < givenItems.length; i++) { + if (!consumption.containsKey(i) && component.matches(givenItems[i])) { + consumption.put(i, component); + continue componentLoop; + } + } + + return RecipeMatchResult.NO_MATCH; + } + + return RecipeMatchResult.match(consumption); + } + + private static int countNonEmpty(ItemStack[] givenItems) { + int nonEmpty = 0; + for (final ItemStack itemStack : givenItems) { + if (itemStack != null && !itemStack.getType().isAir()) { + nonEmpty++; + } + } + return nonEmpty; + } + + private static int countNonEmpty(List recipe) { + int nonEmpty = 0; + for (final RecipeComponent comp : recipe) { + if (!comp.isAir()) { + nonEmpty++; + } + } + return nonEmpty; + } + + private final NamespacedKey key; + + public RecipeStructure(NamespacedKey key) { + this.key = key; + } + + RecipeStructure(String name) { + this(new NamespacedKey(Slimefun.instance(), name)); + } + + @Override + public NamespacedKey getKey() { + return key; + } + + public String getTranslationKey() { + return key.getNamespace() + "." + key.getKey(); + } + + @Nonnull + @ParametersAreNonnullByDefault + public abstract RecipeMatchResult match(ItemStack[] givenItems, List components); + + @Override + public String toString() { + return key.toString(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeType.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeType.java index e22c947b67..78d696a18a 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeType.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeType.java @@ -25,19 +25,26 @@ import io.github.bakedlibs.dough.recipes.MinecraftRecipe; import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItemStack; +import io.github.thebusybiscuit.slimefun4.api.recipes.output.ItemOutput; import io.github.thebusybiscuit.slimefun4.core.multiblocks.MultiBlockMachine; import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; import io.github.thebusybiscuit.slimefun4.implementation.SlimefunItems; import io.github.thebusybiscuit.slimefun4.implementation.items.altar.AltarRecipe; import io.github.thebusybiscuit.slimefun4.implementation.items.altar.AncientAltar; -// TODO: Remove this class and rewrite the recipe system +// TODO: Remove this class +@Deprecated public class RecipeType implements Keyed { public static final RecipeType MULTIBLOCK = new RecipeType(new NamespacedKey(Slimefun.instance(), "multiblock"), new CustomItemStack(Material.BRICKS, "&bMultiBlock", "", "&a&oBuild it in the World")); public static final RecipeType ARMOR_FORGE = new RecipeType(new NamespacedKey(Slimefun.instance(), "armor_forge"), SlimefunItems.ARMOR_FORGE, "", "&a&oCraft it in an Armor Forge"); public static final RecipeType GRIND_STONE = new RecipeType(new NamespacedKey(Slimefun.instance(), "grind_stone"), SlimefunItems.GRIND_STONE, "", "&a&oGrind it using the Grind Stone"); - public static final RecipeType SMELTERY = new RecipeType(new NamespacedKey(Slimefun.instance(), "smeltery"), SlimefunItems.SMELTERY, "", "&a&oSmelt it using a Smeltery"); + public static final RecipeType SMELTERY = new RecipeType(new NamespacedKey(Slimefun.instance(), "smeltery"), SlimefunItems.SMELTERY, "", "&a&oSmelt it using a Smeltery") { + @Override + public RecipeCategory asRecipeCategory() { + return RecipeCategory.SMELTERY; + } + }; public static final RecipeType ORE_CRUSHER = new RecipeType(new NamespacedKey(Slimefun.instance(), "ore_crusher"), SlimefunItems.ORE_CRUSHER, "", "&a&oCrush it using the Ore Crusher"); public static final RecipeType GOLD_PAN = new RecipeType(new NamespacedKey(Slimefun.instance(), "gold_pan"), SlimefunItems.GOLD_PAN, "", "&a&oUse a Gold Pan on Gravel to obtain this Item"); public static final RecipeType COMPRESSOR = new RecipeType(new NamespacedKey(Slimefun.instance(), "compressor"), SlimefunItems.COMPRESSOR, "", "&a&oCompress it using the Compressor"); @@ -118,6 +125,19 @@ public RecipeType(MinecraftRecipe recipe) { this.key = NamespacedKey.minecraft(recipe.getRecipeClass().getSimpleName().toLowerCase(Locale.ROOT).replace("recipe", "")); } + public RecipeCategory asRecipeCategory() { + return new RecipeCategory(key, item) { + @Override + public void onRegisterRecipe(Recipe recipe) { + if (consumer != null && recipe.getOutput() instanceof final ItemOutput itemOutput) { + consumer.accept( + recipe.getInputs().asDisplayGrid(), + itemOutput.getOutputTemplate()); + } + } + }; + } + public void register(ItemStack[] recipe, ItemStack result) { if (consumer != null) { consumer.accept(recipe, result); diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/SlimefunRecipe.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/SlimefunRecipe.java new file mode 100644 index 0000000000..4b10529ad0 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/SlimefunRecipe.java @@ -0,0 +1,70 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import io.github.thebusybiscuit.slimefun4.api.recipes.input.RecipeInputs; +import io.github.thebusybiscuit.slimefun4.api.recipes.output.RecipeOutput; + +public class SlimefunRecipe implements Recipe { + + private RecipeInputs inputs; + private RecipeOutput outputs; + + @ParametersAreNonnullByDefault + public SlimefunRecipe(RecipeInputs inputs, RecipeOutput outputs) { + this.inputs = inputs; + this.outputs = outputs; + } + + @Override + public void setInputs(RecipeInputs inputs) { + this.inputs = inputs; + } + + @Override + public void setOutputs(RecipeOutput outputs) { + this.outputs = outputs; + } + + @Override + @Nonnull + public RecipeInputs getInputs() { + return inputs; + } + + @Override + @Nonnull + public RecipeOutput getOutput() { + return outputs; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(input(s) = " + inputs + ", output(s) = " + outputs + ")"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + Recipe other = (SlimefunRecipe) obj; + return other.getInputs().equals(getInputs()) && other.getOutput().equals(getOutput()); + } + + @Override + public int hashCode() { + return getInputs().hashCode() * 31 + getOutput().hashCode(); + } + + public TimedRecipe asTimedRecipe(int ticks) { + return new TimedRecipe(ticks, inputs, outputs); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/TimedRecipe.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/TimedRecipe.java new file mode 100644 index 0000000000..8e6901616b --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/TimedRecipe.java @@ -0,0 +1,25 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import io.github.thebusybiscuit.slimefun4.api.recipes.input.RecipeInputs; +import io.github.thebusybiscuit.slimefun4.api.recipes.output.RecipeOutput; + +public class TimedRecipe extends SlimefunRecipe { + + private final int ticks; + + public TimedRecipe(int ticks, RecipeInputs inputs, RecipeOutput outputs) { + super(inputs, outputs); + + this.ticks = ticks; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(input(s) = " + getInputs() + ", output(s) = " + getOutput() + ", ticks = " + ticks + ")"; + } + + public int getTicks() { + return ticks; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/DistinctiveComponent.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/DistinctiveComponent.java new file mode 100644 index 0000000000..01153318db --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/DistinctiveComponent.java @@ -0,0 +1,21 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; + +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.utils.SlimefunUtils; + +/** + * A {@code DistinctiveComponent} only checks the id of the slimefun item + */ +public class DistinctiveComponent extends ItemComponent { + + public DistinctiveComponent(ItemStack slimefunItem) { + super(slimefunItem); + } + + @Override + public boolean matches(ItemStack givenItem) { + return SlimefunUtils.isItemSimilar(givenItem, getComponent(), false, true, false); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/DurabilityComponent.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/DurabilityComponent.java new file mode 100644 index 0000000000..0f13bbb97d --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/DurabilityComponent.java @@ -0,0 +1,68 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; + +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.inventory.meta.ItemMeta; + +import com.google.common.base.Preconditions; + +public class DurabilityComponent extends ItemComponent { + + final int durabilityCost; + + public DurabilityComponent(ItemStack component, int durabilityCost) { + super(component); + this.durabilityCost = durabilityCost; + Preconditions.checkArgument(!component.hasItemMeta() || !(component.getItemMeta() instanceof Damageable), + "DurabilityComponent needs an item with durability!"); + } + + public int getDurabilityCost() { + return durabilityCost; + } + + @Override + public void consume(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + if (meta.isUnbreakable()) { + return; + } + int unbLevel = meta.getEnchantLevel(Enchantment.DURABILITY); + if (unbLevel > 0 && ThreadLocalRandom.current().nextDouble() > (1 / (1 + unbLevel))) { // %TODO + return; + } + Damageable damageable = (Damageable) meta; + damageable.setDamage(Math.min(damageable.getDamage() + durabilityCost, item.getType().getMaxDurability())); + item.setItemMeta(damageable); + } + + @Override + public boolean matches(ItemStack givenItem) { + if (!givenItem.hasItemMeta() || !(givenItem.getItemMeta() instanceof Damageable damageable)) { + return false; + } + + ItemMeta meta = getComponent().getItemMeta(); + + return super.matches(givenItem) + && (meta.isUnbreakable() || damageable.getDamage() <= ((Damageable) meta).getDamage()); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + DurabilityComponent other = (DurabilityComponent) obj; + return other.getComponent().equals(getComponent()) && durabilityCost == other.getDurabilityCost(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/ItemComponent.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/ItemComponent.java new file mode 100644 index 0000000000..0f8c1d1ccf --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/ItemComponent.java @@ -0,0 +1,96 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; + +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.utils.SlimefunUtils; + +public class ItemComponent implements RecipeComponent { + + private final ItemStack component; + private final boolean disabled; + private final @Nullable String slimefunID; + + public ItemComponent(@Nullable ItemStack component) { + this.component = component == null ? new ItemStack(Material.AIR) : component; + if (this.component.getType().isAir()) { + slimefunID = null; + disabled = false; + } else { + SlimefunItem sfItem = SlimefunItem.getByItem(component); + slimefunID = sfItem != null ? sfItem.getId() : null; + disabled = sfItem != null ? sfItem.isDisabled() : false; + } + } + + public ItemComponent(@Nonnull Material component) { + this.component = new ItemStack(component); + this.disabled = false; + this.slimefunID = null; + } + + public ItemStack getComponent() { + return component; + } + + @Override + public boolean isDisabled() { + return disabled; + } + + @Override + public boolean isAir() { + return component.getType().isAir(); + } + + @Override + public int getAmount() { + return component.getAmount(); + } + + @Override + public boolean matches(ItemStack givenItem) { + return SlimefunUtils.isItemSimilar(givenItem, component, true); + } + + @Override + public List getDisplayItems() { + return List.of(component.clone()); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(" + component.toString() + ")"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + return ((ItemComponent) obj).getComponent().equals(component); + } + + @Override + public int hashCode() { + return component.hashCode(); + } + + @Override + public List getSlimefunItemIDs() { + return slimefunID == null ? Collections.emptyList() : List.of(slimefunID); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/MultiItemComponent.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/MultiItemComponent.java new file mode 100644 index 0000000000..0414ddf995 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/MultiItemComponent.java @@ -0,0 +1,121 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.annotation.Nonnull; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import com.google.common.base.Preconditions; + +public class MultiItemComponent implements RecipeComponent { + + private final List choices = new ArrayList<>(); + private final boolean disabled; + private final List slimefunIDs = new ArrayList<>(); + + public MultiItemComponent(@Nonnull List choices) { + Preconditions.checkNotNull(choices, "'choices' cannot be null!"); + Preconditions.checkArgument(!choices.isEmpty(), "'choices' must be non-empty"); + + for (final RecipeComponent choice : choices) { + if (choice.isDisabled()) { + continue; + } + + this.choices.add(choice); + } + + this.disabled = this.choices.size() == 0; + + for (final RecipeComponent choice : choices) { + slimefunIDs.addAll(choice.getSlimefunItemIDs()); + } + } + + public MultiItemComponent(@Nonnull ItemStack... choices) { + this(Arrays.stream(choices).map(choice -> choice == null ? RecipeComponent.AIR : new ItemComponent(choice)).toList()); + } + + public MultiItemComponent(@Nonnull Material... materials) { + Preconditions.checkNotNull(materials, "'materials' cannot be null!"); + Preconditions.checkArgument(materials.length > 0, "'materials' must be non-empty"); + + for (final Material material : materials) { + this.choices.add(new ItemComponent(material)); + } + + this.disabled = false; + } + + public List getChoices() { + return choices; + } + + @Override + public boolean isAir() { + return disabled ? true : choices.get(0).isAir(); + } + + @Override + public boolean isDisabled() { + return disabled; + } + + @Override + public int getAmount() { + return disabled ? 0 : choices.get(0).getAmount(); + } + + @Override + public boolean matches(ItemStack givenItem) { + for (final RecipeComponent item : choices) { + if (item.matches(givenItem)) { + return true; + } + } + + return false; + } + + @Override + public List getDisplayItems() { + List displayItems = new ArrayList<>(); + for (RecipeComponent choice : choices) { + displayItems.addAll(choice.getDisplayItems()); + } + return displayItems; + } + + @Override + public String toString() { + return choices.toString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + return ((MultiItemComponent) obj).getChoices().equals(choices); + } + + @Override + public int hashCode() { + return choices.hashCode(); + } + + @Override + public List getSlimefunItemIDs() { + return slimefunIDs; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/RecipeComponent.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/RecipeComponent.java new file mode 100644 index 0000000000..a59e925f83 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/RecipeComponent.java @@ -0,0 +1,127 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; + +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import io.github.bakedlibs.dough.items.ItemUtils; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.core.attributes.DistinctiveItem; + +public interface RecipeComponent { + + public static RecipeComponent AIR = new RecipeComponent() { + + @Override + public boolean isAir() { + return true; + } + + @Override + public boolean isDisabled() { + return false; + } + + @Override + public int getAmount() { + return 0; + } + + @Override + public boolean matches(ItemStack givenItem) { + return givenItem == null || givenItem.getType().isAir(); + } + + @Override + public List getDisplayItems() { + return List.of(new ItemStack(Material.AIR)); + } + + @Override + public String toString() { + return "EMPTY"; + } + + @Override + public boolean equals(Object obj) { + return obj == null || obj == RecipeComponent.AIR; + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public List getSlimefunItemIDs() { + return Collections.emptyList(); + } + + }; + + public static @Nonnull RecipeComponent of(@Nonnull String slimefunItemId) { + SlimefunItem item = SlimefunItem.getById(slimefunItemId); + + if (item == null) { + return AIR; + } + + if (item instanceof DistinctiveItem) { + return new DistinctiveComponent(item.getItem()); + } + + return new ItemComponent(item.getItem()); + } + + public static @Nonnull RecipeComponent of(@Nullable ItemStack item) { + SlimefunItem sfItem = SlimefunItem.getByItem(item); + + if (sfItem != null && sfItem instanceof DistinctiveItem) { + return new DistinctiveComponent(item); + } + + return item == null ? AIR : new ItemComponent(item); + } + + /** + * @return If this item is disabled and cannot be used in a recipe + */ + public boolean isDisabled(); + + /** + * @return If this item is equivalent to an empty slot + */ + public boolean isAir(); + + /** + * @return The amount this component needs in the recipe + */ + public int getAmount(); + + /** + * @param givenItem The item to match + * @return If the given item can be used as this component in a recipe + */ + public boolean matches(@Nullable ItemStack givenItem); + + /** + * Consumes the item when crafting + * @param item The item to consume + */ + public default void consume(@Nonnull ItemStack item) { + ItemUtils.consumeItem(item, getAmount(), true); + } + + /** + * @return An {@link ItemStack} for display purposes (e.g. in the guide) + */ + public @Nonnull List getDisplayItems(); + + public List getSlimefunItemIDs(); + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TagComponent.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TagComponent.java new file mode 100644 index 0000000000..b63287546d --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TagComponent.java @@ -0,0 +1,85 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; + +import java.util.Collections; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import com.google.common.base.Preconditions; + +import io.github.bakedlibs.dough.items.ItemUtils; +import io.github.thebusybiscuit.slimefun4.utils.tags.SlimefunTag; + +public class TagComponent implements RecipeComponent { + + private final SlimefunTag tag; + + public TagComponent(SlimefunTag tag) { + Preconditions.checkArgument(!tag.isEmpty(), "Tag must not be empty."); + + this.tag = tag; + } + + public SlimefunTag getTag() { + return tag; + } + + @Override + public boolean isAir() { + return tag.isTagged(Material.AIR) || tag.isTagged(Material.CAVE_AIR) || tag.isTagged(Material.VOID_AIR); + } + + @Override + public int getAmount() { + return 1; + } + + @Override + public boolean isDisabled() { + return false; + } + + @Override + public boolean matches(ItemStack givenItem) { + if (tag.isTagged(givenItem.getType())) { + return ItemUtils.canStack(new ItemStack(givenItem.getType()), givenItem); + } + + return false; + } + + @Override + public List getDisplayItems() { + return tag.stream().map(mat -> new ItemStack(mat)).toList(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(" + tag.toString() + ")"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + return ((TagComponent) obj).getTag().equals(this.getTag()); + } + + @Override + public int hashCode() { + return tag.hashCode(); + } + + @Override + public List getSlimefunItemIDs() { + return Collections.emptyList(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/package-info.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/package-info.java new file mode 100644 index 0000000000..57c7ee3dab --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains components that are used in + * {@link io.github.thebusybiscuit.slimefun4.api.recipes.input.RecipeInputs}s + */ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/ItemInputs.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/ItemInputs.java new file mode 100644 index 0000000000..f057cc8836 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/ItemInputs.java @@ -0,0 +1,106 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.input; + +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeStructure; +import io.github.thebusybiscuit.slimefun4.api.recipes.components.RecipeComponent; + +public class ItemInputs implements RecipeInputs { + + private final List components; + private RecipeStructure structure; + private final boolean disabled; + private final boolean empty; + + /** + * The constructor for a basic {@link RecipeInputs} object. + * + * @param structure + * @param components + */ + public ItemInputs(RecipeStructure structure, List components) { + this.structure = structure; + this.components = components; + boolean disabled = false; + boolean empty = true; + for (final RecipeComponent component : components) { + if (component.isDisabled()) { + disabled = true; + } + if (!component.isAir()) { + empty = false; + } + } + this.disabled = disabled; + this.empty = empty; + } + + @Override + public RecipeMatchResult match(RecipeStructure structure, ItemStack[] givenItems) { + return structure.match(givenItems, components); + } + + public List getComponents() { + return components; + } + + @Override + public boolean isDisabled() { + return disabled; + } + + @Override + public boolean isEmpty() { + return empty; + } + + @Override + public void setStructure(RecipeStructure structure) { + this.structure = structure; + } + + @Override + public RecipeStructure getStructure() { + return structure; + } + + @Override + public ItemStack[] asDisplayGrid() { + ItemStack[] displayGrid = new ItemStack[9]; + int numItems = Math.min(9, components.size()); + for (int i = 0; i < numItems; i++) { + List displayItems = components.get(i).getDisplayItems(); + displayGrid[i] = displayItems.size() > 0 ? displayItems.get(0) : new ItemStack(Material.AIR); + } + return displayGrid; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(" + structure + ", " + components.toString() + ")"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + ItemInputs other = (ItemInputs) obj; + return other.getComponents().equals(components) && other.getStructure().equals(structure); + } + + @Override + public int hashCode() { + return components.hashCode() * 31 + structure.hashCode(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/RecipeInputs.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/RecipeInputs.java new file mode 100644 index 0000000000..ec24de0bd2 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/RecipeInputs.java @@ -0,0 +1,101 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.input; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeStructure; +import io.github.thebusybiscuit.slimefun4.api.recipes.components.RecipeComponent; + +public interface RecipeInputs { + + public static final RecipeInputs EMPTY = new RecipeInputs() { + + @Override + public RecipeMatchResult match(RecipeStructure structure, ItemStack[] givenItems) { + return RecipeMatchResult.NO_MATCH; + } + + @Override + public List getComponents() { + return Collections.emptyList(); + } + + @Override + public boolean isDisabled() { + return false; + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public RecipeStructure getStructure() { + return RecipeStructure.IDENTICAL; + } + + @Override + public void setStructure(RecipeStructure structure) {} + + @Override + public ItemStack[] asDisplayGrid() { + return new ItemStack[9]; + } + + }; + + public static RecipeInputs of(RecipeStructure structure, ItemStack... items) { + return new ItemInputs(structure, Arrays.stream(items).map(item -> RecipeComponent.of(item)).toList()); + } + + /** + * Match a set of given items against these inputs with some structure + * + * @param structure The structure of the recipe + * @param givenItems The items to match + * @return The result of the match. See {@link RecipeMatchResult} + */ + @Nonnull + @ParametersAreNonnullByDefault + public RecipeMatchResult match(RecipeStructure structure, ItemStack[] givenItems); + + /** + * @return The {@link RecipeComponents} in this input + */ + public List getComponents(); + + /** + * @return Whether or not any of the components in this input are disabled + */ + public boolean isDisabled(); + + /** + * @return Whether or not this input is empty + */ + public boolean isEmpty(); + + /** + * Sets the structure of this recipe + * @param structure The new structure + */ + public void setStructure(RecipeStructure structure); + + /** + * @return The structure of this input + */ + public @Nonnull RecipeStructure getStructure(); + + /** + * @return A 9-item (3x3) {@link ItemStack} array for display purposes (e.g. in the guide) + */ + public ItemStack[] asDisplayGrid(); + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/package-info.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/package-info.java new file mode 100644 index 0000000000..f4e2aca0c3 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/input/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains inputs that are used in + * {@link io.github.thebusybiscuit.slimefun4.api.recipes.Recipe}s + */ +package io.github.thebusybiscuit.slimefun4.api.recipes.input; diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/ChanceItemOutput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/ChanceItemOutput.java new file mode 100644 index 0000000000..3b60fe10e1 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/ChanceItemOutput.java @@ -0,0 +1,56 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; + +import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +public class ChanceItemOutput extends ItemOutput { + + private final double chance; + + /** + * @param output The output of the recipe + * @param chance The chance [0...1] of the item being crafted + */ + public ChanceItemOutput(ItemStack output, double chance) { + super(output); + + this.chance = Math.max(Math.min(chance, 1), 0); + } + + public double getChance() { + return chance; + } + + @Override + public ItemStack generateOutput() { + return ThreadLocalRandom.current().nextDouble() < chance ? new ItemStack(Material.AIR) : super.generateOutput(); + } + + @Override + public String toString() { + return (chance * 100) + "% " + super.toString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + ChanceItemOutput other = (ChanceItemOutput) obj; + return Objects.equals(other.getOutputTemplate(), getOutputTemplate()) && chance == other.chance; + } + + @Override + public int hashCode() { + return super.hashCode() * 31 + Double.hashCode(chance); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/ItemOutput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/ItemOutput.java new file mode 100644 index 0000000000..4ce3b1e58c --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/ItemOutput.java @@ -0,0 +1,84 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import javax.annotation.Nullable; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; + +public class ItemOutput implements RecipeOutput { + + private final ItemStack output; + private final boolean disabled; + private final @Nullable String slimefunID; + + public ItemOutput(ItemStack output) { + this.output = output == null ? new ItemStack(Material.AIR) : output; + if (this.output.getType().isAir()) { + slimefunID = null; + disabled = false; + } else { + SlimefunItem sfItem = SlimefunItem.getByItem(output); + slimefunID = sfItem != null ? sfItem.getId() : null; + disabled = sfItem != null ? sfItem.isDisabled() : false; + } + } + + @Override + public ItemStack generateOutput() { + return output.clone(); + } + + public ItemStack getOutputTemplate() { + return output; + } + + @Override + public boolean isDisabled() { + return disabled; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(" + output.toString() + ")"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + return Objects.equals(((ItemOutput) obj).getOutputTemplate(), getOutputTemplate()); + } + + @Override + public int hashCode() { + return output.hashCode(); + } + + @Override + public List getSlimefunItemIDs() { + return slimefunID == null ? Collections.emptyList() : List.of(slimefunID); + } + + @Override + public List getDisplayItems() { + return List.of(output.clone()); + } + + @Override + public ItemStack getDisplayItem(String slimefunID) { + return slimefunID.equals(this.slimefunID) ? output.clone() : new ItemStack(Material.AIR); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/MultiItemOutput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/MultiItemOutput.java new file mode 100644 index 0000000000..1131fb095a --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/MultiItemOutput.java @@ -0,0 +1,123 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import com.google.common.base.Preconditions; + +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; + +public class MultiItemOutput implements RecipeOutput { + + private final List outputs = new ArrayList<>(); + private final boolean disabled; + private final List slimefunIDs = new ArrayList<>(); + + public MultiItemOutput(RecipeOutput... outputs) { + Preconditions.checkArgument(outputs.length > 0, "The 'items' array must be non-empty"); + + for (final RecipeOutput output : outputs) { + if (output.isDisabled()) { + continue; + } + + this.outputs.add(output); + } + + this.disabled = this.outputs.size() == 0; + + for (final RecipeOutput output : outputs) { + slimefunIDs.addAll(output.getSlimefunItemIDs()); + } + } + + public List getOutputs() { + return outputs; + } + + @Override + public ItemStack generateOutput() { + return outputs.get(0).generateOutput(); + } + + @Override + public ItemStack generateOutput(ItemStack[] givenItems, Recipe recipe) { + return outputs.get(0).generateOutput(givenItems, recipe); + } + + @Override + public List generateOutputs() { + List outputs = new ArrayList<>(); + for (final RecipeOutput output : this.outputs) { + outputs.addAll(output.generateOutputs()); + } + return outputs; + } + + @Override + public List generateOutputs(ItemStack[] givenItems, Recipe recipe) { + List outputs = new ArrayList<>(); + for (final RecipeOutput output : this.outputs) { + outputs.addAll(output.generateOutputs(givenItems, recipe)); + } + return outputs; + } + + @Override + public boolean isDisabled() { + return disabled; + } + + @Override + public String toString() { + return outputs.toString(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + return ((MultiItemOutput) obj).getOutputs().equals(outputs); + } + + @Override + public int hashCode() { + return outputs.hashCode(); + } + + @Override + public List getSlimefunItemIDs() { + return slimefunIDs; + } + + @Override + public List getDisplayItems() { + List displayItems = new ArrayList<>(); + for (RecipeOutput output : outputs) { + displayItems.addAll(output.getDisplayItems()); + } + return displayItems; + } + + @Override + public ItemStack getDisplayItem(String slimefunID) { + for (RecipeOutput item : outputs) { + ItemStack displayItem = item.getDisplayItem(slimefunID); + if (!displayItem.getType().isAir()) { + return displayItem; + } + } + + return new ItemStack(Material.AIR); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/RandomItemOutput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/RandomItemOutput.java new file mode 100644 index 0000000000..07c2f91823 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/RandomItemOutput.java @@ -0,0 +1,87 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import io.github.bakedlibs.dough.collections.RandomizedSet; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItemStack; + +public class RandomItemOutput implements RecipeOutput { + + private final RandomizedSet outputSet; + private final List slimefunIDs = new ArrayList<>(); + + public RandomItemOutput(RandomizedSet outputSet) { + this.outputSet = outputSet; + for (final ItemStack item : outputSet) { + if (item instanceof final SlimefunItemStack sfStack) { + slimefunIDs.add(sfStack.getItemId()); + } + } + } + + public RandomizedSet getOutputSet() { + return outputSet; + } + + @Override + public ItemStack generateOutput() { + return outputSet.getRandom().clone(); + } + + @Override + public boolean isDisabled() { + // TODO check each element, same as ItemOutput + return false; + } + + @Override + public String toString() { + return getClass().getSimpleName() + "(" + outputSet.toString() + ")"; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + return ((RandomItemOutput) obj).getOutputSet().equals(outputSet); + } + + @Override + public int hashCode() { + return outputSet.hashCode(); + } + + @Override + public List getSlimefunItemIDs() { + return slimefunIDs; + } + + @Override + public List getDisplayItems() { + return outputSet.stream().toList(); + } + + @Override + public ItemStack getDisplayItem(String slimefunID) { + for (ItemStack item : outputSet) { + SlimefunItem sfItem = SlimefunItem.getByItem(item); + if (sfItem != null && sfItem.getId().equals(slimefunID)) { + return item.clone(); + } + } + + return new ItemStack(Material.AIR); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/RecipeOutput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/RecipeOutput.java new file mode 100644 index 0000000000..44ecff77b8 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/RecipeOutput.java @@ -0,0 +1,118 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; + +public interface RecipeOutput { + + public static final RecipeOutput EMPTY = new RecipeOutput() { + + @Override + public ItemStack generateOutput() { + return new ItemStack(Material.AIR); + } + + @Override + public boolean isDisabled() { + return false; + } + + @Override + public List getSlimefunItemIDs() { + return Collections.emptyList(); + } + + @Override + public List getDisplayItems() { + return List.of(new ItemStack(Material.AIR)); + } + + @Override + public ItemStack getDisplayItem(String slimefunID) { + return new ItemStack(Material.AIR); + } + + }; + + @Nonnull + @ParametersAreNonnullByDefault + public static RecipeOutput of(ItemStack... outputs) { + RecipeOutput[] recipeOutputs = Arrays.stream(outputs) + .map(item -> item == null ? ItemOutput.EMPTY : new ItemOutput(item)).toArray(RecipeOutput[]::new); + return recipeOutputs.length == 1 + ? recipeOutputs[0] + : new MultiItemOutput(recipeOutputs); + } + + /** + * To be called when the Workstation/Machine needs to get the output of a + * recipe + * + * @return The output of a recipe + */ + public @Nonnull ItemStack generateOutput(); + + /** + * Override this method if a recipe has multiple outputs at once + * + * @return The outputs of the recipe + */ + public default @Nonnull List generateOutputs() { + return List.of(generateOutput()); + } + + /** + * Override this method if a recipe's output depends on its input. + * + * This method should only be called if you are sure the given items + * will craft this output + * + * @param givenItems The input items + * @return The output of the recipe + */ + public default @Nonnull ItemStack generateOutput(ItemStack[] givenItems, Recipe recipe) { + return generateOutput(); + } + + /** + * Override this method if a recipe's output depends on its input. + * + * This method should only be called if you are sure the given items + * will craft this output + * + * @param givenItems The input items + * @return The outputs of the recipe + */ + public default @Nonnull List generateOutputs(ItemStack[] givenItems, Recipe recipe) { + return generateOutputs(); + } + + /** + * @return The item to be displayed in the guide + */ + public @Nonnull List getDisplayItems(); + + /** + * @return The item to be displayed in the guide. + * @param slimefunID The ID of the slimefun item currently being viewed in the guide. + */ + public @Nonnull ItemStack getDisplayItem(String slimefunID); + + /** + * If the output(s) are disabled and so cannot be crafted + * @return + */ + public boolean isDisabled(); + + public List getSlimefunItemIDs(); + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/package-info.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/package-info.java new file mode 100644 index 0000000000..2af0636596 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains outputs that are used in + * {@link io.github.thebusybiscuit.slimefun4.api.recipes.Recipe}s + */ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/SlimefunRecipeService.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/SlimefunRecipeService.java new file mode 100644 index 0000000000..564129afdd --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/SlimefunRecipeService.java @@ -0,0 +1,414 @@ +package io.github.thebusybiscuit.slimefun4.core.services; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.inventory.ItemStack; + +import io.github.bakedlibs.dough.collections.Pair; +import io.github.bakedlibs.dough.items.ItemUtils; +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeCategory; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeSearchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.components.RecipeComponent; + +/** + * This class holds the recipes that workstations and machines use + * (with exceptions such as Auto Anvil, Auto Enchanter, etc...) + * + * @author SchnTgaiSpock + */ +public final class SlimefunRecipeService { + + public enum CachingStrategy { + /** + * Save the result to LRU cache even if no recipe was found, + * or if only 1 output can be crafted + */ + ALWAYS, + /** + * Save the result to LRU cache if multiple outputs can be crafted, + * or if no recipe was found + */ + IF_MULTIPLE_CRAFTABLE, + /** + * Do not save to LRU cache + */ + NEVER + } + + public final int CACHE_SIZE = 100; + + private final Map> recipesByCategory = new HashMap<>(); + private final Map>> recipesByOutput = new HashMap<>(); + private final Map>> recipesByInput = new HashMap<>(); + // private final Map>> recipeByVanillaOutput = new HashMap<>(); + // private final Map>> recipeByVanillaInput = new HashMap<>(); + private final Map cache = new LinkedHashMap<>(CACHE_SIZE, 0.75f, + true) { + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() >= CACHE_SIZE; + }; + }; + + public SlimefunRecipeService() {} + + /** + * Linearly searches all recipes in a category for a recipe using the given + * items + * + * @param category The category of the recipe to search in + * @param givenItems Items from the crafting grid + * @param cachingStrategy When to save the result to the LRU cache + * @param onRecipeFound To be called when a matching recipe is found + * @return (The recipe if found, The match result) See {@link RecipeMatchResult} + */ + @ParametersAreNonnullByDefault + public RecipeSearchResult searchRecipes( + RecipeCategory category, + ItemStack[] givenItems, + CachingStrategy cachingStrategy, + @Nullable BiConsumer onRecipeFound) { + // No recipes registered, no match + List categoryRecipes = recipesByCategory.get(category); + if (categoryRecipes == null) { + return RecipeSearchResult.NO_MATCH; + } + + // Check LRU cache + int givenItemsHash = hashIgnoreAmount(givenItems, category); + Optional cachedMatchResult = getFromCache(givenItemsHash); + if (cachedMatchResult.isPresent()) { + Recipe recipe = cachedMatchResult.get().getRecipe(); + RecipeMatchResult match = cachedMatchResult.get().getMatchResult(); + if (match.isMatch() && onRecipeFound != null) { + onRecipeFound.accept(recipe, match); + } + + return cachedMatchResult.get(); + } + + // Linearly search through the recipes + for (final Recipe recipe : categoryRecipes) { + RecipeMatchResult match = recipe.match(givenItems); + + if (match.isMatch()) { + if (onRecipeFound != null) { + onRecipeFound.accept(recipe, match); + } + + RecipeSearchResult result = new RecipeSearchResult(recipe, category, match); + + switch (cachingStrategy) { + case IF_MULTIPLE_CRAFTABLE: + boolean isMultipleCraftable = true; + for (final Map.Entry consumptionEntry : match.getConsumption().entrySet()) { + ItemStack item = givenItems[consumptionEntry.getKey()]; + if (item.getAmount() < consumptionEntry.getValue().getAmount() * 2) { + isMultipleCraftable = false; + break; + } + } + + if (!isMultipleCraftable) { + break; + } else { + cache(givenItems, category, result); + } + + case ALWAYS: + cache(givenItems, category, result); + + default: + break; + } + + return result; + } + } + + if (cachingStrategy != CachingStrategy.NEVER) { + cache(givenItems, category, RecipeSearchResult.NO_MATCH); + } + return RecipeSearchResult.NO_MATCH; + } + + /** + * Linearly searches all recipes in a category for a recipe using the given + * items, and crafts it once. + * + * @param category The category of the recipe to search in + * @param givenItems Items from the crafting grid + * @param cachingStrategy When to save the result to the LRU cache + * @param onRecipeFound To be called when a matching recipe is found. If it + * returns true, consumes the input items according to + * the recipe + * @return The search result. See {@link RecipeSearchResult} + */ + @ParametersAreNonnullByDefault + public RecipeSearchResult searchRecipes( + RecipeCategory category, + ItemStack[] givenItems, + CachingStrategy cachingStrategy, + BiPredicate onRecipeFound) { + return searchRecipes(category, givenItems, cachingStrategy, (recipe, match) -> { + if (onRecipeFound != null && onRecipeFound.test(recipe, match)) { + for (final Map.Entry entry : match.getConsumption().entrySet()) { + ItemStack item = givenItems[entry.getKey()]; + entry.getValue().consume(item); + } + } + }); + } + + /** + * Linearly searches all recipes in a category for a recipe using the given + * items, and crafts it once. + * + * @param category The category of the recipe to search in + * @param givenItems Items from the crafting grid + * @param cachingStrategy When to save the result to the LRU cache + * @return The search result. See {@link RecipeSearchResult} + */ + @ParametersAreNonnullByDefault + public RecipeSearchResult searchRecipes( + RecipeCategory category, + ItemStack[] givenItems, + CachingStrategy cachingStrategy) { + return searchRecipes(category, givenItems, cachingStrategy, (recipe, match) -> { + }); + } + + /** + * Registers the given recipe to the given category + * + * @param category The recipe category + * @param recipe The recipe to register + */ + @ParametersAreNonnullByDefault + public void registerRecipe(RecipeCategory category, Recipe recipe) { + registerRecipes(category, List.of(recipe)); + } + + /** + * Registers all given recipes to the given category + * + * @param category The recipe category + * @param recipes The recipes to register + */ + @ParametersAreNonnullByDefault + public void registerRecipes(RecipeCategory category, List recipes) { + List categoryRecipes = this.recipesByCategory.getOrDefault(category, new ArrayList<>()); + for (final Recipe recipe : recipes) { + if (recipe.isDisabled()) { + continue; + } + + category.onRegisterRecipe(recipe); + categoryRecipes.add(recipe); + + for (final RecipeComponent comp : recipe.getInputs().getComponents()) { + for (final String id : comp.getSlimefunItemIDs()) { + Map> inputRecipesByCategory = recipesByInput.getOrDefault(id, new HashMap<>()); + addToRecipeSet(category, recipe, inputRecipesByCategory); + recipesByInput.put(id, inputRecipesByCategory); + } + } + + for (final String id : recipe.getOutput().getSlimefunItemIDs()) { + Map> outputRecipesByCategory = recipesByOutput.getOrDefault(id, new HashMap<>()); + addToRecipeSet(category, recipe, outputRecipesByCategory); + recipesByOutput.put(id, outputRecipesByCategory); + } + } + this.recipesByCategory.put(category, categoryRecipes); + } + + private static void addToRecipeSet(RecipeCategory category, Recipe recipe, Map> map) { + Set categoryRecipes = map.getOrDefault(category, new LinkedHashSet<>()); + categoryRecipes.add(recipe); + map.put(category, categoryRecipes); + } + + /** + * @return All Slimefun recipes by category + */ + @Nonnull + public Map> getAllRecipes() { + return recipesByCategory; + } + + /** + * Gets all recipes in the specified category + * @param category The category to get + */ + @Nonnull + public List getRecipes(@Nonnull RecipeCategory category) { + return recipesByCategory.getOrDefault(category, Collections.emptyList()); + } + + /** + * Gets all recipes that craft the specified Slimefun item + * @param slimefunID The id of the item + */ + @Nonnull + public Map> getRecipesByOutput(@Nonnull String slimefunID) { + return recipesByOutput.getOrDefault(slimefunID, Collections.emptyMap()); + } + + /** + * Gets a stream of all recipes that craft the specified Slimefun item + * @param slimefunID The id of the item + */ + @Nonnull + public Stream> getRecipeStreamByOutput(@Nonnull String slimefunID) { + return recipesByOutput + .get(slimefunID) + .entrySet() + .stream() + .flatMap(entry -> entry + .getValue() + .stream() + .map(elem -> new Pair<>(entry.getKey(), elem))); + } + + /** + * Gets all recipes in a category that craft the specified item + * @param slimefunID The id of the item + * @param category The category + */ + @Nonnull + @ParametersAreNonnullByDefault + public Set getRecipesByOutput(String slimefunID, RecipeCategory category) { + return getRecipesByOutput(slimefunID).getOrDefault(category, Collections.emptySet()); + } + + public int getNumberOfRecipes(@Nonnull String slimefunID) { + Map> recipesByOutput = getRecipesByOutput(slimefunID); + int count = 0; + for (Map.Entry> category : recipesByOutput.entrySet()) { + count += category.getValue().size(); + } + return count; + } + + public int getNumberOfRecipesUsedIn(@Nonnull String slimefunID) { + Map> recipesByInput = getRecipesByInput(slimefunID); + int count = 0; + for (Map.Entry> category : recipesByInput.entrySet()) { + count += category.getValue().size(); + } + return count; + } + + /** + * Gets all recipes that the item is used in + * @param slimefunID The id of the Slimefun item + */ + @Nonnull + public Map> getRecipesByInput(@Nonnull String slimefunID) { + return recipesByInput.getOrDefault(slimefunID, Collections.emptyMap()); + } + + /** + * Gets a stream of the recipes that the item is used in + * @param slimefunID The id of the Slimefun item + */ + @Nonnull + public Stream> getRecipeStreamByInput(@Nonnull String slimefunID) { + return recipesByInput + .get(slimefunID) + .entrySet() + .stream() + .flatMap(entry -> entry + .getValue() + .stream() + .map(elem -> new Pair<>(entry.getKey(), elem))); + } + + /** + * Gets all recipes in a category that the item is used in + * @param slimefunID The id of the Slimefun item + * @param category The category + */ + @Nonnull + @ParametersAreNonnullByDefault + public Set getRecipesByInput(String slimefunID, RecipeCategory category) { + return getRecipesByInput(slimefunID).getOrDefault(category, Collections.emptySet()); + } + + /** + * Saves a pattern of given items and its match result to the LRU cache + * + * @param givenItems The items to save + * @param matchResult The match result to save + * @return The hash of the given items + */ + @ParametersAreNonnullByDefault + public int cache(ItemStack[] givenItems, RecipeCategory category, RecipeSearchResult matchResult) { + int hash = hashIgnoreAmount(givenItems, category); + cache.put(hash, matchResult); + return hash; + } + + /** + * Returns the LRU recipe cache + * + * @return The cache + */ + @Nonnull + public Map getCache() { + return cache; + } + + /** + * Returns the RecipeMatchResult associated with the given hash, + * if it exists in the cache + * + * @param hash The hash to check + * @return The Recipe, or empty if nonexisting + */ + @Nonnull + public Optional getFromCache(int hash) { + return Optional.ofNullable(cache.get(hash)); + } + + @ParametersAreNonnullByDefault + public int hashIgnoreAmount(ItemStack[] items, RecipeCategory category) { + int hash = 1; + for (final ItemStack item : items) { + if (item != null) { + hash = hash * 31 + item.getType().hashCode(); + hash = hash * 31 + (item.hasItemMeta() ? item.getItemMeta().hashCode() : 0); + } else { + hash *= 31; + } + } + hash = hash * 31 + category.hashCode(); + return hash; + } + + /** + * Clears the cache + */ + public void clearCache() { + cache.clear(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/localization/SlimefunLocalization.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/localization/SlimefunLocalization.java index 893e3baf21..f207dd4d81 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/localization/SlimefunLocalization.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/localization/SlimefunLocalization.java @@ -28,6 +28,7 @@ import io.github.bakedlibs.dough.items.CustomItemStack; import io.github.thebusybiscuit.slimefun4.api.MinecraftVersion; import io.github.thebusybiscuit.slimefun4.api.SlimefunBranch; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeCategory; import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeType; import io.github.thebusybiscuit.slimefun4.core.services.LocalizationService; import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; @@ -307,11 +308,16 @@ protected void loadEmbeddedLanguages() { return getStringOrNull(getLanguage(p), LanguageFile.RESOURCES, key); } + @Deprecated public @Nonnull ItemStack getRecipeTypeItem(@Nonnull Player p, @Nonnull RecipeType recipeType) { + return getRecipeCategoryItem(p, recipeType.asRecipeCategory()); + } + + public @Nonnull ItemStack getRecipeCategoryItem(@Nonnull Player p, @Nonnull RecipeCategory recipeCategory) { Validate.notNull(p, "Player cannot be null!"); - Validate.notNull(recipeType, "Recipe type cannot be null!"); + Validate.notNull(recipeCategory, "Recipe type cannot be null!"); - ItemStack item = recipeType.toItem(); + ItemStack item = recipeCategory.getDisplayItem(); if (item == null) { // Fixes #3088 @@ -319,7 +325,7 @@ protected void loadEmbeddedLanguages() { } Language language = getLanguage(p); - NamespacedKey key = recipeType.getKey(); + NamespacedKey key = recipeCategory.getKey(); return new CustomItemStack(item, meta -> { String displayName = getStringOrNull(language, LanguageFile.RECIPES, key.getNamespace() + "." + key.getKey() + ".name"); diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java index ae065bc062..69da7c6261 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java @@ -7,12 +7,15 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.annotation.Nonnull; import javax.annotation.Nullable; +import javax.annotation.ParametersAreNonnullByDefault; import io.github.thebusybiscuit.slimefun4.storage.Storage; import io.github.thebusybiscuit.slimefun4.storage.backend.legacy.LegacyStorage; @@ -24,7 +27,7 @@ import org.bukkit.command.Command; import org.bukkit.entity.Player; import org.bukkit.event.Listener; -import org.bukkit.inventory.Recipe; +import org.bukkit.inventory.ItemStack; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.PluginDescriptionFile; import org.bukkit.plugin.java.JavaPlugin; @@ -39,6 +42,10 @@ import io.github.thebusybiscuit.slimefun4.api.gps.GPSNetwork; import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; import io.github.thebusybiscuit.slimefun4.api.player.PlayerProfile; +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeCategory; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeSearchResult; import io.github.thebusybiscuit.slimefun4.core.SlimefunRegistry; import io.github.thebusybiscuit.slimefun4.core.commands.SlimefunCommand; import io.github.thebusybiscuit.slimefun4.core.networks.NetworkManager; @@ -53,6 +60,8 @@ import io.github.thebusybiscuit.slimefun4.core.services.MinecraftRecipeService; import io.github.thebusybiscuit.slimefun4.core.services.PerWorldSettingsService; import io.github.thebusybiscuit.slimefun4.core.services.PermissionsService; +import io.github.thebusybiscuit.slimefun4.core.services.SlimefunRecipeService; +import io.github.thebusybiscuit.slimefun4.core.services.SlimefunRecipeService.CachingStrategy; import io.github.thebusybiscuit.slimefun4.core.services.ThreadService; import io.github.thebusybiscuit.slimefun4.core.services.UpdaterService; import io.github.thebusybiscuit.slimefun4.core.services.github.GitHubService; @@ -182,6 +191,7 @@ public class Slimefun extends JavaPlugin implements SlimefunAddon { private final PermissionsService permissionsService = new PermissionsService(this); private final PerWorldSettingsService worldSettingsService = new PerWorldSettingsService(this); private final MinecraftRecipeService recipeService = new MinecraftRecipeService(this); + private final SlimefunRecipeService slimefunRecipeService = new SlimefunRecipeService(); private final HologramsService hologramsService = new HologramsService(this); private final SoundService soundService = new SoundService(this); private final ThreadService threadService = new ThreadService(this); @@ -798,7 +808,7 @@ private static void validateInstance() { /** * This method returns out {@link MinecraftRecipeService} for Slimefun. - * This service is responsible for finding/identifying {@link Recipe Recipes} + * This service is responsible for finding/identifying {@link org.bukkit.inventory.Recipe Recipes} * from vanilla Minecraft. * * @return Slimefun's {@link MinecraftRecipeService} instance @@ -828,6 +838,74 @@ private static void validateInstance() { return instance.blockDataService; } + public static @Nonnull SlimefunRecipeService getSlimefunRecipeService() { + validateInstance(); + return instance.slimefunRecipeService; + } + + /** + * Searches all recipes in a category for a recipe using the given items. + * + * Shorthand for {@code Slimefun.getSlimefunRecipeService.searchRecipes()} + * + * @param category The category of the recipe to search in + * @param givenItems Items from the crafting grid + * @param cachingStrategy When to save the result to the LRU cache + * @param onRecipeFound To be called when a matching recipe is found + * @return + */ + @ParametersAreNonnullByDefault + public static RecipeSearchResult searchRecipes( + RecipeCategory category, + ItemStack[] givenItems, + CachingStrategy cachingStrategy, + BiConsumer onRecipeFound + ) { + return getSlimefunRecipeService().searchRecipes(category, givenItems, cachingStrategy, onRecipeFound); + } + + /** + * Searches all recipes in a category for a recipe using the given items. + * + * Shorthand for {@code Slimefun.getSlimefunRecipeService.searchRecipes()} + * + * @param category The category of the recipe to search in + * @param givenItems Items from the crafting grid + * @param cachingStrategy When to save the result to the LRU cache + * @param onRecipeFound To be called when a matching recipe is found. If it + * returns true, consumes the input items according to + * the recipe + * @return + */ + @ParametersAreNonnullByDefault + public static RecipeSearchResult searchRecipes( + RecipeCategory category, + ItemStack[] givenItems, + CachingStrategy cachingStrategy, + BiPredicate onRecipeFound + ) { + return getSlimefunRecipeService().searchRecipes(category, givenItems, cachingStrategy, onRecipeFound); + } + + /** + * Searches all recipes in a category for a recipe using the given items. + * + * Shorthand for {@code Slimefun.getSlimefunRecipeService.searchRecipes()} + * + * @param category The category of the recipe to search in + * @param givenItems Items from the crafting grid + * @param cachingStrategy When to save the result to the LRU cache + * @return + */ + @ParametersAreNonnullByDefault + public static RecipeSearchResult searchRecipes( + RecipeCategory category, + ItemStack[] givenItems, + CachingStrategy cachingStrategy + ) { + return getSlimefunRecipeService().searchRecipes(category, givenItems, cachingStrategy); + } + /** * This method returns out world settings service. * That service is responsible for managing item settings per diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeCategory.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeCategory.java new file mode 100644 index 0000000000..76bc203d2e --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeCategory.java @@ -0,0 +1,52 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +class TestRecipeCategory { + + private static Slimefun plugin; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + @Deprecated + @DisplayName("Test backwards compatibility with RecipeType") + void testBackwardsCompat() { + RecipeType type = new RecipeType(new NamespacedKey(plugin, "crafting-table"), new ItemStack(Material.CRAFTING_TABLE)); + RecipeCategory category = new RecipeCategory(new NamespacedKey(plugin, "crafting-table"), new ItemStack(Material.CRAFTING_TABLE)); + + Assertions.assertEquals(type.key(), category.key()); + } + + @Test + void testRecipeCategory() { + NamespacedKey key = new NamespacedKey(plugin, "crafting-table"); + ItemStack item = new ItemStack(Material.CRAFTING_TABLE); + RecipeCategory category = new RecipeCategory(key, item, RecipeStructure.IDENTICAL); + + Assertions.assertEquals(key, category.getKey()); + Assertions.assertEquals("slimefun.crafting-table", category.getTranslationKey()); + Assertions.assertEquals(item, category.getDisplayItem()); + Assertions.assertEquals(RecipeStructure.IDENTICAL, category.getDefaultStructure()); + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeCrafter.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeCrafter.java new file mode 100644 index 0000000000..2e2b7cd461 --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeCrafter.java @@ -0,0 +1,75 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.Collection; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +class TestRecipeCrafter { + + private static Slimefun plugin; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + void testRecipeCrafter() { + RecipeCategory testCategory1 = new RecipeCategory(new NamespacedKey(plugin, "crafter1"), new ItemStack(Material.CRAFTING_TABLE)); + RecipeCategory testCategory2 = new RecipeCategory(new NamespacedKey(plugin, "crafter2"), new ItemStack(Material.CRAFTING_TABLE)); + + Recipe testRecipe1 = Recipe.of(RecipeStructure.IDENTICAL, new ItemStack[] { + null, null, null, + null, new ItemStack(Material.IRON_AXE), null, + null, null, null, + }, new ItemStack(Material.ACACIA_BOAT)); + Recipe testRecipe2 = Recipe.of(RecipeStructure.IDENTICAL, new ItemStack[] { + null, null, null, + null, new ItemStack(Material.IRON_AXE), null, + new ItemStack(Material.IRON_PICKAXE), null, null, + }, new ItemStack(Material.ACACIA_BOAT)); + Recipe testRecipe3 = Recipe.of(RecipeStructure.IDENTICAL, new ItemStack[] { + null, null, null, + null, new ItemStack(Material.GOLDEN_AXE), null, + null, null, null, + }, new ItemStack(Material.ACACIA_BOAT)); + Recipe testRecipe4 = Recipe.of(RecipeStructure.IDENTICAL, new ItemStack[] { + null, null, null, + null, new ItemStack(Material.IRON_AXE), null, + new ItemStack(Material.GOLDEN_AXE), null, null, + }, new ItemStack(Material.ACACIA_BOAT)); + + testCategory1.registerRecipe(testRecipe1); + testCategory1.registerRecipe(testRecipe2); + testCategory2.registerRecipe(testRecipe3); + testCategory2.registerRecipe(testRecipe4); + + RecipeCrafter crafter = () -> List.of(testCategory1, testCategory2); + + Collection craftableRecipes = crafter.getRecipes(); + + Assertions.assertTrue(craftableRecipes.contains(testRecipe1)); + Assertions.assertTrue(craftableRecipes.contains(testRecipe2)); + Assertions.assertTrue(craftableRecipes.contains(testRecipe3)); + Assertions.assertTrue(craftableRecipes.contains(testRecipe4)); + + Assertions.assertEquals(2, crafter.getSingleInputRecipes().size()); + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeStructure.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeStructure.java new file mode 100644 index 0000000000..a03203a2cc --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipeStructure.java @@ -0,0 +1,220 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.thebusybiscuit.slimefun4.api.recipes.components.ItemComponent; +import io.github.thebusybiscuit.slimefun4.api.recipes.components.RecipeComponent; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +class TestRecipeStructure { + + @BeforeAll + public static void load() { + MockBukkit.mock(); + MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + @DisplayName("Tests IDENTICAL structure matching") + void testIdentical() { + + Assertions.assertTrue(RecipeStructure.IDENTICAL.match(new ItemStack[9], List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR + )).isMatch()); + + Assertions.assertTrue(RecipeStructure.IDENTICAL.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, RecipeComponent.AIR + )).isMatch()); + + Assertions.assertFalse(RecipeStructure.IDENTICAL.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), + RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR + )).isMatch()); + + Assertions.assertFalse(RecipeStructure.IDENTICAL.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of(RecipeComponent.AIR)).isMatch()); + + } + + @Test + @DisplayName("Tests SHAPED structure matching") + void testShaped() { + + Assertions.assertTrue(RecipeStructure.SHAPED.match(new ItemStack[9], List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR + )).isMatch()); + + Assertions.assertTrue(RecipeStructure.SHAPED.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, RecipeComponent.AIR + )).isMatch()); + + Assertions.assertTrue(RecipeStructure.SHAPED.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), + RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR + )).isMatch()); + + Assertions.assertFalse(RecipeStructure.SHAPED.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG) + )).isMatch()); + + Assertions.assertFalse(RecipeStructure.SHAPED.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG) + )).isMatch()); + + Assertions.assertFalse(RecipeStructure.SHAPED.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of(RecipeComponent.AIR)).isMatch()); + + } + + @Test + @DisplayName("Tests SHAPELESS structure matching") + void testShapeless() { + + Assertions.assertTrue(RecipeStructure.SHAPELESS.match(new ItemStack[9], List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR + )).isMatch()); + + Assertions.assertTrue(RecipeStructure.SHAPELESS.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, RecipeComponent.AIR + )).isMatch()); + + Assertions.assertTrue(RecipeStructure.SHAPELESS.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG) + )).isMatch()); + + Assertions.assertFalse(RecipeStructure.SHAPELESS.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG) + )).isMatch()); + + Assertions.assertFalse(RecipeStructure.SHAPELESS.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of(RecipeComponent.AIR)).isMatch()); + + } + + @Test + @DisplayName("Tests SHAPELESS structure matching") + void testSubset() { + + Assertions.assertTrue(RecipeStructure.SUBSET.match(new ItemStack[9], List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR + )).isMatch()); + + Assertions.assertTrue(RecipeStructure.SUBSET.match(new ItemStack[] { + new ItemStack(Material.OAK_LOG), new ItemStack(Material.OAK_LOG) + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, RecipeComponent.AIR + )).isMatch()); + + Assertions.assertTrue(RecipeStructure.SUBSET.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null, null, null, null, null, new ItemStack(Material.OAK_LOG) + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG) + )).isMatch()); + + Assertions.assertFalse(RecipeStructure.SUBSET.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of( + RecipeComponent.AIR, RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG), + RecipeComponent.AIR, RecipeComponent.AIR, RecipeComponent.AIR, + new ItemComponent(Material.OAK_LOG), RecipeComponent.AIR, new ItemComponent(Material.OAK_LOG) + )).isMatch()); + + Assertions.assertTrue(RecipeStructure.SUBSET.match(new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + new ItemStack(Material.OAK_LOG), null, null + }, List.of(RecipeComponent.AIR)).isMatch()); + + } +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestSlimefunRecipe.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestSlimefunRecipe.java new file mode 100644 index 0000000000..195a78df16 --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestSlimefunRecipe.java @@ -0,0 +1,45 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.TestUtilities; + +class TestSlimefunRecipe { + + private static Slimefun plugin; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + @DisplayName("Test recipes disabling when its inputs or output is disabled") + void testDisabling() { + SlimefunItem disabledItem = TestUtilities.mockSlimefunItem(plugin, "DISABLED_RECIPE_TEST", new ItemStack(Material.IRON_INGOT)); + Slimefun.getItemCfg().setValue("DISABLED_RECIPE_TEST.enabled", false); + disabledItem.register(plugin); + + Recipe inputRecipe = Recipe.of(RecipeStructure.IDENTICAL, disabledItem.getItem(), new ItemStack(Material.IRON_NUGGET)); + Recipe outputRecipe = Recipe.of(RecipeStructure.IDENTICAL, new ItemStack(Material.IRON_NUGGET), disabledItem.getItem()); + + Assertions.assertTrue(inputRecipe.isDisabled()); + Assertions.assertTrue(outputRecipe.isDisabled()); + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TestItemComponent.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TestItemComponent.java new file mode 100644 index 0000000000..903192565a --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TestItemComponent.java @@ -0,0 +1,53 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.TestUtilities; + +class TestItemComponent { + + private static Slimefun plugin; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + void testItemComponent() { + SlimefunItem sfItem = TestUtilities.mockSlimefunItem(plugin, "BIG_IRON_INGOT", new ItemStack(Material.IRON_INGOT)); + + sfItem.register(plugin); + + ItemComponent vanillaComponent = new ItemComponent(new ItemStack(Material.IRON_INGOT, 5)); + ItemComponent slimefunComponent = new ItemComponent(sfItem.getItem()); + + Assertions.assertEquals(new ItemStack(Material.IRON_INGOT, 5), vanillaComponent.getComponent()); + Assertions.assertFalse(vanillaComponent.isDisabled()); + Assertions.assertTrue(vanillaComponent.getSlimefunItemIDs().isEmpty()); + Assertions.assertTrue(vanillaComponent.matches(new ItemStack(Material.IRON_INGOT, 5))); + Assertions.assertTrue(vanillaComponent.matches(new ItemStack(Material.IRON_INGOT, 64))); + + Assertions.assertEquals(sfItem.getItem(), slimefunComponent.getComponent()); + Assertions.assertFalse(slimefunComponent.isDisabled()); + Assertions.assertEquals(1, slimefunComponent.getSlimefunItemIDs().size()); + Assertions.assertTrue(slimefunComponent.getSlimefunItemIDs().contains(sfItem.getId())); + Assertions.assertFalse(slimefunComponent.matches(new ItemStack(Material.IRON_INGOT))); + + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TestMultiItemComponent.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TestMultiItemComponent.java new file mode 100644 index 0000000000..1ce8fe4c5a --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/components/TestMultiItemComponent.java @@ -0,0 +1,52 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.components; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.TestUtilities; + +class TestMultiItemComponent { + + private static Slimefun plugin; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + void testItemComponent() { + SlimefunItem sfItem = TestUtilities.mockSlimefunItem(plugin, "BIG_GOLD_INGOT", new ItemStack(Material.GOLD_INGOT)); + + sfItem.register(plugin); + + MultiItemComponent multiComponent = new MultiItemComponent( + new ItemStack(Material.GOLD_INGOT), + sfItem.getItem().clone() + ); + + Assertions.assertEquals(2, multiComponent.getChoices().size()); + Assertions.assertTrue(multiComponent.getChoices().contains(new ItemComponent(Material.GOLD_INGOT))); + Assertions.assertTrue(multiComponent.getChoices().contains(new ItemComponent(sfItem.getItem()))); + Assertions.assertTrue(multiComponent.matches(new ItemStack(Material.GOLD_INGOT))); + Assertions.assertTrue(multiComponent.matches(sfItem.getItem())); + Assertions.assertFalse(multiComponent.isDisabled()); + Assertions.assertEquals(1, multiComponent.getSlimefunItemIDs().size()); + Assertions.assertTrue(multiComponent.getSlimefunItemIDs().contains(sfItem.getId())); + + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestItemOutput.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestItemOutput.java new file mode 100644 index 0000000000..5b6261c005 --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestItemOutput.java @@ -0,0 +1,49 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.TestUtilities; + +class TestItemOutput { + + private static Slimefun plugin; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + void testItemOutput() { + SlimefunItem sfItem = TestUtilities.mockSlimefunItem(plugin, "SMALL_IRON_INGOT", new ItemStack(Material.IRON_INGOT)); + + sfItem.register(plugin); + + ItemOutput vanillaOutput = new ItemOutput(new ItemStack(Material.IRON_INGOT, 5)); + ItemOutput slimefunOutput = new ItemOutput(sfItem.getItem()); + + Assertions.assertEquals(new ItemStack(Material.IRON_INGOT, 5), vanillaOutput.generateOutput()); + Assertions.assertFalse(vanillaOutput.isDisabled()); + Assertions.assertTrue(vanillaOutput.getSlimefunItemIDs().isEmpty()); + + Assertions.assertEquals(sfItem.getItem(), slimefunOutput.generateOutput()); + Assertions.assertFalse(slimefunOutput.isDisabled()); + Assertions.assertEquals(1, slimefunOutput.getSlimefunItemIDs().size()); + Assertions.assertTrue(slimefunOutput.getSlimefunItemIDs().contains(sfItem.getId())); + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestMultiItemOutput.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestMultiItemOutput.java new file mode 100644 index 0000000000..5dc27f4e1b --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestMultiItemOutput.java @@ -0,0 +1,49 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.TestUtilities; + +class TestMultiItemOutput { + + private static Slimefun plugin; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + void testMultiItemOutput() { + SlimefunItem sfItem = TestUtilities.mockSlimefunItem(plugin, "SMALL_GOLD_INGOT", new ItemStack(Material.GOLD_INGOT)); + + sfItem.register(plugin); + + MultiItemOutput multiComponent = new MultiItemOutput( + new ItemOutput(new ItemStack(Material.GOLD_INGOT)), + new ItemOutput(sfItem.getItem().clone()) + ); + + Assertions.assertEquals(2, multiComponent.getOutputs().size()); + Assertions.assertTrue(multiComponent.generateOutputs().contains(new ItemStack(Material.GOLD_INGOT))); + Assertions.assertTrue(multiComponent.generateOutputs().contains(sfItem.getItem())); + Assertions.assertFalse(multiComponent.isDisabled()); + Assertions.assertEquals(1, multiComponent.getSlimefunItemIDs().size()); + Assertions.assertTrue(multiComponent.getSlimefunItemIDs().contains(sfItem.getId())); + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestRandomItemOutput.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestRandomItemOutput.java new file mode 100644 index 0000000000..2bebe81219 --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/output/TestRandomItemOutput.java @@ -0,0 +1,49 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.output; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.bakedlibs.dough.collections.RandomizedSet; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.TestUtilities; + +class TestRandomItemOutput { + + private static Slimefun plugin; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + void testRandomItemOutput() { + SlimefunItem sfItem = TestUtilities.mockSlimefunItem(plugin, "RANDOM_GOLD_INGOT", new ItemStack(Material.GOLD_INGOT)); + + sfItem.register(plugin); + + RandomizedSet output = new RandomizedSet<>(); + output.add(new ItemStack(Material.GOLD_INGOT), 2); + output.add(sfItem.getItem().clone(), 2); + + RandomItemOutput randomComponent = new RandomItemOutput(output); + + Assertions.assertEquals(output, randomComponent.getOutputSet()); + Assertions.assertFalse(randomComponent.isDisabled()); + Assertions.assertEquals(1, randomComponent.getSlimefunItemIDs().size()); + Assertions.assertTrue(randomComponent.getSlimefunItemIDs().contains(sfItem.getId())); + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/core/services/TestSlimefunRecipeService.java b/src/test/java/io/github/thebusybiscuit/slimefun4/core/services/TestSlimefunRecipeService.java new file mode 100644 index 0000000000..ea325f702c --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/core/services/TestSlimefunRecipeService.java @@ -0,0 +1,322 @@ +package io.github.thebusybiscuit.slimefun4.core.services; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import be.seeseemelk.mockbukkit.MockBukkit; +import io.github.bakedlibs.dough.items.CustomItemStack; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeCategory; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeSearchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeStructure; +import io.github.thebusybiscuit.slimefun4.core.services.SlimefunRecipeService.CachingStrategy; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.TestUtilities; + +/** + * These tests are for the Slimefun recipe service, and they assume + * the recipe API works. Recipe API unit tests are in api/recipes + */ +class TestSlimefunRecipeService { + + private static Slimefun plugin; + + private static RecipeCategory testCategory1; + private static RecipeCategory testCategory2; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + plugin = MockBukkit.load(Slimefun.class); + + testCategory1 = new RecipeCategory( + new NamespacedKey(plugin, "test-category-1"), + new ItemStack(Material.ENCHANTING_TABLE), RecipeStructure.IDENTICAL); + testCategory2 = new RecipeCategory( + new NamespacedKey(plugin, "test-category-2"), + new ItemStack(Material.CRAFTING_TABLE), RecipeStructure.IDENTICAL); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + @DisplayName("Test registering a recipe with the recipe service") + void testRegistration() { + SlimefunRecipeService service = new SlimefunRecipeService(); + + ItemStack[] inputs1 = new ItemStack[] { + null, null, new ItemStack(Material.STICK), + null, new ItemStack(Material.STICK), null, + null, null, null + }; + ItemStack output1 = new CustomItemStack(Material.GOLD_BLOCK, "Not an Iron Block", "", "..."); + + ItemStack[] inputs2 = new ItemStack[] { + null, null, null, + null, new ItemStack(Material.STICK), null, + new ItemStack(Material.STICK), null, null + }; + ItemStack output2 = new CustomItemStack(Material.IRON_BLOCK, "Not a Gold Block", "", "..."); + + Recipe testRecipe1 = Recipe.of(RecipeStructure.IDENTICAL, inputs1, output1); + Recipe testRecipe2 = Recipe.of(RecipeStructure.IDENTICAL, inputs2, output2); + + service.registerRecipe(testCategory1, testRecipe2); + service.registerRecipe(testCategory1, testRecipe1); + service.registerRecipe(testCategory2, testRecipe1); + + RecipeSearchResult searchResult = service.searchRecipes(testCategory1, inputs1, CachingStrategy.NEVER); + + Assertions.assertEquals(testCategory1, searchResult.getSearchCategory()); + Assertions.assertEquals(testRecipe1, searchResult.getRecipe()); + + List cat1Recipes = service.getRecipes(testCategory1); + List cat2Recipes = service.getRecipes(testCategory2); + + Assertions.assertTrue(cat1Recipes.contains(testRecipe2)); + Assertions.assertTrue(cat1Recipes.contains(testRecipe1)); + Assertions.assertTrue(cat2Recipes.contains(testRecipe1)); + + } + + @Test + @DisplayName("Test getting the recipes by output") + void testGetByOutput() { + SlimefunRecipeService service = new SlimefunRecipeService(); + + ItemStack[] inputs1 = new ItemStack[] { + null, null, new ItemStack(Material.STICK), + null, new ItemStack(Material.STICK), null, + null, null, null + }; + ItemStack[] inputs2 = new ItemStack[] { + null, null, null, + null, new ItemStack(Material.STICK), null, + new ItemStack(Material.STICK), null, null + }; + + SlimefunItem item1 = TestUtilities.mockSlimefunItem(plugin, "TEST_IRON", new ItemStack(Material.IRON_INGOT)); + SlimefunItem item2 = TestUtilities.mockSlimefunItem(plugin, "TEST_GOLD", new ItemStack(Material.GOLD_INGOT)); + + item1.register(plugin); + item2.register(plugin); + + Recipe testRecipe1 = Recipe.of(RecipeStructure.IDENTICAL, inputs1, item1.getItem()); + Recipe testRecipe2 = Recipe.of(RecipeStructure.IDENTICAL, inputs2, item1.getItem()); + Recipe testRecipe3 = Recipe.of(RecipeStructure.IDENTICAL, inputs2, item1.getItem()); + Recipe testRecipe4 = Recipe.of(RecipeStructure.IDENTICAL, inputs2, item2.getItem()); + + service.registerRecipe(testCategory1, testRecipe1); + service.registerRecipe(testCategory1, testRecipe2); + service.registerRecipe(testCategory2, testRecipe3); + service.registerRecipe(testCategory2, testRecipe4); + + Map> recipesByOutput = service.getRecipesByOutput(item1.getId()); + + Assertions.assertTrue(recipesByOutput.containsKey(testCategory1)); + Assertions.assertTrue(recipesByOutput.containsKey(testCategory2)); + + Assertions.assertTrue(recipesByOutput.get(testCategory1).contains(testRecipe1)); + Assertions.assertTrue(recipesByOutput.get(testCategory1).contains(testRecipe2)); + Assertions.assertTrue(recipesByOutput.get(testCategory2).contains(testRecipe3)); + Assertions.assertFalse(recipesByOutput.get(testCategory2).contains(testRecipe4)); + + Assertions.assertEquals(3, service.getNumberOfRecipes(item1.getId())); + Assertions.assertEquals(1, service.getNumberOfRecipes(item2.getId())); + + } + + @Test + @DisplayName("Test getting the recipes by input") + void testGetByInput() { + SlimefunRecipeService service = new SlimefunRecipeService(); + + SlimefunItem item1 = TestUtilities.mockSlimefunItem(plugin, "TEST_SALMON", new ItemStack(Material.SALMON)); + SlimefunItem item2 = TestUtilities.mockSlimefunItem(plugin, "TEST_COD", new ItemStack(Material.COD)); + + item1.register(plugin); + item2.register(plugin); + + ItemStack itemStack1 = item1.getItem(); + ItemStack itemStack2 = item2.getItem(); + + ItemStack[] inputs1 = new ItemStack[] { + null, null, itemStack1, + null, itemStack2, null, + null, null, null + }; + ItemStack[] inputs2 = new ItemStack[] { + itemStack1, null, itemStack2, + itemStack1, itemStack1, null, + itemStack1, null, null + }; + ItemStack[] inputs3 = new ItemStack[] { + null, null, null, + null, itemStack1, null, + null, null, null + }; + ItemStack[] inputs4 = new ItemStack[] { + itemStack2, null, itemStack2, + null, null, null, + null, itemStack2, null + }; + + Recipe testRecipe1 = Recipe.of(RecipeStructure.IDENTICAL, inputs1, new ItemStack(Material.IRON_BLOCK)); + Recipe testRecipe2 = Recipe.of(RecipeStructure.IDENTICAL, inputs2, new ItemStack(Material.GOLD_BLOCK)); + Recipe testRecipe3 = Recipe.of(RecipeStructure.IDENTICAL, inputs3, new ItemStack(Material.COAL_BLOCK)); + Recipe testRecipe4 = Recipe.of(RecipeStructure.IDENTICAL, inputs4, new ItemStack(Material.DIAMOND_BLOCK)); + + service.registerRecipe(testCategory1, testRecipe1); + service.registerRecipe(testCategory1, testRecipe2); + service.registerRecipe(testCategory2, testRecipe3); + service.registerRecipe(testCategory2, testRecipe4); + + Map> recipesByInput1 = service.getRecipesByInput(item1.getId()); + Map> recipesByInput2 = service.getRecipesByInput(item2.getId()); + + Assertions.assertTrue(recipesByInput1.containsKey(testCategory1)); + Assertions.assertTrue(recipesByInput1.containsKey(testCategory2)); + Assertions.assertTrue(recipesByInput2.containsKey(testCategory1)); + Assertions.assertTrue(recipesByInput2.containsKey(testCategory2)); + + // item1 should be used in recipes 1, 2, 3 + Assertions.assertTrue(recipesByInput1.get(testCategory1).contains(testRecipe1)); + Assertions.assertTrue(recipesByInput1.get(testCategory1).contains(testRecipe2)); + Assertions.assertTrue(recipesByInput1.get(testCategory2).contains(testRecipe3)); + Assertions.assertFalse(recipesByInput1.get(testCategory2).contains(testRecipe4)); + + // item2 should be used in recipes 1, 2, 4 + Assertions.assertTrue(recipesByInput2.get(testCategory1).contains(testRecipe1)); + Assertions.assertTrue(recipesByInput2.get(testCategory1).contains(testRecipe2)); + Assertions.assertFalse(recipesByInput2.get(testCategory2).contains(testRecipe3)); + Assertions.assertTrue(recipesByInput2.get(testCategory2).contains(testRecipe4)); + + Assertions.assertEquals(3, service.getNumberOfRecipesUsedIn(item1.getId())); + Assertions.assertEquals(3, service.getNumberOfRecipesUsedIn(item2.getId())); + + } + + @Test + @DisplayName("Test the hashing function used by the service") + void testHashIgnoreAmount() { + SlimefunRecipeService service = new SlimefunRecipeService(); + + ItemStack[] items1 = new ItemStack[] { + null, null, null, + null, new ItemStack(Material.STICK), null, + new ItemStack(Material.STICK), null, null + }; + ItemStack[] items2 = new ItemStack[] { + null, null, null, + null, new ItemStack(Material.STICK, 64), null, + new ItemStack(Material.STICK), null, null + }; + ItemStack[] items3 = new ItemStack[] { + null, null, new ItemStack(Material.STICK), + null, new ItemStack(Material.STICK), null, + null, null, null + }; + + int hash1 = service.hashIgnoreAmount(items1, testCategory1); + int hash2 = service.hashIgnoreAmount(items2, testCategory1); + int hash3 = service.hashIgnoreAmount(items3, testCategory1); + int hash4 = service.hashIgnoreAmount(items1, testCategory2); + + Assertions.assertEquals(hash1, hash2); + Assertions.assertNotEquals(hash1, hash3); + Assertions.assertNotEquals(hash1, hash4); + + } + + @Test + @DisplayName("Test the different CachingStrategy enums") + void testCachingStrategies() { + SlimefunRecipeService service = new SlimefunRecipeService(); + + ItemStack[] input = new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + null, null, null + }; + + ItemStack[] singleCraft = new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG), null, + null, null, null + }; + ItemStack[] multipleCraft = new ItemStack[] { + null, null, null, + null, new ItemStack(Material.OAK_LOG, 32), null, + null, null, null + }; + ItemStack[] noCraft = new ItemStack[9]; + + int singleHash = service.hashIgnoreAmount(singleCraft, testCategory1); + int multipleHash = service.hashIgnoreAmount(multipleCraft, testCategory1); + int noHash = service.hashIgnoreAmount(noCraft, testCategory1); + + Recipe testRecipe = Recipe.of(RecipeStructure.IDENTICAL, input, new ItemStack(Material.OAK_PLANKS, 4)); + + service.registerRecipe(testCategory1, testRecipe); + + // Not cached + service.searchRecipes(testCategory1, singleCraft, CachingStrategy.NEVER); + Assertions.assertFalse(service.getCache().containsKey(singleHash)); + service.clearCache(); + + // Not cached + service.searchRecipes(testCategory1, multipleCraft, CachingStrategy.NEVER); + Assertions.assertFalse(service.getCache().containsKey(multipleHash)); + service.clearCache(); + + // Not cached + service.searchRecipes(testCategory1, noCraft, CachingStrategy.NEVER); + Assertions.assertFalse(service.getCache().containsKey(noHash)); + service.clearCache(); + + // Not cached + service.searchRecipes(testCategory1, singleCraft, CachingStrategy.IF_MULTIPLE_CRAFTABLE); + Assertions.assertFalse(service.getCache().containsKey(singleHash)); + service.clearCache(); + + // Cached + service.searchRecipes(testCategory1, multipleCraft, CachingStrategy.IF_MULTIPLE_CRAFTABLE); + Assertions.assertEquals(testRecipe, service.getFromCache(multipleHash).map(result -> result.getRecipe()).orElse(null)); + service.clearCache(); + + // Cached, no match + service.searchRecipes(testCategory1, noCraft, CachingStrategy.IF_MULTIPLE_CRAFTABLE); + Assertions.assertFalse(service.getFromCache(noHash).map(result -> result.isMatch()).orElse(true)); + service.clearCache(); + + // Cached + service.searchRecipes(testCategory1, singleCraft, CachingStrategy.ALWAYS); + Assertions.assertEquals(testRecipe, service.getFromCache(singleHash).map(result -> result.getRecipe()).orElse(null)); + service.clearCache(); + + // Cached + service.searchRecipes(testCategory1, multipleCraft, CachingStrategy.ALWAYS); + Assertions.assertEquals(testRecipe, service.getFromCache(multipleHash).map(result -> result.getRecipe()).orElse(null)); + service.clearCache(); + + // Cached, no match + service.searchRecipes(testCategory1, noCraft, CachingStrategy.ALWAYS); + Assertions.assertFalse(service.getFromCache(noHash).map(result -> result.isMatch()).orElse(true)); + service.clearCache(); + + } +}