diff --git a/demo/src/main/java/net/minestom/demo/Main.java b/demo/src/main/java/net/minestom/demo/Main.java index fbe9cb8c4..0baf150c1 100644 --- a/demo/src/main/java/net/minestom/demo/Main.java +++ b/demo/src/main/java/net/minestom/demo/Main.java @@ -8,6 +8,7 @@ import net.kyori.adventure.text.format.TextDecoration; import net.minestom.demo.block.TestBlockHandler; import net.minestom.demo.block.placement.DripstonePlacementRule; import net.minestom.demo.commands.*; +import net.minestom.demo.recipe.ShapelessRecipe; import net.minestom.server.MinecraftServer; import net.minestom.server.command.CommandManager; import net.minestom.server.event.server.ServerListPingEvent; @@ -19,8 +20,7 @@ import net.minestom.server.item.ItemComponent; import net.minestom.server.item.ItemStack; import net.minestom.server.item.Material; import net.minestom.server.ping.ResponseData; -import net.minestom.server.recipe.Recipe; -import net.minestom.server.recipe.RecipeCategory; +import net.minestom.server.recipe.RecipeBookCategory; import net.minestom.server.utils.identity.NamedAndIdentified; import net.minestom.server.utils.time.TimeUnit; @@ -120,28 +120,13 @@ public class Main { //responseData.setPlayersHidden(true); }); - var ironBlockRecipe = new Recipe( - "minestom:test", - new Recipe.Shaped("", RecipeCategory.Crafting.MISC, 2, 2, - List.of( - new Recipe.Ingredient(Material.IRON_INGOT), - new Recipe.Ingredient(Material.IRON_INGOT), - new Recipe.Ingredient(Material.IRON_INGOT), - new Recipe.Ingredient(Material.IRON_INGOT) - ), ItemStack.of(Material.IRON_BLOCK), true)); - MinecraftServer.getRecipeManager().addRecipe(ironBlockRecipe); - var recipe = new Recipe( - "minestom:test2", - new Recipe.Shapeless("abc", - RecipeCategory.Crafting.MISC, - List.of( - new Recipe.Ingredient(Material.DIRT) - ), - ItemStack.builder(Material.GOLD_BLOCK) - .set(ItemComponent.CUSTOM_NAME, Component.text("abc")) - .build()) - ); - MinecraftServer.getRecipeManager().addRecipe(recipe); + MinecraftServer.getRecipeManager().addRecipe(new ShapelessRecipe( + RecipeBookCategory.CRAFTING_MISC, + List.of(Material.DIRT), + ItemStack.builder(Material.GOLD_BLOCK) + .set(ItemComponent.CUSTOM_NAME, Component.text("abc")) + .build() + )); new PlayerInit().init(); diff --git a/demo/src/main/java/net/minestom/demo/recipe/ShapelessRecipe.java b/demo/src/main/java/net/minestom/demo/recipe/ShapelessRecipe.java new file mode 100644 index 000000000..8c6765d0c --- /dev/null +++ b/demo/src/main/java/net/minestom/demo/recipe/ShapelessRecipe.java @@ -0,0 +1,34 @@ +package net.minestom.demo.recipe; + +import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; +import net.minestom.server.recipe.Ingredient; +import net.minestom.server.recipe.Recipe; +import net.minestom.server.recipe.RecipeBookCategory; +import net.minestom.server.recipe.display.RecipeDisplay; +import net.minestom.server.recipe.display.SlotDisplay; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public record ShapelessRecipe( + @NotNull RecipeBookCategory recipeBookCategory, + @NotNull List ingredients, + @NotNull ItemStack result +) implements Recipe { + + @Override + public @NotNull List createRecipeDisplays() { + return List.of(new RecipeDisplay.CraftingShapeless( + ingredients.stream().map(item -> (SlotDisplay) new SlotDisplay.Item(item)).toList(), + new SlotDisplay.ItemStack(result), + new SlotDisplay.Item(Material.CRAFTING_TABLE) + )); + } + + @Override + public @NotNull List craftingRequirements() { + return List.of(new Ingredient(ingredients)); + } + +} diff --git a/src/main/java/net/minestom/server/entity/Player.java b/src/main/java/net/minestom/server/entity/Player.java index d437b13fb..7964ff451 100644 --- a/src/main/java/net/minestom/server/entity/Player.java +++ b/src/main/java/net/minestom/server/entity/Player.java @@ -72,7 +72,6 @@ import net.minestom.server.network.packet.server.play.data.WorldPos; import net.minestom.server.network.player.ClientSettings; import net.minestom.server.network.player.GameProfile; import net.minestom.server.network.player.PlayerConnection; -import net.minestom.server.recipe.Recipe; import net.minestom.server.recipe.RecipeManager; import net.minestom.server.registry.DynamicRegistry; import net.minestom.server.scoreboard.BelowNameTag; @@ -340,27 +339,8 @@ public class Player extends LivingEntity implements CommandSender, HoverEventSou // Commands refreshCommands(); - // Recipes start - { - RecipeManager recipeManager = MinecraftServer.getRecipeManager(); - sendPacket(recipeManager.getDeclareRecipesPacket()); - - List recipesIdentifier = new ArrayList<>(); - for (Recipe recipe : recipeManager.consumeRecipes(this)) { - recipesIdentifier.add(recipe.id()); - } - if (!recipesIdentifier.isEmpty()) { - // TODO(1.21.2): Recipes -// UnlockRecipesPacket unlockRecipesPacket = new UnlockRecipesPacket(0, -// false, false, -// false, false, -// false, false, -// false, false, -// recipesIdentifier, recipesIdentifier); -// sendPacket(unlockRecipesPacket); - } - } - // Recipes end + // Recipes + refreshRecipes(); // Some client updates sendPacket(getPropertiesPacket()); // Send default properties @@ -561,6 +541,17 @@ public class Player extends LivingEntity implements CommandSender, HoverEventSou sendPacket(MinecraftServer.getCommandManager().createDeclareCommandsPacket(this)); } + /** + * Refreshes the recipes and recipe book for this player, testing recipe predicates again. + */ + public void refreshRecipes() { + RecipeManager recipeManager = MinecraftServer.getRecipeManager(); + sendPackets( + recipeManager.getDeclareRecipesPacket(), + recipeManager.createRecipeBookResetPacket(this) + ); + } + @Override public boolean isOnGround() { return onGround; diff --git a/src/main/java/net/minestom/server/listener/RecipeListener.java b/src/main/java/net/minestom/server/listener/RecipeListener.java index 9560b0a55..c1044d78c 100644 --- a/src/main/java/net/minestom/server/listener/RecipeListener.java +++ b/src/main/java/net/minestom/server/listener/RecipeListener.java @@ -1,12 +1,19 @@ package net.minestom.server.listener; +import net.minestom.server.MinecraftServer; import net.minestom.server.entity.Player; import net.minestom.server.network.packet.client.play.ClientPlaceRecipePacket; +import net.minestom.server.network.packet.server.play.PlaceGhostRecipePacket; +import net.minestom.server.recipe.RecipeManager; +import net.minestom.server.recipe.display.RecipeDisplay; public class RecipeListener { public static void listener(ClientPlaceRecipePacket packet, Player player) { - // TODO(1.21.2) -// player.sendPacket(new PlaceGhostRecipePacket(packet.windowId(), packet.recipe())); + final RecipeManager recipeManager = MinecraftServer.getRecipeManager(); + final RecipeDisplay recipeDisplay = recipeManager.getRecipeDisplay(packet.recipeDisplayId(), player); + if (recipeDisplay == null) return; + + player.sendPacket(new PlaceGhostRecipePacket(packet.windowId(), recipeDisplay)); } } diff --git a/src/main/java/net/minestom/server/network/packet/server/play/RecipeBookAddPacket.java b/src/main/java/net/minestom/server/network/packet/server/play/RecipeBookAddPacket.java index c97b1b5e2..dec8d68ad 100644 --- a/src/main/java/net/minestom/server/network/packet/server/play/RecipeBookAddPacket.java +++ b/src/main/java/net/minestom/server/network/packet/server/play/RecipeBookAddPacket.java @@ -4,9 +4,8 @@ import net.kyori.adventure.text.Component; import net.minestom.server.network.NetworkBuffer; import net.minestom.server.network.NetworkBufferTemplate; import net.minestom.server.network.packet.server.ServerPacket; -import net.minestom.server.recipe.Recipe; +import net.minestom.server.recipe.Ingredient; import net.minestom.server.recipe.RecipeBookCategory; -import net.minestom.server.recipe.RecipeSerializers; import net.minestom.server.recipe.display.RecipeDisplay; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -30,7 +29,7 @@ public record RecipeBookAddPacket(@NotNull List entries, boolean replace) public record Entry( int displayId, @NotNull RecipeDisplay display, @Nullable Integer group, @NotNull RecipeBookCategory category, - @Nullable List craftingRequirements, + @Nullable List craftingRequirements, byte flags ) { public static final NetworkBuffer.Type SERIALIZER = NetworkBufferTemplate.template( @@ -38,13 +37,13 @@ public record RecipeBookAddPacket(@NotNull List entries, boolean replace) RecipeDisplay.NETWORK_TYPE, Entry::display, NetworkBuffer.OPTIONAL_VAR_INT, Entry::group, RecipeBookCategory.NETWORK_TYPE, Entry::category, - RecipeSerializers.INGREDIENT.list().optional(), Entry::craftingRequirements, + Ingredient.NETWORK_TYPE.list().optional(), Entry::craftingRequirements, NetworkBuffer.BYTE, Entry::flags, Entry::new); public Entry(int displayId, @NotNull RecipeDisplay display, @Nullable Integer group, @NotNull RecipeBookCategory category, - @Nullable List craftingRequirements, + @Nullable List craftingRequirements, boolean notification, boolean highlight) { this(displayId, display, group, category, craftingRequirements, (byte) ((notification ? FLAG_NOTIFICATION : 0) | (highlight ? FLAG_HIGHLIGHT : 0))); diff --git a/src/main/java/net/minestom/server/recipe/Ingredient.java b/src/main/java/net/minestom/server/recipe/Ingredient.java index 6b06dc41c..5362eaca4 100644 --- a/src/main/java/net/minestom/server/recipe/Ingredient.java +++ b/src/main/java/net/minestom/server/recipe/Ingredient.java @@ -4,8 +4,10 @@ package net.minestom.server.recipe; import net.minestom.server.item.Material; import net.minestom.server.network.NetworkBuffer; import net.minestom.server.network.NetworkBufferTemplate; +import net.minestom.server.recipe.display.SlotDisplay; import net.minestom.server.utils.validate.Check; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.List; @@ -52,4 +54,15 @@ public record Ingredient(@NotNull List<@NotNull Material> items) { public Ingredient(@NotNull Material @NotNull ... items) { this(List.of(items)); } + + public static @Nullable Ingredient fromSlotDisplay(@NotNull SlotDisplay slotDisplay) { + return switch (slotDisplay) { + case SlotDisplay.Item item -> new Ingredient(item.material()); + case SlotDisplay.Tag ignored -> { + // TODO: Support tags in ingredients (ObjectSet for non static registries) + yield null; + } + default -> null; + }; + } } diff --git a/src/main/java/net/minestom/server/recipe/Recipe.java b/src/main/java/net/minestom/server/recipe/Recipe.java index dc5aa9819..60e7afe20 100644 --- a/src/main/java/net/minestom/server/recipe/Recipe.java +++ b/src/main/java/net/minestom/server/recipe/Recipe.java @@ -1,18 +1,54 @@ package net.minestom.server.recipe; +import net.minestom.server.item.Material; import net.minestom.server.recipe.display.RecipeDisplay; +import net.minestom.server.recipe.display.SlotDisplay; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.List; +import java.util.Map; + public interface Recipe { - @NotNull RecipeDisplay toDisplay(); + /** + * Creates recipe displays for use in the recipe book. + * + *

Displays should be consistent across calls and not specific to a player, they may be cached in {@link RecipeManager}.

+ * + *

Note that stonecutter recipes are always sent to the client and not present in the recipe book. + * Stonecutter ingredients must be {@link SlotDisplay.Item} or {@link SlotDisplay.Tag} to be shown + * on the client.

+ * + * @return a list of recipe displays, or none if the recipe should not be displayed in the recipe book + */ + default @NotNull List createRecipeDisplays() { + return List.of(); + } /** - * Returns the protocol recipe type. - * @return + * Returns the item properties associated with this recipe. These are sent to the client to indicate + * client side special slot prediction. For example, if a recipe includes {@link Material#STONE} in + * {@link RecipeProperty#FURNACE_INPUT}, the client will predict that item being placed into a furnace + * input (note that final placement is still decided by the server). + * + *

Item properties should be consistent across calls and not specific to a player, they may be cached in {@link RecipeManager}.

+ * + * @return A map of item properties associated with this recipe. */ - default @Nullable RecipeType recipeType() { + default @NotNull Map> itemProperties() { + return Map.of(); + } + + default @Nullable String recipeBookGroup() { + return null; + } + + default @Nullable RecipeBookCategory recipeBookCategory() { + return null; + } + + default @Nullable List craftingRequirements() { return null; } diff --git a/src/main/java/net/minestom/server/recipe/RecipeManager.java b/src/main/java/net/minestom/server/recipe/RecipeManager.java index 93e4d7021..b1203ee53 100644 --- a/src/main/java/net/minestom/server/recipe/RecipeManager.java +++ b/src/main/java/net/minestom/server/recipe/RecipeManager.java @@ -2,71 +2,137 @@ package net.minestom.server.recipe; import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectMaps; import net.minestom.server.entity.Player; import net.minestom.server.item.Material; import net.minestom.server.network.packet.server.CachedPacket; import net.minestom.server.network.packet.server.SendablePacket; import net.minestom.server.network.packet.server.play.DeclareRecipesPacket; +import net.minestom.server.network.packet.server.play.RecipeBookAddPacket; import net.minestom.server.recipe.display.RecipeDisplay; -import net.minestom.server.recipe.display.SlotDisplay; +import net.minestom.server.utils.validate.Check; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Predicate; public final class RecipeManager { - private final CachedPacket declareRecipesPacket = new CachedPacket(this::createDeclareRecipesPacket); - private final Map> recipes = new ConcurrentHashMap<>(); + private static final AtomicInteger NEXT_DISPLAY_ID = new AtomicInteger(); - private final Int2ObjectMap displayIdMap = new Int2ObjectArrayMap<>(); - - public void addRecipe(@NotNull Recipe recipe, @NotNull Predicate predicate) { - var previous = recipes.put(recipe, predicate); - if (previous == null) { - declareRecipesPacket.invalidate(); - } + private record RecipeData( + @NotNull Recipe recipe, + @NotNull List displays, + @NotNull Predicate predicate + ) { } + private final CachedPacket declareRecipesPacket = new CachedPacket(this::createDeclareRecipesPacket); + + private final Map recipes = new ConcurrentHashMap<>(); + private final Int2ObjectMap>> recipeBookEntryIdMap = + Int2ObjectMaps.synchronize(new Int2ObjectArrayMap<>()); + public void addRecipe(@NotNull Recipe recipe) { addRecipe(recipe, player -> true); } - public void removeRecipe(@NotNull Recipe recipe) { - if (this.recipes.remove(recipe) != null) { - declareRecipesPacket.invalidate(); + public void addRecipe(@NotNull Recipe recipe, @NotNull Predicate predicate) { + List recipeBookEntries = new ArrayList<>(); + final RecipeBookCategory recipeBookCategory = recipe.recipeBookCategory(); + if (recipeBookCategory != null) { + for (var display : recipe.createRecipeDisplays()) { + int displayId = NEXT_DISPLAY_ID.getAndIncrement(); + recipeBookEntries.add(new RecipeBookAddPacket.Entry( //todo groups + displayId, display, null, recipeBookCategory, + recipe.craftingRequirements(), false, false + )); + } + } + + var existingRecipe = recipes.putIfAbsent(recipe, new RecipeData(recipe, recipeBookEntries, predicate)); + Check.argCondition(existingRecipe != null, "Recipe is already registered: " + recipe); + for (RecipeBookAddPacket.Entry entry : recipeBookEntries) { + recipeBookEntryIdMap.put(entry.displayId(), Map.entry(entry, predicate)); } } - public List consumeRecipes(Player player) { - return recipes.entrySet().stream() - .filter(entry -> entry.getValue().test(player)) - .map(Map.Entry::getKey) - .toList(); + public void removeRecipe(@NotNull Recipe recipe) { + final RecipeData removed = recipes.remove(recipe); + if (removed != null) { + for (var entry : removed.displays) { + recipeBookEntryIdMap.remove(entry.displayId()); + } + } } public @NotNull Set getRecipes() { return recipes.keySet(); } + /** + * Get the recipe display for the specified display id, optionally testing visibility against the given player. + * + * @param displayId the display id + * @param player the player to test visibility against, or null to ignore visibility + * @return the recipe display, or null if not found or not visible + */ + public @Nullable RecipeDisplay getRecipeDisplay(int displayId, @Nullable Player player) { + var recipeBookEntry = recipeBookEntryIdMap.get(displayId); + if (recipeBookEntry == null || (player != null && !recipeBookEntry.getValue().test(player))) return null; + + return recipeBookEntry.getKey().display(); + } + public @NotNull SendablePacket getDeclareRecipesPacket() { return declareRecipesPacket; } + /** + * Creates a {@link RecipeBookAddPacket} which replaces the recipe book with the currently unlocked + * recipes for this player. + * + * @param player the player to create the packet for + * @return the recipe book add packet with replace set to true + */ + public @NotNull RecipeBookAddPacket createRecipeBookResetPacket(@NotNull Player player) { + final List entries = new ArrayList<>(); + for (final Map.Entry recipeEntry : recipes.entrySet()) { + if (!recipeEntry.getValue().predicate.test(player)) continue; + + entries.addAll(recipeEntry.getValue().displays); + } + return new RecipeBookAddPacket(entries, true); + } + private @NotNull DeclareRecipesPacket createDeclareRecipesPacket() { - // Collect the special recipe entries requested by the client. - final Map> itemProperties = new HashMap<>(); - final List stonecutterRecipes = new ArrayList<>(); - for (var recipeDisplay : displayIdMap.values()) { - if (recipeDisplay instanceof RecipeDisplay.Stonecutter stonecutterDisplay) { - - - stonecutterRecipes.add(new DeclareRecipesPacket.StonecutterRecipe( -// Ingredient, display - )) + // Collect the item properties for the client + final Map> itemProperties = new HashMap<>(); + for (var recipe : recipes.keySet()) { + for (var entry : recipe.itemProperties().entrySet()) { + itemProperties.computeIfAbsent(entry.getKey(), k -> new HashSet<>()).addAll(entry.getValue()); } } + final Map> itemPropertiesLists = new HashMap<>(); + for (var entry : itemProperties.entrySet()) { // Sets to lists + itemPropertiesLists.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } - return new DeclareRecipesPacket(itemProperties, stonecutterRecipes); + // Collect the stonecutter recipes for the client + final List stonecutterRecipes = new ArrayList<>(); + for (var recipeBookEntry : recipeBookEntryIdMap.values()) { + if (!(recipeBookEntry.getKey().display() instanceof RecipeDisplay.Stonecutter stonecutterDisplay)) + continue; + + final Ingredient input = Ingredient.fromSlotDisplay(stonecutterDisplay.ingredient()); + if (input == null) continue; + + stonecutterRecipes.add(new DeclareRecipesPacket.StonecutterRecipe(input, stonecutterDisplay.result())); + } + + return new DeclareRecipesPacket(itemPropertiesLists, stonecutterRecipes); } + } diff --git a/src/test/java/net/minestom/server/network/PacketWriteReadTest.java b/src/test/java/net/minestom/server/network/PacketWriteReadTest.java index ee15d304d..360ab17af 100644 --- a/src/test/java/net/minestom/server/network/PacketWriteReadTest.java +++ b/src/test/java/net/minestom/server/network/PacketWriteReadTest.java @@ -23,7 +23,7 @@ import net.minestom.server.network.packet.server.login.SetCompressionPacket; import net.minestom.server.network.packet.server.play.*; import net.minestom.server.network.packet.server.status.ResponsePacket; import net.minestom.server.network.player.GameProfile; -import net.minestom.server.recipe.Recipe; +import net.minestom.server.recipe.Ingredient; import net.minestom.server.recipe.RecipeBookCategory; import net.minestom.server.recipe.RecipeProperty; import net.minestom.server.recipe.display.RecipeDisplay; @@ -97,11 +97,11 @@ public class PacketWriteReadTest { RecipeProperty.BLAST_FURNACE_INPUT, List.of(Material.IRON_HOE, Material.DANDELION), RecipeProperty.SMOKER_INPUT, List.of(Material.STONE), RecipeProperty.CAMPFIRE_INPUT, List.of(Material.STONE)), - List.of(new DeclareRecipesPacket.StonecutterRecipe(new Recipe.Ingredient(Material.DIAMOND), + List.of(new DeclareRecipesPacket.StonecutterRecipe(new Ingredient(Material.DIAMOND), new SlotDisplay.ItemStack(ItemStack.of(Material.GOLD_BLOCK)))) )); SERVER_PACKETS.add(new RecipeBookAddPacket(List.of(new RecipeBookAddPacket.Entry(1, recipeDisplay, null, - RecipeBookCategory.CRAFTING_MISC, List.of(new Recipe.Ingredient(Material.STONE)), true, true)), false)); + RecipeBookCategory.CRAFTING_MISC, List.of(new Ingredient(Material.STONE)), true, true)), false)); SERVER_PACKETS.add(new RecipeBookRemovePacket(List.of(1))); SERVER_PACKETS.add(new DestroyEntitiesPacket(List.of(5, 5, 5))); diff --git a/src/test/java/net/minestom/server/recipe/IngredientTest.java b/src/test/java/net/minestom/server/recipe/IngredientTest.java index 50c493148..9fc9f8381 100644 --- a/src/test/java/net/minestom/server/recipe/IngredientTest.java +++ b/src/test/java/net/minestom/server/recipe/IngredientTest.java @@ -11,11 +11,11 @@ public class IngredientTest { @Test public void cannotCreateAirIngredient() { - assertThrows(IllegalArgumentException.class, () -> new Recipe.Ingredient(Material.AIR)); + assertThrows(IllegalArgumentException.class, () -> new Ingredient(Material.AIR)); } @Test public void cannotCreateEmptyIngredient() { - assertThrows(IllegalArgumentException.class, () -> new Recipe.Ingredient(List.of())); + assertThrows(IllegalArgumentException.class, () -> new Ingredient(List.of())); } }