From c94be261fd86edccb530a4a1287ac8a00f567b38 Mon Sep 17 00:00:00 2001 From: Anon Date: Sat, 2 May 2026 17:52:17 +0200 Subject: [PATCH] Added Book Support --- MinecraftClient/Commands/Book.cs | 293 ++++++++++++++++++ MinecraftClient/Inventory/BookContent.cs | 183 +++++++++++ MinecraftClient/Inventory/Item.cs | 2 +- .../Inventory/ItemPalettes/ItemPalette113.cs | 22 ++ .../Inventory/ItemPalettes/ItemPalette1132.cs | 22 ++ .../Inventory/ItemPalettes/ItemPalette114.cs | 22 ++ MinecraftClient/McClient.cs | 77 +++++ .../Protocol/Handlers/DataTypes.cs | 4 +- .../Protocol/Handlers/Protocol16.cs | 5 + .../Protocol/Handlers/Protocol18.cs | 97 ++++++ MinecraftClient/Protocol/IMinecraftCom.cs | 11 + .../Protocol/IMinecraftComHandler.cs | 6 + .../Translations/Translations.Designer.cs | 144 +++++++++ .../Resources/Translations/Translations.resx | 108 +++++++ MinecraftClient/Tui/BookTuiHost.cs | 282 +++++++++++++++++ 15 files changed, 1275 insertions(+), 3 deletions(-) create mode 100644 MinecraftClient/Commands/Book.cs create mode 100644 MinecraftClient/Inventory/BookContent.cs create mode 100644 MinecraftClient/Inventory/ItemPalettes/ItemPalette113.cs create mode 100644 MinecraftClient/Inventory/ItemPalettes/ItemPalette1132.cs create mode 100644 MinecraftClient/Inventory/ItemPalettes/ItemPalette114.cs create mode 100644 MinecraftClient/Tui/BookTuiHost.cs diff --git a/MinecraftClient/Commands/Book.cs b/MinecraftClient/Commands/Book.cs new file mode 100644 index 00000000..1362d227 --- /dev/null +++ b/MinecraftClient/Commands/Book.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Inventory; +using MinecraftClient.Tui; + +namespace MinecraftClient.Commands +{ + public class Book : Command + { + private const char PageDelimiter = '\f'; + + public override string CmdName => "book"; + public override string CmdUsage => Translations.cmd_book_usage; + public override string CmdDesc => Translations.cmd_book_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("read").Executes(r => GetUsage(r.Source, "read"))) + .Then(l => l.Literal("write").Executes(r => GetUsage(r.Source, "write"))) + .Then(l => l.Literal("edit").Executes(r => GetUsage(r.Source, "edit"))) + .Then(l => l.Literal("sign").Executes(r => GetUsage(r.Source, "sign"))))); + + dispatcher.Register(l => l.Literal(CmdName) + .Then(l => l.Literal("read") + .Executes(r => ReadBook(r.Source, null)) + .Then(l => l.Argument("Page", Arguments.Integer(min: 1)) + .Executes(r => ReadBook(r.Source, Arguments.GetInteger(r, "Page"))))) + .Then(l => l.Literal("write") + .Then(l => l.Literal("text") + .Then(l => l.Argument("Text", Arguments.GreedyString()) + .Executes(r => WriteBook(r.Source, Arguments.GetString(r, "Text"))))) + .Then(l => l.Literal("file") + .Then(l => l.Argument("Path", Arguments.GreedyString()) + .Executes(r => WriteBookFromFile(r.Source, Arguments.GetString(r, "Path")))))) + .Then(l => l.Literal("edit") + .Executes(r => OpenEditor(r.Source)) + .Then(l => l.Literal("page") + .Then(l => l.Argument("Page", Arguments.Integer(min: 1)) + .Then(l => l.Argument("Text", Arguments.GreedyString()) + .Executes(r => EditPage(r.Source, Arguments.GetInteger(r, "Page"), Arguments.GetString(r, "Text")))))) + .Then(l => l.Literal("insert") + .Then(l => l.Argument("Page", Arguments.Integer(min: 1)) + .Then(l => l.Argument("Text", Arguments.GreedyString()) + .Executes(r => InsertPage(r.Source, Arguments.GetInteger(r, "Page"), Arguments.GetString(r, "Text")))))) + .Then(l => l.Literal("delete") + .Then(l => l.Argument("Page", Arguments.Integer(min: 1)) + .Executes(r => DeletePage(r.Source, Arguments.GetInteger(r, "Page")))))) + .Then(l => l.Literal("sign") + .Then(l => l.Argument("Title", Arguments.GreedyString()) + .Executes(r => SignBook(r.Source, Arguments.GetString(r, "Title"))))) + .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 + { + "read" => Translations.cmd_book_help_read, + "write" => Translations.cmd_book_help_write, + "edit" => Translations.cmd_book_help_edit, + "sign" => Translations.cmd_book_help_sign, + _ => GetCmdDescTranslated() + }); + } + + private int ReadBook(CmdResult r, int? page) + { + McClient handler = CmdResult.currentHandler!; + if (!EnsureInventory(r, handler)) + return -1; + + if (!handler.TryGetHeldBookContent(out BookContent content)) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_not_holding_book); + + if (page is null && BookTuiHost.TryOpen(handler, BookHand.Main, editable: false)) + return r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_tui_opened); + + handler.Log.Info(FormatBook(content, page)); + return r.SetAndReturn(CmdResult.Status.Done); + } + + private int OpenEditor(CmdResult r) + { + McClient handler = CmdResult.currentHandler!; + if (!EnsureWritable(r, handler, out _)) + return -1; + + return BookTuiHost.TryOpen(handler, BookHand.Main, editable: true) + ? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_tui_opened) + : r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_tui_required); + } + + private int WriteBook(CmdResult r, string text) + { + McClient handler = CmdResult.currentHandler!; + if (!EnsureWritable(r, handler, out _)) + return -1; + + IReadOnlyList pages = SplitPages(text); + if (!Validate(r, handler, pages, title: null)) + return -1; + + return handler.SendBookEdit(pages) + ? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_write_sent) + : r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed); + } + + private int WriteBookFromFile(CmdResult r, string path) + { + if (!File.Exists(path)) + return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_file_not_found, path)); + + return WriteBook(r, File.ReadAllText(path, Encoding.UTF8)); + } + + private int EditPage(CmdResult r, int page, string text) + { + McClient handler = CmdResult.currentHandler!; + if (!EnsureWritable(r, handler, out BookContent content)) + return -1; + + List pages = content.Pages.ToList(); + if (page > pages.Count) + return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_page_out_of_range, page, pages.Count)); + + pages[page - 1] = DecodeInlineText(text); + if (!Validate(r, handler, pages, title: null)) + return -1; + + return handler.SendBookEdit(pages) + ? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_edit_sent) + : r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed); + } + + private int InsertPage(CmdResult r, int page, string text) + { + McClient handler = CmdResult.currentHandler!; + if (!EnsureWritable(r, handler, out BookContent content)) + return -1; + + List pages = content.Pages.ToList(); + if (page > pages.Count + 1) + return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_page_out_of_range, page, pages.Count)); + + pages.Insert(page - 1, DecodeInlineText(text)); + if (!Validate(r, handler, pages, title: null)) + return -1; + + return handler.SendBookEdit(pages) + ? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_edit_sent) + : r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed); + } + + private int DeletePage(CmdResult r, int page) + { + McClient handler = CmdResult.currentHandler!; + if (!EnsureWritable(r, handler, out BookContent content)) + return -1; + + List pages = content.Pages.ToList(); + if (page > pages.Count) + return r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_page_out_of_range, page, pages.Count)); + + pages.RemoveAt(page - 1); + if (pages.Count == 0) + pages.Add(string.Empty); + + if (!Validate(r, handler, pages, title: null)) + return -1; + + return handler.SendBookEdit(pages) + ? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_edit_sent) + : r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed); + } + + private int SignBook(CmdResult r, string title) + { + McClient handler = CmdResult.currentHandler!; + if (!EnsureWritable(r, handler, out BookContent content)) + return -1; + + string normalizedTitle = title.Trim(); + if (!Validate(r, handler, content.Pages, normalizedTitle)) + return -1; + + return handler.SendBookEdit(content.Pages, normalizedTitle) + ? r.SetAndReturn(CmdResult.Status.Done, Translations.cmd_book_sign_sent) + : r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_write_failed); + } + + private static bool EnsureInventory(CmdResult r, McClient handler) + { + if (handler.GetInventoryEnabled()) + return true; + + r.SetAndReturn(CmdResult.Status.FailNeedInventory); + return false; + } + + private static bool EnsureWritable(CmdResult r, McClient handler, out BookContent content) + { + content = BookContent.EmptyWritable; + if (!EnsureInventory(r, handler)) + return false; + + Item? item = handler.GetHeldBook(); + if (!BookContentHelper.IsWritableBook(item)) + { + r.SetAndReturn(CmdResult.Status.Fail, Translations.cmd_book_not_holding_writable); + return false; + } + + return BookContentHelper.TryRead(item, out content); + } + + private static IReadOnlyList SplitPages(string text) + { + return BookContentHelper.NormalizePages(DecodeInlineText(text).Split(PageDelimiter)); + } + + private static string DecodeInlineText(string text) + { + return text.Replace("\\f", PageDelimiter.ToString(), StringComparison.Ordinal) + .Replace("\\n", "\n", StringComparison.Ordinal); + } + + private static bool Validate(CmdResult r, McClient handler, IReadOnlyList pages, string? title) + { + BookLimits limits = BookLimits.ForProtocol(handler.GetProtocolVersion()); + + if (pages.Count > limits.MaxPages) + { + r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_too_many_pages, pages.Count, limits.MaxPages)); + return false; + } + + for (int i = 0; i < pages.Count; i++) + { + if (pages[i].Length > limits.MaxPageLength) + { + r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_page_too_long, i + 1, pages[i].Length, limits.MaxPageLength)); + return false; + } + } + + if (title is not null && (title.Length == 0 || title.Length > limits.MaxTitleLength)) + { + r.SetAndReturn(CmdResult.Status.Fail, string.Format(Translations.cmd_book_title_invalid, limits.MaxTitleLength)); + return false; + } + + return true; + } + + private static string FormatBook(BookContent content, int? page) + { + StringBuilder sb = new(); + sb.AppendLine(content.IsSigned + ? string.Format(Translations.cmd_book_header_signed, content.Title ?? string.Empty, content.Author ?? string.Empty) + : Translations.cmd_book_header_writable); + + if (page is not null) + { + int index = page.Value - 1; + if (index < 0 || index >= content.Pages.Count) + return string.Format(Translations.cmd_book_page_out_of_range, page.Value, content.Pages.Count); + + sb.AppendLine(string.Format(Translations.cmd_book_page_header, page.Value, content.Pages.Count)); + sb.Append(content.Pages[index]); + return sb.ToString(); + } + + for (int i = 0; i < content.Pages.Count; i++) + { + sb.AppendLine(string.Format(Translations.cmd_book_page_header, i + 1, content.Pages.Count)); + sb.AppendLine(content.Pages[i]); + } + + return sb.ToString().TrimEnd(); + } + } +} diff --git a/MinecraftClient/Inventory/BookContent.cs b/MinecraftClient/Inventory/BookContent.cs new file mode 100644 index 00000000..239e3813 --- /dev/null +++ b/MinecraftClient/Inventory/BookContent.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using MinecraftClient.Protocol.Handlers; +using MinecraftClient.Protocol.Handlers.StructuredComponents.Components._1_20_6; +using MinecraftClient.Protocol.Message; + +namespace MinecraftClient.Inventory; + +public enum BookHand +{ + Main = 0, + Off = 1 +} + +public sealed record BookLimits(int MaxPages, int MaxPageLength, int MaxTitleLength) +{ + public static BookLimits ForProtocol(int protocolVersion) + { + int maxPageLength = protocolVersion switch + { + >= Protocol18Handler.MC_1_21_2_Version => 1024, + >= Protocol18Handler.MC_1_17_Version => 8192, + _ => 32767 + }; + + int maxTitleLength = protocolVersion switch + { + >= Protocol18Handler.MC_1_21_2_Version => 32, + >= Protocol18Handler.MC_1_17_Version => 128, + _ => 16 + }; + + return new BookLimits(100, maxPageLength, maxTitleLength); + } +} + +public sealed record BookContent( + IReadOnlyList Pages, + string? Title, + string? Author, + int Generation, + bool IsSigned) +{ + public static BookContent EmptyWritable { get; } = new([string.Empty], null, null, 0, false); +} + +public static class BookContentHelper +{ + public static bool IsBook(Item? item) => item?.Type is ItemType.WritableBook or ItemType.WrittenBook; + + public static bool IsWritableBook(Item? item) => item?.Type == ItemType.WritableBook; + + public static bool TryRead(Item? item, out BookContent content) + { + content = BookContent.EmptyWritable; + + if (item is null || item.IsEmpty) + return false; + + return item.Type switch + { + ItemType.WritableBook => TryReadWritable(item, out content), + ItemType.WrittenBook => TryReadWritten(item, out content), + _ => false + }; + } + + public static Item CreateWritablePayload(Item currentBook, IReadOnlyList pages) + { + return new Item(ItemType.WritableBook, 1, currentBook.Data, new Dictionary + { + ["pages"] = pages.Cast().ToArray() + }); + } + + public static Item CreateWrittenPayload(Item currentBook, IReadOnlyList pages, string title, string author, bool encodePagesAsJson) + { + object[] encodedPages = pages + .Select(page => encodePagesAsJson ? ToJsonTextComponent(page) : page) + .Cast() + .ToArray(); + + return new Item(ItemType.WrittenBook, 1, currentBook.Data, new Dictionary + { + ["author"] = author, + ["title"] = title, + ["pages"] = encodedPages + }); + } + + public static IReadOnlyList NormalizePages(IEnumerable pages) + { + string[] normalized = pages.Select(page => page ?? string.Empty).ToArray(); + return normalized.Length == 0 ? [string.Empty] : normalized; + } + + private static bool TryReadWritable(Item item, out BookContent content) + { + if (item.Components is not null) + { + var component = item.Components.OfType().FirstOrDefault(); + if (component is not null) + { + content = new BookContent( + NormalizePages(component.Pages.Select(page => page.RawContent)), + null, + null, + 0, + IsSigned: false); + return true; + } + } + + content = new BookContent(ReadStringList(item.NBT, "pages", parseJson: false), null, null, 0, IsSigned: false); + return true; + } + + private static bool TryReadWritten(Item item, out BookContent content) + { + if (item.Components is not null) + { + var component = item.Components.OfType().FirstOrDefault(); + if (component is not null) + { + content = new BookContent( + NormalizePages(component.Pages.Select(page => page.RawContent)), + component.RawTitle, + component.Author, + component.Generation, + IsSigned: true); + return true; + } + } + + string? title = ReadString(item.NBT, "title"); + string? author = ReadString(item.NBT, "author"); + int generation = ReadInt(item.NBT, "generation"); + content = new BookContent(ReadStringList(item.NBT, "pages", parseJson: true), title, author, generation, IsSigned: true); + return true; + } + + private static IReadOnlyList ReadStringList(Dictionary? nbt, string key, bool parseJson) + { + if (nbt is null || !nbt.TryGetValue(key, out object? value) || value is not object[] values) + return [string.Empty]; + + string[] pages = values + .Select(value => value?.ToString() ?? string.Empty) + .Select(value => parseJson ? ChatParser.ParseText(value) : value) + .ToArray(); + + return pages.Length == 0 ? [string.Empty] : pages; + } + + private static string? ReadString(Dictionary? nbt, string key) + { + return nbt is not null && nbt.TryGetValue(key, out object? value) + ? value?.ToString() + : null; + } + + private static int ReadInt(Dictionary? nbt, string key) + { + if (nbt is null || !nbt.TryGetValue(key, out object? value) || value is null) + return 0; + + return value switch + { + int i => i, + short s => s, + byte b => b, + _ when int.TryParse(value.ToString(), out int parsed) => parsed, + _ => 0 + }; + } + + private static string ToJsonTextComponent(string text) + { + return JsonSerializer.Serialize(new Dictionary { ["text"] = text }); + } +} diff --git a/MinecraftClient/Inventory/Item.cs b/MinecraftClient/Inventory/Item.cs index e794e205..69d175fb 100644 --- a/MinecraftClient/Inventory/Item.cs +++ b/MinecraftClient/Inventory/Item.cs @@ -272,4 +272,4 @@ namespace MinecraftClient.Inventory return sb.ToString(); } } -} \ No newline at end of file +} diff --git a/MinecraftClient/Inventory/ItemPalettes/ItemPalette113.cs b/MinecraftClient/Inventory/ItemPalettes/ItemPalette113.cs new file mode 100644 index 00000000..3e8e1494 --- /dev/null +++ b/MinecraftClient/Inventory/ItemPalettes/ItemPalette113.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace MinecraftClient.Inventory.ItemPalettes +{ + /// + /// Minimal flattened item palette for 1.13-1.14 book handling. + /// + public class ItemPalette113 : ItemPalette + { + private static readonly Dictionary mappings = new() + { + [0] = ItemType.Air, + [687] = ItemType.WritableBook, + [688] = ItemType.WrittenBook + }; + + protected override Dictionary GetDict() + { + return mappings; + } + } +} diff --git a/MinecraftClient/Inventory/ItemPalettes/ItemPalette1132.cs b/MinecraftClient/Inventory/ItemPalettes/ItemPalette1132.cs new file mode 100644 index 00000000..24da1d8f --- /dev/null +++ b/MinecraftClient/Inventory/ItemPalettes/ItemPalette1132.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace MinecraftClient.Inventory.ItemPalettes +{ + /// + /// Minimal flattened item palette for 1.13.2 book handling. + /// + public class ItemPalette1132 : ItemPalette + { + private static readonly Dictionary mappings = new() + { + [0] = ItemType.Air, + [692] = ItemType.WritableBook, + [693] = ItemType.WrittenBook + }; + + protected override Dictionary GetDict() + { + return mappings; + } + } +} diff --git a/MinecraftClient/Inventory/ItemPalettes/ItemPalette114.cs b/MinecraftClient/Inventory/ItemPalettes/ItemPalette114.cs new file mode 100644 index 00000000..c4c02a09 --- /dev/null +++ b/MinecraftClient/Inventory/ItemPalettes/ItemPalette114.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; + +namespace MinecraftClient.Inventory.ItemPalettes +{ + /// + /// Minimal flattened item palette for 1.14 book handling. + /// + public class ItemPalette114 : ItemPalette + { + private static readonly Dictionary mappings = new() + { + [0] = ItemType.Air, + [757] = ItemType.WritableBook, + [758] = ItemType.WrittenBook + }; + + protected override Dictionary GetDict() + { + return mappings; + } + } +} diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 3ba651a5..136fe224 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -1850,6 +1850,72 @@ namespace MinecraftClient return handler.SendPluginChannelPacket(channel, data); } + public Item? GetHeldBook(BookHand hand = BookHand.Main) + { + if (InvokeRequired) + return InvokeOnMainThread(() => GetHeldBook(hand)); + + if (!inventoryHandlingEnabled || !inventories.TryGetValue(0, out Container? inventory)) + return null; + + int slot = hand == BookHand.Off ? 45 : 36 + CurrentSlot; + return inventory.Items.TryGetValue(slot, out Item? item) ? item : null; + } + + public bool TryGetHeldBookContent(out BookContent content, BookHand hand = BookHand.Main) + { + if (InvokeRequired) + { + (bool ok, BookContent value) = InvokeOnMainThread(() => + { + bool ok = TryGetHeldBookContent(out BookContent value, hand); + return (ok, value); + }); + content = value; + return ok; + } + + return BookContentHelper.TryRead(GetHeldBook(hand), out content); + } + + public bool SendBookEdit(IReadOnlyList pages, string? title = null) + { + if (InvokeRequired) + return InvokeOnMainThread(() => SendBookEdit(pages, title)); + + Item? currentBook = GetHeldBook(BookHand.Main); + if (!BookContentHelper.IsWritableBook(currentBook)) + return false; + + IReadOnlyList normalizedPages = BookContentHelper.NormalizePages(pages); + bool sent = handler.SendEditBook(currentBook!, normalizedPages, title, username, CurrentSlot); + if (sent && GetProtocolVersion() < Protocol18Handler.MC_1_17_Version) + SetHeldBook(BookHand.Main, CreateLocalBookResult(currentBook!, normalizedPages, title)); + + return sent; + } + + private Item CreateLocalBookResult(Item currentBook, IReadOnlyList pages, string? title) + { + return title is null + ? BookContentHelper.CreateWritablePayload(currentBook, pages) + : BookContentHelper.CreateWrittenPayload( + currentBook, + pages, + title, + username, + encodePagesAsJson: GetProtocolVersion() < Protocol18Handler.MC_1_9_Version); + } + + private void SetHeldBook(BookHand hand, Item item) + { + if (!inventoryHandlingEnabled || !inventories.TryGetValue(0, out Container? inventory)) + return; + + int slot = hand == BookHand.Off ? 45 : 36 + CurrentSlot; + inventory.Items[slot] = item; + } + /// /// Send the Entity Action packet with the Specified ID /// @@ -3817,6 +3883,17 @@ namespace MinecraftClient } } + public void OnBookOpen(int hand) + { + if (InvokeRequired) + { + InvokeOnMainThread(() => OnBookOpen(hand)); + return; + } + + Tui.BookTuiHost.OpenFromServer(this, hand == (int)BookHand.Off ? BookHand.Off : BookHand.Main); + } + /// /// Called when an entity spawned /// diff --git a/MinecraftClient/Protocol/Handlers/DataTypes.cs b/MinecraftClient/Protocol/Handlers/DataTypes.cs index bdbdecea..36b26917 100644 --- a/MinecraftClient/Protocol/Handlers/DataTypes.cs +++ b/MinecraftClient/Protocol/Handlers/DataTypes.cs @@ -514,7 +514,7 @@ namespace MinecraftClient.Protocol.Handlers var type = itemPalette.FromId(itemId); itemCount = ReadNextByte(cache); nbt = ReadNextNbt(cache); - return new Item(type, itemCount, nbt); + return new Item(type, itemCount, itemId, nbt); } case >= Protocol18Handler.MC_1_13_Version: { @@ -526,7 +526,7 @@ namespace MinecraftClient.Protocol.Handlers var type = itemPalette.FromId(itemId); itemCount = ReadNextByte(cache); nbt = ReadNextNbt(cache); - return new Item(type, itemCount, nbt); + return new Item(type, itemCount, itemId, nbt); } default: { diff --git a/MinecraftClient/Protocol/Handlers/Protocol16.cs b/MinecraftClient/Protocol/Handlers/Protocol16.cs index 6777200d..bb0fb863 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol16.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol16.cs @@ -816,6 +816,11 @@ namespace MinecraftClient.Protocol.Handlers return false; //MC 1.8-1.12.1 recipe book not supported } + public bool SendEditBook(Item currentBook, IReadOnlyList pages, string? title, string author, int selectedHotbarSlot) + { + return false; //MC 1.4.6-1.6.4 book editing is 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 50e7b91a..8a7dbd67 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -241,6 +241,9 @@ namespace MinecraftClient.Protocol.Handlers >= MC_1_16_2_Version => new ItemPalette1162(), >= MC_1_16_1_Version => new ItemPalette1161(), >= MC_1_15_Version => new ItemPalette115(), + >= MC_1_14_Version => new ItemPalette114(), + >= MC_1_13_2_Version => new ItemPalette1132(), + >= MC_1_13_Version => new ItemPalette113(), >= MC_1_12_Version => new ItemPalette112(), >= MC_1_11_Version => new ItemPalette111(), >= MC_1_10_Version => new ItemPalette110(), @@ -2313,8 +2316,13 @@ namespace MinecraftClient.Protocol.Handlers // Length is unneeded as the whole remaining packetData is the entire payload of the packet. if (protocolVersion < MC_1_8_Version) pForge.ReadNextVarShort(packetData); + if (IsOpenBookPluginChannel(channel)) + handler.OnBookOpen(ReadBookHand(new Queue(packetData))); handler.OnPluginChannelMessage(channel, packetData.ToArray()); return pForge.HandlePluginMessage(channel, packetData, ref currentDimension); + case PacketTypesIn.OpenBook: + handler.OnBookOpen(ReadBookHand(packetData)); + break; case PacketTypesIn.Disconnect: handler.OnConnectionLost(ChatBot.DisconnectReason.InGameKick, dataTypes.ReadNextChat(packetData)); @@ -2984,6 +2992,9 @@ namespace MinecraftClient.Protocol.Handlers soundName = null; } + if (protocolVersion < MC_1_19_Version && packetData.Count < 21) + break; + int category = dataTypes.ReadNextVarInt(packetData); double x = dataTypes.ReadNextInt(packetData) / 8.0D; double y = dataTypes.ReadNextInt(packetData) / 8.0D; @@ -5279,6 +5290,18 @@ namespace MinecraftClient.Protocol.Handlers } } + private bool IsOpenBookPluginChannel(string channel) + { + return protocolVersion < MC_1_13_Version + ? string.Equals(channel, "MC|BOpen", StringComparison.Ordinal) + : string.Equals(channel, "minecraft:book_open", StringComparison.Ordinal); + } + + private int ReadBookHand(Queue packetData) + { + return packetData.Count > 0 ? dataTypes.ReadNextVarInt(packetData) : (int)BookHand.Main; + } + /// /// Send a Login Plugin Response packet (0x02) /// @@ -5881,6 +5904,80 @@ namespace MinecraftClient.Protocol.Handlers } } + public bool SendEditBook(Item currentBook, IReadOnlyList pages, string? title, string author, int selectedHotbarSlot) + { + try + { + if (protocolVersion < MC_1_8_Version) + return false; + + bool signing = title is not null; + IReadOnlyList normalizedPages = BookContentHelper.NormalizePages(pages); + + if (protocolVersion < MC_1_13_Version) + { + Item payload = signing + ? BookContentHelper.CreateWrittenPayload( + currentBook, + normalizedPages, + title ?? string.Empty, + author, + encodePagesAsJson: protocolVersion < MC_1_9_Version) + : BookContentHelper.CreateWritablePayload(currentBook, normalizedPages); + + byte[] payloadData = dataTypes.GetItemSlot(payload, itemPalette); + if (payloadData.Length > 32767) + return false; + + return SendPluginChannelPacket(signing ? "MC|BSign" : "MC|BEdit", payloadData); + } + + if (protocolVersion < MC_1_17_Version) + { + Item payload = signing + ? BookContentHelper.CreateWrittenPayload(currentBook, normalizedPages, title ?? string.Empty, author, encodePagesAsJson: false) + : BookContentHelper.CreateWritablePayload(currentBook, normalizedPages); + + List packet = new(); + packet.AddRange(dataTypes.GetItemSlot(payload, itemPalette)); + packet.AddRange(dataTypes.GetBool(signing)); + + if (protocolVersion >= MC_1_16_5_Version) + packet.AddRange(DataTypes.GetVarInt(selectedHotbarSlot)); + else if (protocolVersion >= MC_1_13_2_Version) + packet.AddRange(DataTypes.GetVarInt((int)BookHand.Main)); + + SendPacket(PacketTypesOut.EditBook, packet); + return true; + } + + List modernPacket = new(); + modernPacket.AddRange(DataTypes.GetVarInt(selectedHotbarSlot)); + modernPacket.AddRange(DataTypes.GetVarInt(normalizedPages.Count)); + foreach (string page in normalizedPages) + modernPacket.AddRange(dataTypes.GetString(page)); + + modernPacket.AddRange(dataTypes.GetBool(signing)); + if (signing) + modernPacket.AddRange(dataTypes.GetString(title ?? string.Empty)); + + SendPacket(PacketTypesOut.EditBook, modernPacket); + 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 96b261b1..df0a37fe 100644 --- a/MinecraftClient/Protocol/IMinecraftCom.cs +++ b/MinecraftClient/Protocol/IMinecraftCom.cs @@ -199,6 +199,17 @@ namespace MinecraftClient.Protocol /// True if packet was successfully sent bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll); + /// + /// Send a book edit/sign packet for the currently held writable book. + /// + /// Current held writable book + /// Book pages + /// Title when signing, otherwise null + /// Current player name when signing + /// Selected hotbar slot, 0-8 + /// True if packet was successfully sent + bool SendEditBook(Item currentBook, IReadOnlyList pages, string? title, string author, int selectedHotbarSlot); + /// /// Plays animation /// diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index 5c100981..be406ef8 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -224,6 +224,12 @@ namespace MinecraftClient.Protocol /// The data from the channel void OnPluginChannelMessage(string channel, byte[] data); + /// + /// Called when the server asks the client to open a book UI. + /// + /// Book hand, 0 main hand, 1 off hand. + void OnBookOpen(int hand); + /// /// Called when an entity has spawned /// diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index b6e454cd..bccd0a57 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -7552,5 +7552,149 @@ namespace MinecraftClient { return ResourceManager.GetString("cmd.achievement.entry", resourceCulture); } } + internal static string cmd_book_usage { + get { return ResourceManager.GetString("cmd.book.usage", resourceCulture); } + } + + internal static string cmd_book_desc { + get { return ResourceManager.GetString("cmd.book.desc", resourceCulture); } + } + + internal static string cmd_book_help_read { + get { return ResourceManager.GetString("cmd.book.help_read", resourceCulture); } + } + + internal static string cmd_book_help_write { + get { return ResourceManager.GetString("cmd.book.help_write", resourceCulture); } + } + + internal static string cmd_book_help_edit { + get { return ResourceManager.GetString("cmd.book.help_edit", resourceCulture); } + } + + internal static string cmd_book_help_sign { + get { return ResourceManager.GetString("cmd.book.help_sign", resourceCulture); } + } + + internal static string cmd_book_not_holding_book { + get { return ResourceManager.GetString("cmd.book.not_holding_book", resourceCulture); } + } + + internal static string cmd_book_not_holding_writable { + get { return ResourceManager.GetString("cmd.book.not_holding_writable", resourceCulture); } + } + + internal static string cmd_book_tui_opened { + get { return ResourceManager.GetString("cmd.book.tui_opened", resourceCulture); } + } + + internal static string cmd_book_tui_required { + get { return ResourceManager.GetString("cmd.book.tui_required", resourceCulture); } + } + + internal static string cmd_book_write_sent { + get { return ResourceManager.GetString("cmd.book.write_sent", resourceCulture); } + } + + internal static string cmd_book_edit_sent { + get { return ResourceManager.GetString("cmd.book.edit_sent", resourceCulture); } + } + + internal static string cmd_book_sign_sent { + get { return ResourceManager.GetString("cmd.book.sign_sent", resourceCulture); } + } + + internal static string cmd_book_write_failed { + get { return ResourceManager.GetString("cmd.book.write_failed", resourceCulture); } + } + + internal static string cmd_book_file_not_found { + get { return ResourceManager.GetString("cmd.book.file_not_found", resourceCulture); } + } + + internal static string cmd_book_page_out_of_range { + get { return ResourceManager.GetString("cmd.book.page_out_of_range", resourceCulture); } + } + + internal static string cmd_book_too_many_pages { + get { return ResourceManager.GetString("cmd.book.too_many_pages", resourceCulture); } + } + + internal static string cmd_book_page_too_long { + get { return ResourceManager.GetString("cmd.book.page_too_long", resourceCulture); } + } + + internal static string cmd_book_title_invalid { + get { return ResourceManager.GetString("cmd.book.title_invalid", resourceCulture); } + } + + internal static string cmd_book_header_signed { + get { return ResourceManager.GetString("cmd.book.header_signed", resourceCulture); } + } + + internal static string cmd_book_header_writable { + get { return ResourceManager.GetString("cmd.book.header_writable", resourceCulture); } + } + + internal static string cmd_book_page_header { + get { return ResourceManager.GetString("cmd.book.page_header", resourceCulture); } + } + + internal static string tui_book_title_watermark { + get { return ResourceManager.GetString("tui.book.title_watermark", resourceCulture); } + } + + internal static string tui_book_prev { + get { return ResourceManager.GetString("tui.book.prev", resourceCulture); } + } + + internal static string tui_book_next { + get { return ResourceManager.GetString("tui.book.next", resourceCulture); } + } + + internal static string tui_book_insert { + get { return ResourceManager.GetString("tui.book.insert", resourceCulture); } + } + + internal static string tui_book_delete { + get { return ResourceManager.GetString("tui.book.delete", resourceCulture); } + } + + internal static string tui_book_save { + get { return ResourceManager.GetString("tui.book.save", resourceCulture); } + } + + internal static string tui_book_sign { + get { return ResourceManager.GetString("tui.book.sign", resourceCulture); } + } + + internal static string tui_book_close { + get { return ResourceManager.GetString("tui.book.close", resourceCulture); } + } + + internal static string tui_book_saved { + get { return ResourceManager.GetString("tui.book.saved", resourceCulture); } + } + + internal static string tui_book_save_failed { + get { return ResourceManager.GetString("tui.book.save_failed", resourceCulture); } + } + + internal static string tui_book_signed { + get { return ResourceManager.GetString("tui.book.signed", resourceCulture); } + } + + internal static string tui_book_page_header { + get { return ResourceManager.GetString("tui.book.page_header", resourceCulture); } + } + + internal static string tui_book_editing { + get { return ResourceManager.GetString("tui.book.editing", resourceCulture); } + } + + internal static string tui_book_reading { + get { return ResourceManager.GetString("tui.book.reading", resourceCulture); } + } + } } diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 2e9da34a..aa167797 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2659,4 +2659,112 @@ see item details. {0} {1} [{2}] + + book <read|write|edit|sign> + + + read, write, edit, and sign the book held in your main hand. + + + /book read [page] + + + /book write text <text> or /book write file <path>. Use \n for line breaks and \f for page breaks. + + + /book edit, /book edit page <page> <text>, /book edit insert <page> <text>, or /book edit delete <page>. + + + /book sign <title> + + + You are not holding a book. + + + You must hold a writable book in your main hand. + + + Book TUI opened. + + + The book editor is only available in TUI mode. + + + Book write packet sent. + + + Book edit packet sent. + + + Book sign packet sent. + + + Book packet could not be sent. + + + Book file not found: {0} + + + Page {0} is outside the current page range 1-{1}. + + + Book has {0} pages, but this version supports at most {1} pages. + + + Page {0} has {1} characters, but this version supports at most {2} characters per page. + + + Book title must be 1-{0} characters. + + + Written book: {0} by {1} + + + Writable book + + + Page {0}/{1} + + + Title + + + Prev + + + Next + + + Insert + + + Delete + + + Save + + + Sign + + + Close + + + Saved. + + + Book packet could not be sent. + + + Signed. + + + Book page {0}/{1} + + + Editing writable book. + + + Reading book. + diff --git a/MinecraftClient/Tui/BookTuiHost.cs b/MinecraftClient/Tui/BookTuiHost.cs new file mode 100644 index 00000000..eac8c2b0 --- /dev/null +++ b/MinecraftClient/Tui/BookTuiHost.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using MinecraftClient.Inventory; + +namespace MinecraftClient.Tui; + +public static class BookTuiHost +{ + private static volatile bool isRunning; + + public static bool TryOpen(McClient handler, BookHand hand, bool editable) + { + if (ConsoleIO.Backend is not TuiConsoleBackend) + return false; + + Open(handler, hand, editable); + return true; + } + + public static void OpenFromServer(McClient handler, BookHand hand) + { + if (ConsoleIO.Backend is TuiConsoleBackend) + Open(handler, hand, editable: BookContentHelper.IsWritableBook(handler.GetHeldBook(hand))); + } + + private static void Open(McClient handler, BookHand hand, bool editable) + { + Dispatcher.UIThread.Post(() => + { + if (isRunning) + return; + + MainTuiView? view = TuiConsoleBackend.Instance?.GetView(); + if (view is null) + return; + + if (!handler.TryGetHeldBookContent(out BookContent content, hand)) + return; + + isRunning = true; + var bookView = new BookView(handler, content, editable && !content.IsSigned); + view.ShowOverlay(bookView, () => isRunning = false); + }); + } +} + +internal sealed class BookView : UserControl +{ + private readonly McClient handler; + private readonly bool editable; + private readonly List pages; + private readonly TextBlock header; + private readonly TextBlock status; + private readonly TextBox pageText; + private readonly TextBox titleText; + private int pageIndex; + + public BookView(McClient handler, BookContent content, bool editable) + { + this.handler = handler; + this.editable = editable; + pages = content.Pages.ToList(); + if (pages.Count == 0) + pages.Add(string.Empty); + + Focusable = true; + Background = Brushes.Black; + + header = new TextBlock + { + Foreground = Brushes.Yellow, + Margin = new Thickness(1, 0), + TextWrapping = TextWrapping.Wrap + }; + + status = new TextBlock + { + Foreground = Brushes.Gray, + Margin = new Thickness(1, 0), + TextWrapping = TextWrapping.Wrap + }; + + pageText = new TextBox + { + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + IsReadOnly = !editable, + Foreground = Brushes.White, + Background = Brushes.Black, + BorderBrush = Brushes.Gray, + MinHeight = 12, + Margin = new Thickness(1) + }; + pageText.TextChanged += (_, _) => + { + if (editable && pageIndex >= 0 && pageIndex < pages.Count) + pages[pageIndex] = pageText.Text ?? string.Empty; + }; + + titleText = new TextBox + { + Watermark = Translations.tui_book_title_watermark, + IsVisible = editable, + Foreground = Brushes.White, + Background = Brushes.Black, + BorderBrush = Brushes.Gray, + Margin = new Thickness(1) + }; + + var controls = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 1, + Margin = new Thickness(1), + Children = + { + Button(Translations.tui_book_prev, (_, _) => MovePage(-1)), + Button(Translations.tui_book_next, (_, _) => MovePage(1)), + Button(Translations.tui_book_insert, (_, _) => InsertPage(), editable), + Button(Translations.tui_book_delete, (_, _) => DeletePage(), editable), + Button(Translations.tui_book_save, (_, _) => Save(), editable), + Button(Translations.tui_book_sign, (_, _) => Sign(), editable), + Button(Translations.tui_book_close, (_, _) => Close()) + } + }; + + var panel = new DockPanel + { + Background = Brushes.Black, + Children = + { + DockTo(header, Dock.Top), + DockTo(status, Dock.Bottom), + DockTo(controls, Dock.Bottom), + DockTo(titleText, Dock.Bottom), + pageText + } + }; + + Content = panel; + Refresh(); + } + + protected override void OnKeyDown(KeyEventArgs e) + { + if (e.Key == Key.PageUp) + { + MovePage(-1); + e.Handled = true; + return; + } + + if (e.Key == Key.PageDown) + { + MovePage(1); + e.Handled = true; + return; + } + + base.OnKeyDown(e); + } + + private static Control DockTo(Control control, Dock dock) + { + DockPanel.SetDock(control, dock); + return control; + } + + private static Button Button(string text, EventHandler handler, bool enabled = true) + { + var button = new Button + { + Content = text, + IsEnabled = enabled, + Padding = new Thickness(1, 0), + Margin = new Thickness(0) + }; + button.Click += handler; + return button; + } + + private void MovePage(int delta) + { + pageIndex = Math.Clamp(pageIndex + delta, 0, pages.Count - 1); + Refresh(); + } + + private void InsertPage() + { + pages.Insert(pageIndex + 1, string.Empty); + pageIndex++; + Refresh(); + } + + private void DeletePage() + { + if (pages.Count == 1) + pages[0] = string.Empty; + else + { + pages.RemoveAt(pageIndex); + pageIndex = Math.Clamp(pageIndex, 0, pages.Count - 1); + } + Refresh(); + } + + private void Save() + { + if (!Validate(out string error)) + { + status.Text = error; + return; + } + + status.Text = handler.SendBookEdit(pages) + ? Translations.tui_book_saved + : Translations.tui_book_save_failed; + } + + private void Sign() + { + string title = (titleText.Text ?? string.Empty).Trim(); + if (!Validate(out string error, title)) + { + status.Text = error; + return; + } + + status.Text = handler.SendBookEdit(pages, title) + ? Translations.tui_book_signed + : Translations.tui_book_save_failed; + } + + private bool Validate(out string error, string? title = null) + { + BookLimits limits = BookLimits.ForProtocol(handler.GetProtocolVersion()); + error = string.Empty; + + if (pages.Count > limits.MaxPages) + { + error = string.Format(Translations.cmd_book_too_many_pages, pages.Count, limits.MaxPages); + return false; + } + + for (int i = 0; i < pages.Count; i++) + { + if (pages[i].Length > limits.MaxPageLength) + { + error = string.Format(Translations.cmd_book_page_too_long, i + 1, pages[i].Length, limits.MaxPageLength); + return false; + } + } + + if (title is not null && (title.Length == 0 || title.Length > limits.MaxTitleLength)) + { + error = string.Format(Translations.cmd_book_title_invalid, limits.MaxTitleLength); + return false; + } + + return true; + } + + private void Close() + { + TuiConsoleBackend.Instance?.GetView()?.HideOverlay(); + } + + private void Refresh() + { + pageText.Text = pages[pageIndex]; + header.Text = string.Format(Translations.tui_book_page_header, pageIndex + 1, pages.Count); + status.Text = editable ? Translations.tui_book_editing : Translations.tui_book_reading; + pageText.Focus(); + } +}