From 9dad1acfe5a87130b39224f1706ab78176922057 Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 10 Nov 2023 20:45:15 +0100 Subject: [PATCH] Feature: Add recipe unlocking on Bedrock edition (#4016) * Start on 1.20.10+ recipe unlocking system * Keeping track of multiple Bedrock recipes to unlock for a single Java recipe * Unlock stonecutter recipes * Stonecutter recipes * Unlock tipped arrows/shulker box recipes even when Java doesnt (why..?), and dont send trims if Java doesn't * Translate FurnaceDataRecipes * Revert FurnaceRecipe translation, revert stone cutter recipe identifier caching - Bedrock does not need the smelting recipe, and doesn't (un)lock stonecutter recipes (yet...?) * Remove debug message * Make decorated pot crafting just a little bit smoother :p * formatting * Use itemTag descriptors to fix https://github.com/GeyserMC/Geyser/issues/3784 * Use hashmap instead to store item tag overrides * remove unnecessary comment * Address review by @Konicai * Support for 1.20.30 * undo add whitespace * Merge upstream, use FastUtil maps, rename a few methods * Address Camotoy's review * Fix formatting --- .../geysermc/geyser/network/GameProtocol.java | 8 ++ .../geyser/session/GeyserSession.java | 17 ++++ .../JavaClientboundRecipesTranslator.java | 79 +++++++++++++++++ .../java/JavaUpdateRecipesTranslator.java | 85 +++++++++++++++++-- 4 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaClientboundRecipesTranslator.java diff --git a/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java b/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java index bf40ea2f9..5555375cd 100644 --- a/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java +++ b/core/src/main/java/org/geysermc/geyser/network/GameProtocol.java @@ -93,6 +93,14 @@ public final class GameProtocol { return session.getUpstream().getProtocolVersion() < Bedrock_v594.CODEC.getProtocolVersion(); } + /** + * @param session the session to check + * @return true if the session needs an experiment for recipe unlocking + */ + public static boolean isUsingExperimentalRecipeUnlocking(GeyserSession session) { + return session.getUpstream().getProtocolVersion() == Bedrock_v594.CODEC.getProtocolVersion(); + } + /** * Gets the {@link PacketCodec} for Minecraft: Java Edition. * diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java index 35b532905..4d4b2a5ba 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -128,6 +128,7 @@ import org.geysermc.geyser.item.Items; import org.geysermc.geyser.level.JavaDimension; import org.geysermc.geyser.level.WorldManager; import org.geysermc.geyser.level.physics.CollisionManager; +import org.geysermc.geyser.network.GameProtocol; import org.geysermc.geyser.network.netty.LocalSession; import org.geysermc.geyser.registry.Registries; import org.geysermc.geyser.registry.type.BlockMappings; @@ -391,6 +392,13 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { @Setter private Entity mouseoverEntity; + /** + * Stores all Java recipes by recipe identifier, and matches them to all possible Bedrock recipe identifiers. + * They are not 1:1, since Bedrock can have multiple recipes for the same Java recipe. + */ + @Setter + private Map> javaToBedrockRecipeIds; + @Setter private Int2ObjectMap craftingRecipes; private final AtomicInteger lastRecipeNetId; @@ -611,6 +619,7 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { this.playerInventory = new PlayerInventory(); this.openInventory = null; this.craftingRecipes = new Int2ObjectOpenHashMap<>(); + this.javaToBedrockRecipeIds = new Object2ObjectOpenHashMap<>(); this.lastRecipeNetId = new AtomicInteger(1); this.spawned = false; @@ -690,6 +699,10 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { gamerulePacket.getGameRules().add(new GameRuleData<>("keepinventory", true)); // Ensure client doesn't try and do anything funky; the server handles this for us gamerulePacket.getGameRules().add(new GameRuleData<>("spawnradius", 0)); + // Recipe unlocking - only needs to be added if 1. it isn't already on via an experiment, or 2. the client is on pre 1.20.10 + if (!GameProtocol.isPre1_20_10(this) && !GameProtocol.isUsingExperimentalRecipeUnlocking(this)) { + gamerulePacket.getGameRules().add(new GameRuleData<>("recipesunlock", true)); + } upstream.sendPacket(gamerulePacket); } @@ -1527,6 +1540,10 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { startGamePacket.setRewindHistorySize(0); startGamePacket.setServerAuthoritativeBlockBreaking(false); + if (GameProtocol.isUsingExperimentalRecipeUnlocking(this)) { + startGamePacket.getExperiments().add(new ExperimentData("recipe_unlocking", true)); + } + upstream.sendPacket(startGamePacket); } diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaClientboundRecipesTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaClientboundRecipesTranslator.java new file mode 100644 index 000000000..1ccbcfdec --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaClientboundRecipesTranslator.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2019-2023 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.translator.protocol.java; + +import com.github.steveice10.mc.protocol.packet.ingame.clientbound.ClientboundRecipePacket; +import org.cloudburstmc.protocol.bedrock.packet.UnlockedRecipesPacket; +import org.geysermc.geyser.network.GameProtocol; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.translator.protocol.PacketTranslator; +import org.geysermc.geyser.translator.protocol.Translator; + +import java.util.ArrayList; +import java.util.List; + +@Translator(packet = ClientboundRecipePacket.class) +public class JavaClientboundRecipesTranslator extends PacketTranslator { + + @Override + public void translate(GeyserSession session, ClientboundRecipePacket packet) { + // recipe unlocking does not exist pre 1.20.10 + if (GameProtocol.isPre1_20_10(session)) { + return; + } + + UnlockedRecipesPacket recipesPacket = new UnlockedRecipesPacket(); + switch (packet.getAction()) { + case INIT -> { + recipesPacket.setAction(UnlockedRecipesPacket.ActionType.INITIALLY_UNLOCKED); + recipesPacket.getUnlockedRecipes().addAll(getBedrockRecipes(session, packet.getAlreadyKnownRecipes())); + } + case ADD -> { + recipesPacket.setAction(UnlockedRecipesPacket.ActionType.NEWLY_UNLOCKED); + recipesPacket.getUnlockedRecipes().addAll(getBedrockRecipes(session, packet.getRecipes())); + } + case REMOVE -> { + recipesPacket.setAction(UnlockedRecipesPacket.ActionType.REMOVE_UNLOCKED); + recipesPacket.getUnlockedRecipes().addAll(getBedrockRecipes(session, packet.getRecipes())); + } + } + session.sendUpstreamPacket(recipesPacket); + } + + private List getBedrockRecipes(GeyserSession session, String[] javaRecipeIdentifiers) { + List recipes = new ArrayList<>(); + for (String javaIdentifier : javaRecipeIdentifiers) { + List bedrockRecipes = session.getJavaToBedrockRecipeIds().get(javaIdentifier); + // Some recipes are not (un)lockable on Bedrock edition, like furnace or stonecutter recipes. + // So we don't store/send these. + if (bedrockRecipes != null) { + recipes.addAll(bedrockRecipes); + } + } + return recipes; + } +} + diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateRecipesTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateRecipesTranslator.java index 7a14cebcb..5beb1a201 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateRecipesTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/JavaUpdateRecipesTranslator.java @@ -44,6 +44,7 @@ import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.RecipeDa import org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.SmithingTrimRecipeData; import org.cloudburstmc.protocol.bedrock.data.inventory.descriptor.DefaultDescriptor; import org.cloudburstmc.protocol.bedrock.data.inventory.descriptor.ItemDescriptorWithCount; +import org.cloudburstmc.protocol.bedrock.data.inventory.descriptor.ItemTagDescriptor; import org.cloudburstmc.protocol.bedrock.packet.CraftingDataPacket; import org.cloudburstmc.protocol.bedrock.packet.TrimDataPacket; import org.geysermc.geyser.GeyserImpl; @@ -67,7 +68,6 @@ import static org.geysermc.geyser.util.InventoryUtils.LAST_RECIPE_NET_ID; /** * Used to send all valid recipes from Java to Bedrock. - * * Bedrock REQUIRES a CraftingDataPacket to be sent in order to craft anything. */ @Translator(packet = ClientboundUpdateRecipesPacket.class) @@ -94,17 +94,27 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator RECIPE_TAGS = Map.of( + "minecraft:wood", "minecraft:logs", + "minecraft:wooden_slab", "minecraft:wooden_slabs", + "minecraft:planks", "minecraft:planks"); + @Override public void translate(GeyserSession session, ClientboundUpdateRecipesPacket packet) { Map> recipeTypes = Registries.CRAFTING_DATA.forVersion(session.getUpstream().getProtocolVersion()); // Get the last known network ID (first used for the pregenerated recipes) and increment from there. int netId = InventoryUtils.LAST_RECIPE_NET_ID + 1; boolean sendTrimRecipes = false; - + Map> recipeIDs = session.getJavaToBedrockRecipeIds(); Int2ObjectMap recipeMap = new Int2ObjectOpenHashMap<>(Registries.RECIPES.forVersion(session.getUpstream().getProtocolVersion())); Int2ObjectMap> unsortedStonecutterData = new Int2ObjectOpenHashMap<>(); CraftingDataPacket craftingDataPacket = new CraftingDataPacket(); craftingDataPacket.setCleanRecipes(true); + for (Recipe recipe : packet.getRecipes()) { switch (recipe.getType()) { case CRAFTING_SHAPELESS -> { @@ -121,12 +131,15 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator bedrockRecipeIDs = new ArrayList<>(); for (ItemDescriptorWithCount[] inputs : inputCombinations) { UUID uuid = UUID.randomUUID(); + bedrockRecipeIDs.add(uuid.toString()); craftingDataPacket.getCraftingData().add(org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.ShapelessRecipeData.shapeless(uuid.toString(), Arrays.asList(inputs), Collections.singletonList(output), uuid, "crafting_table", 0, netId)); recipeMap.put(netId++, new GeyserShapelessRecipe(shapelessRecipeData)); } + addRecipeIdentifier(session, recipe.getIdentifier(), bedrockRecipeIDs); } case CRAFTING_SHAPED -> { ShapedRecipeData shapedRecipeData = (ShapedRecipeData) recipe.getData(); @@ -141,13 +154,17 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator bedrockRecipeIDs = new ArrayList<>(); for (ItemDescriptorWithCount[] inputs : inputCombinations) { UUID uuid = UUID.randomUUID(); + bedrockRecipeIDs.add(uuid.toString()); craftingDataPacket.getCraftingData().add(org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.ShapedRecipeData.shaped(uuid.toString(), shapedRecipeData.getWidth(), shapedRecipeData.getHeight(), Arrays.asList(inputs), Collections.singletonList(output), uuid, "crafting_table", 0, netId)); recipeMap.put(netId++, new GeyserShapedRecipe(shapedRecipeData)); } + addRecipeIdentifier(session, recipe.getIdentifier(), bedrockRecipeIDs); } case STONECUTTING -> { StoneCuttingRecipeData stoneCuttingData = (StoneCuttingRecipeData) recipe.getData(); @@ -157,8 +174,8 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator(); unsortedStonecutterData.put(ingredient.getId(), data); } - data.add(stoneCuttingData); // Save for processing after all recipes have been received + data.add(stoneCuttingData); } case SMITHING_TRANSFORM -> { SmithingTransformRecipeData data = (SmithingTransformRecipeData) recipe.getData(); @@ -173,21 +190,29 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator(Collections.singletonList(id))); } } } - } case SMITHING_TRIM -> { sendTrimRecipes = true; // ignored currently - see below } + case CRAFTING_DECORATED_POT -> { + // Paper 1.20 seems to send only one recipe, which seems to be hardcoded to include all recipes. + // We can send the equivalent Bedrock MultiRecipe! :) + craftingDataPacket.getCraftingData().add(MultiRecipeData.of(UUID.fromString("685a742a-c42e-4a4e-88ea-5eb83fc98e5b"), netId++)); + } default -> { List craftingData = recipeTypes.get(recipe.getType()); if (craftingData != null) { + addSpecialRecipesIdentifiers(session, recipe, craftingData); craftingDataPacket.getCraftingData().addAll(craftingData); } } @@ -218,14 +243,15 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator craftingData) { + String javaRecipeID = recipe.getIdentifier(); + + switch (recipe.getType()) { + case CRAFTING_SPECIAL_BOOKCLONING, CRAFTING_SPECIAL_REPAIRITEM, CRAFTING_SPECIAL_MAPEXTENDING, CRAFTING_SPECIAL_MAPCLONING: + // We do not want to (un)lock these, since BDS does not do it for MultiRecipes + return; + case CRAFTING_SPECIAL_SHULKERBOXCOLORING: + // BDS (un)locks the dyeing with the shulker box recipe, Java never - we want BDS behavior for ease of use + javaRecipeID = "minecraft:shulker_box"; + break; + case CRAFTING_SPECIAL_TIPPEDARROW: + // similar as above + javaRecipeID = "minecraft:arrow"; + break; + } + List bedrockRecipeIDs = new ArrayList<>(); + + // defined in the recipes.json mappings file: Only tipped arrows use shaped recipes, we need the cast for the identifier + if (recipe.getType() == RecipeType.CRAFTING_SPECIAL_TIPPEDARROW) { + for (RecipeData data : craftingData) { + bedrockRecipeIDs.add(((org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.ShapedRecipeData) data).getId()); + } + } else { + for (RecipeData data : craftingData) { + bedrockRecipeIDs.add(((org.cloudburstmc.protocol.bedrock.data.inventory.crafting.recipe.ShapelessRecipeData) data).getId()); + } + } + addRecipeIdentifier(session, javaRecipeID, bedrockRecipeIDs); } //TODO: rewrite @@ -277,6 +335,13 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator> entry : groupedByIds.entrySet()) { if (entry.getValue().size() > 1) { GroupedItem groupedItem = entry.getKey(); + + String recipeTag = RECIPE_TAGS.get(groupedItem.id.getIdentifier()); + if (recipeTag != null) { + optionSet.add(new ItemDescriptorWithCount(new ItemTagDescriptor(recipeTag), groupedItem.count)); + continue; + } + int idCount = 0; //not optimal for (ItemMapping mapping : session.getItemMappings().getItems()) { @@ -337,6 +402,10 @@ public class JavaUpdateRecipesTranslator extends PacketTranslator bedrockIdentifiers) { + session.getJavaToBedrockRecipeIds().computeIfAbsent(javaIdentifier, k -> new ArrayList<>()).addAll(bedrockIdentifiers); + } + @EqualsAndHashCode @AllArgsConstructor private static class GroupedItem {