chore: vastly simplified recipe manager behavior

This commit is contained in:
mworzala 2024-12-01 22:03:28 -05:00 committed by Matt Worzala
parent c3a71ad10f
commit 2233ec8362
10 changed files with 222 additions and 91 deletions

View File

@ -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();

View File

@ -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<Material> ingredients,
@NotNull ItemStack result
) implements Recipe {
@Override
public @NotNull List<RecipeDisplay> 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<Ingredient> craftingRequirements() {
return List.of(new Ingredient(ingredients));
}
}

View File

@ -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<String> 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;

View File

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

View File

@ -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<Entry> entries, boolean replace)
public record Entry(
int displayId, @NotNull RecipeDisplay display,
@Nullable Integer group, @NotNull RecipeBookCategory category,
@Nullable List<Recipe.Ingredient> craftingRequirements,
@Nullable List<Ingredient> craftingRequirements,
byte flags
) {
public static final NetworkBuffer.Type<Entry> SERIALIZER = NetworkBufferTemplate.template(
@ -38,13 +37,13 @@ public record RecipeBookAddPacket(@NotNull List<Entry> 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<Recipe.Ingredient> craftingRequirements,
@Nullable List<Ingredient> craftingRequirements,
boolean notification, boolean highlight) {
this(displayId, display, group, category, craftingRequirements,
(byte) ((notification ? FLAG_NOTIFICATION : 0) | (highlight ? FLAG_HIGHLIGHT : 0)));

View File

@ -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;
};
}
}

View File

@ -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.
*
* <p>Displays should be consistent across calls and not specific to a player, they may be cached in {@link RecipeManager}.</p>
*
* <p>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.</p>
*
* @return a list of recipe displays, or none if the recipe should not be displayed in the recipe book
*/
default @NotNull List<RecipeDisplay> 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).
*
* <p>Item properties should be consistent across calls and not specific to a player, they may be cached in {@link RecipeManager}.</p>
*
* @return A map of item properties associated with this recipe.
*/
default @Nullable RecipeType recipeType() {
default @NotNull Map<RecipeProperty, List<Material>> itemProperties() {
return Map.of();
}
default @Nullable String recipeBookGroup() {
return null;
}
default @Nullable RecipeBookCategory recipeBookCategory() {
return null;
}
default @Nullable List<Ingredient> craftingRequirements() {
return null;
}

View File

@ -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<Recipe, Predicate<Player>> recipes = new ConcurrentHashMap<>();
private static final AtomicInteger NEXT_DISPLAY_ID = new AtomicInteger();
private final Int2ObjectMap<RecipeDisplay> displayIdMap = new Int2ObjectArrayMap<>();
public void addRecipe(@NotNull Recipe recipe, @NotNull Predicate<Player> predicate) {
var previous = recipes.put(recipe, predicate);
if (previous == null) {
declareRecipesPacket.invalidate();
}
private record RecipeData(
@NotNull Recipe recipe,
@NotNull List<RecipeBookAddPacket.Entry> displays,
@NotNull Predicate<Player> predicate
) {
}
private final CachedPacket declareRecipesPacket = new CachedPacket(this::createDeclareRecipesPacket);
private final Map<Recipe, RecipeData> recipes = new ConcurrentHashMap<>();
private final Int2ObjectMap<Map.Entry<RecipeBookAddPacket.Entry, Predicate<Player>>> 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<Player> predicate) {
List<RecipeBookAddPacket.Entry> 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<Recipe> 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<Recipe> 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<RecipeBookAddPacket.Entry> entries = new ArrayList<>();
for (final Map.Entry<Recipe, RecipeData> 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<RecipeProperty, List<Material>> itemProperties = new HashMap<>();
final List<DeclareRecipesPacket.StonecutterRecipe> 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<RecipeProperty, Set<Material>> 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<RecipeProperty, List<Material>> 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<DeclareRecipesPacket.StonecutterRecipe> 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);
}
}

View File

@ -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)));

View File

@ -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()));
}
}