diff --git a/.skills/mcc-integration-testing/SKILL.md b/.skills/mcc-integration-testing/SKILL.md index d6685ff6..9f70a804 100644 --- a/.skills/mcc-integration-testing/SKILL.md +++ b/.skills/mcc-integration-testing/SKILL.md @@ -139,6 +139,34 @@ log. Version-gated components (v1212+, v1215+, v12111+, v261) are tested only on the versions that support them. See `SC_Integration_Test_Report.md` for a reference run across all 6 version groups. +### 6. Dialog integration test + +Use this after touching any dialog system code (packet handling, NBT parsing, +models, TUI, command dispatch, the state machine in `DialogManager`, or the +codec in `DialogNbtParser`). Tests all 5 dialog types, button actions (close, +run_command, show_dialog), cancel/dismiss, click-label, and body content: + +```bash +tools/run-dialog-test.sh 26.1 +``` + +The script starts the server if needed, generates a temp MCC config, launches +MCC with file-input mode (requires both `MCC_FILE_INPUT=1` and +`MCC_INPUT_FILE=` env vars), sends inline SNBT dialogs via RCON, and +asserts 29 checks against the MCC log. + +Key requirements that differ from other test modes: + +- FileInputBot is loaded only when `MCC_FILE_INPUT=1` is set in the + environment. The `[ChatBot.FileInput]` config section is ignored at load + time. +- The input file path is controlled by `MCC_INPUT_FILE`, *not* by the config + `File` setting. +- Dialogs use inline SNBT syntax through `ResourceOrIdArgument`, e.g.: + `dialog show {type:"minecraft:notice", title:{text:"Hello"}}` +- The `ActionButton.CODEC` flattens `CommonButtonData` fields (`label`, + `tooltip`, `width`) into the same object as `action` — no `button` wrapper. + ### 5. Full inventory regression sweep Use this when touching inventory snapshots, player/container slot sync, creative inventory, item-slot serialization, packet palettes, game-mode updates, or block-use paths that open containers: @@ -224,6 +252,9 @@ Optionally override the login name with the fourth argument to the config helper - full inventory command/API sweep across one or more versions - `tools/run-structured-components-test.sh` - exercises every structured component via RCON `/give` across versions 1.20.6-26.1 +- `tools/run-dialog-test.sh` + - dialog integration test: all 5 types, run_command/show_dialog actions, + cancel/dismiss/click-label, body content; 29 assertions on MCC log ## Evidence Discipline @@ -287,3 +318,6 @@ Always summarize: - If an inventory row crashes with `Queue empty` or `Failed to process incoming packet`, inspect packet palette routing before changing inventory code. A single shifted packet ID can make a healthy inventory feature look broken. - For chest-open failures, separate product and harness causes. The player may be standing inside the chest or suffocating on older servers. Stand beside the chest, put a floor under the player, and retry `useblock`. - For shared local servers, a `Done` log line does not prove RCON is ready. Retry setup commands and verify the actual RCON port from `server.properties`. +- If `tools/run-dialog-test.sh` fails with "FileInput Watching: .../mcc_input.txt" pointing to the wrong directory, the `MCC_INPUT_FILE` env var was not set in the tmux command. FileInputBot ignores the config `File` setting entirely. +- If inline SNBT dialogs fail on the server side (`Failed to parse structure: No key ...`), check whether `ActionButton.CODEC` fields are flat (no `button` wrapper) and whether the dialog type fields match the 26.1 server (`label` not `text` in `CommonButtonData`). +- If a dialog integration test fails on "Server showed custom dialog", the dialog packet (id=0x8C in 26.1 play phase) may not have been sent. Verify the RCON command succeeded and the server printed "Displayed dialog to ...". diff --git a/MinecraftClient/ClassicConsoleBackend.cs b/MinecraftClient/ClassicConsoleBackend.cs index 02712459..c771d273 100644 --- a/MinecraftClient/ClassicConsoleBackend.cs +++ b/MinecraftClient/ClassicConsoleBackend.cs @@ -1,12 +1,81 @@ using System; +using System.Text.RegularExpressions; namespace MinecraftClient { /// /// Console backend wrapping the ConsoleInteractive library (existing behavior). /// - public class ClassicConsoleBackend : IConsoleBackend + public partial class ClassicConsoleBackend : IConsoleBackend { + private static readonly (byte R, byte G, byte B, char Code)[] McStandardColors = + [ + (0, 0, 0, '0'), // black + (0, 0, 170, '1'), // dark_blue + (0, 170, 0, '2'), // dark_green + (0, 170, 170, '3'), // dark_aqua + (170, 0, 0, '4'), // dark_red + (170, 0, 170, '5'), // dark_purple + (255, 170, 0, '6'), // gold + (170, 170, 170, '7'), // gray + (85, 85, 85, '8'), // dark_gray + (85, 85, 255, '9'), // blue + (85, 255, 85, 'a'), // green + (85, 255, 255, 'b'), // aqua + (255, 85, 85, 'c'), // red + (255, 85, 255, 'd'), // light_purple + (255, 255, 85, 'e'), // yellow + (255, 255, 255, 'f'), // white + ]; + + [GeneratedRegex("§#([0-9a-fA-F]{6})")] + private static partial Regex HexColorRegex(); + + private static char NearestMcColor(byte r, byte g, byte b) + { + int bestIdx = 0; + long bestDist = long.MaxValue; + + for (int i = 0; i < McStandardColors.Length; i++) + { + var (sr, sg, sb, _) = McStandardColors[i]; + long dr = r - sr; + long dg = g - sg; + long db = b - sb; + long dist = dr * dr + dg * dg + db * db; + if (dist < bestDist) + { + bestDist = dist; + bestIdx = i; + } + } + + return McStandardColors[bestIdx].Code; + } + + private static string ResolveHexColors(string text) + { + if (string.IsNullOrEmpty(text) || !text.Contains("§#", StringComparison.Ordinal)) + return text; + + return HexColorRegex().Replace(text, match => + { + ReadOnlySpan hex = match.Groups[1].ValueSpan; + byte r = (byte)((HexVal(hex[0]) << 4) | HexVal(hex[1])); + byte g = (byte)((HexVal(hex[2]) << 4) | HexVal(hex[3])); + byte b = (byte)((HexVal(hex[4]) << 4) | HexVal(hex[5])); + return $"§{NearestMcColor(r, g, b)}"; + }); + } + + private static int HexVal(char c) => c switch + { + >= '0' and <= '9' => c - '0', + >= 'a' and <= 'f' => c - 'a' + 10, + >= 'A' and <= 'F' => c - 'A' + 10, + _ => 0 + }; + public event EventHandler? MessageReceived; public event EventHandler? OnInputChange; @@ -28,7 +97,7 @@ namespace MinecraftClient public void WriteLineFormatted(string text) { - ConsoleInteractive.ConsoleWriter.WriteLineFormatted(text); + ConsoleInteractive.ConsoleWriter.WriteLineFormatted(ResolveHexColors(text)); } public void BeginReadThread() diff --git a/MinecraftClient/Commands/Dialog.cs b/MinecraftClient/Commands/Dialog.cs new file mode 100644 index 00000000..826fa592 --- /dev/null +++ b/MinecraftClient/Commands/Dialog.cs @@ -0,0 +1,108 @@ +using Brigadier.NET; +using Brigadier.NET.Builder; +using MinecraftClient.CommandHandler; +using MinecraftClient.Dialogs; +using MinecraftClient.Tui; + +namespace MinecraftClient.Commands; + +public class Dialog : Command +{ + public override string CmdName => "dialog"; + public override string CmdUsage => Translations.cmd_dialog_usage; + public override string CmdDesc => Translations.cmd_dialog_desc; + + public override void RegisterCommand(CommandDispatcher dispatcher) + { + dispatcher.Register(l => l.Literal("help") + .Then(l => l.Literal(CmdName) + .Executes(r => GetUsage(r.Source)))); + + dispatcher.Register(l => l.Literal(CmdName) + .Executes(r => Show(r.Source)) + .Then(l => l.Literal("show") + .Executes(r => Show(r.Source))) + .Then(l => l.Literal("open") + .Executes(r => Open(r.Source))) + .Then(l => l.Literal("set") + .Then(l => l.Argument("Input", Arguments.String()) + .Then(l => l.Argument("Value", Arguments.GreedyString()) + .Executes(r => SetInput(r.Source, Arguments.GetString(r, "Input"), Arguments.GetString(r, "Value")))))) + .Then(l => l.Literal("input") + .Then(l => l.Argument("Input", Arguments.String()) + .Then(l => l.Argument("Value", Arguments.GreedyString()) + .Executes(r => SetInput(r.Source, Arguments.GetString(r, "Input"), Arguments.GetString(r, "Value")))))) + .Then(l => l.Literal("click") + .Then(l => l.Argument("Index", Arguments.Integer(min: 1)) + .Executes(r => Click(r.Source, Arguments.GetInteger(r, "Index"))))) + .Then(l => l.Literal("click-label") + .Then(l => l.Argument("Label", Arguments.GreedyString()) + .Executes(r => ClickLabel(r.Source, Arguments.GetString(r, "Label"))))) + .Then(l => l.Literal("cancel") + .Executes(r => Cancel(r.Source))) + .Then(l => l.Literal("dismiss") + .Executes(r => Dismiss(r.Source))) + .Then(l => l.Literal("_help") + .Executes(r => GetUsage(r.Source)) + .Redirect(dispatcher.GetRoot().GetChild("help").GetChild(CmdName)))); + } + + private int GetUsage(CmdResult r) => r.SetAndReturn(GetCmdDescTranslated()); + + private static int Show(CmdResult r) + { + var handler = CmdResult.currentHandler!; + var current = handler.Dialogs.Current; + if (current is null) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_none); + + ConsoleIO.WriteLineFormatted(DialogFormatter.Render(current), acceptnewlines: true); + return r.SetAndReturn(CmdResult.Status.Done); + } + + private static int Open(CmdResult r) + { + var handler = CmdResult.currentHandler!; + var current = handler.Dialogs.Current; + if (current is null) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_none); + + if (ConsoleIO.Backend is not TuiConsoleBackend) + return r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_tui_unavailable); + + return DialogTuiHost.TryOpen(handler, current, force: true) + ? r.SetAndReturn(CmdResult.Status.Done, Translations.dialog_tui_opened) + : r.SetAndReturn(CmdResult.Status.Fail, Translations.dialog_tui_unavailable); + } + + private static int SetInput(CmdResult r, string key, string value) + { + var result = CmdResult.currentHandler!.Dialogs.SetInput(key, value); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } + + private static int Click(CmdResult r, int index) + { + var result = CmdResult.currentHandler!.Dialogs.Click(index); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } + + private static int ClickLabel(CmdResult r, string label) + { + var result = CmdResult.currentHandler!.Dialogs.ClickLabel(label); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } + + private static int Cancel(CmdResult r) + { + var result = CmdResult.currentHandler!.Dialogs.Cancel(); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } + + private static int Dismiss(CmdResult r) + { + var result = CmdResult.currentHandler!.Dialogs.Dismiss(); + DialogTuiHost.CloseCurrent(); + return r.SetAndReturn(result.Success ? CmdResult.Status.Done : CmdResult.Status.Fail, result.Message); + } +} diff --git a/MinecraftClient/Dialogs/DialogFormatter.cs b/MinecraftClient/Dialogs/DialogFormatter.cs new file mode 100644 index 00000000..9e836e3e --- /dev/null +++ b/MinecraftClient/Dialogs/DialogFormatter.cs @@ -0,0 +1,108 @@ +using System; +using System.Linq; +using System.Text; + +namespace MinecraftClient.Dialogs; + +public static class DialogFormatter +{ + public static string DisplayTitle(this DialogDefinition definition) + { + if (!string.IsNullOrWhiteSpace(definition.ExternalTitle)) + return definition.ExternalTitle!; + + return string.IsNullOrWhiteSpace(definition.Title) ? DisplayType(definition.Type) : definition.Title; + } + + private const int BoxWidth = 50; + + public static string Render(DialogInstance instance) + { + StringBuilder builder = new(); + string border = new('-', BoxWidth); + builder.AppendLine(border); + builder.AppendLine(" " + string.Format(Translations.dialog_render_header, instance.Revision, instance.Phase, instance.Definition.DisplayTitle())); + builder.AppendLine(border); + + foreach (var body in instance.Definition.Body.Where(static body => !string.IsNullOrWhiteSpace(body.Text))) + builder.AppendLine(body.Text); + + if (instance.Definition.Inputs.Count > 0) + { + builder.AppendLine(Translations.dialog_render_inputs); + foreach (var input in instance.Definition.Inputs) + { + instance.Values.TryGetValue(input.Key, out var value); + value ??= input.InitialValue; + builder.AppendLine(string.Format(Translations.dialog_render_input, input.Key, DescribeKind(input.Kind), input.Label, value, DescribeInput(input))); + } + } + + if (instance.Definition.Actions.Count > 0) + { + builder.AppendLine(Translations.dialog_render_actions); + foreach (var action in instance.Definition.Actions) + builder.AppendLine(string.Format(Translations.dialog_render_action, action.Index, action.Label, DescribeAction(action.Action))); + } + + builder.AppendLine(); + builder.AppendLine("§o" + Translations.dialog_render_help_hint + "§r"); + builder.Append(border); + return builder.ToString(); + } + + public static string DisplayType(string rawType) + { + return rawType switch + { + "minecraft:notice" => Translations.dialog_type_notice, + "minecraft:confirmation" => Translations.dialog_type_confirmation, + "minecraft:multi_action" => Translations.dialog_type_multi_action, + "minecraft:dialog_list" => Translations.dialog_type_dialog_list, + "minecraft:server_links" => Translations.dialog_type_server_links, + _ => string.IsNullOrEmpty(rawType) ? Translations.dialog_type_unknown : rawType + }; + } + + private static string DescribeKind(DialogInputKind kind) + { + return kind switch + { + DialogInputKind.Text => Translations.dialog_input_kind_text, + DialogInputKind.Boolean => Translations.dialog_input_kind_boolean, + DialogInputKind.SingleOption => Translations.dialog_input_kind_options, + DialogInputKind.NumberRange => Translations.dialog_input_kind_number, + _ => Translations.dialog_input_kind_unknown + }; + } + + private static string DescribeInput(DialogInput input) + { + return input.Kind switch + { + DialogInputKind.Text => string.Format(Translations.dialog_input_desc_text, input.MaxLength), + DialogInputKind.Boolean => string.Format(Translations.dialog_input_desc_boolean, input.OnTrue, input.OnFalse), + DialogInputKind.SingleOption => string.Format(Translations.dialog_input_desc_options, + string.Join(", ", input.Options?.Select(static option => option.Id) ?? [])), + DialogInputKind.NumberRange => string.Format(Translations.dialog_input_desc_number, input.Start, input.End), + _ => input.Type ?? Translations.dialog_input_desc_unknown + }; + } + + private static string DescribeAction(DialogActionDefinition? action) + { + if (action is null) + return Translations.dialog_action_desc_close; + + return action.Kind switch + { + DialogActionKind.RunCommand => Translations.dialog_action_desc_command, + DialogActionKind.CustomClick => Translations.dialog_action_desc_custom, + DialogActionKind.ShowDialog => Translations.dialog_action_desc_show_dialog, + DialogActionKind.OpenUrl => Translations.dialog_action_desc_open_url, + DialogActionKind.SuggestCommand => Translations.dialog_action_desc_suggest, + DialogActionKind.CopyToClipboard => Translations.dialog_action_desc_copy, + _ => action.Type ?? Translations.dialog_action_desc_unknown + }; + } +} diff --git a/MinecraftClient/Dialogs/DialogManager.cs b/MinecraftClient/Dialogs/DialogManager.cs new file mode 100644 index 00000000..40876b61 --- /dev/null +++ b/MinecraftClient/Dialogs/DialogManager.cs @@ -0,0 +1,445 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; + +namespace MinecraftClient.Dialogs; + +public sealed class DialogManager +{ + private readonly McClient _client; + private readonly Lock _lock = new(); + private readonly Dictionary _registryById = new(); + private readonly Dictionary _registryByName = new(StringComparer.Ordinal); + private readonly List _serverLinks = []; + private DialogInstance? _current; + private int _revision; + + public DialogManager(McClient client) + { + ArgumentNullException.ThrowIfNull(client); + _client = client; + } + + public event Action? DialogShown; + public event Action? DialogCleared; + + public DialogInstance? Current + { + get + { + lock (_lock) + return _current; + } + } + + public void StoreRegistryDialog(int protocolId, string resourceId, DialogDefinition definition) + { + lock (_lock) + { + _registryById[protocolId] = definition; + _registryByName[resourceId] = definition; + } + } + + public void ClearRegistry() + { + lock (_lock) + { + _registryById.Clear(); + _registryByName.Clear(); + } + } + + public void SetServerLinks(IEnumerable links) + { + lock (_lock) + { + _serverLinks.Clear(); + _serverLinks.AddRange(links); + } + } + + public DialogInstance Show(DialogDefinition definition, DialogPhase phase) + { + DialogInstance instance; + lock (_lock) + { + var expanded = ExpandServerLinks(definition); + var values = expanded.Inputs.ToDictionary(static input => input.Key, static input => input.InitialValue, StringComparer.Ordinal); + instance = new DialogInstance(++_revision, phase, expanded, values, DateTimeOffset.UtcNow); + _current = instance; + } + + _client.Log.Info("§e" + string.Format(Translations.dialog_received, instance.Definition.DisplayTitle())); + DialogShown?.Invoke(instance); + return instance; + } + + public DialogInstance ShowRegistryReference(int protocolId, DialogPhase phase) + { + DialogDefinition? definition; + lock (_lock) + _registryById.TryGetValue(protocolId, out definition); + + if (definition is not null) + return Show(definition, phase); + + var unresolved = new DialogDefinition( + "minecraft:unresolved", + string.Format(CultureInfo.InvariantCulture, Translations.dialog_unresolved_title, protocolId), + null, + CanCloseWithEscape: true, + Pause: false, + DialogAfterAction.Close, + [new DialogBody(DialogBodyKind.Unknown, string.Format(CultureInfo.InvariantCulture, Translations.dialog_unresolved_body, protocolId))], + [], + [], + null, + IsResolved: false, + UnresolvedReference: protocolId.ToString(CultureInfo.InvariantCulture)); + return Show(unresolved, phase); + } + + public void Clear() + { + int revision; + lock (_lock) + { + revision = _current?.Revision ?? _revision; + _current = null; + } + + _client.Log.Info(Translations.dialog_cleared); + DialogCleared?.Invoke(revision); + } + + public DialogActionResult Dismiss() + { + int revision; + lock (_lock) + { + if (_current is null) + return new DialogActionResult(false, Translations.dialog_none); + + revision = _current.Revision; + _current = null; + } + + DialogCleared?.Invoke(revision); + return new DialogActionResult(true, Translations.dialog_dismissed); + } + + public DialogActionResult SetInput(string key, string value) + { + lock (_lock) + { + if (_current is null) + return new DialogActionResult(false, Translations.dialog_none); + + var input = _current.Definition.Inputs.FirstOrDefault(input => input.Key.Equals(key, StringComparison.Ordinal)); + if (input is null) + return new DialogActionResult(false, string.Format(Translations.dialog_input_unknown, key)); + + var normalized = NormalizeInputValue(input, value, out var error); + if (error is not null) + return new DialogActionResult(false, error); + + var values = _current.Values.ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + values[key] = normalized; + _current = _current with { Values = values }; + return new DialogActionResult(true, string.Format(Translations.dialog_input_set, key, normalized)); + } + } + + public DialogActionResult Click(int index) + { + DialogButton? button; + DialogInstance? instance; + lock (_lock) + { + instance = _current; + button = instance?.Definition.Actions.FirstOrDefault(action => action.Index == index); + } + + if (instance is null) + return new DialogActionResult(false, Translations.dialog_none); + + if (button is null) + return new DialogActionResult(false, string.Format(Translations.dialog_action_unknown, index)); + + return Execute(instance, button.Action, ShouldCloseAfterAction(instance.Definition.AfterAction)); + } + + public DialogActionResult ClickLabel(string label) + { + DialogButton[] matches; + DialogInstance? instance; + lock (_lock) + { + instance = _current; + matches = instance?.Definition.Actions + .Where(action => action.Label.Equals(label, StringComparison.OrdinalIgnoreCase)) + .ToArray() ?? []; + } + + if (instance is null) + return new DialogActionResult(false, Translations.dialog_none); + + return matches.Length switch + { + 0 => new DialogActionResult(false, string.Format(Translations.dialog_action_label_unknown, label)), + > 1 => new DialogActionResult(false, string.Format(Translations.dialog_action_label_ambiguous, label)), + _ => Execute(instance, matches[0].Action, ShouldCloseAfterAction(instance.Definition.AfterAction)) + }; + } + + public DialogActionResult Cancel() + { + DialogInstance? instance; + lock (_lock) + instance = _current; + + if (instance is null) + return new DialogActionResult(false, Translations.dialog_none); + + if (!instance.Definition.CanCloseWithEscape && instance.Definition.CancelAction is null) + return new DialogActionResult(false, Translations.dialog_cannot_cancel); + + return Execute(instance, instance.Definition.CancelAction, closeWhenDone: true); + } + + private DialogActionResult Execute(DialogInstance instance, DialogActionDefinition? action, bool closeWhenDone) + { + if (!instance.Definition.IsResolved) + return new DialogActionResult(false, Translations.dialog_unresolved_action_disabled); + + if (action is null || action.Kind == DialogActionKind.None) + { + if (closeWhenDone) + _ = Dismiss(); + return new DialogActionResult(true, Translations.dialog_action_closed); + } + + var values = BuildActionValues(instance); + switch (action.Kind) + { + case DialogActionKind.RunCommand: + if (instance.Phase != DialogPhase.Play) + return new DialogActionResult(false, Translations.dialog_action_command_not_in_play); + + var command = ApplyTemplate(action.Value ?? string.Empty, values.TemplateValues); + _client.SendText(command); + if (closeWhenDone) + _ = Dismiss(); + return new DialogActionResult(true, string.Format(Translations.dialog_action_command_sent, command)); + + case DialogActionKind.CustomClick: + if (action.Id is null) + return new DialogActionResult(false, Translations.dialog_action_invalid); + + var payload = action.Type == "minecraft:custom" && action.Payload is null && values.TagValues.Count == 0 + ? null + : MergePayload(action.Payload, values.TagValues); + if (!_client.SendCustomClickAction(action.Id, payload)) + return new DialogActionResult(false, Translations.dialog_action_custom_failed); + + if (closeWhenDone) + _ = Dismiss(); + return new DialogActionResult(true, string.Format(Translations.dialog_action_custom_sent, action.Id)); + + case DialogActionKind.ShowDialog: + if (action.NestedDialog is not null) + { + Show(action.NestedDialog, instance.Phase); + return new DialogActionResult(true, Translations.dialog_action_nested_opened); + } + + if (action.DialogReferenceId is int referenceId) + { + ShowRegistryReference(referenceId, instance.Phase); + return new DialogActionResult(true, Translations.dialog_action_nested_opened); + } + + if (action.Value is not null) + { + DialogDefinition? referencedDialog; + lock (_lock) + _registryByName.TryGetValue(action.Value, out referencedDialog); + + if (referencedDialog is not null) + { + Show(referencedDialog, instance.Phase); + return new DialogActionResult(true, Translations.dialog_action_nested_opened); + } + } + + return new DialogActionResult(false, Translations.dialog_action_invalid); + + case DialogActionKind.OpenUrl: + return new DialogActionResult(true, string.Format(Translations.dialog_action_open_url, action.Value ?? string.Empty)); + + case DialogActionKind.SuggestCommand: + return new DialogActionResult(true, string.Format(Translations.dialog_action_suggest_command, action.Value ?? string.Empty)); + + case DialogActionKind.CopyToClipboard: + return new DialogActionResult(true, string.Format(Translations.dialog_action_copy, action.Value ?? string.Empty)); + + default: + return new DialogActionResult(false, string.Format(Translations.dialog_action_unsupported, action.Type ?? action.Kind.ToString())); + } + } + + private static bool ShouldCloseAfterAction(DialogAfterAction afterAction) + { + return afterAction == DialogAfterAction.Close; + } + + private DialogDefinition ExpandServerLinks(DialogDefinition definition) + { + if (!definition.Type.Equals("minecraft:server_links", StringComparison.Ordinal)) + return definition; + + var linkActions = _serverLinks + .Select((link, index) => new DialogButton( + index + 1, + link.Label, + new DialogActionDefinition(DialogActionKind.OpenUrl, Value: link.Url))) + .ToList(); + + if (definition.Actions.Count > 0) + linkActions.AddRange(definition.Actions.Select((button, i) => button with { Index = linkActions.Count + i + 1 })); + + return definition with { Actions = linkActions }; + } + + private static DialogActionValues BuildActionValues(DialogInstance instance) + { + Dictionary templateValues = new(StringComparer.Ordinal); + Dictionary tagValues = new(StringComparer.Ordinal); + + foreach (var input in instance.Definition.Inputs) + { + instance.Values.TryGetValue(input.Key, out var value); + value ??= input.InitialValue; + templateValues[input.Key] = ToTemplateValue(input, value); + tagValues[input.Key] = ToNbtValue(input, value); + } + + return new DialogActionValues(templateValues, tagValues); + } + + private static Dictionary MergePayload(Dictionary? basePayload, Dictionary inputTags) + { + Dictionary payload = basePayload is null + ? new(StringComparer.Ordinal) + : new(basePayload, StringComparer.Ordinal); + + foreach (var (key, value) in inputTags) + payload[key] = value; + + return payload; + } + + private static string NormalizeInputValue(DialogInput input, string value, out string? error) + { + error = null; + switch (input.Kind) + { + case DialogInputKind.Text: + if (value.Length > input.MaxLength) + { + error = string.Format(Translations.dialog_input_too_long, input.Key, input.MaxLength); + return input.InitialValue; + } + return value; + + case DialogInputKind.Boolean: + if (bool.TryParse(value, out var boolValue)) + return boolValue ? "true" : "false"; + + if (value.Equals(input.OnTrue, StringComparison.OrdinalIgnoreCase)) + return "true"; + + if (value.Equals(input.OnFalse, StringComparison.OrdinalIgnoreCase)) + return "false"; + + error = string.Format(Translations.dialog_input_boolean_invalid, input.Key); + return input.InitialValue; + + case DialogInputKind.SingleOption: + if (input.Options?.Any(option => option.Id.Equals(value, StringComparison.Ordinal)) == true) + return value; + + error = string.Format(Translations.dialog_input_option_invalid, input.Key); + return input.InitialValue; + + case DialogInputKind.NumberRange: + if (!float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number)) + { + error = string.Format(Translations.dialog_input_number_invalid, input.Key); + return input.InitialValue; + } + + var min = Math.Min(input.Start, input.End); + var max = Math.Max(input.Start, input.End); + if (number < min || number > max) + { + error = string.Format(CultureInfo.InvariantCulture, Translations.dialog_input_number_range_invalid, input.Key, min, max); + return input.InitialValue; + } + + return NumberToString(number); + + default: + return value; + } + } + + private static string ToTemplateValue(DialogInput input, string value) + { + return input.Kind switch + { + DialogInputKind.Boolean => value.Equals("true", StringComparison.OrdinalIgnoreCase) ? input.OnTrue : input.OnFalse, + DialogInputKind.Text => EscapeStringTagWithoutQuotes(value), + _ => value + }; + } + + private static object ToNbtValue(DialogInput input, string value) + { + return input.Kind switch + { + DialogInputKind.Boolean => (byte)(value.Equals("true", StringComparison.OrdinalIgnoreCase) ? 1 : 0), + DialogInputKind.NumberRange when float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) => number, + _ => value + }; + } + + private static string ApplyTemplate(string template, IReadOnlyDictionary values) + { + var result = template; + foreach (var (key, value) in values) + result = result.Replace("$(" + key + ")", value, StringComparison.Ordinal); + + return result; + } + + private static string EscapeStringTagWithoutQuotes(string value) + { + return value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); + } + + private static string NumberToString(float value) + { + var integer = (int)value; + return integer == value + ? integer.ToString(CultureInfo.InvariantCulture) + : value.ToString(CultureInfo.InvariantCulture); + } + + private sealed record DialogActionValues( + IReadOnlyDictionary TemplateValues, + Dictionary TagValues); +} diff --git a/MinecraftClient/Dialogs/DialogModels.cs b/MinecraftClient/Dialogs/DialogModels.cs new file mode 100644 index 00000000..fc40b480 --- /dev/null +++ b/MinecraftClient/Dialogs/DialogModels.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; + +namespace MinecraftClient.Dialogs; + +public enum DialogPhase +{ + Configuration, + Play +} + +public enum DialogAfterAction +{ + Close, + None, + WaitForResponse +} + +public enum DialogBodyKind +{ + PlainMessage, + Item, + Unknown +} + +public enum DialogInputKind +{ + Text, + Boolean, + SingleOption, + NumberRange, + Unknown +} + +public enum DialogActionKind +{ + None, + RunCommand, + CustomClick, + ShowDialog, + OpenUrl, + SuggestCommand, + CopyToClipboard, + Unknown +} + +public sealed record DialogBody(DialogBodyKind Kind, string Text, string? Type = null); + +public sealed record DialogOption(string Id, string Display, bool Initial) +{ + public override string ToString() + { + return string.IsNullOrWhiteSpace(Display) ? Id : Display; + } +} + +public sealed record DialogInput( + string Key, + DialogInputKind Kind, + string Label, + string InitialValue, + int MaxLength = 32, + bool LabelVisible = true, + bool Multiline = false, + IReadOnlyList? Options = null, + string OnTrue = "true", + string OnFalse = "false", + float Start = 0, + float End = 1, + float? InitialNumber = null, + float? Step = null, + string? Type = null); + +public sealed record DialogActionDefinition( + DialogActionKind Kind, + string? Value = null, + string? Id = null, + Dictionary? Payload = null, + DialogDefinition? NestedDialog = null, + int? DialogReferenceId = null, + string? Type = null); + +public sealed record DialogButton(int Index, string Label, DialogActionDefinition? Action, bool IsCancel = false); + +public sealed record DialogServerLink(string Label, string Url); + +public sealed record DialogDefinition( + string Type, + string Title, + string? ExternalTitle, + bool CanCloseWithEscape, + bool Pause, + DialogAfterAction AfterAction, + IReadOnlyList Body, + IReadOnlyList Inputs, + IReadOnlyList Actions, + DialogActionDefinition? CancelAction, + int Columns = 1, + int ButtonWidth = 150, + bool IsResolved = true, + string? UnresolvedReference = null); + +public sealed record DialogInstance( + int Revision, + DialogPhase Phase, + DialogDefinition Definition, + IReadOnlyDictionary Values, + DateTimeOffset ReceivedAt); + +public sealed record DialogActionResult(bool Success, string Message); diff --git a/MinecraftClient/McClient.cs b/MinecraftClient/McClient.cs index 0c7665fd..753de486 100644 --- a/MinecraftClient/McClient.cs +++ b/MinecraftClient/McClient.cs @@ -11,6 +11,7 @@ using MinecraftClient.ChatBots; using MinecraftClient.CommandHandler; using MinecraftClient.CommandHandler.Patch; using MinecraftClient.Commands; +using MinecraftClient.Dialogs; using MinecraftClient.Inventory; using MinecraftClient.Logger; using MinecraftClient.Mapping; @@ -235,6 +236,7 @@ namespace MinecraftClient private bool consoleHandlersAttached = false; public ILogger Log; + public DialogManager Dialogs { get; } private static IMinecraftComHandler? instance; public static IMinecraftComHandler? Instance => instance; @@ -276,6 +278,7 @@ namespace MinecraftClient Log.ChatEnabled = Config.Logging.ChatMessages; Log.WarnEnabled = Config.Logging.WarningMessages; Log.ErrorEnabled = Config.Logging.ErrorMessages; + Dialogs = new DialogManager(this); // SENTRY: Send our client version and server version to Sentry SentrySdk.ConfigureScope(scope => @@ -1819,6 +1822,14 @@ namespace MinecraftClient } } + public bool SendCustomClickAction(string id, Dictionary? payload) + { + if (InvokeRequired) + return InvokeOnMainThread(() => SendCustomClickAction(id, payload)); + + return handler.SendCustomClickAction(id, payload); + } + /// /// Allow to respawn after death /// @@ -3421,6 +3432,36 @@ namespace MinecraftClient DispatchBotEvent(bot => bot.OnNetworkPacket(packetID, packetData, isLogin, isInbound)); } + public void OnDialogRegistryData(int protocolId, string resourceId, DialogDefinition dialog) + { + Dialogs.StoreRegistryDialog(protocolId, resourceId, dialog); + } + + public void OnDialogShown(DialogDefinition dialog, DialogPhase phase) + { + var instance = Dialogs.Show(dialog, phase); + if (!Tui.DialogTuiHost.TryOpen(this, instance, force: phase == DialogPhase.Configuration)) + ConsoleIO.WriteLineFormatted(DialogFormatter.Render(instance), acceptnewlines: true); + } + + public void OnDialogRegistryReferenceShown(int protocolId, DialogPhase phase) + { + var instance = Dialogs.ShowRegistryReference(protocolId, phase); + if (!Tui.DialogTuiHost.TryOpen(this, instance, force: phase == DialogPhase.Configuration)) + ConsoleIO.WriteLineFormatted(DialogFormatter.Render(instance), acceptnewlines: true); + } + + public void OnDialogCleared() + { + Dialogs.Clear(); + Tui.DialogTuiHost.CloseCurrent(); + } + + public void OnServerLinksUpdated(IReadOnlyList links) + { + Dialogs.SetServerLinks(links); + } + /// /// Called when a server was successfully joined /// diff --git a/MinecraftClient/Protocol/Dialogs/DialogNbtParser.cs b/MinecraftClient/Protocol/Dialogs/DialogNbtParser.cs new file mode 100644 index 00000000..2636b5be --- /dev/null +++ b/MinecraftClient/Protocol/Dialogs/DialogNbtParser.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using MinecraftClient.Dialogs; +using MinecraftClient.Protocol.Message; + +namespace MinecraftClient.Protocol.Dialogs; + +public sealed class DialogNbtParser +{ + public DialogDefinition Parse(Dictionary nbt) + { + var type = NormalizeType(GetString(nbt, "type") ?? "minecraft:notice"); + var common = ParseCommon(nbt, type); + var actions = new List(); + DialogActionDefinition? cancelAction = null; + var columns = GetInt(nbt, "columns", 1); + var buttonWidth = GetInt(nbt, "button_width", 150); + + switch (type) + { + case "minecraft:notice": + var noticeAction = ParseButton(nbt, "action", 1); + actions.Add(noticeAction ?? new DialogButton(1, Translations.dialog_action_ok, null)); + cancelAction = actions[0].Action; + break; + + case "minecraft:confirmation": + AddIfNotNull(actions, ParseButton(nbt, "yes", 1)); + AddIfNotNull(actions, ParseButton(nbt, "no", 2)); + cancelAction = actions.Count >= 2 ? actions[1].Action : null; + break; + + case "minecraft:multi_action": + actions.AddRange(ParseButtonList(GetValue(nbt, "actions"))); + cancelAction = ParseButton(nbt, "exit_action", 0)?.Action; + break; + + case "minecraft:dialog_list": + actions.AddRange(ParseDialogListActions(GetValue(nbt, "dialogs"))); + cancelAction = ParseButton(nbt, "exit_action", 0)?.Action; + break; + + case "minecraft:server_links": + cancelAction = ParseButton(nbt, "exit_action", 0)?.Action; + break; + } + + return new DialogDefinition( + type, + common.Title, + common.ExternalTitle, + common.CanCloseWithEscape, + common.Pause, + common.AfterAction, + common.Body, + common.Inputs, + actions, + cancelAction, + columns, + buttonWidth); + } + + public DialogDefinition? TryParse(Dictionary? nbt) + { + return nbt is null ? null : Parse(nbt); + } + + private static DialogCommon ParseCommon(Dictionary nbt, string type) + { + var title = ParseComponent(GetValue(nbt, "title")); + var externalTitle = nbt.TryGetValue("external_title", out var externalTitleValue) + ? ParseComponent(externalTitleValue) + : null; + var canCloseWithEscape = GetBool(nbt, "can_close_with_escape", true); + var pause = GetBool(nbt, "pause", true); + var afterAction = ParseAfterAction(GetString(nbt, "after_action") ?? "close"); + var body = ParseBody(GetValue(nbt, "body")); + var inputs = ParseInputs(GetValue(nbt, "inputs")); + + return new DialogCommon(title, externalTitle, canCloseWithEscape, pause, afterAction, body, inputs); + } + + private static IReadOnlyList ParseBody(object? value) + { + if (value is null) + return []; + + List body = []; + foreach (var item in Enumerate(value)) + { + if (item is Dictionary compound) + { + var type = NormalizeType(GetString(compound, "type") ?? "minecraft:plain_message"); + if (type == "minecraft:item") + { + var description = compound.TryGetValue("description", out var desc) + ? ParsePlainMessage(desc) + : string.Empty; + body.Add(new DialogBody(DialogBodyKind.Item, string.IsNullOrWhiteSpace(description) ? Translations.dialog_item_body : description, type)); + continue; + } + + body.Add(new DialogBody(DialogBodyKind.PlainMessage, ParsePlainMessage(compound), type)); + continue; + } + + body.Add(new DialogBody(DialogBodyKind.PlainMessage, ParseComponent(item), "minecraft:plain_message")); + } + + return body; + } + + private static string ParsePlainMessage(object? value) + { + if (value is Dictionary compound && compound.TryGetValue("contents", out var contents)) + return ParseComponent(contents); + + return ParseComponent(value); + } + + private static IReadOnlyList ParseInputs(object? value) + { + if (value is null) + return []; + + List inputs = []; + foreach (var item in Enumerate(value)) + { + if (item is not Dictionary inputData) + continue; + + var key = GetString(inputData, "key"); + if (string.IsNullOrWhiteSpace(key)) + continue; + + var control = inputData.TryGetValue("control", out var controlValue) && controlValue is Dictionary controlData + ? controlData + : inputData; + + var type = NormalizeType(GetString(control, "type") ?? "minecraft:text"); + inputs.Add(type switch + { + "minecraft:boolean" => ParseBooleanInput(key, type, control), + "minecraft:number_range" => ParseNumberInput(key, type, control), + "minecraft:single_option" => ParseOptionInput(key, type, control), + "minecraft:text" => ParseTextInput(key, type, control), + _ => new DialogInput(key, DialogInputKind.Unknown, ParseComponent(GetValue(control, "label")), string.Empty, Type: type) + }); + } + + return inputs; + } + + private static DialogInput ParseTextInput(string key, string type, Dictionary control) + { + return new DialogInput( + key, + DialogInputKind.Text, + ParseComponent(GetValue(control, "label")), + GetString(control, "initial") ?? string.Empty, + MaxLength: GetInt(control, "max_length", 32), + LabelVisible: GetBool(control, "label_visible", true), + Multiline: control.ContainsKey("multiline"), + Type: type); + } + + private static DialogInput ParseBooleanInput(string key, string type, Dictionary control) + { + var initial = GetBool(control, "initial", false); + return new DialogInput( + key, + DialogInputKind.Boolean, + ParseComponent(GetValue(control, "label")), + initial ? "true" : "false", + OnTrue: GetString(control, "on_true") ?? "true", + OnFalse: GetString(control, "on_false") ?? "false", + Type: type); + } + + private static DialogInput ParseOptionInput(string key, string type, Dictionary control) + { + var options = ParseOptions(GetValue(control, "options")); + var initial = options.FirstOrDefault(static option => option.Initial)?.Id + ?? options.FirstOrDefault()?.Id + ?? string.Empty; + return new DialogInput( + key, + DialogInputKind.SingleOption, + ParseComponent(GetValue(control, "label")), + initial, + LabelVisible: GetBool(control, "label_visible", true), + Options: options, + Type: type); + } + + private static DialogInput ParseNumberInput(string key, string type, Dictionary control) + { + var range = control.TryGetValue("range_info", out var rangeValue) && rangeValue is Dictionary rangeData + ? rangeData + : control; + var start = GetFloat(range, "start", 0); + var end = GetFloat(range, "end", 1); + var initial = TryGetFloat(range, "initial") ?? ((start + end) / 2F); + return new DialogInput( + key, + DialogInputKind.NumberRange, + ParseComponent(GetValue(control, "label")), + NumberToString(initial), + Start: start, + End: end, + InitialNumber: initial, + Step: TryGetFloat(range, "step"), + Type: type); + } + + private static IReadOnlyList ParseOptions(object? value) + { + if (value is null) + return []; + + List options = []; + foreach (var item in Enumerate(value)) + { + if (item is string id) + { + options.Add(new DialogOption(id, id, false)); + continue; + } + + if (item is Dictionary option) + { + var optionId = GetString(option, "id"); + if (optionId is null) + continue; + + var display = option.TryGetValue("display", out var displayValue) + ? ParseComponent(displayValue) + : optionId; + options.Add(new DialogOption(optionId, display, GetBool(option, "initial", false))); + } + } + + return options; + } + + private static List ParseButtonList(object? value) + { + List buttons = []; + var index = 1; + foreach (var item in Enumerate(value)) + { + if (item is Dictionary buttonData) + buttons.Add(ParseButton(buttonData, index++) ?? new DialogButton(index - 1, Translations.dialog_action_unnamed, null)); + } + + return buttons; + } + + private static IEnumerable ParseDialogListActions(object? value) + { + List buttons = []; + var index = 1; + foreach (var item in Enumerate(value)) + { + switch (item) + { + case string tag when tag.StartsWith('#'): + buttons.Add(new DialogButton(index++, tag, new DialogActionDefinition(DialogActionKind.Unknown, Type: "dialog_tag"))); + break; + case string resource: + buttons.Add(new DialogButton(index++, resource, new DialogActionDefinition(DialogActionKind.ShowDialog, Value: resource, Type: "dialog_reference_name"))); + break; + case Dictionary dialog: + var nested = new DialogNbtParser().Parse(dialog); + buttons.Add(new DialogButton(index++, nested.DisplayTitle(), new DialogActionDefinition(DialogActionKind.ShowDialog, NestedDialog: nested))); + break; + } + } + + return buttons; + } + + private static DialogButton? ParseButton(Dictionary owner, string key, int index) + { + return owner.TryGetValue(key, out var value) && value is Dictionary data + ? ParseButton(data, index) + : null; + } + + private static DialogButton? ParseButton(Dictionary data, int index) + { + var label = data.TryGetValue("label", out var labelValue) + ? ParseComponent(labelValue) + : Translations.dialog_action_unnamed; + var action = data.TryGetValue("action", out var actionValue) && actionValue is Dictionary actionData + ? ParseAction(actionData) + : null; + return new DialogButton(index, label, action); + } + + private static DialogActionDefinition ParseAction(Dictionary action) + { + var type = NormalizeType(GetString(action, "type") ?? GetString(action, "action") ?? "minecraft:none"); + return type switch + { + "minecraft:run_command" => new DialogActionDefinition(DialogActionKind.RunCommand, Value: GetString(action, "command"), Type: type), + "minecraft:dynamic/run_command" => new DialogActionDefinition(DialogActionKind.RunCommand, Value: GetString(action, "template"), Type: type), + "minecraft:custom" => new DialogActionDefinition(DialogActionKind.CustomClick, Id: GetString(action, "id"), Payload: GetCompound(action, "payload"), Type: type), + "minecraft:dynamic/custom" => new DialogActionDefinition(DialogActionKind.CustomClick, Id: GetString(action, "id"), Payload: GetCompound(action, "additions"), Type: type), + "minecraft:open_url" => new DialogActionDefinition(DialogActionKind.OpenUrl, Value: GetString(action, "url"), Type: type), + "minecraft:suggest_command" => new DialogActionDefinition(DialogActionKind.SuggestCommand, Value: GetString(action, "command"), Type: type), + "minecraft:copy_to_clipboard" => new DialogActionDefinition(DialogActionKind.CopyToClipboard, Value: GetString(action, "value"), Type: type), + "minecraft:show_dialog" => ParseShowDialogAction(action, type), + _ => new DialogActionDefinition(DialogActionKind.Unknown, Type: type) + }; + } + + private static DialogActionDefinition ParseShowDialogAction(Dictionary action, string type) + { + if (!action.TryGetValue("dialog", out var value)) + return new DialogActionDefinition(DialogActionKind.ShowDialog, Type: type); + + if (value is Dictionary dialogData) + return new DialogActionDefinition(DialogActionKind.ShowDialog, NestedDialog: new DialogNbtParser().Parse(dialogData), Type: type); + + if (value is int protocolId) + return new DialogActionDefinition(DialogActionKind.ShowDialog, DialogReferenceId: protocolId, Type: type); + + return new DialogActionDefinition(DialogActionKind.ShowDialog, Value: value.ToString(), Type: type); + } + + private static DialogAfterAction ParseAfterAction(string value) + { + return value switch + { + "none" => DialogAfterAction.None, + "wait_for_response" => DialogAfterAction.WaitForResponse, + _ => DialogAfterAction.Close + }; + } + + private static string ParseComponent(object? value) + { + if (value is null) + return string.Empty; + + try + { + return value switch + { + Dictionary compound => ChatParser.ParseText(compound), + string text when text.StartsWith('{') || text.StartsWith('[') => ChatParser.ParseText(text), + string text => text, + _ => value.ToString() ?? string.Empty + }; + } + catch + { + return value.ToString() ?? string.Empty; + } + } + + private static IEnumerable Enumerate(object? value) + { + if (value is null) + yield break; + + if (value is object[] array) + { + foreach (var item in array) + yield return item; + yield break; + } + + yield return value; + } + + private static object? GetValue(Dictionary data, string key) + { + return data.TryGetValue(key, out var value) ? value : null; + } + + private static string? GetString(Dictionary data, string key) + { + return data.TryGetValue(key, out var value) ? value as string ?? value.ToString() : null; + } + + private static Dictionary? GetCompound(Dictionary data, string key) + { + return data.TryGetValue(key, out var value) && value is Dictionary compound ? compound : null; + } + + private static bool GetBool(Dictionary data, string key, bool fallback) + { + if (!data.TryGetValue(key, out var value)) + return fallback; + + return value switch + { + bool boolean => boolean, + byte number => number != 0, + sbyte number => number != 0, + int number => number != 0, + string text when bool.TryParse(text, out var parsed) => parsed, + _ => fallback + }; + } + + private static int GetInt(Dictionary data, string key, int fallback) + { + if (!data.TryGetValue(key, out var value)) + return fallback; + + return value switch + { + byte number => number, + short number => number, + int number => number, + long number => (int)number, + string text when int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => fallback + }; + } + + private static float GetFloat(Dictionary data, string key, float fallback) + { + return TryGetFloat(data, key) ?? fallback; + } + + private static float? TryGetFloat(Dictionary data, string key) + { + if (!data.TryGetValue(key, out var value)) + return null; + + return value switch + { + float number => number, + double number => (float)number, + int number => number, + long number => number, + string text when float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) => parsed, + _ => null + }; + } + + private static string NormalizeType(string type) + { + return type.Contains(':', StringComparison.Ordinal) ? type : "minecraft:" + type; + } + + private static void AddIfNotNull(List buttons, DialogButton? button) + { + if (button is not null) + buttons.Add(button); + } + + private static string NumberToString(float value) + { + var integer = (int)value; + return integer == value + ? integer.ToString(CultureInfo.InvariantCulture) + : value.ToString(CultureInfo.InvariantCulture); + } + + private sealed record DialogCommon( + string Title, + string? ExternalTitle, + bool CanCloseWithEscape, + bool Pause, + DialogAfterAction AfterAction, + IReadOnlyList Body, + IReadOnlyList Inputs); +} diff --git a/MinecraftClient/Protocol/Handlers/Protocol16.cs b/MinecraftClient/Protocol/Handlers/Protocol16.cs index cedbd412..f7bd3d4a 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol16.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol16.cs @@ -266,6 +266,11 @@ namespace MinecraftClient.Protocol.Handlers throw new NotImplementedException(); } + public bool SendCustomClickAction(string id, Dictionary? payload) + { + return false; + } + public void Dispose() { try diff --git a/MinecraftClient/Protocol/Handlers/Protocol18.cs b/MinecraftClient/Protocol/Handlers/Protocol18.cs index 3e6dccc9..90786f5b 100644 --- a/MinecraftClient/Protocol/Handlers/Protocol18.cs +++ b/MinecraftClient/Protocol/Handlers/Protocol18.cs @@ -10,6 +10,7 @@ using System.Text; using System.Text.RegularExpressions; using System.Threading; using MinecraftClient.Crypto; +using MinecraftClient.Dialogs; using MinecraftClient.Inventory; using MinecraftClient.Inventory.ItemPalettes; using MinecraftClient.Logger; @@ -19,6 +20,7 @@ using MinecraftClient.Mapping.EntityPalettes; using MinecraftClient.Protocol.Handlers.Forge; using MinecraftClient.Protocol.Handlers.packet.s2c; using MinecraftClient.Protocol.Handlers.PacketPalettes; +using MinecraftClient.Protocol.Dialogs; using MinecraftClient.Protocol.Message; using MinecraftClient.Protocol.ProfileKey; using MinecraftClient.Protocol.Session; @@ -118,6 +120,7 @@ namespace MinecraftClient.Protocol.Handlers readonly PacketTypePalette packetPalette; readonly SocketWrapper socketWrapper; readonly DataTypes dataTypes; + readonly DialogNbtParser dialogNbtParser = new(); Tuple? netMain = null; // main thread Tuple? netReader = null; // reader thread readonly ILogger log; @@ -564,6 +567,7 @@ namespace MinecraftClient.Protocol.Handlers var isDimension = registryId == "minecraft:dimension_type"; var isAttribute = registryId == "minecraft:attribute"; var isEnchantment = registryId == "minecraft:enchantment"; + var isDialog = registryId == "minecraft:dialog"; var availableChats = isChat ? new Dictionary() : null; var dimensionIdMap = isDimension ? new Dictionary() : null; @@ -596,6 +600,8 @@ namespace MinecraftClient.Protocol.Handlers } else if (isEnchantment) enchantmentIdMap!.Add(i, entryId); + else if (isDialog && nbtData is not null) + handler.OnDialogRegistryData(i, entryId, dialogNbtParser.Parse(nbtData)); } if (isChat) @@ -671,16 +677,15 @@ namespace MinecraftClient.Protocol.Handlers break; case ConfigurationPacketTypesIn.ServerLinks: - var cfgLinksCount = dataTypes.ReadNextVarInt(packetData); - for (var i = 0; i < cfgLinksCount; i++) - { - var cfgIsBuiltIn = dataTypes.ReadNextBool(packetData); - if (cfgIsBuiltIn) - dataTypes.ReadNextVarInt(packetData); // Known type ID - else - dataTypes.ReadNextChat(packetData); // Component label - dataTypes.ReadNextString(packetData); // URL - } + handler.OnServerLinksUpdated(ReadServerLinks(packetData)); + break; + + case ConfigurationPacketTypesIn.ClearDialog: + handler.OnDialogCleared(); + break; + + case ConfigurationPacketTypesIn.ShowDialog: + HandleShowDialog(packetData, DialogPhase.Configuration); break; // Ignore other packets at this stage @@ -3373,16 +3378,15 @@ namespace MinecraftClient.Protocol.Handlers break; case PacketTypesIn.ServerLinks: - var linksCount = dataTypes.ReadNextVarInt(packetData); - for (var i = 0; i < linksCount; i++) - { - var isBuiltIn = dataTypes.ReadNextBool(packetData); - if (isBuiltIn) - dataTypes.ReadNextVarInt(packetData); // Known type ID - else - dataTypes.ReadNextChat(packetData); // Component label - dataTypes.ReadNextString(packetData); // URL - } + handler.OnServerLinksUpdated(ReadServerLinks(packetData)); + break; + + case PacketTypesIn.ClearDialog: + handler.OnDialogCleared(); + break; + + case PacketTypesIn.ShowDialog: + HandleShowDialog(packetData, DialogPhase.Play); break; // 1.21.2+ new packets @@ -4105,6 +4109,61 @@ namespace MinecraftClient.Protocol.Handlers SendPacket(packetPalette.GetOutgoingIdByTypeConfiguration(packet), packetData, packet.ToString()); } + private void HandleShowDialog(Queue packetData, DialogPhase phase) + { + if (phase == DialogPhase.Play) + { + var holderId = dataTypes.ReadNextVarInt(packetData); + if (holderId != 0) + { + handler.OnDialogRegistryReferenceShown(holderId - 1, phase); + return; + } + } + + var dialog = dialogNbtParser.Parse(dataTypes.ReadNextNbt(packetData)); + handler.OnDialogShown(dialog, phase); + } + + private IReadOnlyList ReadServerLinks(Queue packetData) + { + var linksCount = dataTypes.ReadNextVarInt(packetData); + List links = new(linksCount); + + for (var i = 0; i < linksCount; i++) + { + string label; + var isBuiltIn = dataTypes.ReadNextBool(packetData); + if (isBuiltIn) + label = GetKnownServerLinkLabel(dataTypes.ReadNextVarInt(packetData)); + else + label = dataTypes.ReadNextChat(packetData); + + var url = dataTypes.ReadNextString(packetData); + links.Add(new DialogServerLink(label, url)); + } + + return links; + } + + private static string GetKnownServerLinkLabel(int id) + { + return id switch + { + 0 => Translations.dialog_server_link_report_bug, + 1 => Translations.dialog_server_link_community_guidelines, + 2 => Translations.dialog_server_link_support, + 3 => Translations.dialog_server_link_status, + 4 => Translations.dialog_server_link_feedback, + 5 => Translations.dialog_server_link_community, + 6 => Translations.dialog_server_link_website, + 7 => Translations.dialog_server_link_forums, + 8 => Translations.dialog_server_link_news, + 9 => Translations.dialog_server_link_announcements, + _ => id.ToString(CultureInfo.InvariantCulture) + }; + } + /// /// Send a packet to the server. Compression and encryption will be handled automatically. /// @@ -5025,6 +5084,49 @@ namespace MinecraftClient.Protocol.Handlers } } + public bool SendCustomClickAction(string id, Dictionary? payload) + { + if (protocolVersion < MC_1_21_6_Version) + return false; + + try + { + List fields = new(); + fields.AddRange(dataTypes.GetString(id.Contains(':', StringComparison.Ordinal) ? id : "minecraft:" + id)); + + var tagBytes = dataTypes.GetNbtTag(payload); + if (tagBytes.Length > 65536) + return false; + + fields.AddRange(DataTypes.GetVarInt(tagBytes.Length)); + fields.AddRange(tagBytes); + + switch (currentState) + { + case CurrentState.Configuration: + SendPacket(ConfigurationPacketTypesOut.CustomClickAction, fields); + return true; + case CurrentState.Play: + SendPacket(PacketTypesOut.CustomClickAction, fields); + return true; + default: + return false; + } + } + catch (SocketException) + { + return false; + } + catch (System.IO.IOException) + { + return false; + } + catch (ObjectDisposedException) + { + return false; + } + } + /// /// Send a chat message to the server /// diff --git a/MinecraftClient/Protocol/IMinecraftCom.cs b/MinecraftClient/Protocol/IMinecraftCom.cs index d8f87a2a..ee604ceb 100644 --- a/MinecraftClient/Protocol/IMinecraftCom.cs +++ b/MinecraftClient/Protocol/IMinecraftCom.cs @@ -48,6 +48,14 @@ namespace MinecraftClient.Protocol /// True if successfully sent bool SendChatMessage(string message, PlayerKeyPair? playerKeyPair = null); + /// + /// Send a custom click action packet introduced for dialogs in Minecraft 1.21.6. + /// + /// Custom action resource location + /// Optional NBT payload + /// True if successfully sent + bool SendCustomClickAction(string id, Dictionary? payload); + /// /// Allow to respawn after death /// diff --git a/MinecraftClient/Protocol/IMinecraftComHandler.cs b/MinecraftClient/Protocol/IMinecraftComHandler.cs index ec3881eb..447c7287 100644 --- a/MinecraftClient/Protocol/IMinecraftComHandler.cs +++ b/MinecraftClient/Protocol/IMinecraftComHandler.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using MinecraftClient.Dialogs; using MinecraftClient.Inventory; using MinecraftClient.Logger; using MinecraftClient.Mapping; @@ -93,6 +94,31 @@ namespace MinecraftClient.Protocol /// Message received public void OnTextReceived(ChatMessage message); + /// + /// Called when the server synchronizes a dialog registry entry. + /// + void OnDialogRegistryData(int protocolId, string resourceId, DialogDefinition dialog); + + /// + /// Called when the server shows a custom dialog. + /// + void OnDialogShown(DialogDefinition dialog, DialogPhase phase); + + /// + /// Called when the server shows a custom dialog by registry protocol ID. + /// + void OnDialogRegistryReferenceShown(int protocolId, DialogPhase phase); + + /// + /// Called when the server clears the current custom dialog. + /// + void OnDialogCleared(); + + /// + /// Called when the server sends updated server links. + /// + void OnServerLinksUpdated(IReadOnlyList links); + /// /// Will be called every animations of the hit and place block /// diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index de89b133..8a0d373e 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -7846,5 +7846,329 @@ namespace MinecraftClient { get { return ResourceManager.GetString("debug.packet.loop_reason.cancelled", resourceCulture); } } + internal static string cmd_dialog_usage { + get { return ResourceManager.GetString("cmd.dialog.usage", resourceCulture); } + } + + internal static string cmd_dialog_desc { + get { return ResourceManager.GetString("cmd.dialog.desc", resourceCulture); } + } + + internal static string dialog_received { + get { return ResourceManager.GetString("dialog.received", resourceCulture); } + } + + internal static string dialog_cleared { + get { return ResourceManager.GetString("dialog.cleared", resourceCulture); } + } + + internal static string dialog_none { + get { return ResourceManager.GetString("dialog.none", resourceCulture); } + } + + internal static string dialog_dismissed { + get { return ResourceManager.GetString("dialog.dismissed", resourceCulture); } + } + + internal static string dialog_unresolved_title { + get { return ResourceManager.GetString("dialog.unresolved_title", resourceCulture); } + } + + internal static string dialog_unresolved_body { + get { return ResourceManager.GetString("dialog.unresolved_body", resourceCulture); } + } + + internal static string dialog_unresolved_action_disabled { + get { return ResourceManager.GetString("dialog.unresolved_action_disabled", resourceCulture); } + } + + internal static string dialog_input_unknown { + get { return ResourceManager.GetString("dialog.input_unknown", resourceCulture); } + } + + internal static string dialog_input_set { + get { return ResourceManager.GetString("dialog.input_set", resourceCulture); } + } + + internal static string dialog_input_too_long { + get { return ResourceManager.GetString("dialog.input_too_long", resourceCulture); } + } + + internal static string dialog_input_boolean_invalid { + get { return ResourceManager.GetString("dialog.input_boolean_invalid", resourceCulture); } + } + + internal static string dialog_input_option_invalid { + get { return ResourceManager.GetString("dialog.input_option_invalid", resourceCulture); } + } + + internal static string dialog_input_number_invalid { + get { return ResourceManager.GetString("dialog.input_number_invalid", resourceCulture); } + } + + internal static string dialog_input_number_range_invalid { + get { return ResourceManager.GetString("dialog.input_number_range_invalid", resourceCulture); } + } + + internal static string dialog_action_unknown { + get { return ResourceManager.GetString("dialog.action_unknown", resourceCulture); } + } + + internal static string dialog_action_label_unknown { + get { return ResourceManager.GetString("dialog.action_label_unknown", resourceCulture); } + } + + internal static string dialog_action_label_ambiguous { + get { return ResourceManager.GetString("dialog.action_label_ambiguous", resourceCulture); } + } + + internal static string dialog_cannot_cancel { + get { return ResourceManager.GetString("dialog.cannot_cancel", resourceCulture); } + } + + internal static string dialog_action_closed { + get { return ResourceManager.GetString("dialog.action_closed", resourceCulture); } + } + + internal static string dialog_action_command_not_in_play { + get { return ResourceManager.GetString("dialog.action_command_not_in_play", resourceCulture); } + } + + internal static string dialog_action_invalid { + get { return ResourceManager.GetString("dialog.action_invalid", resourceCulture); } + } + + internal static string dialog_action_custom_failed { + get { return ResourceManager.GetString("dialog.action_custom_failed", resourceCulture); } + } + + internal static string dialog_action_command_sent { + get { return ResourceManager.GetString("dialog.action_command_sent", resourceCulture); } + } + + internal static string dialog_action_custom_sent { + get { return ResourceManager.GetString("dialog.action_custom_sent", resourceCulture); } + } + + internal static string dialog_action_nested_opened { + get { return ResourceManager.GetString("dialog.action_nested_opened", resourceCulture); } + } + + internal static string dialog_action_open_url { + get { return ResourceManager.GetString("dialog.action_open_url", resourceCulture); } + } + + internal static string dialog_action_suggest_command { + get { return ResourceManager.GetString("dialog.action_suggest_command", resourceCulture); } + } + + internal static string dialog_action_copy { + get { return ResourceManager.GetString("dialog.action_copy", resourceCulture); } + } + + internal static string dialog_action_unsupported { + get { return ResourceManager.GetString("dialog.action_unsupported", resourceCulture); } + } + + internal static string dialog_tui_pending { + get { return ResourceManager.GetString("dialog.tui_pending", resourceCulture); } + } + + internal static string dialog_tui_unavailable { + get { return ResourceManager.GetString("dialog.tui_unavailable", resourceCulture); } + } + + internal static string dialog_tui_opened { + get { return ResourceManager.GetString("dialog.tui_opened", resourceCulture); } + } + + internal static string dialog_action_unnamed { + get { return ResourceManager.GetString("dialog.action_unnamed", resourceCulture); } + } + + internal static string dialog_action_ok { + get { return ResourceManager.GetString("dialog.action_ok", resourceCulture); } + } + + internal static string dialog_item_body { + get { return ResourceManager.GetString("dialog.item_body", resourceCulture); } + } + + internal static string dialog_server_link_report_bug { + get { return ResourceManager.GetString("dialog.server_link.report_bug", resourceCulture); } + } + + internal static string dialog_server_link_community_guidelines { + get { return ResourceManager.GetString("dialog.server_link.community_guidelines", resourceCulture); } + } + + internal static string dialog_server_link_support { + get { return ResourceManager.GetString("dialog.server_link.support", resourceCulture); } + } + + internal static string dialog_server_link_status { + get { return ResourceManager.GetString("dialog.server_link.status", resourceCulture); } + } + + internal static string dialog_server_link_feedback { + get { return ResourceManager.GetString("dialog.server_link.feedback", resourceCulture); } + } + + internal static string dialog_server_link_community { + get { return ResourceManager.GetString("dialog.server_link.community", resourceCulture); } + } + + internal static string dialog_server_link_website { + get { return ResourceManager.GetString("dialog.server_link.website", resourceCulture); } + } + + internal static string dialog_server_link_forums { + get { return ResourceManager.GetString("dialog.server_link.forums", resourceCulture); } + } + + internal static string dialog_server_link_news { + get { return ResourceManager.GetString("dialog.server_link.news", resourceCulture); } + } + + internal static string dialog_server_link_announcements { + get { return ResourceManager.GetString("dialog.server_link.announcements", resourceCulture); } + } + + internal static string tui_dialog_cancel { + get { return ResourceManager.GetString("tui.dialog.cancel", resourceCulture); } + } + + internal static string dialog_render_header { + get { return ResourceManager.GetString("dialog.render.header", resourceCulture); } + } + + internal static string dialog_render_type { + get { return ResourceManager.GetString("dialog.render.type", resourceCulture); } + } + + internal static string dialog_render_body { + get { return ResourceManager.GetString("dialog.render.body", resourceCulture); } + } + + internal static string dialog_render_inputs { + get { return ResourceManager.GetString("dialog.render.inputs", resourceCulture); } + } + + internal static string dialog_render_input { + get { return ResourceManager.GetString("dialog.render.input", resourceCulture); } + } + + internal static string dialog_render_actions { + get { return ResourceManager.GetString("dialog.render.actions", resourceCulture); } + } + + internal static string dialog_render_action { + get { return ResourceManager.GetString("dialog.render.action", resourceCulture); } + } + + internal static string dialog_render_cancel_hint { + get { return ResourceManager.GetString("dialog.render.cancel_hint", resourceCulture); } + } + + internal static string dialog_input_desc_text { + get { return ResourceManager.GetString("dialog.input_desc_text", resourceCulture); } + } + + internal static string dialog_input_desc_boolean { + get { return ResourceManager.GetString("dialog.input_desc_boolean", resourceCulture); } + } + + internal static string dialog_input_desc_options { + get { return ResourceManager.GetString("dialog.input_desc_options", resourceCulture); } + } + + internal static string dialog_input_desc_number { + get { return ResourceManager.GetString("dialog.input_desc_number", resourceCulture); } + } + + internal static string dialog_input_desc_unknown { + get { return ResourceManager.GetString("dialog.input_desc_unknown", resourceCulture); } + } + + internal static string dialog_action_desc_close { + get { return ResourceManager.GetString("dialog.action_desc_close", resourceCulture); } + } + + internal static string dialog_action_desc_command { + get { return ResourceManager.GetString("dialog.action_desc_command", resourceCulture); } + } + + internal static string dialog_action_desc_custom { + get { return ResourceManager.GetString("dialog.action_desc_custom", resourceCulture); } + } + + internal static string dialog_action_desc_show_dialog { + get { return ResourceManager.GetString("dialog.action_desc_show_dialog", resourceCulture); } + } + + internal static string dialog_action_desc_open_url { + get { return ResourceManager.GetString("dialog.action_desc_open_url", resourceCulture); } + } + + internal static string dialog_action_desc_suggest { + get { return ResourceManager.GetString("dialog.action_desc_suggest", resourceCulture); } + } + + internal static string dialog_action_desc_copy { + get { return ResourceManager.GetString("dialog.action_desc_copy", resourceCulture); } + } + + internal static string dialog_action_desc_unknown { + get { return ResourceManager.GetString("dialog.action_desc_unknown", resourceCulture); } + } + + internal static string dialog_type_notice { + get { return ResourceManager.GetString("dialog.type.notice", resourceCulture); } + } + + internal static string dialog_type_confirmation { + get { return ResourceManager.GetString("dialog.type.confirmation", resourceCulture); } + } + + internal static string dialog_type_multi_action { + get { return ResourceManager.GetString("dialog.type.multi_action", resourceCulture); } + } + + internal static string dialog_type_dialog_list { + get { return ResourceManager.GetString("dialog.type.dialog_list", resourceCulture); } + } + + internal static string dialog_type_server_links { + get { return ResourceManager.GetString("dialog.type.server_links", resourceCulture); } + } + + internal static string dialog_type_unknown { + get { return ResourceManager.GetString("dialog.type.unknown", resourceCulture); } + } + + internal static string dialog_input_kind_text { + get { return ResourceManager.GetString("dialog.input_kind.text", resourceCulture); } + } + + internal static string dialog_input_kind_boolean { + get { return ResourceManager.GetString("dialog.input_kind.boolean", resourceCulture); } + } + + internal static string dialog_input_kind_options { + get { return ResourceManager.GetString("dialog.input_kind.options", resourceCulture); } + } + + internal static string dialog_input_kind_number { + get { return ResourceManager.GetString("dialog.input_kind.number", resourceCulture); } + } + + internal static string dialog_input_kind_unknown { + get { return ResourceManager.GetString("dialog.input_kind.unknown", resourceCulture); } + } + + internal static string dialog_render_help_hint { + get { return ResourceManager.GetString("dialog.render.help_hint", resourceCulture); } + } + } } diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index 882faab3..0fc9f081 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -2842,4 +2842,247 @@ see item details. cancelled + + dialog [show|open|set|click|click-label|cancel|dismiss] + + + View and interact with the current server custom dialog. + + + Server showed custom dialog: {0}. Use /dialog show. + + + Server cleared the custom dialog. + + + No custom dialog is active. + + + Dialog dismissed locally. + + + Unresolved dialog {0} + + + The server referenced dialog registry entry {0}, but MCC has no data for it. + + + This dialog could not be resolved, so actions are disabled. + + + Unknown dialog input: {0} + + + Dialog input {0} set to {1}. + + + Input {0} is longer than {1} characters. + + + Input {0} expects true or false. + + + Input {0} expects one of the listed option IDs. + + + Input {0} expects a number. + + + Input {0} must be between {1} and {2}. + + + Unknown dialog action index: {0} + + + Unknown dialog action label: {0} + + + Dialog action label is ambiguous: {0} + + + This dialog cannot be closed with cancel. + + + Dialog action closed locally. + + + This dialog command action is only available in play state. + + + Dialog action is invalid. + + + Custom dialog action packet could not be sent. + + + Dialog command sent: {0} + + + Dialog custom action sent: {0} + + + Nested dialog opened. + + + Dialog URL action: {0} + + + Dialog suggested command: {0} + + + Dialog copy action: {0} + + + Unsupported dialog action: {0} + + + Custom dialog is pending. Use dialog open after closing the current overlay. + + + Dialog TUI is unavailable. + + + Dialog TUI opened. + + + Action + + + OK + + + Item preview + + + Report bug + + + Community guidelines + + + Support + + + Status + + + Feedback + + + Community + + + Website + + + Forums + + + News + + + Announcements + + + Cancel + + + Dialog #{0} [{1}]: {2} + + + Type: {0} + + + Body: {0} + + + Inputs: + + + {0} ({1}) {2} = {3} [{4}] + + + Actions: + + + [{0}] {1} ({2}) + + + Use /dialog cancel to close or run the cancel action. + + + max {0} chars + + + Yes={0}, No={1} + + + options: {0} + + + range {0}..{1} + + + unknown input + + + close + + + command + + + custom + + + show dialog + + + open URL + + + suggest command + + + copy + + + unknown + + + Notice + + + Confirmation + + + Multi Action + + + Dialog list + + + Server links + + + Unknown + + + Text + + + Check box + + + Options + + + Number + + + Unknown + + + Use /dialog help for a list of commands. + diff --git a/MinecraftClient/Tui/DialogTuiHost.cs b/MinecraftClient/Tui/DialogTuiHost.cs new file mode 100644 index 00000000..f94370e6 --- /dev/null +++ b/MinecraftClient/Tui/DialogTuiHost.cs @@ -0,0 +1,449 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; +using MinecraftClient.Dialogs; + +namespace MinecraftClient.Tui; + +internal interface IOverlayCloseHandler +{ + bool TryCloseByUser(); +} + +public static class DialogTuiHost +{ + public static bool TryOpen(McClient handler, DialogInstance instance, bool force) + { + if (ConsoleIO.Backend is not TuiConsoleBackend) + return false; + + Dispatcher.UIThread.Post(() => + { + var view = TuiConsoleBackend.Instance?.GetView(); + if (view is null) + return; + + if (view.HasOverlay && view.OverlayContent is not DialogView && !force) + { + handler.Log.Info(Translations.dialog_tui_pending); + return; + } + + view.ShowOverlay(new DialogView(handler, instance)); + }); + + return true; + } + + public static void CloseCurrent() + { + if (ConsoleIO.Backend is not TuiConsoleBackend) + return; + + Dispatcher.UIThread.Post(() => + { + var view = TuiConsoleBackend.Instance?.GetView(); + if (view?.OverlayContent is DialogView) + view.HideOverlay(); + }); + } +} + +internal sealed class DialogView : Border, IOverlayCloseHandler +{ + private static readonly Color AccentColor = Color.FromRgb(80, 180, 255); + private static readonly Color BorderColor = Color.FromRgb(70, 70, 70); + private static readonly Color SectionBg = Color.FromRgb(20, 20, 20); + private static readonly Color InputBg = Color.FromRgb(35, 35, 35); + + private readonly McClient _handler; + private readonly DialogInstance _instance; + private readonly TextBlock _status; + private readonly Dictionary _inputControls = new(StringComparer.Ordinal); + private readonly StackPanel _inputsPanel; + private readonly WrapPanel _buttonsPanel; + + public DialogView(McClient handler, DialogInstance instance) + { + _handler = handler; + _instance = instance; + + BorderBrush = new SolidColorBrush(BorderColor); + BorderThickness = new Thickness(1); + Background = new SolidColorBrush(Color.FromRgb(12, 12, 12)); + Padding = new Thickness(2); + HorizontalAlignment = HorizontalAlignment.Stretch; + VerticalAlignment = VerticalAlignment.Stretch; + Focusable = true; + + _status = new TextBlock + { + Foreground = Brushes.Gray, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 1, 0, 0) + }; + + _inputsPanel = new StackPanel { Spacing = 0, Margin = new Thickness(0) }; + _buttonsPanel = new WrapPanel { Orientation = Orientation.Horizontal }; + + Child = BuildContent(); + + AttachedToVisualTree += (_, _) => + { + AddHandler(KeyDownEvent, OnTunnelKeyDown, RoutingStrategies.Tunnel, handledEventsToo: true); + FocusFirstInput(); + Focus(); + }; + DetachedFromVisualTree += (_, _) => RemoveHandler(KeyDownEvent, OnTunnelKeyDown); + } + + public bool TryCloseByUser() + { + if (!_instance.Definition.CanCloseWithEscape && _instance.Definition.CancelAction is null) + { + SetStatus(Translations.dialog_cannot_cancel); + return false; + } + + var result = _handler.Dialogs.Cancel(); + SetStatus(result.Message); + if (result.Success) + CloseIfInactive(); + + return false; + } + + private Control BuildContent() + { + var root = new DockPanel { Margin = new Thickness(0) }; + + var scroll = new ScrollViewer + { + HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto + }; + + var main = new StackPanel { Spacing = 0, Margin = new Thickness(0) }; + + // Title + main.Children.Add(McColorParser.CreateColoredTextBlock(_instance.Definition.DisplayTitle())); + + // Separator + main.Children.Add(new Border + { + Height = 1, + Background = new SolidColorBrush(BorderColor), + Margin = new Thickness(0, 1, 0, 1) + }); + + // Body + foreach (var body in _instance.Definition.Body) + { + if (string.IsNullOrWhiteSpace(body.Text)) + continue; + main.Children.Add(McColorParser.CreateColoredTextBlock(body.Text)); + } + + // Build action buttons + EnsureActionButtons(); + + // Inputs + foreach (var input in _instance.Definition.Inputs) + main.Children.Add(BuildInput(input)); + + // Action buttons + if (_buttonsPanel.Children.Count > 0) + main.Children.Add(_buttonsPanel); + + // Cancel hint + if (_instance.Definition.CancelAction is not null || _instance.Definition.CanCloseWithEscape) + { + main.Children.Add(new TextBlock + { + Text = Translations.dialog_render_cancel_hint, + Foreground = new SolidColorBrush(Color.FromRgb(120, 120, 120)), + TextWrapping = TextWrapping.Wrap + }); + } + + main.Children.Add(new TextBlock + { + Text = Translations.dialog_render_help_hint, + Foreground = new SolidColorBrush(Color.FromRgb(120, 120, 120)), + TextWrapping = TextWrapping.Wrap + }); + + main.Children.Add(_status); + scroll.Content = main; + root.Children.Add(scroll); + + return root; + } + + private void EnsureActionButtons() + { + if (_buttonsPanel.Children.Count > 0) + return; + + foreach (var action in _instance.Definition.Actions) + { + var btn = new Button + { + Content = McColorParser.CreateColoredTextBlock(action.Label), + Padding = new Thickness(1), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromRgb(60, 60, 60)), + Background = new SolidColorBrush(Color.FromRgb(40, 40, 40)), + Margin = new Thickness(0, 0, 1, 1) + }; + btn.Click += (_, _) => Click(action.Index); + _buttonsPanel.Children.Add(btn); + } + + if (_instance.Definition.CancelAction is not null || _instance.Definition.CanCloseWithEscape) + { + var cancel = new Button + { + Content = McColorParser.CreateColoredTextBlock(Translations.tui_dialog_cancel), + Padding = new Thickness(1), + BorderThickness = new Thickness(1), + BorderBrush = new SolidColorBrush(Color.FromRgb(80, 40, 40)), + Background = new SolidColorBrush(Color.FromRgb(50, 25, 25)) + }; + cancel.Click += (_, _) => TryCloseByUser(); + _buttonsPanel.Children.Add(cancel); + } + } + + private Control BuildInput(DialogInput input) + { + _instance.Values.TryGetValue(input.Key, out var value); + value ??= input.InitialValue; + + var panel = new StackPanel { Spacing = 0, Margin = new Thickness(0) }; + + if (input.LabelVisible && !string.IsNullOrWhiteSpace(input.Label)) + panel.Children.Add(McColorParser.CreateColoredTextBlock(input.Label)); + + Control inner = input.Kind switch + { + DialogInputKind.Boolean => BuildBooleanInput(value, input), + DialogInputKind.SingleOption => BuildOptionInput(input, value), + DialogInputKind.NumberRange => BuildNumberInput(input, value), + _ => BuildTextInput(input, value) + }; + + _inputControls[input.Key] = inner; + + if (input.Kind == DialogInputKind.Boolean) + { + panel.Children.Add(inner); + } + else + { + panel.Children.Add(new Border + { + Background = new SolidColorBrush(InputBg), + BorderBrush = new SolidColorBrush(Color.FromRgb(55, 55, 55)), + BorderThickness = new Thickness(1), + Padding = new Thickness(1), + Child = inner + }); + } + + return panel; + } + + private Control BuildTextInput(DialogInput input, string value) + { + var tb = new TextBox + { + Text = value, + AcceptsReturn = input.Multiline, + TextWrapping = input.Multiline ? TextWrapping.Wrap : TextWrapping.NoWrap, + MaxLength = input.MaxLength, + Foreground = Brushes.White, + Background = new SolidColorBrush(InputBg), + BorderThickness = new Thickness(0), + Padding = new Thickness(0) + }; + + return tb; + } + + private Control BuildBooleanInput(string value, DialogInput input) + { + return new CheckBox + { + IsChecked = value.Equals("true", StringComparison.OrdinalIgnoreCase), + Foreground = Brushes.White, + Padding = new Thickness(0) + }; + } + + private Control BuildOptionInput(DialogInput input, string value) + { + var combo = new ComboBox + { + ItemsSource = input.Options ?? [], + Foreground = Brushes.White, + Background = new SolidColorBrush(InputBg), + BorderThickness = new Thickness(0), + Padding = new Thickness(1, 0) + }; + + combo.SelectedItem = input.Options?.FirstOrDefault(option => option.Id.Equals(value, StringComparison.Ordinal)) + ?? input.Options?.FirstOrDefault(); + + return combo; + } + + private Control BuildNumberInput(DialogInput input, string value) + { + double numValue = double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : input.InitialNumber ?? input.Start; + + double min = Math.Min(input.Start, input.End); + double max = Math.Max(input.Start, input.End); + + var panel = new DockPanel { LastChildFill = true }; + + var slider = new Slider + { + Minimum = min, + Maximum = max, + Value = numValue, + TickFrequency = input.Step ?? 1, + IsSnapToTickEnabled = input.Step is not null, + Foreground = new SolidColorBrush(AccentColor) + }; + + var label = new TextBlock + { + Text = numValue.ToString(CultureInfo.InvariantCulture), + Foreground = Brushes.White, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(2, 0, 0, 0), + MinWidth = 16 + }; + + slider.PropertyChanged += (_, e) => + { + if (e.Property == Slider.ValueProperty) + label.Text = ((float)slider.Value).ToString(CultureInfo.InvariantCulture); + }; + + DockPanel.SetDock(label, Dock.Right); + panel.Children.Add(slider); + panel.Children.Add(label); + + return panel; + } + + private void Click(int index) + { + if (!StoreInputs()) + return; + + var result = _handler.Dialogs.Click(index); + SetStatus(result.Message); + if (result.Success) + CloseIfInactive(); + } + + private bool StoreInputs() + { + foreach (var input in _instance.Definition.Inputs) + { + if (!_inputControls.TryGetValue(input.Key, out var control)) + continue; + + var value = control switch + { + TextBox textBox => textBox.Text ?? string.Empty, + CheckBox checkBox => checkBox.IsChecked == true ? "true" : "false", + ComboBox comboBox when comboBox.SelectedItem is DialogOption option => option.Id, + Slider slider => NumberToString((float)slider.Value), + _ => input.InitialValue + }; + + var result = _handler.Dialogs.SetInput(input.Key, value); + if (!result.Success) + { + SetStatus(result.Message); + return false; + } + } + + return true; + } + + private void FocusFirstInput() + { + var first = _inputControls.Values.FirstOrDefault(); + if (first is TextBox tb) + { + tb.Focus(); + tb.SelectAll(); + } + else + { + first?.Focus(); + } + } + + private void CloseIfInactive() + { + var current = _handler.Dialogs.Current; + if (current is null) + { + DialogTuiHost.CloseCurrent(); + return; + } + + if (current.Revision != _instance.Revision) + DialogTuiHost.TryOpen(_handler, current, force: true); + } + + private void SetStatus(string text) + { + _status.Text = text; + } + + private void OnTunnelKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Escape) + { + TryCloseByUser(); + e.Handled = true; + return; + } + + if (e.Key == Key.Enter) + { + var focused = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement(); + if (focused is TextBox && _instance.Definition.Actions.Count > 0) + { + Click(_instance.Definition.Actions[0].Index); + e.Handled = true; + } + } + } + + private static string NumberToString(float value) + { + var integer = (int)value; + return integer == value + ? integer.ToString(CultureInfo.InvariantCulture) + : value.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/MinecraftClient/Tui/MainTuiView.cs b/MinecraftClient/Tui/MainTuiView.cs index 9eda3c62..78ec2592 100644 --- a/MinecraftClient/Tui/MainTuiView.cs +++ b/MinecraftClient/Tui/MainTuiView.cs @@ -1207,6 +1207,15 @@ namespace MinecraftClient.Tui { if (e.Key == Key.Escape && _overlayContent != null) { + if (_overlayContent is IOverlayCloseHandler closeHandler) + { + if (closeHandler.TryCloseByUser()) + HideOverlay(); + + e.Handled = true; + return; + } + HideOverlay(); e.Handled = true; return; diff --git a/docs/guide/usage.md b/docs/guide/usage.md index 5d4d0768..8b84e9bb 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -1647,6 +1647,120 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q +
+dialog + +- **Description:** + + Browse and interact with dialogs sent by the server. Dialogs are popups with a title, body text, and buttons. TUI mode gives the best experience -- use `/dialog open` to view the dialog in a full-screen overlay. + + When a server shows a dialog, MCC prints: + + ``` + [MCC] Server showed custom dialog: Server Notice. Use dialog show. + ``` + + Run `/dialog show` to see the full dialog. Buttons show up like this: + + ``` + Actions: + [1] OK (close) + [2] Visit (command) + [3] Rules (show dialog) + ``` + + The text in parentheses tells you what the button does: `(close)` closes the dialog, `(command)` sends a chat command, `(show dialog)` opens another dialog. + +- **Dialog types:** + + Dialogs come in a few shapes. You might see these in the output of `/dialog show`: + + **Notice** -- A popup with a title, optional body, and one button. + + ``` + Type: Notice + + Welcome to the server! + + Body: Read the rules before playing. + + Actions: + [1] OK (close) + ``` + + **Confirmation** -- A choice between two buttons. + + ``` + Type: Confirmation + + Reset your progress? + + Actions: + [1] Yes (close) + [2] No (close) + ``` + + **Multi-action** -- A grid of buttons. + + ``` + Type: Multi-action + + Choose a destination + + Actions: + [1] Spawn (close) + [2] Shop (close) + [3] Arena (close) + ``` + + **Dialog list** -- A list of sub-dialogs. Clicking one opens another dialog. + + ``` + Type: Dialog list + + Help Topics + + Actions: + [1] Rules (show dialog) + [2] Commands (show dialog) + ``` + + **Server links** -- Shows the server's configured links as buttons. May be empty. + + ``` + Type: Server links + + Server Links + ``` + +- **Usage:** + + ``` + /dialog + /dialog show + /dialog open + /dialog click + /dialog click-label
+
debug diff --git a/tools/run-dialog-test.sh b/tools/run-dialog-test.sh new file mode 100755 index 00000000..134fe31f --- /dev/null +++ b/tools/run-dialog-test.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$REPO_ROOT/tools/mcc-env.sh" +source "$REPO_ROOT/.skills/mcc-integration-testing/scripts/common.sh" + +usage() { + cat <<'EOF' +Usage: tools/run-dialog-test.sh + +Integration test for MCC dialog system against a real local server. +Tests all 5 dialog types, button actions, cancel/dismiss, and body content. + +Examples: + tools/run-dialog-test.sh 26.1 + tools/run-dialog-test.sh 1.21.11 +EOF +} + +MC_VERSION="${1:-}" +if [[ -z "$MC_VERSION" ]]; then + usage >&2 + exit 1 +fi + +SESSION_NAME="dialog-test-${MC_VERSION//[^a-zA-Z0-9]/_}" +TEST_ROOT="${TMPDIR:-/tmp}/mcc-dialog-test/${MC_VERSION//\//_}" +CFG="$TEST_ROOT/custom.ini" +MCC_LOG="$TEST_ROOT/mcc-output.log" +INPUT_FILE="$TEST_ROOT/mcc_input.txt" +SERVER_PORT="25565" +PASS=0 +FAIL=0 + +cleanup() { + tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true +} +trap cleanup EXIT + +header() { + echo "" + echo "===== $* =====" +} + +# Strip ANSI escape codes for grep matching +ansi_strip() { + sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' +} + +assert_log() { + local label="$1" + local pattern="$2" + local timeout="${3:-5}" + local elapsed=0 + while (( elapsed < timeout )); do + if [[ -f "$MCC_LOG" ]] && ansi_strip < "$MCC_LOG" | grep -Fq "$pattern" 2>/dev/null; then + echo " PASS: $label" + PASS=$((PASS + 1)) + return 0 + fi + sleep 1 + ((elapsed += 1)) + done + echo " FAIL: $label (expected: '$pattern')" + FAIL=$((FAIL + 1)) +} + +wait_for_pattern() { + local file="$1" + local pattern="$2" + local timeout="${3:-60}" + local elapsed=0 + while (( elapsed < timeout )); do + if [[ -f "$file" ]] && ansi_strip < "$file" | grep -Fq "$pattern" 2>/dev/null; then + return 0 + fi + sleep 1 + ((elapsed += 1)) + done + return 1 +} + +write_input() { + echo "$1" >> "$INPUT_FILE" + sleep 1 +} + +assert_dialog_shown() { + assert_log "$1 received" "Server showed custom dialog: $2" 10 +} + +# ---- Setup ---- + +mkdir -p "$TEST_ROOT" +rm -f "$MCC_LOG" "$INPUT_FILE" + +echo "[Dialog Test] Version: $MC_VERSION, Session: $SESSION_NAME" +echo "[Dialog Test] Log: $MCC_LOG" + +# Ensure server is running +if ! server_running "$MC_VERSION"; then + echo "[Setup] Starting server..." + bash "$REPO_ROOT/tools/start-server.sh" "$MC_VERSION" 2>&1 | tail -1 + wait_for_server_ready "$MC_VERSION" 120 +fi + +echo "[Setup] Server ready." + +# Prepare temp config +echo "[Setup] Preparing MCC config..." +bash "$REPO_ROOT/.skills/mcc-integration-testing/scripts/prepare_offline_mcc_config.sh" \ + "$CFG" "$MC_VERSION" "MCCBot" >/dev/null + +# Disable packet debug (fix in-place to avoid dup sections) +sed_in_place \ + -e '/^\[Debug\]/,/^\s*$/d' \ + "$CFG" + +cat >> "$CFG" < "$INPUT_FILE" +tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true +sleep 1 + +cd "$REPO_ROOT" +# FileInputBot ignores config and uses MCC_INPUT_FILE env var only +INPUT_FILE_ABS="$(realpath "$INPUT_FILE")" +tmux new-session -d -s "$SESSION_NAME" \ + "bash -c 'export MCC_FILE_INPUT=1; export MCC_INPUT_FILE=\"$INPUT_FILE_ABS\"; exec dotnet run --no-build --project MinecraftClient -c Release -- \"$CFG\" \"MCCBot\" \"-\" \"localhost:$SERVER_PORT\"' > '$MCC_LOG' 2>&1" + +echo "[Setup] Waiting for MCC to join..." +if ! wait_for_pattern "$MCC_LOG" "Server was successfully joined" 90; then + echo "ERROR: MCC did not join the server. Check $MCC_LOG" + tail -10 "$MCC_LOG" | ansi_strip + exit 1 +fi +echo "[Setup] MCC joined." +sleep 2 + +# ---- Tests ---- + +header "1. Notice Dialog" +mc-rcon 'dialog show MCCBot {type:"minecraft:notice", title:{text:"Notice Title"}}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "notice dialog" "Notice Title" +write_input "dialog show" +assert_log "notice render" "Dialog #1" 10 +assert_log "notice OK button" "OK (close)" 3 + +header "2. Confirmation Dialog" +mc-rcon 'dialog show MCCBot {type:"minecraft:confirmation", title:{text:"Confirm?"}, yes:{label:{text:"Yes"}}, no:{label:{text:"No"}}}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "confirmation" "Confirm?" +write_input "dialog show" +assert_log "confirmation render" "Dialog #1" 10 +assert_log "yes button" "Yes (close)" 3 +assert_log "no button" "No (close)" 3 + +header "3. Multi-Action Dialog" +mc-rcon 'dialog show MCCBot {type:"minecraft:multi_action", title:{text:"Choose"}, actions:[{label:{text:"Alpha"}}, {label:{text:"Beta"}}, {label:{text:"Gamma"}}]}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "multi_action" "Choose" +write_input "dialog show" +assert_log "multi_action render" "Dialog #1" 10 +assert_log "multi_action button 1" "Alpha (close)" 3 +assert_log "multi_action button 2" "Beta (close)" 3 +assert_log "multi_action button 3" "Gamma (close)" 3 + +header "4. Dialog-List Dialog" +mc-rcon 'dialog show MCCBot {type:"minecraft:dialog_list", title:{text:"List"}, dialogs:[{type:"minecraft:notice", title:{text:"Sub One"}}, {type:"minecraft:notice", title:{text:"Sub Two"}}]}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "dialog_list" "List" +write_input "dialog show" +assert_log "dialog_list render" "Dialog #1" 10 +assert_log "dialog_list sub 1" "Sub One (show dialog)" 3 +assert_log "dialog_list sub 2" "Sub Two (show dialog)" 3 + +header "5. Server-Links Dialog" +mc-rcon 'dialog show MCCBot {type:"minecraft:server_links", title:{text:"Links"}}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "server_links" "Links" +write_input "dialog show" +assert_log "server_links render" "Dialog #1" 10 + +header "6. Body Content" +mc-rcon 'dialog show MCCBot {type:"minecraft:notice", title:{text:"With Body"}, body:[{type:"minecraft:plain_message", contents:{text:"Hello from body"}}]}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "body dialog" "With Body" +write_input "dialog show" +assert_log "body content" "Hello from body" 10 + +header "7. Custom run_command Action" +mc-rcon 'dialog show MCCBot {type:"minecraft:notice", title:{text:"Run Cmd"}, action:{label:{text:"/list"}, action:{type:"minecraft:run_command", command:"/list"}}}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "command action" "Run Cmd" +write_input "dialog show" +assert_log "command action button" "/list (command)" 10 +write_input "dialog click 1" +assert_log "command executed" "There are " 10 + +header "8. show_dialog Action (nested)" +mc-rcon 'dialog show MCCBot {type:"minecraft:notice", title:{text:"First"}, action:{label:{text:"Next"}, action:{type:"minecraft:show_dialog", dialog:{type:"minecraft:notice", title:{text:"Second"}}}}}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "first dialog" "First" +write_input "dialog click 1" +assert_dialog_shown "nested dialog" "Second" + +header "9. Dialog Cancel" +mc-rcon 'dialog show MCCBot {type:"minecraft:notice", title:{text:"Cancel Me"}}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "cancel test dialog" "Cancel Me" +write_input "dialog cancel" +assert_log "cancel closed dialog" "Dialog action closed locally" 10 + +header "10. Dialog Click-Label" +mc-rcon 'dialog show MCCBot {type:"minecraft:multi_action", title:{text:"Label Test"}, actions:[{label:{text:"Pick Me"}}, {label:{text:"Leave Me"}}]}' 2>&1 | ansi_strip | grep -v "^$" +assert_dialog_shown "click-label dialog" "Label Test" +write_input "dialog click-label Pick Me" +assert_log "click-label worked" "Dialog action closed locally" 10 + +# ---- Results ---- + +echo "" +echo "==========================================" +echo " Dialog Integration Test Results" +echo "==========================================" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +echo "------------------------------------------" + +if [[ $FAIL -gt 0 ]]; then + echo "FAILURES DETECTED. Full log: $MCC_LOG" + echo "Last 20 lines:" + ansi_strip < "$MCC_LOG" | tail -20 + exit 1 +else + echo "ALL TESTS PASSED." + exit 0 +fi