diff --git a/MinecraftClient/Commands/RecipeBook.cs b/MinecraftClient/Commands/RecipeBook.cs new file mode 100644 index 00000000..a66b2004 --- /dev/null +++ b/MinecraftClient/Commands/RecipeBook.cs @@ -0,0 +1,92 @@ +using System.Text; +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; + +namespace MinecraftClient.Commands +{ + public class RecipeBook : Command + { + public override string CmdName => "recipebook"; + public override string CmdUsage => "recipebook [recipe id]"; + public override string CmdDesc => Translations.cmd_recipebook_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source, string.Empty)) + .Then(l => l.Literal("list") + .Executes(r => GetUsage(r.Source, "list"))) + .Then(l => l.Literal("craft") + .Executes(r => GetUsage(r.Source, "craft"))) + .Then(l => l.Literal("craftall") + .Executes(r => GetUsage(r.Source, "craftall"))) + ) + ); + + dispatcher.Register(l => l.Literal(CmdName) + .Then(l => l.Literal("list") + .Executes(r => ListRecipes(r.Source))) + .Then(l => l.Literal("craft") + .Then(l => l.Argument("RecipeId", Arguments.String()) + .Executes(r => CraftRecipe(r.Source, Arguments.GetString(r, "RecipeId"), makeAll: false)))) + .Then(l => l.Literal("craftall") + .Then(l => l.Argument("RecipeId", Arguments.String()) + .Executes(r => CraftRecipe(r.Source, Arguments.GetString(r, "RecipeId"), makeAll: true)))) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source, string.Empty)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName))) + ); + } + + private int GetUsage(CmdResult r, string? cmd) + { + return r.SetAndReturn(cmd switch + { +#pragma warning disable format // @formatter:off + "list" => GetCmdDescTranslated(), + "craft" => GetCmdDescTranslated(), + "craftall" => GetCmdDescTranslated(), + _ => GetCmdDescTranslated(), +#pragma warning restore format // @formatter:on + }); + } + + private int ListRecipes(CmdResult r) + { + McClient handler = CmdResult.currentHandler!; + if (!handler.GetInventoryEnabled()) + return r.SetAndReturn(CmdResult.Status.FailNeedInventory); + + string[] recipeIds = handler.GetUnlockedRecipes(); + if (recipeIds.Length == 0) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_no_recipes); + + StringBuilder response = new(); + response.AppendLine(Translations.cmd_recipebook_list); + foreach (string recipeId in recipeIds) + response.AppendLine("- " + recipeId); + + handler.Log.Info(response.ToString().TrimEnd()); + return r.SetAndReturn(CmdResult.Status.Done); + } + + private int CraftRecipe(CmdResult r, string recipeId, bool makeAll) + { + McClient handler = CmdResult.currentHandler!; + if (!handler.GetInventoryEnabled()) + return r.SetAndReturn(CmdResult.Status.FailNeedInventory); + + if (handler.GetProtocolVersion() < Protocol.Handlers.Protocol18Handler.MC_1_13_Version) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_unsupported); + + if (handler.GetActiveRecipeBookInventory() is null) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_recipebook_no_active_inventory); + + return handler.SendPlaceRecipe(recipeId, makeAll) + ? r.SetAndReturn(CmdResult.Status.Done, string.Format(Translations.cmd_recipebook_craft_sent, recipeId, makeAll)) + : r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_recipebook_craft_failed, recipeId)); + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 938a85bf..3e12d920 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -44,10 +44,12 @@ namespace MinecraftClient private readonly Queue threadTasks = new(); private readonly Lock threadTasksLock = new(); + private readonly Lock recipeBookLock = new(); private readonly List bots = new(); private static readonly List botsOnHold = new(); private static readonly Dictionary inventories = new(); + private readonly HashSet unlockedRecipes = new(StringComparer.Ordinal); private readonly Dictionary> registeredBotPluginChannels = new(); private readonly List registeredServerPluginChannels = new(); @@ -1237,6 +1239,7 @@ namespace MinecraftClient inventoryHandlingEnabled = false; inventoryHandlingRequested = false; inventories.Clear(); + ClearUnlockedRecipes(); } return true; } @@ -1338,6 +1341,18 @@ namespace MinecraftClient return lastEnchantment; } + /// + /// Get all unlocked recipe book recipe identifiers. + /// + /// Unlocked recipe identifiers sorted alphabetically + public string[] GetUnlockedRecipes() + { + lock (recipeBookLock) + { + return [.. unlockedRecipes.OrderBy(static recipeId => recipeId, StringComparer.Ordinal)]; + } + } + /// /// Get all Entities /// @@ -1384,6 +1399,22 @@ namespace MinecraftClient return GetInventory(0)!; } + /// + /// Get the currently active inventory if it supports recipe book crafting. + /// + /// Active recipe book inventory, or null if the active inventory does not support recipe book crafting + public Container? GetActiveRecipeBookInventory() + { + if (InvokeRequired) + return InvokeOnMainThread(() => GetActiveRecipeBookInventory()); + + if (inventories.Count == 0) + return null; + + Container activeInventory = inventories.Values.Last(); + return SupportsRecipeBook(activeInventory.Type) ? activeInventory : null; + } + /// /// Get a set of online player names /// @@ -2476,6 +2507,7 @@ namespace MinecraftClient inventories.Clear(); inventories[0] = new Container(0, ContainerType.PlayerInventory, "Player Inventory"); + ClearUnlockedRecipes(); return true; } @@ -2677,6 +2709,27 @@ namespace MinecraftClient return handler.SendRenameItem(itemName); } + + /// + /// Send a recipe book craft request for the currently active crafting inventory. + /// + /// Recipe identifier to craft + /// True to craft as many items as possible + /// True if the packet was sent + public bool SendPlaceRecipe(string recipeId, bool makeAll) + { + if (InvokeRequired) + return InvokeOnMainThread(() => SendPlaceRecipe(recipeId, makeAll)); + + if (protocolversion < Protocol18Handler.MC_1_13_Version) + return false; + + Container? activeInventory = GetActiveRecipeBookInventory(); + if (activeInventory is null) + return false; + + return handler.SendPlaceRecipe(activeInventory.ID, NormalizeRecipeId(recipeId), makeAll); + } #endregion #region Event handlers: An event occurs on the Server @@ -4054,6 +4107,33 @@ namespace MinecraftClient Log.Debug("CanSendMessage = " + canSendMessage); } + public void OnRecipeBookAdd(string[] recipeIds, bool replace) + { + lock (recipeBookLock) + { + if (replace) + unlockedRecipes.Clear(); + + foreach (string recipeId in recipeIds) + { + if (!string.IsNullOrWhiteSpace(recipeId)) + unlockedRecipes.Add(recipeId); + } + } + } + + public void OnRecipeBookRemove(string[] recipeIds) + { + lock (recipeBookLock) + { + foreach (string recipeId in recipeIds) + { + if (!string.IsNullOrWhiteSpace(recipeId)) + unlockedRecipes.Remove(recipeId); + } + } + } + /// /// Send a click container button packet to the server. /// Used for Enchanting table, Lectern, stone cutter and loom @@ -4067,6 +4147,36 @@ namespace MinecraftClient return handler.ClickContainerButton(windowId, buttonId); } + private static bool SupportsRecipeBook(ContainerType containerType) + { + return containerType switch + { + ContainerType.PlayerInventory or + ContainerType.Crafting or + ContainerType.Furnace or + ContainerType.BlastFurnace or + ContainerType.Smoker or + ContainerType.Stonecutter => true, + _ => false, + }; + } + + private void ClearUnlockedRecipes() + { + lock (recipeBookLock) + { + unlockedRecipes.Clear(); + } + } + + private static string NormalizeRecipeId(string recipeId) + { + string trimmedRecipeId = recipeId.Trim(); + return trimmedRecipeId.Contains(':', StringComparison.Ordinal) + ? trimmedRecipeId + : "minecraft:" + trimmedRecipeId; + } + #endregion } } diff --git a/MinecraftClient/Protocol/Handlers/Protocol16.cs b/MinecraftClient/Protocol/Handlers/Protocol16.cs index 15d20b71..6777200d 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol16.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol16.cs @@ -811,6 +811,11 @@ namespace MinecraftClient.Protocol.Handlers return false; //Currently not implemented } + public bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll) + { + return false; //MC 1.8-1.12.1 recipe book not supported + } + public bool SendCloseWindow(int windowId) { return false; //Currently not implemented diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 5329cdfb..317090a9 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -3116,8 +3116,17 @@ namespace MinecraftClient.Protocol.Handlers } break; + case PacketTypesIn.UnlockRecipes: + if (protocolVersion >= MC_1_13_Version) + HandleUnlockRecipes(packetData); + break; + case PacketTypesIn.RecipeBookAdd: + HandleRecipeBookAdd(packetData); + break; case PacketTypesIn.RecipeBookRemove: + handler.OnRecipeBookRemove(ReadRecipeBookRecipeIds(packetData)); + break; case PacketTypesIn.RecipeBookSettings: break; @@ -3128,6 +3137,63 @@ namespace MinecraftClient.Protocol.Handlers return true; //Packet processed } + private void HandleUnlockRecipes(Queue packetData) + { + int action = dataTypes.ReadNextVarInt(packetData); + SkipRecipeBookSettings(packetData); + + string[] recipeIds = ReadRecipeBookRecipeIds(packetData); + + switch (action) + { + case 0: + handler.OnRecipeBookAdd(recipeIds, replace: true); + _ = ReadRecipeBookRecipeIds(packetData); + break; + case 1: + case 3: + handler.OnRecipeBookAdd(recipeIds, replace: false); + break; + case 2: + handler.OnRecipeBookRemove(recipeIds); + break; + } + } + + private void HandleRecipeBookAdd(Queue packetData) + { + int entryCount = dataTypes.ReadNextVarInt(packetData); + string[] recipeIds = new string[entryCount]; + + for (int i = 0; i < entryCount; i++) + { + recipeIds[i] = dataTypes.ReadNextString(packetData); + _ = dataTypes.ReadNextBool(packetData); // notification + _ = dataTypes.ReadNextBool(packetData); // highlight + } + + bool replace = dataTypes.ReadNextBool(packetData); + handler.OnRecipeBookAdd(recipeIds, replace); + } + + private string[] ReadRecipeBookRecipeIds(Queue packetData) + { + int recipeCount = dataTypes.ReadNextVarInt(packetData); + string[] recipeIds = new string[recipeCount]; + + for (int i = 0; i < recipeCount; i++) + recipeIds[i] = dataTypes.ReadNextString(packetData); + + return recipeIds; + } + + private void SkipRecipeBookSettings(Queue packetData) + { + int boolCount = protocolVersion >= MC_1_14_Version ? 8 : 4; + for (int i = 0; i < boolCount; i++) + _ = dataTypes.ReadNextBool(packetData); + } + /// /// Start the updating thread. Should be called after login success. /// @@ -5018,6 +5084,34 @@ namespace MinecraftClient.Protocol.Handlers } } + public bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll) + { + try + { + List packet = new(); + if (protocolVersion < MC_1_13_Version) + return false; + + packet.AddRange(DataTypes.GetVarInt(windowId)); + packet.AddRange(dataTypes.GetString(recipeId)); + packet.AddRange(dataTypes.GetBool(makeAll)); + SendPacket(PacketTypesOut.CraftRecipeRequest, packet); + return true; + } + catch (SocketException) + { + return false; + } + catch (System.IO.IOException) + { + return false; + } + catch (ObjectDisposedException) + { + return false; + } + } + public bool SendAnimation(int animation, int playerId) { try diff --git a/MinecraftClient/Protocol/IMinecraftCom.cs b/MinecraftClient/Protocol/IMinecraftCom.cs index 6c7dd596..96b261b1 100644 --- a/MinecraftClient/Protocol/IMinecraftCom.cs +++ b/MinecraftClient/Protocol/IMinecraftCom.cs @@ -190,6 +190,15 @@ namespace MinecraftClient.Protocol bool ClickContainerButton(int windowId, int buttonId); + /// + /// Send a place recipe packet to the server for the active recipe book container. + /// + /// Id of the window being clicked + /// Recipe identifier to craft + /// True to craft as many items as possible + /// True if packet was successfully sent + bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll); + /// /// Plays animation /// diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index 94fe0590..d6bd5eb7 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -517,6 +517,19 @@ namespace MinecraftClient.Protocol public void SetCanSendMessage(bool canSendMessage); + /// + /// Called when recipe book recipes are added or replaced. + /// + /// Recipe identifiers to add + /// True to replace the currently tracked recipe book entries + public void OnRecipeBookAdd(string[] recipeIds, bool replace); + + /// + /// Called when recipe book recipes are removed. + /// + /// Recipe identifiers to remove + public void OnRecipeBookRemove(string[] recipeIds); + /// /// Send a click container button packet to the server. /// Used for Enchanting table, Lectern, stone cutter and loom diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index 3fc6e722..b6bea198 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -4359,6 +4359,69 @@ namespace MinecraftClient { return ResourceManager.GetString("cmd.nameitem.successful", resourceCulture); } } + + /// + /// Looks up a localized string similar to Failed to send recipe book craft request for {0}.. + /// + internal static string cmd_recipebook_craft_failed { + get { + return ResourceManager.GetString("cmd.recipebook.craft.failed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Requested recipe {0} (craft all: {1}).. + /// + internal static string cmd_recipebook_craft_sent { + get { + return ResourceManager.GetString("cmd.recipebook.craft.sent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to List unlocked recipe book recipes and craft them through the active recipe book inventory.. + /// + internal static string cmd_recipebook_desc { + get { + return ResourceManager.GetString("cmd.recipebook.desc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unlocked recipe book recipes. + /// + internal static string cmd_recipebook_list { + get { + return ResourceManager.GetString("cmd.recipebook.list", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory.. + /// + internal static string cmd_recipebook_no_active_inventory { + get { + return ResourceManager.GetString("cmd.recipebook.no.active.inventory", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No unlocked recipe book recipes are currently tracked.. + /// + internal static string cmd_recipebook_no_recipes { + get { + return ResourceManager.GetString("cmd.recipebook.no.recipes", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Recipe book crafting is only supported on Minecraft 1.13 and newer.. + /// + internal static string cmd_recipebook_unsupported { + get { + return ResourceManager.GetString("cmd.recipebook.unsupported", resourceCulture); + } + } /// /// Looks up a localized string similar to restart and reconnect to the server.. diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index c3acc0fc..42a9d8db 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2208,6 +2208,27 @@ Logging in... Set an item name when an Anvil inventory is active and the item is in the first slot. + + Failed to send recipe book craft request for {0}. + + + Requested recipe {0} (craft all: {1}). + + + List unlocked recipe book recipes and craft them through the active recipe book inventory. + + + Unlocked recipe book recipes + + + You need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory. + + + No unlocked recipe book recipes are currently tracked. + + + Recipe book crafting is only supported on Minecraft 1.13 and newer. + Bot movement lock is held by bot {0}, so the Anti AFK bot might not move!