diff --git a/docs/adr/0002-recipe-rewrite.md b/docs/adr/0002-recipe-rewrite.md new file mode 100644 index 0000000000..4fd0434348 --- /dev/null +++ b/docs/adr/0002-recipe-rewrite.md @@ -0,0 +1,254 @@ +# 2. Recipe rewrite + +Date: 2024-11-03 +Last update: 2024-11-08 + +**DO NOT rely on any APIs introduced until we finish the work completely!** + +## Status + +Phase 1: Work in progress + +## Context + +Slimefun currently lacks a robust recipe system. Multiblock crafting +does one thing, while electric machines do another, even though some +of them craft the same items. + +Slimefun also lacks certain features that vanilla minecraft has, like true +shaped and shapeless recipes, tagged inputs, and the ability to edit recipes +without any code. + +## Goals + +The goal of this rewrite is to introduce an improved recipe system to +Slimefun, focusing on + +- Ease of use: The API should be clean and the system intuitive for + developers to use +- Extensibility: Addons should be able to create and use their own types + of recipes with this system. +- Customizability: Server owners should be able to customize any and all + Slimefun recipes +- Performance: Should be on par or better than the current system. + +The new recipe system should also be completely backwards compatible. + +## API Additions + +### 5 main recipe classes + +All recipes are now `Recipe` objects. It is an association between +inputs (see `RecipeInput`) and outputs (see `RecipeOutput`), along with other metadata +for how the recipe should be crafted -- recipe type, energy cost, base crafting duration, etc. + +`RecipeInput`s are a list of `RecipeInputItem`s plus a `MatchProcedure` -- how the inputs of +the recipe should be matched to items in a multiblock/machine when crafting. The base ones are: + +- Shaped/Shapeless: Exactly the same as vanilla +- Subset: How the current smeltery, etc. craft +- Shaped-flippable: The recipe can be flipped on the Y-axis +- Shaped-rotatable: The recipe can be rotated (currently only 45deg, 3x3) + +`RecipeInputItem`s describe a single slot of a recipe and determines what +items match it. There can be a single item that matches (see `RecipeInputSlimefunItem`, +`RecipeInputItemStack`), or a list (tag) of items all of which can be used +in that slot (see `RecipeInputGroup`, `RecipeInputTag`). + +`RecipeOutput`s are just a list of `RecipeOutputItem`s, all of which are crafted by the recipe. + +An `RecipeOutputItem`s controls how an output is generated when the recipe is +crafted. It can be a single item (see `RecipeOutputItemStack`, `RecipeOutputSlimefunItem`), +or a group of items each with a certain weight of being output (see `RecipeOutputGroup`). + +#### Examples (pseudocode) + +Here are the inputs and outputs of the recipe for a vanilla torch + +```txt +RecipeInput ( + { + EMPTY, EMPTY, EMPTY + EMPTY, RecipeInputGroup(COAL, CHARCOAL), EMPTY, + EMPTY, RecipeInputItemStack(STICK), EMPTY + }, + SHAPED +) +RecipeOutput ( + RecipeOutputItemStack(4 x TORCH) +) +``` + +Here are the inputs and outputs of a gold pan + +```txt +RecipeInput ( + { RecipeOutputItemStack(GRAVEL) }, + SUBSET +) +RecipeOutput ( + RecipeOutputGroup( + 40 RecipeOutputItemStack(FLINT) + 5 RecipeOutputItemStack(IRON_NUGGET) + 20 RecipeOutputItemStack(CLAY_BALL) + 35 RecipeOutputSlimefunItem(SIFTED_ORE) + ) +) +``` + +This would remove the need to use ItemSettings to determine the gold pan weights + +### RecipeService + +This is the public interface for the recipe system, there are methods here to register, +load, save, and search recipes. It also stores a map of `MatchProcedures` and +`RecipeType` by key for conversions from a string + +### JSON Serialization + +All recipes are able to be serialized to and deserialized +from JSON. The schemas are shown below. + +Here, `key` is the string representation of a namespaced key + +`Recipe` + +```txt +{ + "input"?: RecipeInput + "output"?: RecipeOutput + "type": NamespacedKey | NamespacedKey[] + "energy"?: int + "crafting-time"?: int + "permission-node"?: string | string[] +} +``` + +The recipe deserializer technically needs a `__filename` field, but it is +inserted when the file is read, so it isn't (and shouldn't) be in the schema + +`RecipeInput` + +```txt +{ + "items": string | string[] + "key": { + [key: string]: RecipeInputItem + } + "match"?: NamespacedKey +} +``` + +`RecipeOutput` + +```txt +{ + "items": RecipeOutputItem[] +} +``` + +`RecipeInputItem`* + +```txt +{ + "id": NamespacedKey + "amount"?: int + "durability"?: int +} | { + "tag": NamespacedKey + "amount"?: int + "durability"?: int +} | { + "group": RecipeInputItem[] +} +``` + +`RecipeOutputItem`* + +```txt +{ + "id": key + "amount"?: int +} | { + "group": RecipeInputItem[] + "weights"?: int[] +} +``` + +*In addition to those schemas, items can be in short form: + +- Single items: `:|` +- Tags: `#:|` + +## Extensibility + +The 5 main recipe classes are all polymorphic, and subclasses can be used in their +stead, and should not affect the recipe system (as long as the right methods are +overriden, see javadocs) + +### Custom serialization/deserialization + +The default deserializers recognize subclasses with custom deserializers by +the presence of a `class` field in the json, which should be the key of a +custom deserializer registered with Slimefun's `RecipeService`. +For custom serializers, override the `serialize` method on the subclass, +and ensure they also add the `class` field + +## Recipe Lifecycle + +### Stage 1a + +When Slimefun is enabled, all recipes in the resources folder will be +moved to `plugins/Slimefun/recipes/` (unless a file with its name already exists). + +Addons should do the same. (We recommend saving to +`plugins/Slimefun/recipes//` but it's not required). + +### Stage 1b + +Also on enable, recipes defined in code should be registered. These two steps +can be done in no particular order. + +### Stage 2 + +On the first server tick, all recipes in the `plugins/Slimefun/recipes` folder +are read and added to the `RecipeService`, removing all recipes with the +same filename. This is why recipes should ideally be *defined* in JSON, +to prevent unnecessary work. + +When loading JSON recipes, we also need to be able to tell the difference between +a server owner changing a recipe, and a developer changing a recipe. To do this, +we use a system called Recipe Overrides; it allows for updates to recipes from +developers while also preserving custom recipes by server owners + +- Slimefun/addons should tell the recipe service it will apply a recipe + override on enable, **before** any JSON recipes are copied from the resources + folder +- The recipe service checks all recipe overrides that have already run + (in the file `plugins/Slimefun/recipe-overrides`) and if it never received + that override before, it deletes the old files and all recipes inside them. + Then all recipes are loaded as before. + +### Stage 3 + +While the server is running, recipes can be modified in code, saved to disk, or +re-loaded from disk. New recipes can also be added, however not to any existing +file (unless forced, which is not recommended) + +### Stage 4 + +On server shutdown (or `/sf recipe save`), **all** recipes are saved to disk. +This means any changes made while the server is running will be overwritten. +Server owners should run `/sf recipe reload ` to load new recipes +dynamically from disk. + +## Phases + +Each phase should be a separate PR + +- Phase 1 - Add the new API +- Phase 2 - Migrate Slimefun items/multiblocks/machines toward the new API +- Phase 3 - Update the Slimefun Guide to use the new API + +The entire process should be seamless for the end users, and +backwards compatible with addons that haven't yet migrated diff --git a/pom.xml b/pom.xml index 5bc0d0d651..9122bd28eb 100644 --- a/pom.xml +++ b/pom.xml @@ -317,6 +317,7 @@ biome-maps/*.json languages/**/*.yml + recipes/**/*.json diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/SlimefunAddon.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/SlimefunAddon.java index 8d8609c8be..2b2f8424d8 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/api/SlimefunAddon.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/SlimefunAddon.java @@ -1,6 +1,18 @@ package io.github.thebusybiscuit.slimefun4.api; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Set; import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -11,6 +23,8 @@ import org.bukkit.plugin.java.JavaPlugin; import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.core.services.RecipeService; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; /** * This is a very basic interface that will be used to identify @@ -97,4 +111,78 @@ default boolean hasDependency(@Nonnull String dependency) { return description.getDepend().contains(dependency) || description.getSoftDepend().contains(dependency); } + /** + * @return A list of all recipes in the resources folder. Addons + * can override this to filter out certain recipes, if desired. + */ + default Set getResourceRecipeFilenames() { + URL resourceDir = getClass().getResource("/recipes"); + if (resourceDir == null) { + return Collections.emptySet(); + } + URI resourceUri; + try { + resourceUri = resourceDir.toURI(); + } catch (URISyntaxException e) { + return Collections.emptySet(); + } + if (!resourceUri.getScheme().equals("jar")) { + return Collections.emptySet(); + } + try (FileSystem fs = FileSystems.newFileSystem(resourceUri, Collections.emptyMap())) { + Path recipeDir = fs.getPath("/recipes"); + try (Stream files = Files.walk(recipeDir)) { + var names = files + .filter(file -> file.toString().endsWith(".json")) + .map(file -> { + String filename = recipeDir.relativize(file).toString(); + return filename.substring(0, filename.length() - 5); + }) + .collect(Collectors.toSet()); + return names; + } catch (Exception e) { + return Collections.emptySet(); + } + } catch (Exception e) { + return Collections.emptySet(); + } + } + + /** + * Copies all recipes in the recipes folder of the jar to + * plugins/Slimefun/recipes/[subdirectory] + * This should be done on enable. If you need to add + * any recipe overrides, those should be done before calling + * this method. + * @param subdirectory The subdirectory to copy files to + */ + default void copyResourceRecipes(String subdirectory) { + Set existingRecipes = Slimefun.getRecipeService().getAllRecipeFilenames(subdirectory); + Set resourceNames = getResourceRecipeFilenames(); + resourceNames.removeIf(existingRecipes::contains); + for (String name : resourceNames) { + try (InputStream source = getClass().getResourceAsStream("/recipes/" + name + ".json")) { + Path dest = Path.of(RecipeService.SAVED_RECIPE_DIR, subdirectory, name + ".json"); + Path parent = dest.getParent(); + if (parent != null && !parent.toFile().exists()) { + parent.toFile().mkdirs(); + } + Files.copy(source, dest); + } catch (Exception e) { + getLogger().warning("Couldn't copy recipes in resource file '" + name + "': " + e.getLocalizedMessage()); + throw new RuntimeException(e); + } + } + } + + /** + * Copies all recipes in the recipes folder of the jar to + * plugins/Slimefun/recipes. This should be done on enable. + * If you need to add any recipe overrides, those should + * be done before calling this method. + */ + default void copyResourceRecipes() { + copyResourceRecipes(""); + } + } diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/AbstractRecipeInput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/AbstractRecipeInput.java new file mode 100644 index 0000000000..0a150eb88d --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/AbstractRecipeInput.java @@ -0,0 +1,61 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.List; +import java.util.Optional; + +import javax.annotation.Nonnull; + +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.InputMatchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils.BoundingBox; + +public abstract class AbstractRecipeInput { + + public abstract int getWidth(); + public abstract void setWidth(int width); + + public abstract int getHeight(); + public abstract void setHeight(int height); + + public abstract boolean isEmpty(); + + @Nonnull + public abstract List getItems(); + public AbstractRecipeInputItem getItem(int index) { + return getItems().get(index); + } + public abstract void setItems(@Nonnull List items); + + @Nonnull + public abstract MatchProcedure getMatchProcedure(); + public abstract void setMatchProcedure(MatchProcedure matchProcedure); + + public abstract Optional getBoundingBox(); + + public InputMatchResult match(List givenItems) { + return getMatchProcedure().match(this, givenItems); + } + + public InputMatchResult matchAs(MatchProcedure match, List givenItems) { + return match.match(this, givenItems); + } + + public ItemStack[] getInputDisplay() { + return getItems().stream().map(i -> i.getItemDisplay()).toArray(ItemStack[]::new); + }; + + @Override + public abstract String toString(); + + public abstract JsonElement serialize(JsonSerializationContext context); + + @Override + public abstract boolean equals(Object obj); + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/AbstractRecipeOutput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/AbstractRecipeOutput.java new file mode 100644 index 0000000000..90e6dd786a --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/AbstractRecipeOutput.java @@ -0,0 +1,80 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; + +public abstract class AbstractRecipeOutput { + + // TODO find a better name + public static class Inserter { + private final Inventory inv; + private final Map addToStacks; + private final Map newStacks; + private final List leftovers; + + public Inserter(Inventory inv, Map addToStacks, Map newStacks, List leftovers) { + this.inv = inv; + this.addToStacks = addToStacks; + this.newStacks = newStacks; + this.leftovers = leftovers; + } + + public Inserter(Inventory inv) { + this.inv = inv; + this.addToStacks = Collections.emptyMap(); + this.newStacks = Collections.emptyMap(); + this.leftovers = Collections.emptyList(); + } + + public void insert() { + for (Map.Entry entry : addToStacks.entrySet()) { + ItemStack item = inv.getItem(entry.getKey()); + item.setAmount(item.getAmount() + entry.getValue()); + } + for (Map.Entry entry : newStacks.entrySet()) { + inv.setItem(entry.getKey(), entry.getValue()); + } + } + + public List getLeftovers() { + return Collections.unmodifiableList(leftovers); + } + } + + @ParametersAreNonnullByDefault + public abstract Inserter checkSpace(RecipeMatchResult matchResult, Inventory inventory, int[] outputSlots); + + @Nonnull + @ParametersAreNonnullByDefault + public List insertIntoInventory(RecipeMatchResult matchResult, Inventory inventory, int[] outputSlots) { + Inserter inserter = checkSpace(matchResult, inventory, outputSlots); + inserter.insert(); + return inserter.getLeftovers(); + } + + @Nonnull + public abstract List generateOutput(@Nonnull RecipeMatchResult result); + + public abstract boolean isEmpty(); + + @Override + public abstract String toString(); + + @Override + public abstract boolean equals(Object obj); + + public abstract JsonElement serialize(JsonSerializationContext context); + +} 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..31894add28 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/Recipe.java @@ -0,0 +1,249 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.InputMatchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +public class Recipe { + + private final Optional id; + private final String filename; // this is where a recipe gets saved to, it doesn't have to be unique among recipes + private AbstractRecipeInput input; + private AbstractRecipeOutput output; + private final Set types = new HashSet<>(); + private Optional energy; + private Optional craftingTime; + private final Set permissionNodes = new HashSet<>(); + + public Recipe(Optional id, String filename, AbstractRecipeInput input, AbstractRecipeOutput output, Collection types, Optional energy, + Optional craftingTime, Collection permissionNodes) { + this.id = id; + this.filename = filename; + this.input = input; + this.output = output; + for (RecipeType type : types) { + this.types.add(type); + } + this.energy = energy; + this.craftingTime = craftingTime; + for (String perm : permissionNodes) { + this.permissionNodes.add(perm); + } + } + + public static Recipe fromItemStacks(String subdirectory, String id, ItemStack[] inputs, ItemStack[] outputs, RecipeType type, MatchProcedure match) { + return new Recipe( + Optional.of(id), + Path.of(subdirectory, id.toLowerCase()).toString(), + RecipeInput.fromItemStacks(inputs, match), + RecipeOutput.fromItemStacks(outputs), + List.of(type), + Optional.empty(), + Optional.empty(), + List.of() + ); + } + + public static Recipe fromItemStacks(String subdirectory, String id, ItemStack[] inputs, ItemStack[] outputs, RecipeType type) { + return fromItemStacks(subdirectory, id, inputs, outputs, type, type.getDefaultMatchProcedure()); + } + + public static Recipe fromItemStacks(String id, ItemStack[] inputs, ItemStack[] outputs, RecipeType type, MatchProcedure match) { + return new Recipe( + Optional.of(id), + id.toLowerCase(), + RecipeInput.fromItemStacks(inputs, match), + RecipeOutput.fromItemStacks(outputs), + List.of(type), + Optional.empty(), + Optional.empty(), + List.of() + ); + } + + public static Recipe fromItemStacks(String id, ItemStack[] inputs, ItemStack[] outputs, RecipeType type) { + return fromItemStacks(id, inputs, outputs, type, type.getDefaultMatchProcedure()); + } + + public static Recipe fromItemStacks(ItemStack[] inputs, ItemStack[] outputs, RecipeType type, MatchProcedure match) { + return new Recipe( + Optional.empty(), + "other_recipes.json", + RecipeInput.fromItemStacks(inputs, match), + RecipeOutput.fromItemStacks(outputs), + List.of(type), + Optional.empty(), + Optional.empty(), + List.of() + ); + } + + public static Recipe fromItemStacks(ItemStack[] inputs, ItemStack[] outputs, RecipeType type) { + return fromItemStacks(inputs, outputs, type, type.getDefaultMatchProcedure()); + } + + public Optional getId() { + return id; + } + + public String getFilename() { + return filename; + } + + public AbstractRecipeInput getInput() { + return input; + } + public void setInput(AbstractRecipeInput input) { + this.input = input; + } + + public AbstractRecipeOutput getOutput() { + return output; + } + public void setOutput(AbstractRecipeOutput output) { + this.output = output; + } + + public Optional getEnergy() { + return energy; + } + public void setEnergy(Optional energy) { + this.energy = energy; + } + + public Optional getCraftingTime() { + return craftingTime; + } + public void setCraftingTime(Optional craftingTime) { + this.craftingTime = craftingTime; + } + + public Set getPermissionNodes() { + return permissionNodes; + } + + public Set getTypes() { + return Collections.unmodifiableSet(types); + } + public void addRecipeType(RecipeType type) { + if (types.contains(type)) { + return; + } + types.add(type); + Slimefun.getRecipeService().addRecipeToType(this, type); + } + + public RecipeMatchResult match(List givenItems) { + InputMatchResult result = getInput().match(givenItems); + return new RecipeMatchResult(this, result); + } + + public RecipeMatchResult matchAs(MatchProcedure match, List givenItems) { + InputMatchResult result = getInput().matchAs(match, givenItems); + return new RecipeMatchResult(this, result); + } + + /** + * The guide display will need to be overhauled, + * as it only displays individual items, and not + * individual recipes; Furthermore, the input + * cycling animation only works for vanilla recipes. + * + * In the meantime, DO NOT rely on this method or + * any similar ones in the recipe components + */ + @Deprecated + public ItemStack[] getInputDisplay() { + return input.getInputDisplay(); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("Recipe { "); + builder.append(input.toString()); + builder.append(", "); + builder.append(output.toString()); + if (!types.isEmpty()) { + builder.append(", RecipeType(s) { "); + builder.append(String.join(", ", types.stream().map(t -> t.toString()).toList())); + builder.append(" }"); + } + if (energy.isPresent()) { + builder.append(", energy="); + builder.append(energy.get()); + } + if (craftingTime.isPresent()) { + builder.append(", craftingTime="); + builder.append(craftingTime.get()); + } + if (!permissionNodes.isEmpty()) { + builder.append(", Permission(s) { "); + builder.append(String.join(", ", permissionNodes)); + builder.append(" }"); + } + builder.append(" }"); + return builder.toString(); + } + + /** + * Serialize this recipe to JSON. If you are subclassing + * Recipe, make sure to override this method and serialize the + * key of the custom deserializer in the "class" field + * @param context + * @return + */ + public JsonElement serialize(JsonSerializationContext context) { + JsonObject recipe = new JsonObject(); + + if (id.isPresent()) { + recipe.addProperty("id", id.get()); + } + if (!input.isEmpty()) { + recipe.add("input", input.serialize(context)); + } + if (!output.isEmpty()) { + recipe.add("output", output.serialize(context)); + } + if (types.size() == 1) { + recipe.addProperty("type", types.stream().findFirst().get().toString()); + } else if (types.size() > 1) { + JsonArray t = new JsonArray(types.size()); + for (RecipeType recipeType : types) { + t.add(recipeType.getKey().toString()); + } + recipe.add("type", t); + } + if (energy.isPresent()) { + recipe.addProperty("energy", energy.get()); + } + if (craftingTime.isPresent()) { + recipe.addProperty("crafting-time", craftingTime.get()); + } + if (!permissionNodes.isEmpty()) { + JsonArray p = new JsonArray(permissionNodes.size()); + for (String node : permissionNodes) { + p.add(node); + } + recipe.add("permission-node", p); + } + + return recipe; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeBuilder.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeBuilder.java new file mode 100644 index 0000000000..2dc1542fb5 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeBuilder.java @@ -0,0 +1,182 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import javax.annotation.Nonnull; + +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputGroup; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputItemStack; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputSlimefunItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputTag; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputItemStack; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputSlimefunItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputTag; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; + +public class RecipeBuilder { + + @FunctionalInterface + static interface InputGenerator { + AbstractRecipeInput create(List inputs, MatchProcedure match, int width, int height); + } + + private final List inputItems = new ArrayList<>(); + private final List outputItems = new ArrayList<>(); + private final List types = new ArrayList<>(); + private final List permissionNodes = new ArrayList<>(); + private MatchProcedure match = null; + private int width = 3; + private int height = 3; + private InputGenerator inputGenerator = RecipeInput::new; + private Function, AbstractRecipeOutput> outputGenerator = RecipeOutput::new; + private Optional energy = Optional.empty(); + private Optional craftingTime = Optional.empty(); + private Optional id = Optional.empty(); + private String filename = null; + + public RecipeBuilder() {} + + protected String getRecipeSubdirectory() { + return ""; + } + + public Recipe build() { + return new Recipe( + id, + Path.of(getRecipeSubdirectory(), filename).toString(), + inputGenerator.create(inputItems, match, width, height), + outputGenerator.apply(outputItems), + types, + energy, + craftingTime, + permissionNodes + ); + } + + public RecipeBuilder i(@Nonnull AbstractRecipeInputItem i) { + inputItems.add(i); + return this; + } + + // Forwards arguments to the respective ctors + public RecipeBuilder i(ItemStack item, int amount, int durabilityCost) { return i(RecipeInputItem.fromItemStack(item, amount, durabilityCost)); } + public RecipeBuilder i(ItemStack item, int amount) { return i(RecipeInputItem.fromItemStack(item, amount)); } + public RecipeBuilder i(ItemStack item) { return i(RecipeInputItem.fromItemStack(item)); } + public RecipeBuilder i(@Nonnull Material mat, int amount, int durabilityCost) { return i(new RecipeInputItemStack(mat, amount, durabilityCost)); } + public RecipeBuilder i(@Nonnull Material mat, int amount) { return i(new RecipeInputItemStack(mat, amount)); } + public RecipeBuilder i(@Nonnull Material mat) { return i(new RecipeInputItemStack(mat)); } + public RecipeBuilder i(@Nonnull String id, int amount, int durabilityCost) { return i(new RecipeInputSlimefunItem(id, amount, durabilityCost)); } + public RecipeBuilder i(@Nonnull String id, int amount) { return i(new RecipeInputSlimefunItem(id, amount)); } + public RecipeBuilder i(@Nonnull String id) { return i(new RecipeInputSlimefunItem(id)); } + public RecipeBuilder i(@Nonnull Tag id, int amount, int durabilityCost) { return i(new RecipeInputTag(id, amount, durabilityCost)); } + public RecipeBuilder i(@Nonnull Tag id, int amount) { return i(new RecipeInputTag(id, amount)); } + public RecipeBuilder i(@Nonnull Tag id) { return i(new RecipeInputTag(id)); } + public RecipeBuilder i(@Nonnull List group) { return i(new RecipeInputGroup(group)); } + + public RecipeBuilder i(@Nonnull ItemStack[] items) { + for (ItemStack item : items) { + i(item); + } + return this; + } + + public RecipeBuilder i() { + return i(RecipeInputItem.EMPTY); + } + + public RecipeBuilder i(int amount) { + for (int i = 0; i < amount; i++) { + i(RecipeInputItem.EMPTY); + } + return this; + } + + + public RecipeBuilder inputGenerator(@Nonnull InputGenerator generator) { + this.inputGenerator = generator; + return this; + } + + public RecipeBuilder o(@Nonnull AbstractRecipeOutputItem o) { + outputItems.add(o); + return this; + } + + public RecipeBuilder o(ItemStack item, int amount) { return o(RecipeOutputItem.fromItemStack(item, amount)); } + public RecipeBuilder o(ItemStack item) { return o(RecipeOutputItem.fromItemStack(item)); } + public RecipeBuilder o(@Nonnull Material item, int amount) { return o(new RecipeOutputItemStack(item, amount)); } + public RecipeBuilder o(@Nonnull Material item) { return o(new RecipeOutputItemStack(item)); } + public RecipeBuilder o(@Nonnull String id, int amount) { return o(new RecipeOutputSlimefunItem(id, amount)); } + public RecipeBuilder o(@Nonnull String id) { return o(new RecipeOutputSlimefunItem(id)); } + public RecipeBuilder o(@Nonnull Tag id, int amount) { return o(new RecipeOutputTag(id, amount)); } + public RecipeBuilder o(@Nonnull Tag id) { return o(new RecipeOutputTag(id)); } + + public RecipeBuilder o() { + return o(RecipeOutputItem.EMPTY); + } + + public RecipeBuilder outputGenerator(@Nonnull Function, AbstractRecipeOutput> generator) { + this.outputGenerator = generator; + return this; + } + + public RecipeBuilder type(@Nonnull RecipeType t) { + types.add(t); + if (match == null) { + match = t.getDefaultMatchProcedure(); + } + return this; + } + + public RecipeBuilder match(@Nonnull MatchProcedure match) { + this.match = match; + return this; + } + + public RecipeBuilder dim(int width, int height) { + this.width = width; + this.height = height; + return this; + } + + public RecipeBuilder permission(@Nonnull String p) { + permissionNodes.add(p); + return this; + } + + public RecipeBuilder energy(int energy) { + this.energy = Optional.of(energy); + return this; + } + + public RecipeBuilder craftingTime(int ticks) { + this.craftingTime = Optional.of(ticks); + return this; + } + + public RecipeBuilder id(@Nonnull String id) { + this.id = Optional.of(id); + if (filename == null){ + filename = id; + } + return this; + } + + public RecipeBuilder filename(@Nonnull String filename) { + this.filename = filename; + return this; + } + +} 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..a7711a02f9 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeCrafter.java @@ -0,0 +1,18 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.Collection; +import java.util.List; + +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeSearchResult; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +public interface RecipeCrafter { + + public Collection getCraftableRecipeTypes(); + public default RecipeSearchResult searchRecipes(List givenItems) { + return Slimefun.getRecipeService().searchRecipes(getCraftableRecipeTypes(), givenItems); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeInput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeInput.java new file mode 100644 index 0000000000..01f225fc97 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeInput.java @@ -0,0 +1,241 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils.BoundingBox; + +public class RecipeInput extends AbstractRecipeInput { + + public static final AbstractRecipeInput EMPTY = new AbstractRecipeInput() { + + @Override + public int getWidth() { + return 0; + } + + @Override + public void setWidth(int width) {} + + @Override + public int getHeight() { + return 0; + } + + @Override + public void setHeight(int height) {} + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public List getItems() { + return Collections.emptyList(); + } + + @Override + public void setItems(List items) {} + + @Override + public MatchProcedure getMatchProcedure() { + return MatchProcedure.EMPTY; + } + + @Override + public void setMatchProcedure(MatchProcedure matchProcedure) {} + + @Override + public Optional getBoundingBox() { + return Optional.empty(); + } + + @Override + public String toString() { + return "RInput { EMPTY }"; + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + return new JsonObject(); + } + + @Override + public boolean equals(Object obj) { + return obj == this; + } + + }; + + private List items; + private MatchProcedure match; + private int width; + private int height; + private Optional boundingBox; + + public RecipeInput(List items, MatchProcedure match, int width, int height) { + this.items = items; + this.match = match; + this.width = width; + this.height = height; + saveBoundingBox(); + } + + public RecipeInput(List items, MatchProcedure match) { + this.items = items; + this.match = match; + this.width = items.size(); + this.height = 1; + saveBoundingBox(); + } + + public static RecipeInput fromItemStacks(ItemStack[] items, MatchProcedure match) { + return new RecipeInput(Arrays.stream(items).map(item -> RecipeInputItem.fromItemStack(item)).toList(), match, 3, 3); + } + + protected void saveBoundingBox() { + if (this.match.recipeShouldSaveBoundingBox()) { + this.boundingBox = Optional.of(RecipeUtils.calculateBoundingBox(items, width, height, item -> item.isEmpty())); + } + } + + public int getWidth() { + return width; + }; + public void setWidth(int width) { + this.width = width; + saveBoundingBox(); + }; + + public int getHeight() { + return height; + }; + public void setHeight(int height) { + this.height = height; + saveBoundingBox(); + }; + + public List getItems() { + return Collections.unmodifiableList(items); + }; + public void setItems(List items) { + this.items = items; + saveBoundingBox(); + }; + public void setItems(List items, int width, int height) { + this.items = items; + this.width = width; + this.height = height; + saveBoundingBox(); + }; + + public MatchProcedure getMatchProcedure() { return match; }; + public void setMatchProcedure(MatchProcedure match) { + this.match = match; + saveBoundingBox(); + }; + + public Optional getBoundingBox() { return boundingBox; } + + @Override + public String toString() { + return "RecipeInput { " + items + ", match=" + match + ", w=" + width + ", h=" + height + ", bb=" + boundingBox + " }"; + } + + @Override + public boolean isEmpty() { + if (boundingBox.isPresent()) { + return boundingBox.get().getWidth() == 0 || boundingBox.get().getHeight() == 0; + } + return items.stream().allMatch(i -> i.isEmpty()); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeInput input = (RecipeInput) obj; + return input.items.equals(items) && + input.width == width && + input.height == height && + input.match == match; + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + int current = 0; + Map keys = new LinkedHashMap<>(); + List template = new ArrayList<>(); + for (int y = 0; y < height; y++) { + char[] line = new char[width]; + Arrays.fill(line, ' '); + for (int x = 0; x < width; x++) { + int i = y * width + x; + if (i >= items.size()) { + break; + } + + AbstractRecipeInputItem inputItem = items.get(i); + if (inputItem.isEmpty()) { + continue; + } + + int keyNum; + if (keys.containsKey(inputItem)) { + keyNum = keys.get(inputItem); + } else { + keys.put(inputItem, current); + keyNum = current; + current++; + } + + line[x] = RecipeUtils.getKeyCharByNumber(keyNum); + } + template.add(new String(line)); + } + + JsonObject input = new JsonObject(); + JsonElement jsonTemplate; + if (template.size() == 1) { + jsonTemplate = new JsonPrimitive(template.get(0)); + } else { + JsonArray arr = new JsonArray(); + for (String line : template) { + arr.add(line); + } + jsonTemplate = arr; + } + JsonObject key = new JsonObject(); + for (Map.Entry entry : keys.entrySet()) { + key.add( + String.valueOf(RecipeUtils.getKeyCharByNumber(entry.getValue())), + entry.getKey().serialize(context) + ); + } + input.add("items", jsonTemplate); + input.add("key", key); + input.addProperty("match", match.getKey().toString()); + return input; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeOutput.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeOutput.java new file mode 100644 index 0000000000..79c1ce4fa5 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/RecipeOutput.java @@ -0,0 +1,176 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeOutputItem.SpaceRequirement; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.utils.SlimefunUtils; + +public class RecipeOutput extends AbstractRecipeOutput { + + public static final AbstractRecipeOutput EMPTY = new AbstractRecipeOutput() { + + @Override + public Inserter checkSpace(RecipeMatchResult result, Inventory inventory, int[] outputSlots) { + return new Inserter(inventory); + } + + @Override + public List generateOutput(RecipeMatchResult result) { + return List.of(); + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public String toString() { + return "Empty Recipe Output"; + } + + @Override + public boolean equals(Object obj) { + return obj == this; + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + return new JsonObject(); + } + + }; + + private List items; + + public RecipeOutput(List items) { + this.items = items; + } + + public static RecipeOutput fromItemStacks(ItemStack[] items) { + return new RecipeOutput(Arrays.stream(items).map(item -> RecipeOutputItem.fromItemStack(item)).toList()); + } + + public List getItems() { + return this.items; + } + public AbstractRecipeOutputItem getItem(int index) { + return this.items.get(index); + } + + public List generateOutput(RecipeMatchResult result) { + return items.stream().map(i -> i.generateOutput(result)).toList(); + } + + @Override + public Inserter checkSpace(RecipeMatchResult result, Inventory inventory, int[] outputSlots) { + // Check all outputs slots to see if they are empty + List freeSlots = new LinkedList<>(); + List filledSlots = new ArrayList<>(); + for (int i : outputSlots) { + ItemStack item = inventory.getItem(i); + if (item == null || item.getType().isAir()) { + freeSlots.add(i); + } else { + filledSlots.add(i); + } + } + + // Go through each output and check if there is space, if none, then put it into the leftovers + Map addToStacks = new HashMap<>(); + Map newStacks = new HashMap<>(); + List leftovers = new ArrayList<>(); + for (AbstractRecipeOutputItem output : items) { + ItemStack outputStack = output.generateOutput(result); + if (output.getSpaceRequirement() == SpaceRequirement.EMPTY_SLOT) { + if (freeSlots.isEmpty()) { + leftovers.add(outputStack); + } else { + int newSlot = freeSlots.removeFirst(); + newStacks.put(newSlot, outputStack); + } + } else { + // Search for matching item + int amount = outputStack.getAmount(); + int stackSize = outputStack.getType().getMaxStackSize(); // TODO item components + for (int i : filledSlots) { + if (amount <= 0) break; + ItemStack filledItem = inventory.getItem(i); + if (!SlimefunUtils.isItemSimilar(filledItem, outputStack, true, false)) { + continue; + } + int filledAmount = filledItem.getAmount(); + int currentAdd = addToStacks.getOrDefault(i, 0); + if (filledAmount + currentAdd >= stackSize) { + continue; + } else if (filledAmount + currentAdd + amount > stackSize) { + int diff = stackSize - filledAmount - currentAdd; + amount -= diff; + addToStacks.put(i, diff + currentAdd); + } else { + addToStacks.put(i, amount + currentAdd); + amount = 0; + } + } + if (amount > 0) { + outputStack.setAmount(amount); + if (freeSlots.isEmpty()) { + leftovers.add(outputStack); + } else { + int newSlot = freeSlots.removeFirst(); + newStacks.put(newSlot, outputStack); + } + } + } + } + return new Inserter(inventory, addToStacks, newStacks, leftovers); + } + + @Override + public boolean isEmpty() { + return items.isEmpty(); + } + + @Override + public String toString() { + return "RecipeOutput { " + items + " }"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeOutput output = (RecipeOutput) obj; + return output.items.equals(items); + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + JsonObject output = new JsonObject(); + JsonArray arr = new JsonArray(); + for (AbstractRecipeOutputItem item : items) { + arr.add(item.serialize(context)); + } + output.add("items", arr); + return output; + } + +} 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..0209e81021 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 @@ -3,9 +3,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.function.BiConsumer; @@ -25,60 +27,67 @@ 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.matching.MatchProcedure; 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 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 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"); - public static final RecipeType PRESSURE_CHAMBER = new RecipeType(new NamespacedKey(Slimefun.instance(), "pressure_chamber"), SlimefunItems.PRESSURE_CHAMBER, "", "&a&oCompress it using the Pressure Chamber"); - public static final RecipeType MAGIC_WORKBENCH = new RecipeType(new NamespacedKey(Slimefun.instance(), "magic_workbench"), SlimefunItems.MAGIC_WORKBENCH, "", "&a&oCraft it in a Magic Workbench"); - public static final RecipeType ORE_WASHER = new RecipeType(new NamespacedKey(Slimefun.instance(), "ore_washer"), SlimefunItems.ORE_WASHER, "", "&a&oWash it in an Ore Washer"); - public static final RecipeType ENHANCED_CRAFTING_TABLE = new RecipeType(new NamespacedKey(Slimefun.instance(), "enhanced_crafting_table"), SlimefunItems.ENHANCED_CRAFTING_TABLE, "", "&a&oA regular Crafting Table cannot", "&a&ohold this massive Amount of Power..."); - public static final RecipeType JUICER = new RecipeType(new NamespacedKey(Slimefun.instance(), "juicer"), SlimefunItems.JUICER, "", "&a&oUsed for Juice Creation"); - - public static final RecipeType ANCIENT_ALTAR = new RecipeType(new NamespacedKey(Slimefun.instance(), "ancient_altar"), SlimefunItems.ANCIENT_ALTAR, (recipe, output) -> { + public static final Map recipeTypesByKey = new HashMap<>(); + + public static final RecipeType MULTIBLOCK = new RecipeType(new NamespacedKey(Slimefun.instance(), "multiblock"), new CustomItemStack(Material.BRICKS, "&bMultiBlock", "", "&a&oBuild it in the World"), MatchProcedure.DUMMY); + public static final RecipeType ARMOR_FORGE = new RecipeType(new NamespacedKey(Slimefun.instance(), "armor_forge"), SlimefunItems.ARMOR_FORGE, MatchProcedure.SHAPED, "", "&a&oCraft it in an Armor Forge"); + public static final RecipeType GRIND_STONE = new RecipeType(new NamespacedKey(Slimefun.instance(), "grind_stone"), SlimefunItems.GRIND_STONE, MatchProcedure.SUBSET, "", "&a&oGrind it using the Grind Stone"); + @Deprecated + public static final RecipeType SMELTERY = new RecipeType(new NamespacedKey(Slimefun.instance(), "smeltery"), SlimefunItems.SMELTERY, MatchProcedure.SUBSET, "", "&a&oSmelt it using a Smeltery"); + public static final RecipeType ORE_CRUSHER = new RecipeType(new NamespacedKey(Slimefun.instance(), "ore_crusher"), SlimefunItems.ORE_CRUSHER, MatchProcedure.SUBSET, "", "&a&oCrush it using the Ore Crusher"); + public static final RecipeType GOLD_PAN = new RecipeType(new NamespacedKey(Slimefun.instance(), "gold_pan"), SlimefunItems.GOLD_PAN, MatchProcedure.SUBSET, "", "&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, MatchProcedure.SUBSET, "", "&a&oCompress it using the Compressor"); + public static final RecipeType PRESSURE_CHAMBER = new RecipeType(new NamespacedKey(Slimefun.instance(), "pressure_chamber"), SlimefunItems.PRESSURE_CHAMBER, MatchProcedure.SUBSET, "", "&a&oCompress it using the Pressure Chamber"); + public static final RecipeType MAGIC_WORKBENCH = new RecipeType(new NamespacedKey(Slimefun.instance(), "magic_workbench"), SlimefunItems.MAGIC_WORKBENCH, MatchProcedure.SHAPED, "", "&a&oCraft it in a Magic Workbench"); + public static final RecipeType ORE_WASHER = new RecipeType(new NamespacedKey(Slimefun.instance(), "ore_washer"), SlimefunItems.ORE_WASHER, MatchProcedure.SUBSET, "", "&a&oWash it in an Ore Washer"); + public static final RecipeType ENHANCED_CRAFTING_TABLE = new RecipeType(new NamespacedKey(Slimefun.instance(), "enhanced_crafting_table"), SlimefunItems.ENHANCED_CRAFTING_TABLE, MatchProcedure.SHAPED, "", "&a&oA regular Crafting Table cannot", "&a&ohold this massive Amount of Power..."); + public static final RecipeType JUICER = new RecipeType(new NamespacedKey(Slimefun.instance(), "juicer"), SlimefunItems.JUICER, MatchProcedure.SUBSET, "", "&a&oUsed for Juice Creation"); + + public static final RecipeType ANCIENT_ALTAR = new RecipeType(new NamespacedKey(Slimefun.instance(), "ancient_altar"), SlimefunItems.ANCIENT_ALTAR, MatchProcedure.SHAPED_ROTATABLE_45_3X3, (recipe, output) -> { AltarRecipe altarRecipe = new AltarRecipe(Arrays.asList(recipe), output); AncientAltar altar = ((AncientAltar) SlimefunItems.ANCIENT_ALTAR.getItem()); altar.getRecipes().add(altarRecipe); }); - public static final RecipeType MOB_DROP = new RecipeType(new NamespacedKey(Slimefun.instance(), "mob_drop"), new CustomItemStack(Material.IRON_SWORD, "&bMob Drop"), RecipeType::registerMobDrop, "", "&rKill the specified Mob to obtain this Item"); - public static final RecipeType BARTER_DROP = new RecipeType(new NamespacedKey(Slimefun.instance(), "barter_drop"), new CustomItemStack(Material.GOLD_INGOT, "&bBarter Drop"), RecipeType::registerBarterDrop, "&aBarter with piglins for a chance", "&ato obtain this item"); - public static final RecipeType INTERACT = new RecipeType(new NamespacedKey(Slimefun.instance(), "interact"), new CustomItemStack(Material.PLAYER_HEAD, "&bInteract", "", "&a&oRight click with this item")); + public static final RecipeType MOB_DROP = new RecipeType(new NamespacedKey(Slimefun.instance(), "mob_drop"), new CustomItemStack(Material.IRON_SWORD, "&bMob Drop"), MatchProcedure.DUMMY, RecipeType::registerMobDrop, "", "&rKill the specified Mob to obtain this Item"); + public static final RecipeType BARTER_DROP = new RecipeType(new NamespacedKey(Slimefun.instance(), "barter_drop"), new CustomItemStack(Material.GOLD_INGOT, "&bBarter Drop"), MatchProcedure.DUMMY, RecipeType::registerBarterDrop, "&aBarter with piglins for a chance", "&ato obtain this item"); + public static final RecipeType INTERACT = new RecipeType(new NamespacedKey(Slimefun.instance(), "interact"), new CustomItemStack(Material.PLAYER_HEAD, "&bInteract", "", "&a&oRight click with this item"), MatchProcedure.DUMMY); - public static final RecipeType HEATED_PRESSURE_CHAMBER = new RecipeType(new NamespacedKey(Slimefun.instance(), "heated_pressure_chamber"), SlimefunItems.HEATED_PRESSURE_CHAMBER); - public static final RecipeType FOOD_FABRICATOR = new RecipeType(new NamespacedKey(Slimefun.instance(), "food_fabricator"), SlimefunItems.FOOD_FABRICATOR); - public static final RecipeType FOOD_COMPOSTER = new RecipeType(new NamespacedKey(Slimefun.instance(), "food_composter"), SlimefunItems.FOOD_COMPOSTER); - public static final RecipeType FREEZER = new RecipeType(new NamespacedKey(Slimefun.instance(), "freezer"), SlimefunItems.FREEZER); - public static final RecipeType REFINERY = new RecipeType(new NamespacedKey(Slimefun.instance(), "refinery"), SlimefunItems.REFINERY); + public static final RecipeType HEATED_PRESSURE_CHAMBER = new RecipeType(new NamespacedKey(Slimefun.instance(), "heated_pressure_chamber"), SlimefunItems.HEATED_PRESSURE_CHAMBER, MatchProcedure.SUBSET); + public static final RecipeType FOOD_FABRICATOR = new RecipeType(new NamespacedKey(Slimefun.instance(), "food_fabricator"), SlimefunItems.FOOD_FABRICATOR, MatchProcedure.SUBSET); + public static final RecipeType FOOD_COMPOSTER = new RecipeType(new NamespacedKey(Slimefun.instance(), "food_composter"), SlimefunItems.FOOD_COMPOSTER, MatchProcedure.SUBSET); + public static final RecipeType FREEZER = new RecipeType(new NamespacedKey(Slimefun.instance(), "freezer"), SlimefunItems.FREEZER, MatchProcedure.SUBSET); + public static final RecipeType REFINERY = new RecipeType(new NamespacedKey(Slimefun.instance(), "refinery"), SlimefunItems.REFINERY, MatchProcedure.SUBSET); - public static final RecipeType GEO_MINER = new RecipeType(new NamespacedKey(Slimefun.instance(), "geo_miner"), SlimefunItems.GEO_MINER); - public static final RecipeType NUCLEAR_REACTOR = new RecipeType(new NamespacedKey(Slimefun.instance(), "nuclear_reactor"), SlimefunItems.NUCLEAR_REACTOR); + public static final RecipeType GEO_MINER = new RecipeType(new NamespacedKey(Slimefun.instance(), "geo_miner"), SlimefunItems.GEO_MINER, MatchProcedure.DUMMY); + public static final RecipeType NUCLEAR_REACTOR = new RecipeType(new NamespacedKey(Slimefun.instance(), "nuclear_reactor"), SlimefunItems.NUCLEAR_REACTOR, MatchProcedure.SUBSET); public static final RecipeType NULL = new RecipeType(); private final ItemStack item; private final NamespacedKey key; - private final String machine; + private final @Deprecated String machine; private BiConsumer consumer; + private final MatchProcedure defaultMatch; private RecipeType() { this.item = null; this.machine = ""; this.key = new NamespacedKey(Slimefun.instance(), "null"); + this.defaultMatch = MatchProcedure.DUMMY; + recipeTypesByKey.put(key, this); } + @Deprecated public RecipeType(ItemStack item, String machine) { this.item = item; this.machine = machine; @@ -88,34 +97,58 @@ public RecipeType(ItemStack item, String machine) { } else { this.key = new NamespacedKey(Slimefun.instance(), "unknown"); } + this.defaultMatch = MatchProcedure.SHAPELESS; + recipeTypesByKey.put(key, this); } public RecipeType(NamespacedKey key, SlimefunItemStack slimefunItem, String... lore) { - this(key, slimefunItem, null, lore); + this(key, slimefunItem, MatchProcedure.SHAPED, null, lore); } - public RecipeType(NamespacedKey key, ItemStack item, BiConsumer callback, String... lore) { + public RecipeType(NamespacedKey key, SlimefunItemStack slimefunItem, MatchProcedure defaultMatch, String... lore) { + this(key, slimefunItem, defaultMatch, null, lore); + } + + public RecipeType(NamespacedKey key, ItemStack item, MatchProcedure defaultMatch, BiConsumer callback, String... lore) { this.item = new CustomItemStack(item, null, lore); this.key = key; this.consumer = callback; + this.defaultMatch = defaultMatch; if (item instanceof SlimefunItemStack slimefunItemStack) { this.machine = slimefunItemStack.getItemId(); } else { this.machine = ""; } + recipeTypesByKey.put(key, this); + } + + public RecipeType(NamespacedKey key, ItemStack item, BiConsumer callback, String... lore) { + this(key, item, MatchProcedure.SHAPED, callback, lore); } public RecipeType(NamespacedKey key, ItemStack item) { + this(key, item, MatchProcedure.SHAPED); + } + + public RecipeType(NamespacedKey key, ItemStack item, MatchProcedure defaultMatch) { this.key = key; this.item = item; this.machine = item instanceof SlimefunItemStack slimefunItemStack ? slimefunItemStack.getItemId() : ""; + this.defaultMatch = defaultMatch; + recipeTypesByKey.put(key, this); } public RecipeType(MinecraftRecipe recipe) { this.item = new ItemStack(recipe.getMachine()); this.machine = ""; this.key = NamespacedKey.minecraft(recipe.getRecipeClass().getSimpleName().toLowerCase(Locale.ROOT).replace("recipe", "")); + this.defaultMatch = MatchProcedure.DUMMY; + recipeTypesByKey.put(key, this); + } + + public MatchProcedure getDefaultMatchProcedure() { + return defaultMatch; } public void register(ItemStack[] recipe, ItemStack result) { @@ -230,4 +263,13 @@ public static ItemStack getRecipeOutputList(MultiBlockMachine machine, ItemStack List recipes = machine.getRecipes(); return recipes.get((recipes.indexOf(input) + 1))[0]; } + + @Override + public String toString() { + return key.toString(); + } + + public static RecipeType fromString(String key) { + return recipeTypesByKey.get(NamespacedKey.fromString(key)); + } } \ No newline at end of file diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/AbstractRecipeInputItem.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/AbstractRecipeInputItem.java new file mode 100644 index 0000000000..5b5b7fe1b3 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/AbstractRecipeInputItem.java @@ -0,0 +1,49 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.ItemMatchResult; + +public abstract class AbstractRecipeInputItem implements Cloneable { + + protected abstract ItemMatchResult matchItem(@Nullable ItemStack item, @Nonnull AbstractRecipeInputItem root); + + @Nonnull + public ItemMatchResult matchItem(@Nullable ItemStack item) { + return matchItem(item, clone()); + } + + public abstract boolean isEmpty(); + + public abstract ItemStack getItemDisplay(); + + @Override + public abstract AbstractRecipeInputItem clone(); + + @Override + public abstract String toString(); + + @Override + public abstract boolean equals(Object obj); + + @Override + public abstract int hashCode(); + + /** + * Used in the serialize method to determine if it can convert to a + * JSON string (e.g. minecraft:netherrack|16) instead of an object + * + * Should be overriden in any custom subclasses that contain extra fields + */ + public boolean canUseShortSerialization() { + return false; + }; + + public abstract JsonElement serialize(JsonSerializationContext context); +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/AbstractRecipeOutputItem.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/AbstractRecipeOutputItem.java new file mode 100644 index 0000000000..47346ec13e --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/AbstractRecipeOutputItem.java @@ -0,0 +1,53 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; + +public abstract class AbstractRecipeOutputItem { + + public enum SpaceRequirement { + EMPTY_SLOT, MATCHING_ITEM + } + + /** + * Generate the output given the match result. + * @param result The result of this recipe being matched + * @return Item to be output + */ + @Nonnull + public abstract ItemStack generateOutput(@Nonnull RecipeMatchResult result); + public abstract boolean matchItem(@Nullable ItemStack item); + /** + * @return What space the item needs to be placed in the output + */ + public SpaceRequirement getSpaceRequirement() { + return SpaceRequirement.EMPTY_SLOT; + } + + @Override + public abstract String toString(); + + @Override + public abstract boolean equals(Object obj); + + /** + * Used in the serialize method to determine if it can convert to a + * JSON string (e.g. minecraft:netherrack|16) instead of an object + * + * Should be overriden in any custom subclasses that contain extra fields + */ + public boolean canUseShortSerialization() { + return false; + }; + + public abstract JsonElement serialize(JsonSerializationContext context); + +} + diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputGroup.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputGroup.java new file mode 100644 index 0000000000..d5c8f09dfa --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputGroup.java @@ -0,0 +1,102 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.ItemMatchResult; + +public class RecipeInputGroup extends AbstractRecipeInputItem { + + List items; + + public RecipeInputGroup(List items) { + this.items = items; + } + + public RecipeInputGroup(AbstractRecipeInputItem... items) { + this.items = List.of(items); + } + + public List getItems() { return items; } + public void setItems(List items) { this.items = items; } + + @Override + public ItemMatchResult matchItem(ItemStack item, AbstractRecipeInputItem root) { + for (AbstractRecipeInputItem i : items) { + ItemMatchResult result = i.matchItem(item, root); + if (result.itemsMatch()) { + return result; + } + } + return new ItemMatchResult(false, root, item, 0); + } + + @Override + public ItemStack getItemDisplay() { + // TODO guide display overhaul + if (items.size() == 0) { + return new ItemStack(Material.AIR); + } + return items.get(0).getItemDisplay(); + } + + @Override + public boolean isEmpty() { + return items.size() == 0 || items.stream().allMatch(i -> i.isEmpty()); + } + + @Override + public RecipeInputGroup clone() { + List items = new ArrayList<>(); + for (AbstractRecipeInputItem item : this.items) { + items.add(item.clone()); + } + return new RecipeInputGroup(items); + } + + @Override + public String toString() { + return "RIGroup { " + items + " }"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeInputGroup item = (RecipeInputGroup) obj; + if (item.items.size() != items.size()) return false; + for (int i = 0; i < items.size(); i++) { + if (!item.items.get(i).equals(items.get(i))) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + return items.hashCode(); + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + JsonObject group = new JsonObject(); + JsonArray arr = new JsonArray(items.size()); + for (AbstractRecipeInputItem item : items) { + arr.add(context.serialize(item, AbstractRecipeInputItem.class)); + } + group.add("group", arr); + return group; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputItem.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputItem.java new file mode 100644 index 0000000000..b632210b0b --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputItem.java @@ -0,0 +1,180 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import java.util.Optional; + +import javax.annotation.Nullable; + +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItemStack; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.ItemMatchResult; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils; + +public abstract class RecipeInputItem extends AbstractRecipeInputItem { + + public static final AbstractRecipeInputItem EMPTY = new AbstractRecipeInputItem() { + + @Override + protected ItemMatchResult matchItem(ItemStack item, AbstractRecipeInputItem root) { + return new ItemMatchResult(item == null || item.getType().isAir(), this, item, 0); + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public AbstractRecipeInputItem clone() { + return this; + } + + @Override + public ItemStack getItemDisplay() { + return new ItemStack(Material.AIR); + } + + @Override + public String toString() { + return "EMPTY"; + } + + @Override + public boolean equals(Object obj) { + if (obj == null) return false; + if (obj == this) return true; + if (obj instanceof final AbstractRecipeInputItem item) { + return item.isEmpty(); + } + return false; + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + // Shouldn't ever be called but eh + return new JsonPrimitive("minecraft:air"); + } + + }; + + private int amount; + private int durabilityCost = 0; + + public RecipeInputItem(int amount, int durabilityCost) { + this.amount = amount; + this.durabilityCost = durabilityCost; + } + + public RecipeInputItem(int amount) { + this.amount = amount; + } + + public int getAmount() { return amount; } + public void setAmount(int amount) { this.amount = amount; } + + public int getDurabilityCost() { return durabilityCost; } + public void setDurabilityCost(int durabilityCost) { this.durabilityCost = durabilityCost; } + + @Override + public abstract RecipeInputItem clone(); + + /** + * Converts a string into a RecipeSingleItem + * @param string A namespace string in the format + *
    + *
  • minecraft:<minecraft_id>
  • + *
  • slimefun:<slimefun_id>
  • + *
+ * @return + */ + public static AbstractRecipeInputItem fromString(String string) { + if (string == null) { + return RecipeInputItem.EMPTY; + } + String[] split = string.split(":"); + if (split.length != 2) { + return RecipeInputItem.EMPTY; + } + String[] pipeSplit = split[1].split("\\|"); + String namespace = split[0]; + String id = pipeSplit[0]; + int amount = 1; + if (pipeSplit.length > 1) { + amount = Integer.parseInt(pipeSplit[1]); + } + if (namespace.startsWith("#")) { + // Is a tag + Optional> tag = RecipeUtils.tagFromString(namespace.substring(1), id); + if (tag.isPresent()) { + return new RecipeInputTag(tag.get(), amount); + } + return RecipeInputItem.EMPTY; + } + if (namespace.equals("minecraft")) { + Material mat = Material.matchMaterial(id); + return mat == null ? RecipeInputItem.EMPTY : new RecipeInputItemStack(mat, amount); + } else if (namespace.equals("slimefun")) { + return new RecipeInputSlimefunItem(id.toUpperCase(), amount); + } + return RecipeInputItem.EMPTY; + } + + public static AbstractRecipeInputItem fromItemStack(@Nullable ItemStack item, int amount, int durabilityCost) { + if (item == null || item.getType().isAir()) { + return RecipeInputItem.EMPTY; + } + + if (item instanceof SlimefunItemStack sfItem) { + return new RecipeInputSlimefunItem(sfItem.getItemId(), amount, durabilityCost); + } + + // The item might not have been registered yet and we only need the id, so no need for `getByItem()` + Optional itemID = Slimefun.getItemDataService().getItemData(item); + + if (itemID.isPresent()) { + return new RecipeInputSlimefunItem(itemID.get(), amount, durabilityCost); + } else { + return new RecipeInputItemStack(item, amount, durabilityCost); + } + } + public static AbstractRecipeInputItem fromItemStack(@Nullable ItemStack item, int amount) { + return fromItemStack(item, amount, 0); + } + public static AbstractRecipeInputItem fromItemStack(@Nullable ItemStack item) { + if (item == null || item.getType().isAir()) { + return RecipeInputItem.EMPTY; + } + return fromItemStack(item, item.getAmount()); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("amount="); + + builder.append(amount); + if (durabilityCost != 0) { + builder.append(", durabilityCost="); + builder.append(durabilityCost); + } + + return builder.toString(); + } + + @Override + public boolean canUseShortSerialization() { + return durabilityCost == 0; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputItemStack.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputItemStack.java new file mode 100644 index 0000000000..67f1ff87cf --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputItemStack.java @@ -0,0 +1,139 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.ItemMatchResult; +import io.github.thebusybiscuit.slimefun4.utils.SlimefunUtils; + +public class RecipeInputItemStack extends RecipeInputItem { + + private @Nonnull ItemStack template; + + @ParametersAreNonnullByDefault + public RecipeInputItemStack(ItemStack template, int amount, int durabilityCost) { + super(amount, durabilityCost); + this.template = template; + } + + @ParametersAreNonnullByDefault + public RecipeInputItemStack(ItemStack template, int amount) { + this(template, amount, 0); + } + + @ParametersAreNonnullByDefault + public RecipeInputItemStack(ItemStack template) { + this(template, template.getAmount()); + } + + @ParametersAreNonnullByDefault + public RecipeInputItemStack(Material template, int amount, int durabilityCost) { + super(amount, durabilityCost); + this.template = new ItemStack(template); + } + + @ParametersAreNonnullByDefault + public RecipeInputItemStack(Material template, int amount) { + this(template, amount, 0); + } + + @ParametersAreNonnullByDefault + public RecipeInputItemStack(Material template) { + this(template, 1); + } + + @Nonnull + public ItemStack getTemplate() { return template; } + public void setTemplate(@Nonnull ItemStack template) { this.template = template; } + + @Override + public ItemStack getItemDisplay() { + // TODO: guide display overhaul + if (getAmount() != template.getAmount()) { + ItemStack display = template.clone(); + display.setAmount(getAmount()); + return display; + } + return template; + } + + @Override + public ItemMatchResult matchItem(ItemStack item, AbstractRecipeInputItem root) { + if (item == null || item.getType().isAir()) { + return new ItemMatchResult(isEmpty(), root, item, getAmount(), getDurabilityCost()); + } else if (item.getAmount() < getAmount()) { + return new ItemMatchResult(false, root, item, getAmount(), getDurabilityCost()); + } + return new ItemMatchResult( + SlimefunUtils.isItemSimilar(item, template, false), + root, item, getAmount(), getDurabilityCost() + ); + } + + @Override + public boolean isEmpty() { + return template.getType().isAir() || getAmount() < 1; + } + + @Override + public RecipeInputItemStack clone() { + return new RecipeInputItemStack(template.clone(), getAmount(), getDurabilityCost()); + } + + @Override + public String toString() { + return "RIItemStack { " + template + ", " + super.toString() + " }"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeInputItemStack item = (RecipeInputItemStack) obj; + return item.template.equals(template) && + item.getAmount() == getAmount() && + item.getDurabilityCost() == getDurabilityCost(); + } + + @Override + public int hashCode() { + int hash = 1; + hash = 31 * hash + template.hashCode(); + hash = 31 * hash + getAmount(); + hash = 31 * hash + getDurabilityCost(); + return hash; + } + + @Override + public boolean canUseShortSerialization() { + return super.canUseShortSerialization() && !template.hasItemMeta(); + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + if (canUseShortSerialization()) { + return new JsonPrimitive(template.getType().getKey() + (getAmount() != 1 ? "|" + getAmount() : "")); + } + + JsonObject item = new JsonObject(); + item.addProperty("id", template.getType().getKey().toString()); + if (getAmount() != 1) { + item.addProperty("amount", getAmount()); + } + if (getDurabilityCost() != 0) { + item.addProperty("durability", getDurabilityCost()); + } + return item; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputSlimefunItem.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputSlimefunItem.java new file mode 100644 index 0000000000..e7efe4e485 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputSlimefunItem.java @@ -0,0 +1,117 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.ItemMatchResult; +import io.github.thebusybiscuit.slimefun4.utils.SlimefunUtils; + +public class RecipeInputSlimefunItem extends RecipeInputItem { + + private String slimefunId; + + public RecipeInputSlimefunItem(String slimefunId, int amount, int durabilityCost) { + super(amount, durabilityCost); + this.slimefunId = slimefunId; + } + + public RecipeInputSlimefunItem(String slimefunId, int amount) { + this(slimefunId, amount, 0); + } + + public RecipeInputSlimefunItem(String slimefunId) { + this(slimefunId, 1); + } + + public String getSlimefunId() { return slimefunId; } + public void setSlimefunId(String slimefunId) { this.slimefunId = slimefunId; } + + @Override + public ItemStack getItemDisplay() { + // TODO: guide display overhaul + SlimefunItem sfItem = SlimefunItem.getById(slimefunId); + if (sfItem == null) { + return new ItemStack(Material.AIR); + } + ItemStack display = sfItem.getItem(); + if (getAmount() != display.getAmount()) { + display = sfItem.getItem().clone(); + display.setAmount(getAmount()); + return display; + } + return sfItem.getItem(); + } + + @Override + public ItemMatchResult matchItem(ItemStack item, AbstractRecipeInputItem root) { + if (item == null || item.getType().isAir()) { + return new ItemMatchResult(isEmpty(), root, item, getAmount(), getDurabilityCost()); + } else if (item.getAmount() < getAmount()) { + return new ItemMatchResult(false, root, item, getAmount(), getDurabilityCost()); + } + return new ItemMatchResult( + SlimefunUtils.isItemSimilar(item, SlimefunItem.getById(slimefunId).getItem(), false), + root, item, getAmount(), getDurabilityCost() + ); + } + + @Override + public RecipeInputSlimefunItem clone() { + return new RecipeInputSlimefunItem(slimefunId, getAmount(), getDurabilityCost()); + } + + @Override + public String toString() { + return "RISlimefunItem { id=" + slimefunId + ", " + super.toString() + " }"; + } + + @Override + public boolean isEmpty() { + return this.getAmount() < 1; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeInputSlimefunItem item = (RecipeInputSlimefunItem) obj; + return item.slimefunId.equals(slimefunId) && + item.getAmount() == getAmount() && + item.getDurabilityCost() == getDurabilityCost(); + } + + @Override + public int hashCode() { + int hash = 1; + hash = 31 * hash + slimefunId.hashCode(); + hash = 31 * hash + getAmount(); + hash = 31 * hash + getDurabilityCost(); + return hash; + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + if (canUseShortSerialization()) { + return new JsonPrimitive("slimefun:" + slimefunId.toLowerCase() + (getAmount() != 1 ? "|" + getAmount() : "")); + } + + JsonObject item = new JsonObject(); + item.addProperty("id", "slimefun:" + slimefunId.toLowerCase()); + if (getAmount() != 1) { + item.addProperty("amount", getAmount()); + } + if (getDurabilityCost() != 0) { + item.addProperty("durability", getDurabilityCost()); + } + return item; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputTag.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputTag.java new file mode 100644 index 0000000000..93674926e8 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeInputTag.java @@ -0,0 +1,115 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.ItemMatchResult; +import io.github.thebusybiscuit.slimefun4.utils.SlimefunUtils; + +public class RecipeInputTag extends RecipeInputItem { + + private Tag tag; + + public RecipeInputTag(Tag tag, int amount, int durabilityCost) { + super(amount, durabilityCost); + this.tag = tag; + } + + public RecipeInputTag(Tag tag, int amount) { + super(amount); + this.tag = tag; + } + + public RecipeInputTag(Tag tag) { + this(tag, 1); + } + + + public Tag getTag() { return tag; } + public void setTag(Tag tag) { this.tag = tag; } + + @Override + public ItemStack getItemDisplay() { + // TODO: guide display overhaul + return new ItemStack(tag.getValues().stream().findFirst().get(), getAmount()); + } + + @Override + public ItemMatchResult matchItem(ItemStack item, AbstractRecipeInputItem root) { + if (item == null || item.getType().isAir()) { + return new ItemMatchResult(isEmpty(), root, item, getAmount(), getDurabilityCost()); + } else if (item.getAmount() < getAmount()) { + return new ItemMatchResult(false, root, item, getAmount(), getDurabilityCost()); + } + for (Material mat : tag.getValues()) { + ItemStack template = new ItemStack(mat); + if (SlimefunUtils.isItemSimilar(item, template, true)) { + return new ItemMatchResult( + SlimefunUtils.isItemSimilar(item, template, false), + root, item, getAmount(), getDurabilityCost() + ); + } + } + return new ItemMatchResult(false, root, item, getAmount(), getDurabilityCost()); + } + + @Override + public boolean isEmpty() { + return tag.getValues().isEmpty() || getAmount() < 1; + } + + @Override + public RecipeInputTag clone() { + return new RecipeInputTag(tag, getAmount(), getDurabilityCost()); + } + + @Override + public String toString() { + return "RITag { " + tag + ", " + super.toString() + " }"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeInputTag item = (RecipeInputTag) obj; + return item.tag.getKey().equals(tag.getKey()) && + item.getAmount() == getAmount() && + item.getDurabilityCost() == getDurabilityCost(); + } + + @Override + public int hashCode() { + int hash = 1; + hash = 31 * hash + tag.getKey().hashCode(); + hash = 31 * hash + getAmount(); + hash = 31 * hash + getDurabilityCost(); + return hash; + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + if (canUseShortSerialization()) { + return new JsonPrimitive("#" + tag.getKey() + (getAmount() != 1 ? "|" + getAmount() : "")); + } + + JsonObject item = new JsonObject(); + item.addProperty("tag", tag.getKey().toString()); + if (getAmount() != 1) { + item.addProperty("amount", getAmount()); + } + if (getDurabilityCost() != 0) { + item.addProperty("durability", getDurabilityCost()); + } + return item; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputGroup.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputGroup.java new file mode 100644 index 0000000000..b3233d7471 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputGroup.java @@ -0,0 +1,74 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import java.util.List; +import java.util.Map; + +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSerializationContext; + +import io.github.bakedlibs.dough.collections.RandomizedSet; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; + +public class RecipeOutputGroup extends AbstractRecipeOutputItem { + + RandomizedSet outputPool; + + public RecipeOutputGroup(List outputs, List weights) { + this.outputPool = new RandomizedSet<>(); + if (outputs.size() != weights.size()) { + return; + } + + for (int i = 0; i < outputs.size(); i++) { + this.outputPool.add(outputs.get(i), weights.get(i)); + } + } + + public RandomizedSet getOutputPool() { + return outputPool; + } + + @Override + public ItemStack generateOutput(RecipeMatchResult result) { + return outputPool.getRandom().generateOutput(result); + } + + @Override + public boolean matchItem(ItemStack item) { + return false; + } + + @Override + public String toString() { + return "ROGroup { " + outputPool + " }"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeOutputGroup output = (RecipeOutputGroup) obj; + return output.outputPool.toMap().equals(outputPool.toMap()); + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + JsonObject outputGroup = new JsonObject(); + JsonArray group = new JsonArray(); + JsonArray weights = new JsonArray(); + for (Map.Entry entry : outputPool.toMap().entrySet()) { + group.add(context.serialize(entry.getKey(), AbstractRecipeOutputItem.class)); + weights.add((int) Math.round(entry.getValue() * outputPool.sumWeights())); + } + outputGroup.add("group", group); + outputGroup.add("weights", weights); + return outputGroup; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputItem.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputItem.java new file mode 100644 index 0000000000..a9aebb7d95 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputItem.java @@ -0,0 +1,146 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItemStack; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils; + +public abstract class RecipeOutputItem extends AbstractRecipeOutputItem { + + /** + * Should not be used in recipes. + * If you need an empty recipe output, use RecipeOutput.EMPTY instead + */ + public static final AbstractRecipeOutputItem EMPTY = new AbstractRecipeOutputItem() { + @Override + public boolean matchItem(ItemStack item) { + return item == null || item.getType().isAir(); + } + + @Override + public SpaceRequirement getSpaceRequirement() { + return SpaceRequirement.MATCHING_ITEM; + } + + @Override + public ItemStack generateOutput(RecipeMatchResult result) { + return new ItemStack(Material.AIR); + } + + @Override + public String toString() { + return "EMPTY"; + } + + @Override + public boolean equals(Object obj) { + return obj == this; + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + return new JsonPrimitive("minecraft:air"); + } + }; + + private int amount; + + public RecipeOutputItem(int amount) { + this.amount = amount; + } + + public int getAmount() { return amount; } + public void setAmount(int amount) { this.amount = amount; } + + @Override + public SpaceRequirement getSpaceRequirement() { + return SpaceRequirement.MATCHING_ITEM; + } + + /** + * Converts a string into a RecipeSingleItem + * @param string A namespace string in the format + *
    + *
  • minecraft:<minecraft_id>
  • + *
  • slimefun:<slimefun_id>
  • + *
+ * @return + */ + public static AbstractRecipeOutputItem fromString(String string) { + if (string == null) { + return RecipeOutputItem.EMPTY; + } + String[] split = string.split(":"); + if (split.length != 2) { + return RecipeOutputItem.EMPTY; + } + String[] pipeSplit = split[1].split("\\|"); + String namespace = split[0]; + String id = pipeSplit[0]; + int amount = 1; + if (pipeSplit.length > 1) { + amount = Integer.parseInt(pipeSplit[1]); + } + if (namespace.startsWith("#")) { + // Is a tag + Optional> tag = RecipeUtils.tagFromString(namespace.substring(1), id); + if (tag.isPresent()) { + return new RecipeOutputTag(tag.get(), amount); + } + return RecipeOutputItem.EMPTY; + } + if (namespace.equals("minecraft")) { + Material mat = Material.matchMaterial(id); + return mat == null ? RecipeOutputItem.EMPTY : new RecipeOutputItemStack(mat, amount); + } else if (namespace.equals("slimefun")) { + return new RecipeOutputSlimefunItem(id.toUpperCase(), amount); + } + return RecipeOutputItem.EMPTY; + } + + public static AbstractRecipeOutputItem fromItemStack(ItemStack item, int amount) { + if (item == null || item.getType().isAir()) { + return RecipeOutputItem.EMPTY; + } + + if (item instanceof SlimefunItemStack sfItem) { + return new RecipeOutputSlimefunItem(sfItem.getItemId(), amount); + } + + // The item might not have been registered yet and we only need the id, so no need for `getByItem()` + Optional itemID = Slimefun.getItemDataService().getItemData(item); + + if (itemID.isPresent()) { + return new RecipeOutputSlimefunItem(itemID.get(), amount); + } else { + return new RecipeOutputItemStack(item, amount); + } + } + public static AbstractRecipeOutputItem fromItemStack(ItemStack item) { + if (item == null || item.getType().isAir()) { + return RecipeOutputItem.EMPTY; + } + return fromItemStack(item, item.getAmount()); + } + + @Override + public String toString() { + return "amount=" + amount; + } + + @Override + public boolean canUseShortSerialization() { + return true; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputItemStack.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputItemStack.java new file mode 100644 index 0000000000..d9c8da0f27 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputItemStack.java @@ -0,0 +1,86 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.utils.SlimefunUtils; + +public class RecipeOutputItemStack extends RecipeOutputItem { + + private ItemStack template; + + public RecipeOutputItemStack(ItemStack template, int amount) { + super(amount); + this.template = template; + } + + public RecipeOutputItemStack(ItemStack template) { + this(template, template.getAmount()); + } + + public RecipeOutputItemStack(Material template, int amount) { + super(amount); + this.template = new ItemStack(template, amount); + } + + public RecipeOutputItemStack(Material template) { + this(template, 1); + } + + + public ItemStack getTemplate() { + return template; + } + + @Override + public ItemStack generateOutput(RecipeMatchResult result) { + return template.clone(); + } + + @Override + public boolean matchItem(ItemStack item) { + return SlimefunUtils.isItemSimilar(item, template, true); + } + + @Override + public String toString() { + return "ROItemStack { " + template + ", " + super.toString() + " }"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeOutputItemStack item = (RecipeOutputItemStack) obj; + return item.template.equals(template) && + item.getAmount() == getAmount(); + } + + @Override + public boolean canUseShortSerialization() { + return super.canUseShortSerialization() && !template.hasItemMeta(); + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + if (canUseShortSerialization()) { + return new JsonPrimitive(template.getType().getKey() + (getAmount() != 1 ? "|" + getAmount() : "")); + } + + JsonObject item = new JsonObject(); + item.addProperty("id", template.getType().getKey().toString()); + if (getAmount() != 1) { + item.addProperty("amount", getAmount()); + } + return item; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputSlimefunItem.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputSlimefunItem.java new file mode 100644 index 0000000000..b9495ff290 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputSlimefunItem.java @@ -0,0 +1,75 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; +import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItemStack; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.utils.SlimefunUtils; + +public class RecipeOutputSlimefunItem extends RecipeOutputItem { + + private String slimefunId; + + public RecipeOutputSlimefunItem(String slimefunId, int amount) { + super(amount); + this.slimefunId = slimefunId; + } + + public RecipeOutputSlimefunItem(String slimefunId) { + this(slimefunId, 1); + } + + public RecipeOutputSlimefunItem(SlimefunItemStack sfItemStack) { + this(sfItemStack.getItemId(), sfItemStack.getAmount()); + } + + public String getSlimefunId() { return slimefunId; } + public void setSlimefunId(String slimefunId) { this.slimefunId = slimefunId; } + + @Override + public boolean matchItem(ItemStack item) { + return SlimefunUtils.isItemSimilar(item, SlimefunItem.getById(slimefunId).getItem(), true); + } + + @Override + public ItemStack generateOutput(RecipeMatchResult result) { + return SlimefunItem.getById(slimefunId).getItem().clone(); + } + + @Override + public String toString() { + return "ROSlimefunItem { id=" + slimefunId + ", " + super.toString() + " }"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeOutputSlimefunItem item = (RecipeOutputSlimefunItem) obj; + return item.slimefunId.equals(slimefunId) && + item.getAmount() == getAmount(); + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + if (canUseShortSerialization()) { + return new JsonPrimitive("slimefun:" + slimefunId.toLowerCase() + (getAmount() != 1 ? "|" + getAmount() : "")); + } + + JsonObject item = new JsonObject(); + item.addProperty("id", "slimefun:" + slimefunId.toLowerCase()); + if (getAmount() != 1) { + item.addProperty("amount", getAmount()); + } + return item; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputTag.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputTag.java new file mode 100644 index 0000000000..9b6a2f4104 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/items/RecipeOutputTag.java @@ -0,0 +1,82 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.items; + +import java.util.concurrent.ThreadLocalRandom; + +import org.bukkit.Material; +import org.bukkit.Tag; +import org.bukkit.inventory.ItemStack; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; + +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; + +public class RecipeOutputTag extends RecipeOutputItem { + + private Tag tag; + + public RecipeOutputTag(Tag tag, int amount) { + super(amount); + this.tag = tag; + } + + public RecipeOutputTag(Tag tag) { + this(tag, 1); + } + + public Tag getTag() { return tag; } + public void setTag(Tag tag) { this.tag = tag; } + + @Override + public boolean matchItem(ItemStack item) { + return false; + } + + @Override + public ItemStack generateOutput(RecipeMatchResult result) { + Material[] arr = tag.getValues().toArray(Material[]::new); + int i = ThreadLocalRandom.current().nextInt(arr.length); + return new ItemStack(arr[i], getAmount()); + } + + @Override + public String toString() { + return "ROTag { " + tag + ", " + super.toString() + " }"; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (getClass() != obj.getClass()) return false; + + RecipeOutputTag item = (RecipeOutputTag) obj; + return item.tag.getKey().equals(tag.getKey()) && + item.getAmount() == getAmount(); + } + + @Override + public int hashCode() { + int hash = 1; + hash = 31 * hash + tag.getKey().hashCode(); + hash = 31 * hash + getAmount(); + return hash; + } + + @Override + public JsonElement serialize(JsonSerializationContext context) { + if (canUseShortSerialization()) { + return new JsonPrimitive("#" + tag.getKey() + (getAmount() != 1 ? "|" + getAmount() : "")); + } + + JsonObject item = new JsonObject(); + item.addProperty("tag", tag.getKey().toString()); + if (getAmount() != 1) { + item.addProperty("amount", getAmount()); + } + return item; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/CustomRecipeDeserializer.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/CustomRecipeDeserializer.java new file mode 100644 index 0000000000..0b08ddc07c --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/CustomRecipeDeserializer.java @@ -0,0 +1,11 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.json; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonObject; + +@FunctionalInterface +public interface CustomRecipeDeserializer { + + public T deserialize(T base, JsonObject object, JsonDeserializationContext context); + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeInputItemSerDes.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeInputItemSerDes.java new file mode 100644 index 0000000000..81e6b19666 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeInputItemSerDes.java @@ -0,0 +1,82 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.json; + +import java.lang.reflect.Type; +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputGroup; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputTag; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils; + +public final class RecipeInputItemSerDes implements JsonDeserializer, JsonSerializer { + + @Override + public JsonElement serialize(AbstractRecipeInputItem src, Type typeOfSrc, JsonSerializationContext context) { + return src.serialize(context); + } + + public AbstractRecipeInputItem deserialize(JsonElement el, Type type, + JsonDeserializationContext context) throws JsonParseException { + if (el.isJsonPrimitive()) { + return RecipeInputItem.fromString(el.getAsString()); + } + + AbstractRecipeInputItem inputItem; + JsonObject obj = el.getAsJsonObject(); + + if (obj.has("id")) { + AbstractRecipeInputItem aItem = RecipeInputItem.fromString( + obj.getAsJsonPrimitive("id").getAsString()); + if (aItem instanceof RecipeInputItem item) { + int amount = 1; + if (obj.has("amount")) { + amount = obj.getAsJsonPrimitive("amount").getAsInt(); + } + item.setAmount(amount); + item.setAmount(obj.getAsJsonPrimitive("amount").getAsInt()); + if (obj.has("durability")) { + item.setDurabilityCost(obj.getAsJsonPrimitive("durability").getAsInt()); + } + inputItem = item; + } else { + inputItem = aItem; + } + } else if (obj.has("group")) { + inputItem = new RecipeInputGroup(obj.getAsJsonArray("group") + .asList().stream() + .map(e -> deserialize(e, type, context)) + .toList()); + } else { + Optional> tag = RecipeUtils.tagFromString(obj.getAsJsonPrimitive("tag").getAsString()); + inputItem = tag + .map(t -> (AbstractRecipeInputItem) new RecipeInputTag(t, + obj.getAsJsonPrimitive("amount").getAsInt())) + .orElseGet(() -> RecipeInputItem.EMPTY); + } + + if (obj.has("class")) { + String cl = obj.getAsJsonPrimitive("class").getAsString(); + CustomRecipeDeserializer deserializer = Slimefun.getRecipeService() + .getRecipeInputItemDeserializer(NamespacedKey.fromString(cl)); + if (deserializer != null) { + inputItem = deserializer.deserialize(inputItem, obj, context); + } + } + + return inputItem; + } +} \ No newline at end of file diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeInputSerDes.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeInputSerDes.java new file mode 100644 index 0000000000..7369a26aba --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeInputSerDes.java @@ -0,0 +1,91 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.json; + +import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.bukkit.NamespacedKey; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import io.github.thebusybiscuit.slimefun4.api.recipes.AbstractRecipeInput; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeInput; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +public final class RecipeInputSerDes implements JsonDeserializer, JsonSerializer { + + @Override + public JsonElement serialize(AbstractRecipeInput src, Type typeOfSrc, JsonSerializationContext context) { + return src.serialize(context); + } + + public AbstractRecipeInput deserialize(JsonElement el, Type type, + JsonDeserializationContext context) throws JsonParseException { + JsonObject obj = el.getAsJsonObject(); + + String template = ""; + int width = 0; + int height = 0; + JsonElement jsonItems = obj.get("items"); + if (jsonItems.isJsonPrimitive()) { + template = jsonItems.toString(); + width = template.length(); + } else { + // Join the entire string together + List itemsList = jsonItems.getAsJsonArray().asList(); + StringBuilder builder = new StringBuilder(); + width = 1; + height = itemsList.size(); + for (JsonElement item : itemsList) { + String itemString = item.getAsString(); + width = Math.max(width, itemString.length()); + builder.append(itemString); + } + template = builder.toString(); + } + + Map key = new HashMap<>(); + JsonObject jsonKey = obj.getAsJsonObject("key"); + jsonKey.entrySet().forEach(e -> { + key.put(e.getKey().charAt(0), context.deserialize(e.getValue(), AbstractRecipeInputItem.class)); + }); + + MatchProcedure match = obj.has("match") ? Slimefun.getRecipeService().getMatchProcedure( + NamespacedKey.fromString(obj.getAsJsonPrimitive("match").getAsString()) + ) : null; + + AbstractRecipeInput input = new RecipeInput( + template.chars().mapToObj(i -> { + char c = (char) i; // i is a zero-extended char so this is fine + if (key.containsKey(c)) { + return key.get(c); + } + return RecipeInputItem.EMPTY; + }).toList(), + match == null ? MatchProcedure.SHAPED : match, + width, + height + ); + + if (obj.has("class")) { + String cl = obj.getAsJsonPrimitive("class").getAsString(); + CustomRecipeDeserializer deserializer = Slimefun.getRecipeService().getRecipeInputDeserializer(NamespacedKey.fromString(cl)); + if (deserializer != null) { + input = deserializer.deserialize(input, obj, context); + } + } + + return input; + + } +} \ No newline at end of file diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeOutputItemSerDes.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeOutputItemSerDes.java new file mode 100644 index 0000000000..7d3b6ea958 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeOutputItemSerDes.java @@ -0,0 +1,76 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.json; + +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.List; + +import org.bukkit.NamespacedKey; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputGroup; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +public final class RecipeOutputItemSerDes implements JsonDeserializer, JsonSerializer { + + @Override + public JsonElement serialize(AbstractRecipeOutputItem src, Type typeOfSrc, JsonSerializationContext context) { + return src.serialize(context); + } + + public AbstractRecipeOutputItem deserialize(JsonElement el, Type type, + JsonDeserializationContext context) throws JsonParseException { + if (el.isJsonPrimitive()) { + return RecipeOutputItem.fromString(el.getAsString()); + } + + JsonObject obj = el.getAsJsonObject(); + AbstractRecipeOutputItem outputItem; + + if (obj.has("id")) { + AbstractRecipeOutputItem aItem = RecipeOutputItem.fromString( + obj.getAsJsonPrimitive("id").getAsString()); + if (aItem instanceof RecipeOutputItem item) { + item.setAmount(obj.getAsJsonPrimitive("amount").getAsInt()); + outputItem = item; + } else { + outputItem = aItem; + } + } else { + List group = obj.getAsJsonArray("group") + .asList().stream() + .map(e -> deserialize(e, type, context)) + .toList(); + List weights; + if (obj.has("weights")) { + weights = obj.getAsJsonArray("weights") + .asList().stream() + .map(e -> e.getAsInt()) + .toList(); + } else { + int[] arr = new int[group.size()]; + Arrays.fill(arr, 1); + weights = Arrays.stream(arr).boxed().toList(); + } + outputItem = new RecipeOutputGroup(group, weights); + } + + if (obj.has("class")) { + String cl = obj.getAsJsonPrimitive("class").getAsString(); + CustomRecipeDeserializer deserializer = Slimefun.getRecipeService().getRecipeOutputItemDeserializer(NamespacedKey.fromString(cl)); + if (deserializer != null) { + outputItem = deserializer.deserialize(outputItem, obj, context); + } + } + + return outputItem; + } +} \ No newline at end of file diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeOutputSerDes.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeOutputSerDes.java new file mode 100644 index 0000000000..984bddcec7 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeOutputSerDes.java @@ -0,0 +1,48 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.json; + +import java.lang.reflect.Type; + +import org.bukkit.NamespacedKey; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import io.github.thebusybiscuit.slimefun4.api.recipes.AbstractRecipeOutput; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeOutput; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +public final class RecipeOutputSerDes implements JsonDeserializer, JsonSerializer { + + @Override + public JsonElement serialize(AbstractRecipeOutput src, Type typeOfSrc, JsonSerializationContext context) { + return src.serialize(context); + } + + public AbstractRecipeOutput deserialize(JsonElement el, Type type, + JsonDeserializationContext context) throws JsonParseException { + JsonObject obj = el.getAsJsonObject(); + + AbstractRecipeOutput output = new RecipeOutput( + obj.getAsJsonArray("items") + .asList().stream() + .map(e -> (AbstractRecipeOutputItem) context.deserialize(e, AbstractRecipeOutputItem.class)) + .toList() + ); + + if (obj.has("class")) { + String cl = obj.getAsJsonPrimitive("class").getAsString(); + CustomRecipeDeserializer deserializer = Slimefun.getRecipeService().getRecipeOutputDeserializer(NamespacedKey.fromString(cl)); + if (deserializer != null) { + output = deserializer.deserialize(output, obj, context); + } + } + + return output; + } +} \ No newline at end of file diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeSerDes.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeSerDes.java new file mode 100644 index 0000000000..346b2647b3 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/RecipeSerDes.java @@ -0,0 +1,99 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.json; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.bukkit.NamespacedKey; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeInput; +import io.github.thebusybiscuit.slimefun4.api.recipes.AbstractRecipeInput; +import io.github.thebusybiscuit.slimefun4.api.recipes.AbstractRecipeOutput; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeOutput; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeType; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +public final class RecipeSerDes implements JsonDeserializer, JsonSerializer { + + @Override + public JsonElement serialize(Recipe src, Type typeOfSrc, JsonSerializationContext context) { + return src.serialize(context); + } + + public Recipe deserialize(JsonElement el, Type type, + JsonDeserializationContext context) throws JsonParseException { + JsonObject obj = el.getAsJsonObject(); + + Optional id = Optional.empty(); + if (obj.has("id")) { + id = Optional.of(obj.getAsJsonPrimitive("id").getAsString()); + } + String filename = obj.getAsJsonPrimitive("__filename").getAsString(); + AbstractRecipeInput input = RecipeInput.EMPTY; + if (obj.has("input")) { + input = context.deserialize(obj.get("input"), AbstractRecipeInput.class); + } + AbstractRecipeOutput output = RecipeOutput.EMPTY; + if (obj.has("output")) { + output = context.deserialize(obj.get("output"), AbstractRecipeOutput.class); + } + Optional energy = Optional.empty(); + Optional craftingTime = Optional.empty(); + if (obj.has("energy")) { + energy = Optional.of(obj.get("energy").getAsInt()); + } + if (obj.has("crafting-time")) { + craftingTime = Optional.of(obj.get("crafting-time").getAsInt()); + } + List types = new ArrayList<>(); + List perms = new ArrayList<>(); + JsonElement jsonType = obj.get("type"); + if (jsonType.isJsonPrimitive()) { + RecipeType recipeType = RecipeType.fromString(jsonType.getAsString()); + if (recipeType == null) { + Slimefun.logger().warning("Invalid Recipe Type '" + jsonType.getAsString() + "'"); + } else { + types.add(recipeType); + } + } else { + jsonType.getAsJsonArray().forEach(e -> { + RecipeType recipeType = RecipeType.fromString(jsonType.getAsString()); + if (recipeType == null) { + Slimefun.logger().warning("Invalid Recipe Type '" + jsonType.getAsString() + "'"); + } else { + types.add(recipeType); + } + }); + } + if (obj.has("permission-node")) { + JsonElement jsonPerms = obj.get("permission-node"); + if (jsonPerms.isJsonPrimitive()) { + perms.add(jsonType.getAsString()); + } else { + jsonPerms.getAsJsonArray().forEach(e -> perms.add(e.getAsString())); + } + } + + Recipe recipe = new Recipe(id, filename, input, output, types, energy, craftingTime, perms); + + if (obj.has("class")) { + String cl = obj.getAsJsonPrimitive("class").getAsString(); + CustomRecipeDeserializer deserializer = Slimefun.getRecipeService().getRecipeDeserializer(NamespacedKey.fromString(cl)); + if (deserializer != null) { + recipe = deserializer.deserialize(recipe, obj, context); + } + } + + return recipe; + } +} \ No newline at end of file diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/package-info.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/package-info.java new file mode 100644 index 0000000000..32ba2b7155 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/json/package-info.java @@ -0,0 +1,4 @@ +/** + * Contains classes for serializing and deserializing components to and from JSON + */ +package io.github.thebusybiscuit.slimefun4.api.recipes.json; \ No newline at end of file diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/InputMatchResult.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/InputMatchResult.java new file mode 100644 index 0000000000..1ff05b3622 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/InputMatchResult.java @@ -0,0 +1,88 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.matching; + +import java.util.Collections; +import java.util.List; + +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.AbstractRecipeInput; + +public class InputMatchResult { + + private final AbstractRecipeInput input; + private final List itemMatchResults; + private final boolean itemsMatch; + private int possibleCrafts; + + public InputMatchResult(AbstractRecipeInput input, List itemMatchResults, boolean itemsMatch) { + this.input = input; + this.itemMatchResults = itemMatchResults; + this.itemsMatch = itemsMatch; + possibleCrafts = 2147483647; + for (ItemMatchResult res : itemMatchResults) { + ItemStack item = res.getMatchedItem(); + if (item == null || res.getRecipeItem().isEmpty()) { + continue; + } + possibleCrafts = Math.min(possibleCrafts, item.getAmount() / res.getConsumeAmount()); + } + } + + public InputMatchResult(AbstractRecipeInput input, List itemMatchResults) { + this(input, itemMatchResults, itemMatchResults.stream().allMatch(r -> r.itemsMatch())); + } + + public static InputMatchResult noMatch(AbstractRecipeInput input) { + return new InputMatchResult(input, Collections.emptyList()) { + @Override + public boolean itemsMatch() { + return false; + } + }; + } + + public AbstractRecipeInput getInput() { + return input; + } + + public int getPossibleCrafts() { + return possibleCrafts; + } + + /** + * IMPORTANT If itemsMatch() is false, then not all match results may be present + */ + public List getItemMatchResults() { + return Collections.unmodifiableList(itemMatchResults); + } + + public boolean itemsMatch() { + return itemsMatch; + } + + /** + * Consumes the items that were used in matching + * the recipe, based on the amount of input required + * in the recipe. + * @param amount Number of times to consume. May + * be less if there are not enough items. + * @return How many times the inputs were actually consumed + */ + public int consumeItems(int amount) { + if (possibleCrafts == 0 || amount == 0) { + return 0; + } + + int amountToCraft = Math.min(possibleCrafts, amount); + for (ItemMatchResult res : itemMatchResults) { + ItemStack item = res.getMatchedItem(); + if (item == null || res.getRecipeItem().isEmpty()) { + continue; + } + item.setAmount(item.getAmount() - amountToCraft * res.getConsumeAmount()); + } + possibleCrafts -= amountToCraft; + return amountToCraft; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/ItemMatchResult.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/ItemMatchResult.java new file mode 100644 index 0000000000..08dd4d7405 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/ItemMatchResult.java @@ -0,0 +1,52 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.matching; + +import javax.annotation.Nullable; + +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; + +public class ItemMatchResult { + + private final boolean itemsMatch; + private final AbstractRecipeInputItem recipeItem; + private final @Nullable ItemStack matchedItem; + private final int consumeAmount; + private final int durabilityConsumeAmount; + + public ItemMatchResult(boolean itemsMatch, AbstractRecipeInputItem recipeItem, ItemStack matchedItem, int consumeAmount, int durabilityConsumeAmount) { + this.itemsMatch = itemsMatch; + this.recipeItem = recipeItem; + this.matchedItem = matchedItem; + this.consumeAmount = consumeAmount; + this.durabilityConsumeAmount = durabilityConsumeAmount; + } + + public ItemMatchResult(boolean itemsMatch, AbstractRecipeInputItem recipeItem, ItemStack matchedItem, int consumeAmount) { + this(itemsMatch, recipeItem, matchedItem, consumeAmount, 0); + } + + /** + * @return True if the provided items match the recipe items + */ + public boolean itemsMatch() { return itemsMatch; } + /** + * @return The item in the recipe that was being matched to + */ + public AbstractRecipeInputItem getRecipeItem() { return recipeItem; } + /** + * @return The item provided that was being matched + */ + @Nullable + public ItemStack getMatchedItem() { return matchedItem; } + /** + * @return How much of the item to consume when crafting + */ + public int getConsumeAmount() { + return consumeAmount; + } + public int getDurabilityConsumeAmount() { + return durabilityConsumeAmount; + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/MatchProcedure.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/MatchProcedure.java new file mode 100644 index 0000000000..825455a186 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/MatchProcedure.java @@ -0,0 +1,172 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.matching; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.api.recipes.AbstractRecipeInput; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils.BoundingBox; + +public abstract class MatchProcedure { + + /** + * For Recipes used as display/dummy purposes. Will never match anything + */ + public static final MatchProcedure DUMMY = new MatchProcedure("slimefun:dummy") { + @Override + public InputMatchResult match(AbstractRecipeInput recipeInput, List givenItems) { + return InputMatchResult.noMatch(recipeInput); + } + }; + + /** + * For Recipes with empty inputs and whose crafters don't take input items + */ + public static final MatchProcedure EMPTY = new MatchProcedure("slimefun:empty") { + @Override + public InputMatchResult match(AbstractRecipeInput recipeInput, List givenItems) { + return new InputMatchResult(recipeInput, Collections.emptyList(), true); + } + }; + + public static final MatchProcedure SHAPED = new MatchProcedure("slimefun:shaped") { + @Override + public InputMatchResult match(AbstractRecipeInput recipeInput, List givenItems) { + final int width = recipeInput.getWidth(); + final int height = recipeInput.getHeight(); + + Optional oRecipeBox = recipeInput.getBoundingBox(); + if (oRecipeBox.isEmpty()) { + return InputMatchResult.noMatch(recipeInput); + } + BoundingBox recipeBox = oRecipeBox.get(); + BoundingBox givenBox = RecipeUtils.calculateBoundingBox(givenItems, width, height, i -> i == null || i.getType().isAir()); + + if (!recipeBox.isSameShape(givenBox)) { + return InputMatchResult.noMatch(recipeInput); + } + + List results = new ArrayList<>(); + for (int i = 0; i < recipeBox.getWidth() * recipeBox.getHeight(); i++) { + int recipeX = recipeBox.left + i % recipeBox.getWidth(); + int recipeY = recipeBox.top + i / recipeBox.getWidth(); + int givenX = givenBox.left + i % givenBox.getWidth(); + int givenY = givenBox.top + i / givenBox.getWidth(); + AbstractRecipeInputItem recipeItem = recipeInput.getItem(recipeY * width + recipeX); + ItemStack givenItem = givenItems.get(givenY * width + givenX); + ItemMatchResult matchResult = recipeItem.matchItem(givenItem); + results.add(matchResult); + if (!matchResult.itemsMatch()) { + return new InputMatchResult(recipeInput, results, false); + } + } + return new InputMatchResult(recipeInput, results, true); + } + }; + + public static final MatchProcedure SHAPED_FLIPPABLE = new MatchProcedure("slimefun:shaped_flippable") { + @Override + public InputMatchResult match(AbstractRecipeInput recipeInput, List givenItems) { + InputMatchResult result = SHAPED.match(recipeInput, givenItems); + if (result.itemsMatch()) { + return result; + } + // Flip given items + List flipped = RecipeUtils.flipY(givenItems, recipeInput.getWidth(), recipeInput.getHeight()); + return SHAPED.match(recipeInput, flipped); + } + }; + + public static final MatchProcedure SHAPED_ROTATABLE_45_3X3 = new MatchProcedure("slimefun:shaped_rotatable_3x3") { + @Override + public InputMatchResult match(AbstractRecipeInput recipeInput, List givenItems) { + InputMatchResult result = null; + for (int i = 0; i < 8; i++) { + result = SHAPED.match(recipeInput, givenItems); + if (result.itemsMatch() || i == 7) { + return result; + } + givenItems = RecipeUtils.rotate45deg3x3(givenItems); + } + return result; + } + }; + + public static final MatchProcedure SUBSET = new MatchProcedure("slimefun:subset") { + @Override + public InputMatchResult match(AbstractRecipeInput recipeInput, List givenItems) { + if ( + recipeInput.getItems().stream().filter(i -> !i.isEmpty()).count() > + givenItems.stream().filter(i -> i != null && !i.getType().isAir()).count() + ) { + return InputMatchResult.noMatch(recipeInput); + } + Map matchedItems = new HashMap<>(); + outer: for (AbstractRecipeInputItem recipeItem : recipeInput.getItems()) { + if (recipeItem.isEmpty()) { + continue; + } + for (int i = 0; i < givenItems.size(); i++) { + if (matchedItems.containsKey(i)) { + continue; + } + ItemMatchResult result = recipeItem.matchItem(givenItems.get(i)); + if (result.itemsMatch()) { + matchedItems.put(i, result); + continue outer; + } + } + + return new InputMatchResult(recipeInput, List.copyOf(matchedItems.values()), false); + } + + return new InputMatchResult(recipeInput, List.copyOf(matchedItems.values()), true); + } + }; + + public static final MatchProcedure SHAPELESS = new MatchProcedure("slimefun:shapeless") { + @Override + public InputMatchResult match(AbstractRecipeInput recipeInput, List givenItems) { + if ( + recipeInput.getItems().stream().filter(i -> !i.isEmpty()).count() != + givenItems.stream().filter(i -> i != null && !i.getType().isAir()).count() + ) { + return InputMatchResult.noMatch(recipeInput); + } + return SUBSET.match(recipeInput, givenItems); + } + }; + + private final NamespacedKey key; + + public MatchProcedure(String key) { + this.key = NamespacedKey.fromString(key); + } + + public NamespacedKey getKey() { + return key; + } + + public abstract InputMatchResult match(AbstractRecipeInput recipeInput, List givenItems); + public InputMatchResult match(AbstractRecipeInput recipeInput, ItemStack[] givenItems) { + return match(recipeInput, List.of(givenItems)); + } + + public boolean recipeShouldSaveBoundingBox() { + return true; + } + + @Override + public String toString() { + return key.toString(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/RecipeMatchResult.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/RecipeMatchResult.java new file mode 100644 index 0000000000..6a97583b4b --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/RecipeMatchResult.java @@ -0,0 +1,37 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.matching; + +import java.util.List; +import java.util.Optional; + +import org.bukkit.inventory.Inventory; + +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; + +public class RecipeMatchResult { + + private final Recipe recipe; + private final InputMatchResult inputMatchResult; + private final Optional craftingInventory; + private final Optional> outputSlots; + + public RecipeMatchResult(Recipe recipe, InputMatchResult inputMatchResult, Optional craftingInventory, Optional> outputSlots) { + this.recipe = recipe; + this.inputMatchResult = inputMatchResult; + this.craftingInventory = craftingInventory; + this.outputSlots = outputSlots; + } + + public RecipeMatchResult(Recipe recipe, InputMatchResult inputMatchResult) { + this(recipe, inputMatchResult, Optional.empty(), Optional.empty()); + } + + public Recipe getRecipe() { return recipe; } + public InputMatchResult getInputMatchResult() { return inputMatchResult; } + public Optional getCraftingInventory() { return craftingInventory; } + public Optional> getOutputSlots() { return outputSlots; } + + public boolean itemsMatch() { + return inputMatchResult.itemsMatch(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/RecipeSearchResult.java b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/RecipeSearchResult.java new file mode 100644 index 0000000000..bccf1efda3 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/api/recipes/matching/RecipeSearchResult.java @@ -0,0 +1,34 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes.matching; + +import java.util.Optional; + +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; + +public class RecipeSearchResult { + + private final boolean matchFound; + private final Optional result; + + public RecipeSearchResult(RecipeMatchResult result) { + this.matchFound = result.itemsMatch(); + this.result = Optional.of(result); + } + + public RecipeSearchResult() { + this.matchFound = false; + this.result = Optional.empty(); + } + + public boolean matchFound() { + return matchFound; + } + + public Optional getResult() { + return result; + } + + public Optional getRecipe() { + return result.map(res -> res.getRecipe()); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/commands/subcommands/RecipeCommand.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/commands/subcommands/RecipeCommand.java new file mode 100644 index 0000000000..fcde7b9c3b --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/commands/subcommands/RecipeCommand.java @@ -0,0 +1,81 @@ +package io.github.thebusybiscuit.slimefun4.core.commands.subcommands; + +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import io.github.thebusybiscuit.slimefun4.core.commands.SlimefunCommand; +import io.github.thebusybiscuit.slimefun4.core.commands.SubCommand; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +class RecipeCommand extends SubCommand { + + @ParametersAreNonnullByDefault + RecipeCommand(Slimefun plugin, SlimefunCommand cmd) { + super(plugin, cmd, "recipe", false); + } + + @Override + public void onExecute(CommandSender sender, String[] args) { + if (sender.hasPermission("slimefun.recipe.reload") && sender instanceof Player) { + Slimefun.getLocalization().sendMessage(sender, "messages.no-permission", true); + } + + if (args.length == 1) { + Slimefun.getLocalization().sendMessage(sender, "messages.usage", true, msg -> msg.replace("%usage%", "/sf recipe ")); + } + + switch (args[1]) { + case "reload": + if (args.length == 2) { + Slimefun.getRecipeService().loadAllRecipes(); + } else { + for (int i = 2; i < args.length; i++) { + Slimefun.getRecipeService().loadRecipesFromFile(args[i]); + } + } + break; + case "save": + if (args.length != 2) { + Slimefun.getLocalization().sendMessage(sender, "messages.usage", true, msg -> msg.replace("%usage%", "/sf recipe save")); + break; + } + Slimefun.getRecipeService().saveAllRecipes(); + break; + case "backup": + if (args.length != 2) { + Slimefun.getLocalization().sendMessage(sender, "messages.usage", true, msg -> msg.replace("%usage%", "/sf recipe backup")); + break; + } + Slimefun.getRecipeService().saveAllRecipes(); + Slimefun.getRecipeService().backUpRecipeFiles(); + break; + case "restore_backup": + if (args.length != 2) { + Slimefun.getLocalization().sendMessage(sender, "messages.usage", true, msg -> msg.replace("%usage%", "/sf recipe restore_backup")); + break; + } + Slimefun.getRecipeService().restoreBackupRecipeFiles(); + Slimefun.getRecipeService().loadAllRecipes(); + break; + case "clear": + if (args.length != 2) { + Slimefun.getLocalization().sendMessage(sender, "messages.usage", true, msg -> msg.replace("%usage%", "/sf recipe clear")); + break; + } + Slimefun.getRecipeService().clear(); + break; + case "delete": + if (args.length != 2) { + Slimefun.getLocalization().sendMessage(sender, "messages.usage", true, msg -> msg.replace("%usage%", "/sf recipe delete")); + break; + } + Slimefun.getRecipeService().deleteRecipeFiles(); + break; + default: + Slimefun.getLocalization().sendMessage(sender, "messages.usage", true, msg -> msg.replace("%usage%", "/sf recipe ")); + break; + } + } +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/commands/subcommands/SlimefunSubCommands.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/commands/subcommands/SlimefunSubCommands.java index 17d70bce3e..41ffc5b5f1 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/core/commands/subcommands/SlimefunSubCommands.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/commands/subcommands/SlimefunSubCommands.java @@ -42,6 +42,7 @@ public static Collection getAllCommands(@Nonnull SlimefunCommand cmd commands.add(new BackpackCommand(plugin, cmd)); commands.add(new ChargeCommand(plugin, cmd)); commands.add(new DebugCommand(plugin, cmd)); + commands.add(new RecipeCommand(plugin, cmd)); return commands; } diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/RecipeService.java b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/RecipeService.java new file mode 100644 index 0000000000..b444839331 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/core/services/RecipeService.java @@ -0,0 +1,549 @@ +package io.github.thebusybiscuit.slimefun4.core.services; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.stream.JsonWriter; + +import io.github.thebusybiscuit.slimefun4.api.recipes.AbstractRecipeInput; +import io.github.thebusybiscuit.slimefun4.api.recipes.AbstractRecipeOutput; +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeType; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeInputSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.CustomRecipeDeserializer; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeInputItemSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeOutputSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeOutputItemSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeMatchResult; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.RecipeSearchResult; +import io.github.thebusybiscuit.slimefun4.utils.RecipeUtils; + +public class RecipeService { + + public static final String SAVED_RECIPE_DIR = "plugins/Slimefun/recipes/"; + public static final String BACKUP_RECIPE_DIR = "plugins/Slimefun/recipe-backups/"; + + private Plugin plugin; + private Gson gson; + + private final Map> customRIItemDeserializers = new HashMap<>(); + private final Map> customRInputDeserializers = new HashMap<>(); + private final Map> customROItemDeserializers = new HashMap<>(); + private final Map> customROutputDeserializers = new HashMap<>(); + private final Map> customRecipeDeserializers = new HashMap<>(); + + private final Map matchProcedures = new HashMap<>(); + + private final Map emptyItems = new HashMap<>(); + + private final Map recipesById = new HashMap<>(); + // This map allows loading and saving from JSON files + private final Map> recipesByFilename = new HashMap<>(); + // This holds the names of json files read in, it helps differentiates between + // entries in `recipesByFilename` existing because it was read in from a file, + // vs if the recipe was added directly in code. + private final Set filesRead = new HashSet<>(); + // This map facilitates searching through recipe with a certain RecipeType + private final Map> recipesByType = new HashMap<>(); + + private final Set recipeOverrides = new HashSet<>(); + + private int maxCacheEntries = 1000; + private boolean allRecipesLoaded = false; + + private final Map recipeCache = new LinkedHashMap<>() { + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > maxCacheEntries; + }; + }; + + public RecipeService(@Nonnull Plugin plugin) { + this.plugin = plugin; + + registerMatchProcedure(MatchProcedure.SHAPED); + registerMatchProcedure(MatchProcedure.SHAPED_FLIPPABLE); + registerMatchProcedure(MatchProcedure.SHAPED_ROTATABLE_45_3X3); + registerMatchProcedure(MatchProcedure.SHAPELESS); + registerMatchProcedure(MatchProcedure.SUBSET); + + this.gson = new GsonBuilder() + .setPrettyPrinting() + .excludeFieldsWithoutExposeAnnotation() + .registerTypeAdapter(Recipe.class, new RecipeSerDes()) + .registerTypeAdapter(AbstractRecipeInput.class, new RecipeInputSerDes()) + .registerTypeAdapter(AbstractRecipeOutput.class, new RecipeOutputSerDes()) + .registerTypeAdapter(AbstractRecipeInputItem.class, new RecipeInputItemSerDes()) + .registerTypeAdapter(AbstractRecipeOutputItem.class, new RecipeOutputItemSerDes()) + .create(); + + try (BufferedReader reader = new BufferedReader(new FileReader("plugins/Slimefun/recipe-overrides"))) { + String line = reader.readLine(); + while (line != null) { + recipeOverrides.add(line); + line = reader.readLine(); + } + } catch (IOException e) { + plugin.getLogger().warning("Could not load recipe overrides: " + e.getLocalizedMessage()); + } finally { + allRecipesLoaded = true; + } + } + + /** + * Adds a recipe override + * @return If the override was applied or not + */ + public boolean addRecipeOverride(String override, String... filenames) { + if (allRecipesLoaded) { + plugin.getLogger().warning("Recipes were already loaded, so the recipe override '" + override + "' was not processed!"); + return false; + } + if (recipeOverrides.contains(override)) { + return false; + } + for (String filename : filenames) { + File file = new File(SAVED_RECIPE_DIR + filename + ".json"); + if (file.isFile()) { + try { + boolean deleted = file.delete(); + if (!deleted) { + plugin.getLogger().severe("Could not delete file '" + filename + "' for recipe override '" + override + "'"); + return false; + } + } catch (Exception e) { + plugin.getLogger().severe("An error occurred when applying recipe override '" + override + "' to file '" + filename + "': " + e.getLocalizedMessage()); + return false; + }; + } else { + plugin.getLogger().warning("Skipping file '" + filename + "' for recipe override '" + override + "' because it is a directory"); + } + } + recipeOverrides.add(override); + return true; + } + + public void registerMatchProcedure(MatchProcedure m) { + matchProcedures.put(m.getKey(), m); + } + + @Nonnull + public Set getRecipesByType(RecipeType type) { + Set set = recipesByType.get(type); + return set == null ? Collections.emptySet() : Collections.unmodifiableSet(set); + } + /** + * You shouldn't call this directly, call recipe.addRecipeType(type) instead + */ + public void addRecipeToType(Recipe recipe, RecipeType type) { + if (!recipesByType.containsKey(type)) { + recipesByType.put(type, new HashSet<>()); + } + recipesByType.get(type).add(recipe); + } + + @Nullable + public Recipe getRecipe(String id) { + return recipesById.get(id); + } + + /** + * Registers a recipe in the service. Ideally recipes should be defined + * in a JSON file in the resources directory of your plugin. + * @param recipe Recipe to add + * @param forceId Override the recipe with the same id, if it exists + * @param forceFilename If file was already read, add this recipe to + * the list anyways. + */ + public void addRecipe(Recipe recipe, boolean forceId, boolean forceFilename) { + // Check id conflicts, add to id map + if (recipe.getId().isPresent()) { + String id = recipe.getId().get(); + if (recipesById.containsKey(id) && !forceId) { + plugin.getLogger().warning("A recipe with id " + id + " already exists!"); + } else { + if (forceId && recipesById.containsKey(id)) { + Recipe old = recipesById.get(id); + removeRecipeFromFilename(old); + removeRecipeFromTypes(old); + recipeCache.clear(); + } + recipesById.put(id, recipe); + } + } + + // Add to file map + if (!recipesByFilename.containsKey(recipe.getFilename())) { + // We want to preserve the order the recipes are + // listed in the file, for consistency. + Set newList = new LinkedHashSet<>(); + newList.add(recipe); + recipesByFilename.put(recipe.getFilename(), newList); + } else if (forceFilename || !filesRead.contains(recipe.getFilename())) { + // If we have already loaded the recipe file with this filename, + // Then we don't want to modify it (or else we get duplicate recipes) + recipesByFilename.get(recipe.getFilename()).add(recipe); + } + + // Add to type map + recipe.getTypes().forEach(type -> addRecipeToType(recipe, type)); + } + /** + * Registers a recipe in the service. + * @param recipe Recipe to register + */ + public void addRecipe(Recipe recipe) { + addRecipe(recipe, false, false); + } + + /** + * Internal utility method for removing old recipes from the id map when a recipe is to be deleted + */ + private void removeRecipeFromId(Recipe recipe) { + if (recipe.getId().isPresent()) { + recipesById.remove(recipe.getId().get()); + } + } + /** + * Internal utility method for removing old recipes from the type map when a recipe is to be deleted + */ + private void removeRecipeFromTypes(Recipe recipe) { + for (RecipeType type : recipe.getTypes()) { + recipesByType.get(type).remove(recipe); + } + } + /** + * Internal utility method for removing old recipes from the file map when a recipe is to be deleted + */ + private void removeRecipeFromFilename(Recipe recipe) { + recipesByFilename.get(recipe.getFilename()).remove(recipe); + } + + public Set getRecipesByFilename(String filename) { + Set set = recipesByFilename.get(filename); + return set == null ? Collections.emptySet() : Collections.unmodifiableSet(set); + } + + @Nullable + public Recipe getCachedRecipe(List givenItems) { + return recipeCache.get(RecipeUtils.hashItemsIgnoreAmount(givenItems)); + } + @Nullable + public Recipe getCachedRecipe(int hash) { + return recipeCache.get(hash); + } + public void cacheRecipe(Recipe recipe, List givenItems) { + cacheRecipe(recipe, RecipeUtils.hashItemsIgnoreAmount(givenItems)); + } + public void cacheRecipe(Recipe recipe, int hash) { + recipeCache.put(hash, recipe); + } + + public RecipeSearchResult searchRecipes(RecipeType type, Function recipeIsMatch, int hash) { + Recipe cachedRecipe = getCachedRecipe(hash); + // Sanity check + if (cachedRecipe != null && cachedRecipe.getTypes().contains(type)) { + RecipeMatchResult result = recipeIsMatch.apply(cachedRecipe); + if (result.itemsMatch()) { + return new RecipeSearchResult(result); + } + } + Set recipes = getRecipesByType(type); + for (Recipe recipe : recipes) { + RecipeMatchResult matchResult = recipeIsMatch.apply(recipe); + if (matchResult.itemsMatch()) { + cacheRecipe(recipe, hash); + return new RecipeSearchResult(matchResult); + } + } + return new RecipeSearchResult(); + } + public RecipeSearchResult searchRecipes(RecipeType type, List givenItems, MatchProcedure matchAs) { + return searchRecipes(type, recipe -> recipe.matchAs(matchAs, givenItems), RecipeUtils.hashItemsIgnoreAmount(givenItems)); + } + public RecipeSearchResult searchRecipes(RecipeType type, List givenItems) { + return searchRecipes(type, recipe -> recipe.match(givenItems), RecipeUtils.hashItemsIgnoreAmount(givenItems)); + } + + public RecipeSearchResult searchRecipes(Collection types, Function recipeIsMatch, int hash) { + for (RecipeType type : types) { + RecipeSearchResult result = searchRecipes(type, recipeIsMatch, hash); + if (result.matchFound()) { + return result; + } + } + return new RecipeSearchResult(); + } + public RecipeSearchResult searchRecipes(Collection types, List givenItems, MatchProcedure matchAs) { + return searchRecipes(types, recipe -> recipe.matchAs(matchAs, givenItems), RecipeUtils.hashItemsIgnoreAmount(givenItems)); + } + public RecipeSearchResult searchRecipes(Collection types, List givenItems) { + return searchRecipes(types, recipe -> recipe.match(givenItems), RecipeUtils.hashItemsIgnoreAmount(givenItems)); + } + + @Nullable + public MatchProcedure getMatchProcedure(@Nonnull NamespacedKey key) { + return matchProcedures.get(key); + } + /** + * Registers another match procedure if one with key key doesn't + * already exist. Used when deserializing recipes from json + * + * @return If the procedure was successfully added + */ + public boolean registerMatchProcedure(NamespacedKey key, MatchProcedure match) { + if (matchProcedures.containsKey(key)) { + return false; + } + matchProcedures.put(key, match); + return true; + } + + public ItemStack getEmptyItem(String id) { + return emptyItems.get(id); + } + public void addEmptyItem(String id, ItemStack empty) { + emptyItems.put(id, empty); + } + + public CustomRecipeDeserializer getRecipeInputItemDeserializer(@Nonnull NamespacedKey key) { + return customRIItemDeserializers.get(key); + } + public CustomRecipeDeserializer getRecipeInputDeserializer(@Nonnull NamespacedKey key) { + return customRInputDeserializers.get(key); + } + public CustomRecipeDeserializer getRecipeOutputItemDeserializer(@Nonnull NamespacedKey key) { + return customROItemDeserializers.get(key); + } + public CustomRecipeDeserializer getRecipeOutputDeserializer(@Nonnull NamespacedKey key) { + return customROutputDeserializers.get(key); + } + public CustomRecipeDeserializer getRecipeDeserializer(@Nonnull NamespacedKey key) { + return customRecipeDeserializers.get(key); + } + @ParametersAreNonnullByDefault + public void addRecipeInputItemDeserializer(NamespacedKey key, CustomRecipeDeserializer des) { + customRIItemDeserializers.put(key, des); + } + @ParametersAreNonnullByDefault + public void addRecipeInputDeserializer(NamespacedKey key, CustomRecipeDeserializer des) { + customRInputDeserializers.put(key, des); + } + @ParametersAreNonnullByDefault + public void addRecipeOutputItemDeserializer(NamespacedKey key, CustomRecipeDeserializer des) { + customROItemDeserializers.put(key, des); + } + @ParametersAreNonnullByDefault + public void addRecipeOutputDeserializer(NamespacedKey key, CustomRecipeDeserializer des) { + customROutputDeserializers.put(key, des); + } + @ParametersAreNonnullByDefault + public void addRecipeDeserializer(NamespacedKey key, CustomRecipeDeserializer des) { + customRecipeDeserializers.put(key, des); + } + + public Recipe parseRecipeString(String s) { + return gson.fromJson(s, Recipe.class); + } + + private Set getAllRecipeFilenames(String directory, String subdirectory) { + Path dir = Path.of(directory, subdirectory); + if (!dir.toFile().exists()) { + return Collections.emptySet(); + } + try (Stream files = Files.walk(dir)) { + return files + .filter(f -> f.toString().endsWith(".json")) + .map(file -> { + String filename = dir.relativize(file).toString(); + return filename.substring(0, filename.length() - 5); + }) + .collect(Collectors.toSet()); + } catch (Exception e) { + return Collections.emptySet(); + } + } + + /** + * @return The list of all recipe files in /plugins/Slimefun/recipes/[subdirectory] + * with the .json removed + */ + public Set getAllRecipeFilenames(String subdirectory) { + return getAllRecipeFilenames(SAVED_RECIPE_DIR, subdirectory); + } + + /** + * @return The list of all recipe files in /plugins/Slimefun/recipes + * directory, with the .json removed + */ + public Set getAllRecipeFilenames() { + return getAllRecipeFilenames(""); + } + + public void loadAllRecipes() { + getAllRecipeFilenames().forEach(this::loadRecipesFromFile); + allRecipesLoaded = true; + } + + /** + * Gets a recipe from a json file + * + * @param filename Filename WITHOUT .json + */ + public List loadRecipesFromFile(String filename) { + return loadRecipesFromFile(filename, gson); + } + + /** + * Gets a recipe from a json file + * + * @param filename Filename WITHOUT .json + * @param gson The instance of gson to use + */ + public List loadRecipesFromFile(String filename, Gson gson) { + List recipes = new ArrayList<>(); + if (recipesByFilename.containsKey(filename)) { + for (Recipe recipe : recipesByFilename.get(filename)) { + removeRecipeFromId(recipe); + removeRecipeFromTypes(recipe); + recipeCache.clear(); + } + recipesByFilename.get(filename).clear(); + } + try (FileReader fileReader = new FileReader(new File(SAVED_RECIPE_DIR + filename + ".json"))) { + JsonElement obj = gson.fromJson(fileReader, JsonElement.class); + if (obj.isJsonArray()) { + JsonArray jsonRecipes = obj.getAsJsonArray(); + recipes = new ArrayList<>(); + for (JsonElement jsonRecipe : jsonRecipes) { + JsonObject recipe = jsonRecipe.getAsJsonObject(); + recipe.addProperty("__filename", filename); + recipes.add(gson.fromJson(recipe, Recipe.class)); + } + } else { + JsonObject recipe = obj.getAsJsonObject(); + recipe.addProperty("__filename", filename); + recipes.add(gson.fromJson(obj, Recipe.class)); + } + filesRead.add(filename); + } catch (IOException e) { + plugin.getLogger().warning("Could not load recipe file '" + filename + "': " + e.getLocalizedMessage()); + recipes = Collections.emptyList(); + } catch (NullPointerException e) { + plugin.getLogger().warning("Could not load recipe file '" + filename + "': " + e.getLocalizedMessage()); + recipes = Collections.emptyList(); + } + + recipes.forEach(r -> addRecipe(r, true, true)); + return recipes; + } + + public boolean areAllRecipesLoaded() { + return allRecipesLoaded; + } + + public void saveAllRecipes() { + for (Map.Entry> entry : recipesByFilename.entrySet()) { + String filename = entry.getKey(); + Set recipes = entry.getValue(); + try (Writer writer = new FileWriter(SAVED_RECIPE_DIR + filename + ".json")) { + JsonWriter jsonWriter = gson.newJsonWriter(writer); + jsonWriter.setIndent(" "); + if (recipes.size() == 1) { + gson.toJson(recipes.stream().findFirst().get(), Recipe.class, jsonWriter); + } else { + gson.toJson(recipes, List.class, jsonWriter); + } + } catch (Exception e) { + plugin.getLogger().warning("Couldn't save recipe to '" + filename + "': " + e.getLocalizedMessage()); + } + } + } + + private void copyRecipeFiles(String sourceDir, String targetDir, boolean clean) { + try (Stream target = Files.list(Path.of(targetDir))) { + Set filenames = getAllRecipeFilenames(sourceDir, ""); + target.forEach(p -> { + if (clean || filenames.contains(Path.of(targetDir).relativize(p).toString())) { + p.toFile().delete(); + } + }); + + getAllRecipeFilenames(sourceDir, "").forEach(source -> { + Path destination = Paths.get(targetDir, source + ".json"); + Path parent = destination.getParent(); + if (parent != null && !parent.toFile().exists()) { + parent.toFile().mkdirs(); + } + try { + Files.copy(Path.of(sourceDir, source + ".json"), destination); + } catch (IOException e) { + plugin.getLogger().warning("Couldn't copy recipe from '" + source + "' to '" + targetDir + "'"); + } + }); + } catch (Exception e) { + plugin.getLogger().warning("Couldn't copy recipes from '" + sourceDir + "' to '" + targetDir + "'"); + } + } + + public void backUpRecipeFiles() { + copyRecipeFiles(SAVED_RECIPE_DIR, BACKUP_RECIPE_DIR, true); + } + + public void restoreBackupRecipeFiles() { + copyRecipeFiles(BACKUP_RECIPE_DIR, SAVED_RECIPE_DIR, false); + } + + public void clear() { + recipesByFilename.clear(); + recipesById.clear(); + recipesByType.clear(); + filesRead.clear(); + recipeCache.clear(); + } + + public void deleteRecipeFiles() { + try (Stream target = Files.list(Path.of(SAVED_RECIPE_DIR))) { + target.forEach(p -> p.toFile().delete()); + } catch (Exception e) { + plugin.getLogger().warning("Couldn't delete recipe files"); + } + } + +} 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 1be851ba17..67932cda6a 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/Slimefun.java @@ -53,6 +53,7 @@ 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.RecipeService; 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; @@ -181,7 +182,8 @@ public class Slimefun extends JavaPlugin implements SlimefunAddon { private final BackupService backupService = new BackupService(); private final PermissionsService permissionsService = new PermissionsService(this); private final PerWorldSettingsService worldSettingsService = new PerWorldSettingsService(this); - private final MinecraftRecipeService recipeService = new MinecraftRecipeService(this); + private final MinecraftRecipeService minecraftRecipeService = new MinecraftRecipeService(this); + private final RecipeService recipeService = new RecipeService(this); private final HologramsService hologramsService = new HologramsService(this); private final SoundService soundService = new SoundService(this); private final ThreadService threadService = new ThreadService(this); @@ -344,6 +346,14 @@ private void onPluginStart() { logger.log(Level.INFO, "Registering listeners..."); registerListeners(); + // Back up recipes + logger.log(Level.INFO, "Backing up recipes..."); + recipeService.backUpRecipeFiles(); + + // Copy all recipes in the resources dir + logger.log(Level.INFO, "Copying default recipes..."); + copyResourceRecipes(); + // Initiating various Stuff and all items with a slight delay (0ms after the Server finished loading) runSync(new SlimefunStartupTask(this, () -> { textureService.register(registry.getAllSlimefunItems(), true); @@ -352,7 +362,7 @@ private void onPluginStart() { // This try/catch should prevent buggy Spigot builds from blocking item loading try { - recipeService.refresh(); + minecraftRecipeService.refresh(); } catch (Exception | LinkageError x) { logger.log(Level.SEVERE, x, () -> "An Exception occurred while iterating through the Recipe list on Minecraft Version " + minecraftVersion.getName() + " (Slimefun v" + getVersion() + ")"); } @@ -448,6 +458,10 @@ public void onDisable() { menu.save(); } + // Save all recipes + Slimefun.logger().info("Saving recipes"); + getRecipeService().saveAllRecipes(); + // Create a new backup zip if (config.getBoolean("options.backup-data")) { backupService.run(); @@ -587,7 +601,7 @@ private boolean isVersionUnsupported() { */ private void createDirectories() { String[] storageFolders = { "Players", "blocks", "stored-blocks", "stored-inventories", "stored-chunks", "universal-inventories", "waypoints", "block-backups" }; - String[] pluginFolders = { "scripts", "error-reports", "cache/github", "world-settings" }; + String[] pluginFolders = { "scripts", "error-reports", "cache/github", "world-settings", "recipes/custom", "recipe-backups" }; for (String folder : storageFolders) { File file = new File("data-storage/Slimefun", folder); @@ -805,6 +819,11 @@ private static void validateInstance() { * @return Slimefun's {@link MinecraftRecipeService} instance */ public static @Nonnull MinecraftRecipeService getMinecraftRecipeService() { + validateInstance(); + return instance.minecraftRecipeService; + } + + public static @Nonnull RecipeService getRecipeService() { validateInstance(); return instance.recipeService; } diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/setup/RecipeSetup.java b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/setup/RecipeSetup.java new file mode 100644 index 0000000000..bef5440f3d --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/setup/RecipeSetup.java @@ -0,0 +1,11 @@ +package io.github.thebusybiscuit.slimefun4.implementation.setup; + +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; + +public final class RecipeSetup { + + public static void setup() { + Slimefun.getRecipeService().loadAllRecipes(); + } + +} diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/setup/SlimefunItemSetup.java b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/setup/SlimefunItemSetup.java index 14575310af..93dc82746d 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/setup/SlimefunItemSetup.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/setup/SlimefunItemSetup.java @@ -23,6 +23,7 @@ import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItem; import io.github.thebusybiscuit.slimefun4.api.items.SlimefunItemStack; import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeType; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; import io.github.thebusybiscuit.slimefun4.core.attributes.Radioactivity; import io.github.thebusybiscuit.slimefun4.core.handlers.RainbowTickHandler; import io.github.thebusybiscuit.slimefun4.core.services.sounds.SoundEffect; @@ -1316,7 +1317,7 @@ public static void setup(@Nonnull Slimefun plugin) { new ItemStack[] {SlimefunItems.GOLD_24K_BLOCK, SlimefunItems.GOLD_24K_BLOCK, SlimefunItems.GOLD_24K_BLOCK, SlimefunItems.GOLD_24K_BLOCK, new ItemStack(Material.APPLE), SlimefunItems.GOLD_24K_BLOCK, SlimefunItems.GOLD_24K_BLOCK, SlimefunItems.GOLD_24K_BLOCK, SlimefunItems.GOLD_24K_BLOCK}) .register(plugin); - new BrokenSpawner(itemGroups.magicalResources, SlimefunItems.BROKEN_SPAWNER, new RecipeType(new NamespacedKey(plugin, "pickaxe_of_containment"), SlimefunItems.PICKAXE_OF_CONTAINMENT), + new BrokenSpawner(itemGroups.magicalResources, SlimefunItems.BROKEN_SPAWNER, new RecipeType(new NamespacedKey(plugin, "pickaxe_of_containment"), SlimefunItems.PICKAXE_OF_CONTAINMENT, MatchProcedure.DUMMY), new ItemStack[] {null, null, null, null, new ItemStack(Material.SPAWNER), null, null, null, null}) .register(plugin); @@ -2242,7 +2243,7 @@ public int getEnergyConsumption() { .setProcessingSpeed(1) .register(plugin); - new SlimefunItem(itemGroups.resources, SlimefunItems.OIL_BUCKET, new RecipeType(new NamespacedKey(plugin, "oil_pump"), SlimefunItems.OIL_PUMP), + new SlimefunItem(itemGroups.resources, SlimefunItems.OIL_BUCKET, new RecipeType(new NamespacedKey(plugin, "oil_pump"), SlimefunItems.OIL_PUMP, MatchProcedure.SUBSET), new ItemStack[] {null, null, null, null, new ItemStack(Material.BUCKET), null, null, null, null}) .register(plugin); diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/tasks/SlimefunStartupTask.java b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/tasks/SlimefunStartupTask.java index 4802161951..7dafd855d3 100644 --- a/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/tasks/SlimefunStartupTask.java +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/implementation/tasks/SlimefunStartupTask.java @@ -12,7 +12,7 @@ import io.github.thebusybiscuit.slimefun4.implementation.listeners.TeleporterListener; import io.github.thebusybiscuit.slimefun4.implementation.listeners.WorldListener; import io.github.thebusybiscuit.slimefun4.implementation.setup.PostSetup; - +import io.github.thebusybiscuit.slimefun4.implementation.setup.RecipeSetup; import me.mrCookieSlime.Slimefun.api.BlockStorage; /** @@ -48,6 +48,10 @@ public void run() { // Load all items PostSetup.loadItems(); + // Load all recipes + Slimefun.logger().info("Loading recipes..."); + RecipeSetup.setup(); + // Load all worlds Slimefun.getWorldSettingsService().load(Bukkit.getWorlds()); diff --git a/src/main/java/io/github/thebusybiscuit/slimefun4/utils/RecipeUtils.java b/src/main/java/io/github/thebusybiscuit/slimefun4/utils/RecipeUtils.java new file mode 100644 index 0000000000..13560eb553 --- /dev/null +++ b/src/main/java/io/github/thebusybiscuit/slimefun4/utils/RecipeUtils.java @@ -0,0 +1,224 @@ +package io.github.thebusybiscuit.slimefun4.utils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +import javax.annotation.Nonnull; +import javax.annotation.ParametersAreNonnullByDefault; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; +import org.bukkit.inventory.ItemStack; + +import io.github.thebusybiscuit.slimefun4.utils.tags.SlimefunTag; + +public class RecipeUtils { + + public static class BoundingBox { + public final int top; + public final int left; + public final int bottom; + public final int right; + + public BoundingBox(int top, int left, int bottom, int right) { + this.top = top; + this.left = left; + this.bottom = bottom; + this.right = right; + } + + @Override + public String toString() { + return "(" + top + ", " + left + ", " + bottom + ", " + right + ")"; + } + + public int getWidth() { + return right - left + 1; + } + + public int getHeight() { + return bottom - top + 1; + } + + public boolean isSameShape(BoundingBox other) { + return this.getWidth() == other.getWidth() && this.getHeight() == other.getHeight(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) return true; + if (obj == null) return false; + if (obj.getClass() != getClass()) return false; + BoundingBox box = (BoundingBox) obj; + return box.top == top && + box.left == left && + box.bottom == bottom && + box.right == right; + + } + } + + /** + * Returns the [top, left, width, height] of the smallest rectangle that includes all non-empty elements in the list + * @param items Items list (represents 2d-grid). Elements of the list can be null + * @param width Width of the grid + * @param height Height of the grid + * @return + */ + @ParametersAreNonnullByDefault + public static BoundingBox calculateBoundingBox(List items, int width, int height) { + return calculateBoundingBox(items, width, height, t -> t == null); + } + + /** + * Returns the [top, left, width, height] of the smallest rectangle that includes all non-empty elements in the list + * @param items Items list (represents 2d-grid). Elements of the list can be null + * @param width Width of the grid, > 0 + * @param height Height of the grid, > 0 + * @param isEmpty Predicate for determining empty values + * @return + */ + @ParametersAreNonnullByDefault + public static BoundingBox calculateBoundingBox(List items, int width, int height, Predicate isEmpty) { + int left = width - 1; + int right = 0; + int top = height - 1; + int bottom = 0; + boolean fullyEmpty = true; + for (int i = 0; i < items.size(); i++) { + int x = i % width; + int y = i / width; + + if (!isEmpty.test(items.get(i))) { + fullyEmpty = false; + if (left > x) { + left = x; + } + if (right < x) { + right = x; + } + if (top > y) { + top = y; + } + if (bottom < y) { + bottom = y; + } + } + } + if (fullyEmpty) { + return new BoundingBox(0, 0, 0, 0); + } + return new BoundingBox(top, left, bottom, right); + } + + public static List flipY(List items, int width, int height) { + List flipped = new ArrayList<>(Collections.nCopies(width * height, null)); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int i = y * width + x; + T item = i < items.size() ? items.get(i) : null; + flipped.set(y * width + (width - 1 - x), item); + } + } + return flipped; + } + + /** + * Rotates a 3x3 list 45 degrees clockwise + */ + public static List rotate45deg3x3(List items) { + if (items.size() < 9) { + List temp = new ArrayList<>(9); + for (T t : items) { + temp.add(t); + } + items = temp; + } + List rotated = new ArrayList<>(Collections.nCopies(9, null)); + rotated.set(0, items.get(3)); + rotated.set(1, items.get(0)); + rotated.set(2, items.get(1)); + rotated.set(3, items.get(6)); + rotated.set(4, items.get(4)); + rotated.set(5, items.get(2)); + rotated.set(6, items.get(7)); + rotated.set(7, items.get(8)); + rotated.set(8, items.get(5)); + return rotated; + } + + public static int hashItemsIgnoreAmount(ItemStack[] items) { + return hashItemsIgnoreAmount(Arrays.stream(items).toList()); + } + + public static int hashItemsIgnoreAmount(@Nonnull Iterable items) { + int hash = 1; + for (ItemStack item : items) { + if (item == null || item.getType().isAir()) { + hash *= 31; + continue; + } + + hash = hash * 31 + item.getType().hashCode(); + hash = hash * 31 + (item.hasItemMeta() ? item.getItemMeta().hashCode() : 0); + } + return hash; + } + + public static Optional> tagFromString(String string) { + if (string == null) { + return Optional.empty(); + } + String[] split = string.split(":"); + if (split.length != 2) { + return Optional.empty(); + } + return tagFromString(split[0], split[1]); + } + + public static Optional> tagFromString(String namespace, String id) { + if (namespace == null || id == null) { + return Optional.empty(); + } + if (namespace.equals("minecraft")) { + Tag tag = Bukkit.getTag("items", NamespacedKey.minecraft(id), Material.class); + return tag == null ? Optional.empty() : Optional.of(tag); + } else if (namespace.equals("slimefun")) { + SlimefunTag tag = SlimefunTag.getTag(id.toUpperCase()); + return tag == null ? Optional.empty() : Optional.of(tag); + } + return Optional.empty(); + } + + /** + * Returns the character used in a recipe key for the ith input + * @param i A number between 0 and 93 (inclusive) + */ + public static char getKeyCharByNumber(int i) { + if (i < 0) { + return ' '; + } else if (i < 9) { + return (char) ('1' + i); // 1 - 9 + } else if (i < 35) { + return (char) ('A' + i - 9); // A - Z + } else if (i < 61) { + return (char) ('a' + i - 35); // a - z + } else if (i < 77) { + return (char) ('!' + i - 61); // ASCII 33 - 47 (! - 0) + } else if (i < 84) { + return (char) (':' + i - 77); // ASCII 58 - 64 (: - @) + } else if (i < 90) { + return (char) ('[' + i - 84); // ASCII 91 - 96 ([ - `) + } else if (i < 94) { + return (char) ('{' + i - 90); // ASCII 123 - 126 ({ - ~) + } + return ' '; + } + +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 5e5a3adbe5..5e20cb0bc6 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -83,3 +83,6 @@ permissions: slimefun.debugging: description: Allows you to use the debugging tool from Slimefun default: op + slimefun.recipe.reload: + description: Allows you to reload slimefun recipes + default: op diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipes.java b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipes.java new file mode 100644 index 0000000000..411d2b1089 --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/api/recipes/TestRecipes.java @@ -0,0 +1,486 @@ +package io.github.thebusybiscuit.slimefun4.api.recipes; + +import java.util.Arrays; +import java.util.List; + +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; +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 com.google.common.base.Predicate; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import io.github.bakedlibs.dough.items.CustomItemStack; +import io.github.thebusybiscuit.slimefun4.api.items.ItemGroup; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.AbstractRecipeOutputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputGroup; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputItemStack; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputSlimefunItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeInputTag; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputGroup; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputItemStack; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputSlimefunItem; +import io.github.thebusybiscuit.slimefun4.api.recipes.items.RecipeOutputTag; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeInputSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeInputItemSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeOutputSerDes; +import io.github.thebusybiscuit.slimefun4.api.recipes.json.RecipeOutputItemSerDes; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.mocks.MockSlimefunItem; +import io.github.thebusybiscuit.slimefun4.utils.tags.SlimefunTag; +import be.seeseemelk.mockbukkit.MockBukkit; + +class TestRecipes { + + private final String recipe1 = """ +{ + __filename: "test", + "input": { + "items": [ + " ", + " 1123", + " 1445" + ], + "key": { + "1": "slimefun:tin_dust|2", + "2": "minecraft:iron_ingot|60", + "3": "#minecraft:logs|6", + "4": { + "group": [ + "slimefun:copper_ingot|32", + { + "id": "minecraft:copper_ingot", + "amount": 48 + } + ] + }, + "5": { + "tag": "slimefun:ice_variants", + "amount": 16 + } + } + }, + "output": { + "items": [ + "slimefun:silver_dust|8" + ] + }, + "type": "slimefun:enhanced_crafting_table" +} + """; + + private final String recipe2 = """ +{ + __filename: "test", + "input": { + "items": ["1"], + "key": { + "1": "slimefun:tin_dust|2" + } + }, + "output": { + "items": [ + "slimefun:silver_dust", + { + "id": "minecraft:oak_log", + "amount": 55 + }, + { + "group": [ + { + "id": "minecraft:iron_ingot", + "amount": 12 + }, + { + "id": "slimefun:iron_dust", + "amount": 24 + } + ] + }, + { + "group": [ + "minecraft:gold_ingot|8", + { + "id": "slimefun:gold_dust", + "amount": 16 + } + ], + "weights": [1, 4] + } + ] + }, + "type": "slimefun:enhanced_crafting_table" +} + """; + + private static Slimefun sf; + private static Gson gson; + private static ItemGroup itemGroup; + private static MockSlimefunItem testItem; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + sf = MockBukkit.load(Slimefun.class); + gson = new GsonBuilder() + .registerTypeAdapter(Recipe.class, new RecipeSerDes()) + .registerTypeAdapter(AbstractRecipeInput.class, new RecipeInputSerDes()) + .registerTypeAdapter(AbstractRecipeOutput.class, new RecipeOutputSerDes()) + .registerTypeAdapter(AbstractRecipeInputItem.class, new RecipeInputItemSerDes()) + .registerTypeAdapter(AbstractRecipeOutputItem.class, new RecipeOutputItemSerDes()) + .create(); + + itemGroup = new ItemGroup(new NamespacedKey(sf, "test_group"), new CustomItemStack(Material.DIAMOND_AXE, "Test Group")); + testItem = new MockSlimefunItem(itemGroup, new ItemStack(Material.IRON_INGOT), "TEST_ITEM"); + testItem.register(sf); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + private List o(Recipe recipe, int... i) { + if (recipe.getOutput() instanceof final RecipeOutput output) { + return Arrays.stream(i).mapToObj(idx ->output.getItem(idx)).toList(); + } + return List.of(); + } + + private boolean isSlimefunItemOutput(List items, String id, int amount) { + return items.stream().allMatch(item -> item instanceof final RecipeOutputSlimefunItem sfItem + && sfItem.getAmount() == amount + && sfItem.getSlimefunId().equals(id)); + } + + private boolean isMinecraftItemOutput(List items, Material mat, int amount) { + return items.stream().allMatch(item -> item instanceof final RecipeOutputItemStack mcItem + && mcItem.getAmount() == amount + && mcItem.getTemplate().getType() == mat); + } + + private boolean isGroupOutput(List items, List> checks, float weightSum) { + return items.stream().allMatch(item -> { + if (item instanceof final RecipeOutputGroup group) { + if (group.getOutputPool().sumWeights() != weightSum) { + return false; + } + var groupItems = group.getOutputPool().toArray(AbstractRecipeOutputItem[]::new); + outer: for (Predicate check : checks) { + for (AbstractRecipeOutputItem groupItem : groupItems) { + if (check.test(groupItem)) { + continue outer; + } + } + return false; + } + return true; + } + return false; + }); + } + + private List i(Recipe recipe, int... i) { + return Arrays.stream(i).mapToObj(idx -> recipe.getInput().getItem(idx)).toList(); + } + + private boolean isEmptyInput(List items) { + return items.stream().allMatch(item -> item == RecipeInputItem.EMPTY); + } + + private boolean isSlimefunItemInput(List items, String id, int amount) { + return items.stream().allMatch(item -> item instanceof final RecipeInputSlimefunItem sfItem + && sfItem.getAmount() == amount + && sfItem.getSlimefunId().equals(id)); + } + + private boolean isMinecraftItemInput(List items, Material mat, int amount) { + return items.stream().allMatch(item -> item instanceof final RecipeInputItemStack mcItem + && mcItem.getAmount() == amount + && mcItem.getTemplate().getType() == mat); + } + + private boolean isTagInput(List items, Tag tag, int amount) { + return items.stream().allMatch(item -> item instanceof final RecipeInputTag tagItem + && tagItem.getTag().getValues().equals(tag.getValues()) + && tagItem.getAmount() == amount); + } + + private boolean isGroupInput(List items, List> checks) { + return items.stream().allMatch(item -> { + if (item instanceof final RecipeInputGroup group) { + for (int i = 0; i < group.getItems().size(); i++) { + final var el = group.getItems().get(i); + if (!checks.get(i).test(el)) { + return false; + } + } + return true; + } + return false; + }); + } + + @Test + @DisplayName("Test Recipe Input Deserialization") + void testInputDeserialization() { + Recipe recipe = gson.fromJson(recipe1, Recipe.class); + + Assertions.assertEquals(5, recipe.getInput().getWidth()); + Assertions.assertEquals(3, recipe.getInput().getHeight()); + Assertions.assertTrue(isEmptyInput(i(recipe, 0, 1, 2, 3, 4, 5, 10))); + + Assertions.assertTrue(isSlimefunItemInput(i(recipe, 6, 7, 11), "TIN_DUST", 2)); + Assertions.assertTrue(isMinecraftItemInput(i(recipe, 8), Material.IRON_INGOT, 60)); + Assertions.assertTrue(isTagInput(i(recipe, 9), Tag.LOGS, 6)); + Assertions.assertTrue(isGroupInput(i(recipe, 12, 13), List.of( + item -> isSlimefunItemInput(List.of(item), "COPPER_INGOT", 32), + item -> isMinecraftItemInput(List.of(item), Material.COPPER_INGOT, 48) + ))); + + Assertions.assertTrue(isTagInput(i(recipe, 14), SlimefunTag.ICE_VARIANTS, 16)); + } + + @Test + @DisplayName("Test Recipe Output Deserialization") + void testOutputDeserialization() { + Recipe recipe = gson.fromJson(recipe2, Recipe.class); + + Assertions.assertEquals(1, recipe.getInput().getWidth()); + Assertions.assertEquals(1, recipe.getInput().getHeight()); + Assertions.assertTrue(isSlimefunItemOutput(o(recipe, 0), "SILVER_DUST", 1)); + Assertions.assertTrue(isMinecraftItemOutput(o(recipe, 1), Material.OAK_LOG, 55)); + Assertions.assertTrue(isGroupOutput(o(recipe, 2), List.of( + item -> isMinecraftItemOutput(List.of(item), Material.IRON_INGOT, 12), + item -> isSlimefunItemOutput(List.of(item), "IRON_DUST", 24) + ), 2)); + Assertions.assertTrue(isGroupOutput(o(recipe, 3), List.of( + item -> isMinecraftItemOutput(List.of(item), Material.GOLD_INGOT, 8), + item -> isSlimefunItemOutput(List.of(item), "GOLD_DUST", 16) + ), 5)); + } + + @Test + @DisplayName("Test Recipe Input Item Serialization") + void testRecipeInputItemSerialization() { + var i1 = new RecipeInputItemStack(new ItemStack(Material.ACACIA_BOAT)); + var i2 = new RecipeInputItemStack(new ItemStack(Material.STICK, 3)); + var i3 = new RecipeInputItemStack(new ItemStack(Material.IRON_SWORD), 1, 2); + var i4 = new RecipeInputSlimefunItem("IRON_DUST", 64); + var i5 = new RecipeInputTag(SlimefunTag.TORCHES, 3); + Assertions.assertEquals( + "\"minecraft:acacia_boat\"", + gson.toJson(i1, AbstractRecipeInputItem.class) + ); + Assertions.assertEquals( + "\"minecraft:stick|3\"", + gson.toJson(i2, AbstractRecipeInputItem.class) + ); + Assertions.assertEquals( + "{\"id\":\"minecraft:iron_sword\",\"durability\":2}", + gson.toJson(i3, AbstractRecipeInputItem.class) + ); + Assertions.assertEquals( + "\"slimefun:iron_dust|64\"", + gson.toJson(i4, AbstractRecipeInputItem.class) + ); + Assertions.assertEquals( + "\"#slimefun:torches|3\"", + gson.toJson(i5, AbstractRecipeInputItem.class) + ); + } + + @Test + @DisplayName("Test Recipe Output Item Serialization") + void testRecipeOutputItemSerialization() { + var i1 = new RecipeOutputItemStack(new ItemStack(Material.ACACIA_BOAT)); + var i2 = new RecipeOutputItemStack(new ItemStack(Material.STICK, 3)); + var i4 = new RecipeOutputSlimefunItem("IRON_DUST", 64); + var i5 = new RecipeOutputTag(SlimefunTag.TORCHES, 3); + Assertions.assertEquals( + "\"minecraft:acacia_boat\"", + gson.toJson(i1, AbstractRecipeOutputItem.class) + ); + Assertions.assertEquals( + "\"minecraft:stick|3\"", + gson.toJson(i2, AbstractRecipeOutputItem.class) + ); + Assertions.assertEquals( + "\"slimefun:iron_dust|64\"", + gson.toJson(i4, AbstractRecipeOutputItem.class) + ); + Assertions.assertEquals( + "\"#slimefun:torches|3\"", + gson.toJson(i5, AbstractRecipeOutputItem.class) + ); + } + + @Test + @DisplayName("Test Shaped and Shaped-Flippable Recipe Matching") + void testShapedRecipeMatching() { + var recipe = Recipe.fromItemStacks(new ItemStack[] { + null, null, null, + new ItemStack(Material.SUGAR, 10), new ItemStack(Material.APPLE, 2), null, + null, new ItemStack(Material.STICK, 3), null, + }, new ItemStack[] { new ItemStack(Material.STICK) }, RecipeType.NULL); + ItemStack sticks = new ItemStack(Material.STICK, 64); + ItemStack apples = new ItemStack(Material.APPLE, 64); + ItemStack sugar = new ItemStack(Material.SUGAR, 64); + var falseResult = recipe.matchAs(MatchProcedure.SHAPED, Arrays.asList( + sugar, apples, null, + null, new ItemStack(Material.ACACIA_BOAT), null, + null, null, null + )); + Assertions.assertFalse(falseResult.itemsMatch()); + falseResult = recipe.matchAs(MatchProcedure.SHAPED, Arrays.asList( + sugar, apples, null, + null, new ItemStack(Material.STICK, 1), null, + null, null, null + )); + Assertions.assertFalse(falseResult.itemsMatch()); + var result = recipe.matchAs(MatchProcedure.SHAPED, Arrays.asList( + sugar, apples, null, + null, sticks, null, + null, null, null + )); + Assertions.assertTrue(result.itemsMatch()); + Assertions.assertEquals(3, result.getInputMatchResult().consumeItems(3)); + Assertions.assertEquals(55, sticks.getAmount()); + Assertions.assertEquals(58, apples.getAmount()); + Assertions.assertEquals(34, sugar.getAmount()); + Assertions.assertEquals(3, result.getInputMatchResult().consumeItems(4)); + Assertions.assertEquals(46, sticks.getAmount()); + Assertions.assertEquals(52, apples.getAmount()); + Assertions.assertEquals(4, sugar.getAmount()); + Assertions.assertEquals(0, result.getInputMatchResult().consumeItems(4)); + Assertions.assertEquals(46, sticks.getAmount()); + Assertions.assertEquals(52, apples.getAmount()); + Assertions.assertEquals(4, sugar.getAmount()); + + sticks = new ItemStack(Material.STICK, 64); + apples = new ItemStack(Material.APPLE, 64); + sugar = new ItemStack(Material.SUGAR, 64); + falseResult = recipe.matchAs(MatchProcedure.SHAPED, Arrays.asList( + null, null, null, + null, apples, sugar, + null, sticks, null + )); + Assertions.assertFalse(falseResult.itemsMatch()); + result = recipe.matchAs(MatchProcedure.SHAPED_FLIPPABLE, Arrays.asList( + null, null, null, + null, apples, sugar, + null, sticks, null + )); + Assertions.assertTrue(result.itemsMatch()); + Assertions.assertEquals(3, result.getInputMatchResult().consumeItems(3)); + Assertions.assertEquals(55, sticks.getAmount()); + Assertions.assertEquals(58, apples.getAmount()); + Assertions.assertEquals(34, sugar.getAmount()); + Assertions.assertEquals(3, result.getInputMatchResult().consumeItems(4)); + Assertions.assertEquals(46, sticks.getAmount()); + Assertions.assertEquals(52, apples.getAmount()); + Assertions.assertEquals(4, sugar.getAmount()); + Assertions.assertEquals(0, result.getInputMatchResult().consumeItems(4)); + Assertions.assertEquals(46, sticks.getAmount()); + Assertions.assertEquals(52, apples.getAmount()); + Assertions.assertEquals(4, sugar.getAmount()); + } + + @Test + @DisplayName("Test Shapeless and Subset Recipe Matching") + void testShapelessRecipeMatching() { + var recipe = Recipe.fromItemStacks(new ItemStack[] { + null, null, new ItemStack(Material.BLAZE_POWDER, 4), + new ItemStack(Material.GUNPOWDER, 3), new ItemStack(Material.COAL, 7), null, + null, null, null, + }, new ItemStack[] { new ItemStack(Material.STICK) }, RecipeType.NULL); + ItemStack blazePowder = new ItemStack(Material.BLAZE_POWDER, 64); + ItemStack gunpowder = new ItemStack(Material.GUNPOWDER, 64); + ItemStack coal = new ItemStack(Material.COAL, 64); + ItemStack sticks = new ItemStack(Material.STICK, 64); + + // If subset is false, then shapeless will also be false + var falseResult = recipe.matchAs(MatchProcedure.SUBSET, Arrays.asList( + null, coal, null, null, null, gunpowder + )); + Assertions.assertFalse(falseResult.itemsMatch()); + falseResult = recipe.matchAs(MatchProcedure.SHAPELESS, Arrays.asList( + null, coal, null, null, null, gunpowder, blazePowder, sticks + )); + Assertions.assertFalse(falseResult.itemsMatch()); + var result = recipe.matchAs(MatchProcedure.SHAPELESS, Arrays.asList( + null, coal, null, null, null, gunpowder, blazePowder + )); + Assertions.assertTrue(result.itemsMatch()); + result = recipe.matchAs(MatchProcedure.SUBSET, Arrays.asList( + null, coal, null, null, null, gunpowder, blazePowder, sticks + )); + Assertions.assertTrue(result.itemsMatch()); + Assertions.assertEquals(recipe, result.getRecipe()); + Assertions.assertEquals(9, result.getInputMatchResult().consumeItems(9)); + Assertions.assertEquals(28, blazePowder.getAmount()); + Assertions.assertEquals(37, gunpowder.getAmount()); + Assertions.assertEquals(1, coal.getAmount()); + Assertions.assertEquals(64, sticks.getAmount()); + Assertions.assertEquals(0, result.getInputMatchResult().consumeItems(9)); + Assertions.assertEquals(28, blazePowder.getAmount()); + Assertions.assertEquals(37, gunpowder.getAmount()); + Assertions.assertEquals(1, coal.getAmount()); + Assertions.assertEquals(64, sticks.getAmount()); + } + + @Test + @DisplayName("Test RecipeInputSlimefunItem Matching") + void testRecipeInputSlimefunItemMatching() { + var item = new RecipeInputSlimefunItem("TEST_ITEM", 2); + Assertions.assertFalse(item.matchItem(new ItemStack(Material.IRON_INGOT)).itemsMatch()); + ItemStack sfItem = testItem.getItem().clone(); + sfItem.setAmount(1); + Assertions.assertFalse(item.matchItem(sfItem).itemsMatch()); + sfItem.setAmount(2); + Assertions.assertTrue(item.matchItem(sfItem).itemsMatch()); + } + + @Test + @DisplayName("Test RecipeInputTag Matching") + void testRecipeInputTagMatching() { + var item = new RecipeInputTag(Tag.LOGS, 2); + Assertions.assertFalse(item.matchItem(new ItemStack(Material.IRON_INGOT)).itemsMatch()); + Assertions.assertFalse(item.matchItem(new ItemStack(Material.OAK_LOG)).itemsMatch()); + Assertions.assertFalse(item.matchItem(new ItemStack(Material.SPRUCE_LOG)).itemsMatch()); + Assertions.assertTrue(item.matchItem(new ItemStack(Material.DARK_OAK_LOG, 2)).itemsMatch()); + Assertions.assertTrue(item.matchItem(new ItemStack(Material.BIRCH_LOG, 2)).itemsMatch()); + } + + @Test + @DisplayName("Test RecipeInputGroup Matching") + void testRecipeInputGroupMatching() { + var item = new RecipeInputGroup(List.of( + new RecipeInputSlimefunItem("TEST_ITEM", 2), + new RecipeInputItemStack(Material.ACACIA_BOAT), + new RecipeInputTag(Tag.ANVIL) + )); + ItemStack sfItem = testItem.getItem().clone(); + sfItem.setAmount(1); + Assertions.assertFalse(item.matchItem(new ItemStack(Material.IRON_INGOT)).itemsMatch()); + Assertions.assertFalse(item.matchItem(new ItemStack(Material.SPRUCE_LOG)).itemsMatch()); + Assertions.assertFalse(item.matchItem(sfItem).itemsMatch()); + sfItem.setAmount(2); + Assertions.assertTrue(item.matchItem(sfItem).itemsMatch()); + Assertions.assertTrue(item.matchItem(new ItemStack(Material.DAMAGED_ANVIL)).itemsMatch()); + Assertions.assertTrue(item.matchItem(new ItemStack(Material.ACACIA_BOAT)).itemsMatch()); + Assertions.assertTrue(item.matchItem(new ItemStack(Material.ANVIL)).itemsMatch()); + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/core/services/TestSFRecipeService.java b/src/test/java/io/github/thebusybiscuit/slimefun4/core/services/TestSFRecipeService.java new file mode 100644 index 0000000000..7bb43062cf --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/core/services/TestSFRecipeService.java @@ -0,0 +1,105 @@ +package io.github.thebusybiscuit.slimefun4.core.services; + +import java.util.Arrays; + +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 io.github.bakedlibs.dough.items.CustomItemStack; +import io.github.thebusybiscuit.slimefun4.api.items.ItemGroup; +import io.github.thebusybiscuit.slimefun4.api.recipes.Recipe; +import io.github.thebusybiscuit.slimefun4.api.recipes.RecipeType; +import io.github.thebusybiscuit.slimefun4.api.recipes.matching.MatchProcedure; +import io.github.thebusybiscuit.slimefun4.implementation.Slimefun; +import io.github.thebusybiscuit.slimefun4.test.mocks.MockSlimefunItem; +import be.seeseemelk.mockbukkit.MockBukkit; + +class TestSFRecipeService { + + private static Slimefun sf; + private static ItemGroup itemGroup; + private static MockSlimefunItem testItem1; + private static MockSlimefunItem testItem2; + private static MockSlimefunItem testItem3; + private static MockSlimefunItem testItem4; + private static MockSlimefunItem testItem5; + + @BeforeAll + public static void load() { + MockBukkit.mock(); + sf = MockBukkit.load(Slimefun.class); + itemGroup = new ItemGroup(new NamespacedKey(sf, "test_group"), new CustomItemStack(Material.DIAMOND_AXE, "Test Group")); + testItem1 = new MockSlimefunItem(itemGroup, new ItemStack(Material.IRON_INGOT), "TEST_ITEM_1"); + testItem2 = new MockSlimefunItem(itemGroup, new ItemStack(Material.IRON_INGOT), "TEST_ITEM_2"); + testItem3 = new MockSlimefunItem(itemGroup, new ItemStack(Material.IRON_INGOT), "TEST_ITEM_3"); + testItem4 = new MockSlimefunItem(itemGroup, new ItemStack(Material.IRON_INGOT), "TEST_ITEM_4"); + testItem5 = new MockSlimefunItem(itemGroup, new ItemStack(Material.IRON_INGOT), "TEST_ITEM_5"); + testItem1.register(sf); + testItem2.register(sf); + testItem3.register(sf); + testItem4.register(sf); + testItem5.register(sf); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + @DisplayName("Test adding recipes") + void testRecipe() { + RecipeService service = new RecipeService(sf); + + Recipe recipe1 = Recipe.fromItemStacks("TEST_ITEM_1", new ItemStack[] { + null, null, null, + null, null, testItem1.getItem(), + null, null, null, + }, new ItemStack[] { testItem1.getItem() }, RecipeType.NULL); + Recipe recipe2 = Recipe.fromItemStacks("TEST_ITEM_2", new ItemStack[] { + null, null, null, + null, null, testItem1.getItem(), + testItem2.getItem(), null, null, + }, new ItemStack[] { testItem2.getItem() }, RecipeType.NULL); + Recipe recipe3 = Recipe.fromItemStacks("TEST_ITEM_3", new ItemStack[] { + null, testItem3.getItem(), null, + null, null, testItem1.getItem(), + testItem2.getItem(), null, null, + }, new ItemStack[] { testItem3.getItem() }, RecipeType.NULL); + Recipe recipe4 = Recipe.fromItemStacks("TEST_ITEM_4", new ItemStack[] { + null, testItem3.getItem(), null, + null, null, testItem1.getItem(), + testItem2.getItem(), null, null, + }, new ItemStack[] { testItem4.getItem() }, RecipeType.NULL); + + service.addRecipe(recipe1); + service.addRecipe(recipe2); + service.addRecipe(recipe3); + service.addRecipe(recipe4); + + Assertions.assertEquals(recipe1, service.getRecipe("TEST_ITEM_1")); + Assertions.assertEquals(recipe2, service.getRecipe("TEST_ITEM_2")); + Assertions.assertEquals(recipe3, service.getRecipe("TEST_ITEM_3")); + Assertions.assertEquals(recipe4, service.getRecipe("TEST_ITEM_4")); + + ItemStack sfItem = testItem1.getItem().clone(); + var search = service.searchRecipes(RecipeType.NULL, Arrays.asList( + null, null, null, + null, sfItem, null, + null, null, null + ), MatchProcedure.SHAPED); + Assertions.assertTrue(search.matchFound()); + var result = search.getResult().get(); + Assertions.assertTrue(result.itemsMatch()); + var recipe = search.getRecipe().get(); + Assertions.assertEquals(recipe1, recipe); + + } + +} diff --git a/src/test/java/io/github/thebusybiscuit/slimefun4/utils/TestRecipeUtils.java b/src/test/java/io/github/thebusybiscuit/slimefun4/utils/TestRecipeUtils.java new file mode 100644 index 0000000000..455db9728d --- /dev/null +++ b/src/test/java/io/github/thebusybiscuit/slimefun4/utils/TestRecipeUtils.java @@ -0,0 +1,181 @@ +package io.github.thebusybiscuit.slimefun4.utils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.bukkit.Color; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.LeatherArmorMeta; +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.utils.RecipeUtils.BoundingBox; + +class TestRecipeUtils { + + @BeforeAll + public static void load() { + MockBukkit.mock(); + } + + @AfterAll + public static void unload() { + MockBukkit.unmock(); + } + + @Test + @DisplayName("Test Bounding Box Calculation") + void testCalculateBoundingBox() { + List a1 = List.of( + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, + 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, + 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, + 0, 0, 0, 1, 0, 0, 1, 0, 0, 0 + ); + Assertions.assertEquals( + new BoundingBox(1, 2, 6, 8), + RecipeUtils.calculateBoundingBox(a1, 10, 7, i -> i == 0) + ); + + List a2 = List.of(1); + Assertions.assertEquals( + new BoundingBox(0, 0, 0, 0), + RecipeUtils.calculateBoundingBox(a2, 1, 1, i -> i == 0) + ); + + List a3 = List.of(0); + Assertions.assertEquals( + new BoundingBox(0, 0, 0, 0), + RecipeUtils.calculateBoundingBox(a3, 1, 1, i -> i == 0) + ); + + List a4 = List.of(1, 1, 1, 1); + Assertions.assertEquals( + new BoundingBox(0, 0, 1, 1), + RecipeUtils.calculateBoundingBox(a4, 2, 2, i -> i == 0) + ); + + List a5 = List.of(0, 0, 0, 0); + Assertions.assertEquals( + new BoundingBox(0, 0, 0, 0), + RecipeUtils.calculateBoundingBox(a5, 2, 2, i -> i == 0) + ); + + List a6 = List.of( + 0, 0, 0, 0, + 0, 0, 0, 0, + 0, 1, 1, 0, + 0, 0, 0, 0 + ); + Assertions.assertEquals( + new BoundingBox(2, 1, 2, 2), + RecipeUtils.calculateBoundingBox(a6, 4, 4, i -> i == 0) + ); + } + + @Test + @DisplayName("Test Bounding Box Calculation") + void testFlipY() { + List a1 = List.of(1); + Assertions.assertEquals( + List.of(1), + RecipeUtils.flipY(a1, 1, 1) + ); + + List a2 = List.of(0, 1, 1, 0); + Assertions.assertEquals( + List.of(1, 0, 0, 1), + RecipeUtils.flipY(a2, 2, 2) + ); + + List a3 = List.of(); + Assertions.assertEquals( + List.of(), + RecipeUtils.flipY(a3, 0, 0) + ); + } + + @Test + @DisplayName("Test Bounding Box Calculation") + void testRotate3x3() { + List a1 = List.of(7, 8, 9, 4, 5, 6, 1, 2, 3); + Assertions.assertEquals( + List.of(4, 7, 8, 1, 5, 9, 2, 3, 6), + RecipeUtils.rotate45deg3x3(a1) + ); + } + + @Test + @DisplayName("Test Amount-agnostic item hashing") + void testHashItemStacks() { + final ItemStack helmetWithMeta1 = new ItemStack(Material.LEATHER_HELMET); + final LeatherArmorMeta m1 = (LeatherArmorMeta) helmetWithMeta1.getItemMeta(); + m1.setColor(Color.AQUA); + helmetWithMeta1.setItemMeta(m1); + + final ItemStack helmetWithMeta2 = new ItemStack(Material.LEATHER_HELMET); + final LeatherArmorMeta m2 = (LeatherArmorMeta) helmetWithMeta2.getItemMeta(); + m2.setUnbreakable(true); + helmetWithMeta2.setItemMeta(m1); + + List a1 = List.of( + new ItemStack(Material.OAK_LOG, 55), + new ItemStack(Material.LEATHER_HELMET) + ); + List a2 = List.of( + new ItemStack(Material.OAK_LOG, 20), + new ItemStack(Material.LEATHER_HELMET) + ); + List a3 = List.of( + new ItemStack(Material.OAK_LOG, 55), + helmetWithMeta1 + ); + List a4 = List.of( + new ItemStack(Material.OAK_LOG, 55), + helmetWithMeta2 + ); + List a5 = List.of( + new ItemStack(Material.BIRCH_LOG, 55), + new ItemStack(Material.LEATHER_HELMET) + ); + Assertions.assertEquals(RecipeUtils.hashItemsIgnoreAmount(a1), RecipeUtils.hashItemsIgnoreAmount(a1)); + Assertions.assertEquals(RecipeUtils.hashItemsIgnoreAmount(a1), RecipeUtils.hashItemsIgnoreAmount(a2)); + Assertions.assertNotEquals(RecipeUtils.hashItemsIgnoreAmount(a1), RecipeUtils.hashItemsIgnoreAmount(a3)); + Assertions.assertNotEquals(RecipeUtils.hashItemsIgnoreAmount(a1), RecipeUtils.hashItemsIgnoreAmount(a4)); + Assertions.assertNotEquals(RecipeUtils.hashItemsIgnoreAmount(a1), RecipeUtils.hashItemsIgnoreAmount(a5)); + Assertions.assertEquals(RecipeUtils.hashItemsIgnoreAmount(a3), RecipeUtils.hashItemsIgnoreAmount(a3)); + Assertions.assertEquals(RecipeUtils.hashItemsIgnoreAmount(a4), RecipeUtils.hashItemsIgnoreAmount(a4)); + Assertions.assertEquals( + RecipeUtils.hashItemsIgnoreAmount(new ArrayList<>(Collections.nCopies(3, null))), + RecipeUtils.hashItemsIgnoreAmount(List.of(new ItemStack(Material.VOID_AIR), new ItemStack(Material.CAVE_AIR), new ItemStack(Material.AIR))) + ); + } + + @Test + @DisplayName("Test key char conversion") + void testKeyCharConversion() { + char[] chars = new char[] { + '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '!', '"', '#', '$', '%', '&', '\'', '(', ')', '*', '+', ',', '-', '.', '/', '0', + ':', ';', '<', '=', '>', '?', '@', + '[', '\\', ']', '^', '_', '`', + '{', '|', '}', '~' + }; + for (int i = 0; i < chars.length; ++i) { + Assertions.assertEquals(chars[i], RecipeUtils.getKeyCharByNumber(i)); + } + } +}