feat: add recipe book command support

Agent-Logs-Url: https://github.com/MCCTeam/Minecraft-Console-Client/sessions/00c8527f-5755-43c1-8916-8d571d28860b

Co-authored-by: milutinke <441903+milutinke@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-29 18:00:44 +00:00
parent 3a28634592
commit d5308ba8c6
8 changed files with 407 additions and 0 deletions

View file

@ -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 <list|craft|craftall> [recipe id]";
public override string CmdDesc => Translations.cmd_recipebook_desc;
public override void RegisterCommand(CommandDispatcher<CmdResult> 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));
}
}
}

View file

@ -44,10 +44,12 @@ namespace MinecraftClient
private readonly Queue<Action> threadTasks = new();
private readonly Lock threadTasksLock = new();
private readonly Lock recipeBookLock = new();
private readonly List<ChatBot> bots = new();
private static readonly List<ChatBot> botsOnHold = new();
private static readonly Dictionary<int, Container> inventories = new();
private readonly HashSet<string> unlockedRecipes = new(StringComparer.Ordinal);
private readonly Dictionary<string, List<ChatBot>> registeredBotPluginChannels = new();
private readonly List<string> 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;
}
/// <summary>
/// Get all unlocked recipe book recipe identifiers.
/// </summary>
/// <returns>Unlocked recipe identifiers sorted alphabetically</returns>
public string[] GetUnlockedRecipes()
{
lock (recipeBookLock)
{
return [.. unlockedRecipes.OrderBy(static recipeId => recipeId, StringComparer.Ordinal)];
}
}
/// <summary>
/// Get all Entities
/// </summary>
@ -1384,6 +1399,22 @@ namespace MinecraftClient
return GetInventory(0)!;
}
/// <summary>
/// Get the currently active inventory if it supports recipe book crafting.
/// </summary>
/// <returns>Active recipe book inventory, or null if the active inventory does not support recipe book crafting</returns>
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;
}
/// <summary>
/// Get a set of online player names
/// </summary>
@ -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);
}
/// <summary>
/// Send a recipe book craft request for the currently active crafting inventory.
/// </summary>
/// <param name="recipeId">Recipe identifier to craft</param>
/// <param name="makeAll">True to craft as many items as possible</param>
/// <returns>True if the packet was sent</returns>
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);
}
}
}
/// <summary>
/// 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
}
}

View file

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

View file

@ -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<byte> 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<byte> 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<byte> 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<byte> packetData)
{
int boolCount = protocolVersion >= MC_1_14_Version ? 8 : 4;
for (int i = 0; i < boolCount; i++)
_ = dataTypes.ReadNextBool(packetData);
}
/// <summary>
/// Start the updating thread. Should be called after login success.
/// </summary>
@ -5018,6 +5084,34 @@ namespace MinecraftClient.Protocol.Handlers
}
}
public bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll)
{
try
{
List<byte> 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

View file

@ -190,6 +190,15 @@ namespace MinecraftClient.Protocol
bool ClickContainerButton(int windowId, int buttonId);
/// <summary>
/// Send a place recipe packet to the server for the active recipe book container.
/// </summary>
/// <param name="windowId">Id of the window being clicked</param>
/// <param name="recipeId">Recipe identifier to craft</param>
/// <param name="makeAll">True to craft as many items as possible</param>
/// <returns>True if packet was successfully sent</returns>
bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll);
/// <summary>
/// Plays animation
/// </summary>

View file

@ -517,6 +517,19 @@ namespace MinecraftClient.Protocol
public void SetCanSendMessage(bool canSendMessage);
/// <summary>
/// Called when recipe book recipes are added or replaced.
/// </summary>
/// <param name="recipeIds">Recipe identifiers to add</param>
/// <param name="replace">True to replace the currently tracked recipe book entries</param>
public void OnRecipeBookAdd(string[] recipeIds, bool replace);
/// <summary>
/// Called when recipe book recipes are removed.
/// </summary>
/// <param name="recipeIds">Recipe identifiers to remove</param>
public void OnRecipeBookRemove(string[] recipeIds);
/// <summary>
/// Send a click container button packet to the server.
/// Used for Enchanting table, Lectern, stone cutter and loom

View file

@ -4359,6 +4359,69 @@ namespace MinecraftClient {
return ResourceManager.GetString("cmd.nameitem.successful", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Failed to send recipe book craft request for {0}..
/// </summary>
internal static string cmd_recipebook_craft_failed {
get {
return ResourceManager.GetString("cmd.recipebook.craft.failed", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Requested recipe {0} (craft all: {1})..
/// </summary>
internal static string cmd_recipebook_craft_sent {
get {
return ResourceManager.GetString("cmd.recipebook.craft.sent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to List unlocked recipe book recipes and craft them through the active recipe book inventory..
/// </summary>
internal static string cmd_recipebook_desc {
get {
return ResourceManager.GetString("cmd.recipebook.desc", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Unlocked recipe book recipes.
/// </summary>
internal static string cmd_recipebook_list {
get {
return ResourceManager.GetString("cmd.recipebook.list", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to You need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory..
/// </summary>
internal static string cmd_recipebook_no_active_inventory {
get {
return ResourceManager.GetString("cmd.recipebook.no.active.inventory", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No unlocked recipe book recipes are currently tracked..
/// </summary>
internal static string cmd_recipebook_no_recipes {
get {
return ResourceManager.GetString("cmd.recipebook.no.recipes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Recipe book crafting is only supported on Minecraft 1.13 and newer..
/// </summary>
internal static string cmd_recipebook_unsupported {
get {
return ResourceManager.GetString("cmd.recipebook.unsupported", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to restart and reconnect to the server..

View file

@ -2208,6 +2208,27 @@ Logging in...</value>
<data name="cmd.nameitem.desc" xml:space="preserve">
<value>Set an item name when an Anvil inventory is active and the item is in the first slot.</value>
</data>
<data name="cmd.recipebook.craft.failed" xml:space="preserve">
<value>Failed to send recipe book craft request for {0}.</value>
</data>
<data name="cmd.recipebook.craft.sent" xml:space="preserve">
<value>Requested recipe {0} (craft all: {1}).</value>
</data>
<data name="cmd.recipebook.desc" xml:space="preserve">
<value>List unlocked recipe book recipes and craft them through the active recipe book inventory.</value>
</data>
<data name="cmd.recipebook.list" xml:space="preserve">
<value>Unlocked recipe book recipes</value>
</data>
<data name="cmd.recipebook.no.active.inventory" xml:space="preserve">
<value>You need an active player crafting grid, crafting table, furnace, blast furnace, smoker, or stonecutter inventory.</value>
</data>
<data name="cmd.recipebook.no.recipes" xml:space="preserve">
<value>No unlocked recipe book recipes are currently tracked.</value>
</data>
<data name="cmd.recipebook.unsupported" xml:space="preserve">
<value>Recipe book crafting is only supported on Minecraft 1.13 and newer.</value>
</data>
<data name="bot.antiafk.may.not.move" xml:space="preserve">
<value>Bot movement lock is held by bot {0}, so the Anti AFK bot might not move!</value>
</data>