Added Book Support

This commit is contained in:
Anon 2026-05-02 17:52:17 +02:00
parent 8ba95c6140
commit c94be261fd
15 changed files with 1275 additions and 3 deletions

View file

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

View file

@ -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<string> 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<string> pages)
{
return new Item(ItemType.WritableBook, 1, currentBook.Data, new Dictionary<string, object>
{
["pages"] = pages.Cast<object>().ToArray()
});
}
public static Item CreateWrittenPayload(Item currentBook, IReadOnlyList<string> pages, string title, string author, bool encodePagesAsJson)
{
object[] encodedPages = pages
.Select(page => encodePagesAsJson ? ToJsonTextComponent(page) : page)
.Cast<object>()
.ToArray();
return new Item(ItemType.WrittenBook, 1, currentBook.Data, new Dictionary<string, object>
{
["author"] = author,
["title"] = title,
["pages"] = encodedPages
});
}
public static IReadOnlyList<string> NormalizePages(IEnumerable<string> 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<WritableBlookContentComponent>().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<WrittenBlookContentComponent>().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<string> ReadStringList(Dictionary<string, object>? 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<string, object>? nbt, string key)
{
return nbt is not null && nbt.TryGetValue(key, out object? value)
? value?.ToString()
: null;
}
private static int ReadInt(Dictionary<string, object>? 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<string, string> { ["text"] = text });
}
}

View file

@ -272,4 +272,4 @@ namespace MinecraftClient.Inventory
return sb.ToString();
}
}
}
}

View file

@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace MinecraftClient.Inventory.ItemPalettes
{
/// <summary>
/// Minimal flattened item palette for 1.13-1.14 book handling.
/// </summary>
public class ItemPalette113 : ItemPalette
{
private static readonly Dictionary<int, ItemType> mappings = new()
{
[0] = ItemType.Air,
[687] = ItemType.WritableBook,
[688] = ItemType.WrittenBook
};
protected override Dictionary<int, ItemType> GetDict()
{
return mappings;
}
}
}

View file

@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace MinecraftClient.Inventory.ItemPalettes
{
/// <summary>
/// Minimal flattened item palette for 1.13.2 book handling.
/// </summary>
public class ItemPalette1132 : ItemPalette
{
private static readonly Dictionary<int, ItemType> mappings = new()
{
[0] = ItemType.Air,
[692] = ItemType.WritableBook,
[693] = ItemType.WrittenBook
};
protected override Dictionary<int, ItemType> GetDict()
{
return mappings;
}
}
}

View file

@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace MinecraftClient.Inventory.ItemPalettes
{
/// <summary>
/// Minimal flattened item palette for 1.14 book handling.
/// </summary>
public class ItemPalette114 : ItemPalette
{
private static readonly Dictionary<int, ItemType> mappings = new()
{
[0] = ItemType.Air,
[757] = ItemType.WritableBook,
[758] = ItemType.WrittenBook
};
protected override Dictionary<int, ItemType> GetDict()
{
return mappings;
}
}
}

View file

@ -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<string> pages, string? title = null)
{
if (InvokeRequired)
return InvokeOnMainThread(() => SendBookEdit(pages, title));
Item? currentBook = GetHeldBook(BookHand.Main);
if (!BookContentHelper.IsWritableBook(currentBook))
return false;
IReadOnlyList<string> 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<string> 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;
}
/// <summary>
/// Send the Entity Action packet with the Specified ID
/// </summary>
@ -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);
}
/// <summary>
/// Called when an entity spawned
/// </summary>

View file

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

View file

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

View file

@ -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<byte>(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<byte> packetData)
{
return packetData.Count > 0 ? dataTypes.ReadNextVarInt(packetData) : (int)BookHand.Main;
}
/// <summary>
/// Send a Login Plugin Response packet (0x02)
/// </summary>
@ -5881,6 +5904,80 @@ namespace MinecraftClient.Protocol.Handlers
}
}
public bool SendEditBook(Item currentBook, IReadOnlyList<string> pages, string? title, string author, int selectedHotbarSlot)
{
try
{
if (protocolVersion < MC_1_8_Version)
return false;
bool signing = title is not null;
IReadOnlyList<string> 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<byte> 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<byte> 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

View file

@ -199,6 +199,17 @@ namespace MinecraftClient.Protocol
/// <returns>True if packet was successfully sent</returns>
bool SendPlaceRecipe(int windowId, string recipeId, bool makeAll);
/// <summary>
/// Send a book edit/sign packet for the currently held writable book.
/// </summary>
/// <param name="currentBook">Current held writable book</param>
/// <param name="pages">Book pages</param>
/// <param name="title">Title when signing, otherwise null</param>
/// <param name="author">Current player name when signing</param>
/// <param name="selectedHotbarSlot">Selected hotbar slot, 0-8</param>
/// <returns>True if packet was successfully sent</returns>
bool SendEditBook(Item currentBook, IReadOnlyList<string> pages, string? title, string author, int selectedHotbarSlot);
/// <summary>
/// Plays animation
/// </summary>

View file

@ -224,6 +224,12 @@ namespace MinecraftClient.Protocol
/// <param name="data">The data from the channel</param>
void OnPluginChannelMessage(string channel, byte[] data);
/// <summary>
/// Called when the server asks the client to open a book UI.
/// </summary>
/// <param name="hand">Book hand, 0 main hand, 1 off hand.</param>
void OnBookOpen(int hand);
/// <summary>
/// Called when an entity has spawned
/// </summary>

View file

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

View file

@ -2659,4 +2659,112 @@ see item details.</value>
<data name="cmd.achievement.entry" xml:space="preserve">
<value>{0} {1} [{2}]</value>
</data>
<data name="cmd.book.usage" xml:space="preserve">
<value>book &lt;read|write|edit|sign&gt;</value>
</data>
<data name="cmd.book.desc" xml:space="preserve">
<value>read, write, edit, and sign the book held in your main hand.</value>
</data>
<data name="cmd.book.help_read" xml:space="preserve">
<value>/book read [page]</value>
</data>
<data name="cmd.book.help_write" xml:space="preserve">
<value>/book write text &lt;text&gt; or /book write file &lt;path&gt;. Use \n for line breaks and \f for page breaks.</value>
</data>
<data name="cmd.book.help_edit" xml:space="preserve">
<value>/book edit, /book edit page &lt;page&gt; &lt;text&gt;, /book edit insert &lt;page&gt; &lt;text&gt;, or /book edit delete &lt;page&gt;.</value>
</data>
<data name="cmd.book.help_sign" xml:space="preserve">
<value>/book sign &lt;title&gt;</value>
</data>
<data name="cmd.book.not_holding_book" xml:space="preserve">
<value>You are not holding a book.</value>
</data>
<data name="cmd.book.not_holding_writable" xml:space="preserve">
<value>You must hold a writable book in your main hand.</value>
</data>
<data name="cmd.book.tui_opened" xml:space="preserve">
<value>Book TUI opened.</value>
</data>
<data name="cmd.book.tui_required" xml:space="preserve">
<value>The book editor is only available in TUI mode.</value>
</data>
<data name="cmd.book.write_sent" xml:space="preserve">
<value>Book write packet sent.</value>
</data>
<data name="cmd.book.edit_sent" xml:space="preserve">
<value>Book edit packet sent.</value>
</data>
<data name="cmd.book.sign_sent" xml:space="preserve">
<value>Book sign packet sent.</value>
</data>
<data name="cmd.book.write_failed" xml:space="preserve">
<value>Book packet could not be sent.</value>
</data>
<data name="cmd.book.file_not_found" xml:space="preserve">
<value>Book file not found: {0}</value>
</data>
<data name="cmd.book.page_out_of_range" xml:space="preserve">
<value>Page {0} is outside the current page range 1-{1}.</value>
</data>
<data name="cmd.book.too_many_pages" xml:space="preserve">
<value>Book has {0} pages, but this version supports at most {1} pages.</value>
</data>
<data name="cmd.book.page_too_long" xml:space="preserve">
<value>Page {0} has {1} characters, but this version supports at most {2} characters per page.</value>
</data>
<data name="cmd.book.title_invalid" xml:space="preserve">
<value>Book title must be 1-{0} characters.</value>
</data>
<data name="cmd.book.header_signed" xml:space="preserve">
<value>Written book: {0} by {1}</value>
</data>
<data name="cmd.book.header_writable" xml:space="preserve">
<value>Writable book</value>
</data>
<data name="cmd.book.page_header" xml:space="preserve">
<value>Page {0}/{1}</value>
</data>
<data name="tui.book.title_watermark" xml:space="preserve">
<value>Title</value>
</data>
<data name="tui.book.prev" xml:space="preserve">
<value>Prev</value>
</data>
<data name="tui.book.next" xml:space="preserve">
<value>Next</value>
</data>
<data name="tui.book.insert" xml:space="preserve">
<value>Insert</value>
</data>
<data name="tui.book.delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="tui.book.save" xml:space="preserve">
<value>Save</value>
</data>
<data name="tui.book.sign" xml:space="preserve">
<value>Sign</value>
</data>
<data name="tui.book.close" xml:space="preserve">
<value>Close</value>
</data>
<data name="tui.book.saved" xml:space="preserve">
<value>Saved.</value>
</data>
<data name="tui.book.save_failed" xml:space="preserve">
<value>Book packet could not be sent.</value>
</data>
<data name="tui.book.signed" xml:space="preserve">
<value>Signed.</value>
</data>
<data name="tui.book.page_header" xml:space="preserve">
<value>Book page {0}/{1}</value>
</data>
<data name="tui.book.editing" xml:space="preserve">
<value>Editing writable book.</value>
</data>
<data name="tui.book.reading" xml:space="preserve">
<value>Reading book.</value>
</data>
</root>

View file

@ -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<string> 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<Avalonia.Interactivity.RoutedEventArgs> 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();
}
}