mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
feat: Implement the Dialog System (1.21.6+)
feat: Implement the Dialog System (1.21.6+)
This commit is contained in:
commit
aaa4908f7d
18 changed files with 2930 additions and 22 deletions
|
|
@ -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=<path>` 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 <player> {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 ...".
|
||||
|
|
|
|||
|
|
@ -1,12 +1,81 @@
|
|||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MinecraftClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Console backend wrapping the ConsoleInteractive library (existing behavior).
|
||||
/// </summary>
|
||||
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<char> 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<string>? MessageReceived;
|
||||
public event EventHandler<ConsoleInputBuffer>? OnInputChange;
|
||||
|
||||
|
|
@ -28,7 +97,7 @@ namespace MinecraftClient
|
|||
|
||||
public void WriteLineFormatted(string text)
|
||||
{
|
||||
ConsoleInteractive.ConsoleWriter.WriteLineFormatted(text);
|
||||
ConsoleInteractive.ConsoleWriter.WriteLineFormatted(ResolveHexColors(text));
|
||||
}
|
||||
|
||||
public void BeginReadThread()
|
||||
|
|
|
|||
108
MinecraftClient/Commands/Dialog.cs
Normal file
108
MinecraftClient/Commands/Dialog.cs
Normal file
|
|
@ -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<CmdResult> 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);
|
||||
}
|
||||
}
|
||||
108
MinecraftClient/Dialogs/DialogFormatter.cs
Normal file
108
MinecraftClient/Dialogs/DialogFormatter.cs
Normal file
|
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
445
MinecraftClient/Dialogs/DialogManager.cs
Normal file
445
MinecraftClient/Dialogs/DialogManager.cs
Normal file
|
|
@ -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<int, DialogDefinition> _registryById = new();
|
||||
private readonly Dictionary<string, DialogDefinition> _registryByName = new(StringComparer.Ordinal);
|
||||
private readonly List<DialogServerLink> _serverLinks = [];
|
||||
private DialogInstance? _current;
|
||||
private int _revision;
|
||||
|
||||
public DialogManager(McClient client)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public event Action<DialogInstance>? DialogShown;
|
||||
public event Action<int>? 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<DialogServerLink> 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<string, string> templateValues = new(StringComparer.Ordinal);
|
||||
Dictionary<string, object> 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<string, object> MergePayload(Dictionary<string, object>? basePayload, Dictionary<string, object> inputTags)
|
||||
{
|
||||
Dictionary<string, object> 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<string, string> 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<string, string> TemplateValues,
|
||||
Dictionary<string, object> TagValues);
|
||||
}
|
||||
110
MinecraftClient/Dialogs/DialogModels.cs
Normal file
110
MinecraftClient/Dialogs/DialogModels.cs
Normal file
|
|
@ -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<DialogOption>? 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<string, object>? 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<DialogBody> Body,
|
||||
IReadOnlyList<DialogInput> Inputs,
|
||||
IReadOnlyList<DialogButton> 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<string, string> Values,
|
||||
DateTimeOffset ReceivedAt);
|
||||
|
||||
public sealed record DialogActionResult(bool Success, string Message);
|
||||
|
|
@ -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<string, object>? payload)
|
||||
{
|
||||
if (InvokeRequired)
|
||||
return InvokeOnMainThread(() => SendCustomClickAction(id, payload));
|
||||
|
||||
return handler.SendCustomClickAction(id, payload);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Allow to respawn after death
|
||||
/// </summary>
|
||||
|
|
@ -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<DialogServerLink> links)
|
||||
{
|
||||
Dialogs.SetServerLinks(links);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a server was successfully joined
|
||||
/// </summary>
|
||||
|
|
|
|||
475
MinecraftClient/Protocol/Dialogs/DialogNbtParser.cs
Normal file
475
MinecraftClient/Protocol/Dialogs/DialogNbtParser.cs
Normal file
|
|
@ -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<string, object> nbt)
|
||||
{
|
||||
var type = NormalizeType(GetString(nbt, "type") ?? "minecraft:notice");
|
||||
var common = ParseCommon(nbt, type);
|
||||
var actions = new List<DialogButton>();
|
||||
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<string, object>? nbt)
|
||||
{
|
||||
return nbt is null ? null : Parse(nbt);
|
||||
}
|
||||
|
||||
private static DialogCommon ParseCommon(Dictionary<string, object> 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<DialogBody> ParseBody(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
return [];
|
||||
|
||||
List<DialogBody> body = [];
|
||||
foreach (var item in Enumerate(value))
|
||||
{
|
||||
if (item is Dictionary<string, object> 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<string, object> compound && compound.TryGetValue("contents", out var contents))
|
||||
return ParseComponent(contents);
|
||||
|
||||
return ParseComponent(value);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DialogInput> ParseInputs(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
return [];
|
||||
|
||||
List<DialogInput> inputs = [];
|
||||
foreach (var item in Enumerate(value))
|
||||
{
|
||||
if (item is not Dictionary<string, object> inputData)
|
||||
continue;
|
||||
|
||||
var key = GetString(inputData, "key");
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
continue;
|
||||
|
||||
var control = inputData.TryGetValue("control", out var controlValue) && controlValue is Dictionary<string, object> 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<string, object> 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<string, object> 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<string, object> 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<string, object> control)
|
||||
{
|
||||
var range = control.TryGetValue("range_info", out var rangeValue) && rangeValue is Dictionary<string, object> 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<DialogOption> ParseOptions(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
return [];
|
||||
|
||||
List<DialogOption> options = [];
|
||||
foreach (var item in Enumerate(value))
|
||||
{
|
||||
if (item is string id)
|
||||
{
|
||||
options.Add(new DialogOption(id, id, false));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item is Dictionary<string, object> 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<DialogButton> ParseButtonList(object? value)
|
||||
{
|
||||
List<DialogButton> buttons = [];
|
||||
var index = 1;
|
||||
foreach (var item in Enumerate(value))
|
||||
{
|
||||
if (item is Dictionary<string, object> buttonData)
|
||||
buttons.Add(ParseButton(buttonData, index++) ?? new DialogButton(index - 1, Translations.dialog_action_unnamed, null));
|
||||
}
|
||||
|
||||
return buttons;
|
||||
}
|
||||
|
||||
private static IEnumerable<DialogButton> ParseDialogListActions(object? value)
|
||||
{
|
||||
List<DialogButton> 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<string, object> 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<string, object> owner, string key, int index)
|
||||
{
|
||||
return owner.TryGetValue(key, out var value) && value is Dictionary<string, object> data
|
||||
? ParseButton(data, index)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static DialogButton? ParseButton(Dictionary<string, object> 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<string, object> actionData
|
||||
? ParseAction(actionData)
|
||||
: null;
|
||||
return new DialogButton(index, label, action);
|
||||
}
|
||||
|
||||
private static DialogActionDefinition ParseAction(Dictionary<string, object> 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<string, object> action, string type)
|
||||
{
|
||||
if (!action.TryGetValue("dialog", out var value))
|
||||
return new DialogActionDefinition(DialogActionKind.ShowDialog, Type: type);
|
||||
|
||||
if (value is Dictionary<string, object> 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<string, object> 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<object> 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<string, object> data, string key)
|
||||
{
|
||||
return data.TryGetValue(key, out var value) ? value : null;
|
||||
}
|
||||
|
||||
private static string? GetString(Dictionary<string, object> data, string key)
|
||||
{
|
||||
return data.TryGetValue(key, out var value) ? value as string ?? value.ToString() : null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object>? GetCompound(Dictionary<string, object> data, string key)
|
||||
{
|
||||
return data.TryGetValue(key, out var value) && value is Dictionary<string, object> compound ? compound : null;
|
||||
}
|
||||
|
||||
private static bool GetBool(Dictionary<string, object> 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<string, object> 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<string, object> data, string key, float fallback)
|
||||
{
|
||||
return TryGetFloat(data, key) ?? fallback;
|
||||
}
|
||||
|
||||
private static float? TryGetFloat(Dictionary<string, object> 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<DialogButton> 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<DialogBody> Body,
|
||||
IReadOnlyList<DialogInput> Inputs);
|
||||
}
|
||||
|
|
@ -266,6 +266,11 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public bool SendCustomClickAction(string id, Dictionary<string, object>? payload)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -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<Thread, CancellationTokenSource>? netMain = null; // main thread
|
||||
Tuple<Thread, CancellationTokenSource>? 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<int, string>() : null;
|
||||
var dimensionIdMap = isDimension ? new Dictionary<int, string>() : 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<byte> 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<DialogServerLink> ReadServerLinks(Queue<byte> packetData)
|
||||
{
|
||||
var linksCount = dataTypes.ReadNextVarInt(packetData);
|
||||
List<DialogServerLink> 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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a packet to the server. Compression and encryption will be handled automatically.
|
||||
/// </summary>
|
||||
|
|
@ -5025,6 +5084,49 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
}
|
||||
}
|
||||
|
||||
public bool SendCustomClickAction(string id, Dictionary<string, object>? payload)
|
||||
{
|
||||
if (protocolVersion < MC_1_21_6_Version)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
List<byte> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a chat message to the server
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ namespace MinecraftClient.Protocol
|
|||
/// <returns>True if successfully sent</returns>
|
||||
bool SendChatMessage(string message, PlayerKeyPair? playerKeyPair = null);
|
||||
|
||||
/// <summary>
|
||||
/// Send a custom click action packet introduced for dialogs in Minecraft 1.21.6.
|
||||
/// </summary>
|
||||
/// <param name="id">Custom action resource location</param>
|
||||
/// <param name="payload">Optional NBT payload</param>
|
||||
/// <returns>True if successfully sent</returns>
|
||||
bool SendCustomClickAction(string id, Dictionary<string, object>? payload);
|
||||
|
||||
/// <summary>
|
||||
/// Allow to respawn after death
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
/// <param name="message">Message received</param>
|
||||
public void OnTextReceived(ChatMessage message);
|
||||
|
||||
/// <summary>
|
||||
/// Called when the server synchronizes a dialog registry entry.
|
||||
/// </summary>
|
||||
void OnDialogRegistryData(int protocolId, string resourceId, DialogDefinition dialog);
|
||||
|
||||
/// <summary>
|
||||
/// Called when the server shows a custom dialog.
|
||||
/// </summary>
|
||||
void OnDialogShown(DialogDefinition dialog, DialogPhase phase);
|
||||
|
||||
/// <summary>
|
||||
/// Called when the server shows a custom dialog by registry protocol ID.
|
||||
/// </summary>
|
||||
void OnDialogRegistryReferenceShown(int protocolId, DialogPhase phase);
|
||||
|
||||
/// <summary>
|
||||
/// Called when the server clears the current custom dialog.
|
||||
/// </summary>
|
||||
void OnDialogCleared();
|
||||
|
||||
/// <summary>
|
||||
/// Called when the server sends updated server links.
|
||||
/// </summary>
|
||||
void OnServerLinksUpdated(IReadOnlyList<DialogServerLink> links);
|
||||
|
||||
/// <summary>
|
||||
/// Will be called every animations of the hit and place block
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2842,4 +2842,247 @@ see item details.</value>
|
|||
<data name="debug.packet.loop_reason.cancelled" xml:space="preserve">
|
||||
<value>cancelled</value>
|
||||
</data>
|
||||
<data name="cmd.dialog.usage" xml:space="preserve">
|
||||
<value>dialog [show|open|set|click|click-label|cancel|dismiss]</value>
|
||||
</data>
|
||||
<data name="cmd.dialog.desc" xml:space="preserve">
|
||||
<value>View and interact with the current server custom dialog.</value>
|
||||
</data>
|
||||
<data name="dialog.received" xml:space="preserve">
|
||||
<value>Server showed custom dialog: {0}. Use /dialog show.</value>
|
||||
</data>
|
||||
<data name="dialog.cleared" xml:space="preserve">
|
||||
<value>Server cleared the custom dialog.</value>
|
||||
</data>
|
||||
<data name="dialog.none" xml:space="preserve">
|
||||
<value>No custom dialog is active.</value>
|
||||
</data>
|
||||
<data name="dialog.dismissed" xml:space="preserve">
|
||||
<value>Dialog dismissed locally.</value>
|
||||
</data>
|
||||
<data name="dialog.unresolved_title" xml:space="preserve">
|
||||
<value>Unresolved dialog {0}</value>
|
||||
</data>
|
||||
<data name="dialog.unresolved_body" xml:space="preserve">
|
||||
<value>The server referenced dialog registry entry {0}, but MCC has no data for it.</value>
|
||||
</data>
|
||||
<data name="dialog.unresolved_action_disabled" xml:space="preserve">
|
||||
<value>This dialog could not be resolved, so actions are disabled.</value>
|
||||
</data>
|
||||
<data name="dialog.input_unknown" xml:space="preserve">
|
||||
<value>Unknown dialog input: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.input_set" xml:space="preserve">
|
||||
<value>Dialog input {0} set to {1}.</value>
|
||||
</data>
|
||||
<data name="dialog.input_too_long" xml:space="preserve">
|
||||
<value>Input {0} is longer than {1} characters.</value>
|
||||
</data>
|
||||
<data name="dialog.input_boolean_invalid" xml:space="preserve">
|
||||
<value>Input {0} expects true or false.</value>
|
||||
</data>
|
||||
<data name="dialog.input_option_invalid" xml:space="preserve">
|
||||
<value>Input {0} expects one of the listed option IDs.</value>
|
||||
</data>
|
||||
<data name="dialog.input_number_invalid" xml:space="preserve">
|
||||
<value>Input {0} expects a number.</value>
|
||||
</data>
|
||||
<data name="dialog.input_number_range_invalid" xml:space="preserve">
|
||||
<value>Input {0} must be between {1} and {2}.</value>
|
||||
</data>
|
||||
<data name="dialog.action_unknown" xml:space="preserve">
|
||||
<value>Unknown dialog action index: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.action_label_unknown" xml:space="preserve">
|
||||
<value>Unknown dialog action label: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.action_label_ambiguous" xml:space="preserve">
|
||||
<value>Dialog action label is ambiguous: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.cannot_cancel" xml:space="preserve">
|
||||
<value>This dialog cannot be closed with cancel.</value>
|
||||
</data>
|
||||
<data name="dialog.action_closed" xml:space="preserve">
|
||||
<value>Dialog action closed locally.</value>
|
||||
</data>
|
||||
<data name="dialog.action_command_not_in_play" xml:space="preserve">
|
||||
<value>This dialog command action is only available in play state.</value>
|
||||
</data>
|
||||
<data name="dialog.action_invalid" xml:space="preserve">
|
||||
<value>Dialog action is invalid.</value>
|
||||
</data>
|
||||
<data name="dialog.action_custom_failed" xml:space="preserve">
|
||||
<value>Custom dialog action packet could not be sent.</value>
|
||||
</data>
|
||||
<data name="dialog.action_command_sent" xml:space="preserve">
|
||||
<value>Dialog command sent: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.action_custom_sent" xml:space="preserve">
|
||||
<value>Dialog custom action sent: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.action_nested_opened" xml:space="preserve">
|
||||
<value>Nested dialog opened.</value>
|
||||
</data>
|
||||
<data name="dialog.action_open_url" xml:space="preserve">
|
||||
<value>Dialog URL action: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.action_suggest_command" xml:space="preserve">
|
||||
<value>Dialog suggested command: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.action_copy" xml:space="preserve">
|
||||
<value>Dialog copy action: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.action_unsupported" xml:space="preserve">
|
||||
<value>Unsupported dialog action: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.tui_pending" xml:space="preserve">
|
||||
<value>Custom dialog is pending. Use dialog open after closing the current overlay.</value>
|
||||
</data>
|
||||
<data name="dialog.tui_unavailable" xml:space="preserve">
|
||||
<value>Dialog TUI is unavailable.</value>
|
||||
</data>
|
||||
<data name="dialog.tui_opened" xml:space="preserve">
|
||||
<value>Dialog TUI opened.</value>
|
||||
</data>
|
||||
<data name="dialog.action_unnamed" xml:space="preserve">
|
||||
<value>Action</value>
|
||||
</data>
|
||||
<data name="dialog.action_ok" xml:space="preserve">
|
||||
<value>OK</value>
|
||||
</data>
|
||||
<data name="dialog.item_body" xml:space="preserve">
|
||||
<value>Item preview</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.report_bug" xml:space="preserve">
|
||||
<value>Report bug</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.community_guidelines" xml:space="preserve">
|
||||
<value>Community guidelines</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.support" xml:space="preserve">
|
||||
<value>Support</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.status" xml:space="preserve">
|
||||
<value>Status</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.feedback" xml:space="preserve">
|
||||
<value>Feedback</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.community" xml:space="preserve">
|
||||
<value>Community</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.website" xml:space="preserve">
|
||||
<value>Website</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.forums" xml:space="preserve">
|
||||
<value>Forums</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.news" xml:space="preserve">
|
||||
<value>News</value>
|
||||
</data>
|
||||
<data name="dialog.server_link.announcements" xml:space="preserve">
|
||||
<value>Announcements</value>
|
||||
</data>
|
||||
<data name="tui.dialog.cancel" xml:space="preserve">
|
||||
<value>Cancel</value>
|
||||
</data>
|
||||
<data name="dialog.render.header" xml:space="preserve">
|
||||
<value>Dialog #{0} [{1}]: {2}</value>
|
||||
</data>
|
||||
<data name="dialog.render.type" xml:space="preserve">
|
||||
<value>Type: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.render.body" xml:space="preserve">
|
||||
<value>Body: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.render.inputs" xml:space="preserve">
|
||||
<value>Inputs:</value>
|
||||
</data>
|
||||
<data name="dialog.render.input" xml:space="preserve">
|
||||
<value> {0} ({1}) {2} = {3} [{4}]</value>
|
||||
</data>
|
||||
<data name="dialog.render.actions" xml:space="preserve">
|
||||
<value>Actions:</value>
|
||||
</data>
|
||||
<data name="dialog.render.action" xml:space="preserve">
|
||||
<value> [{0}] {1} ({2})</value>
|
||||
</data>
|
||||
<data name="dialog.render.cancel_hint" xml:space="preserve">
|
||||
<value>Use /dialog cancel to close or run the cancel action.</value>
|
||||
</data>
|
||||
<data name="dialog.input_desc_text" xml:space="preserve">
|
||||
<value>max {0} chars</value>
|
||||
</data>
|
||||
<data name="dialog.input_desc_boolean" xml:space="preserve">
|
||||
<value>Yes={0}, No={1}</value>
|
||||
</data>
|
||||
<data name="dialog.input_desc_options" xml:space="preserve">
|
||||
<value>options: {0}</value>
|
||||
</data>
|
||||
<data name="dialog.input_desc_number" xml:space="preserve">
|
||||
<value>range {0}..{1}</value>
|
||||
</data>
|
||||
<data name="dialog.input_desc_unknown" xml:space="preserve">
|
||||
<value>unknown input</value>
|
||||
</data>
|
||||
<data name="dialog.action_desc_close" xml:space="preserve">
|
||||
<value>close</value>
|
||||
</data>
|
||||
<data name="dialog.action_desc_command" xml:space="preserve">
|
||||
<value>command</value>
|
||||
</data>
|
||||
<data name="dialog.action_desc_custom" xml:space="preserve">
|
||||
<value>custom</value>
|
||||
</data>
|
||||
<data name="dialog.action_desc_show_dialog" xml:space="preserve">
|
||||
<value>show dialog</value>
|
||||
</data>
|
||||
<data name="dialog.action_desc_open_url" xml:space="preserve">
|
||||
<value>open URL</value>
|
||||
</data>
|
||||
<data name="dialog.action_desc_suggest" xml:space="preserve">
|
||||
<value>suggest command</value>
|
||||
</data>
|
||||
<data name="dialog.action_desc_copy" xml:space="preserve">
|
||||
<value>copy</value>
|
||||
</data>
|
||||
<data name="dialog.action_desc_unknown" xml:space="preserve">
|
||||
<value>unknown</value>
|
||||
</data>
|
||||
<data name="dialog.type.notice" xml:space="preserve">
|
||||
<value>Notice</value>
|
||||
</data>
|
||||
<data name="dialog.type.confirmation" xml:space="preserve">
|
||||
<value>Confirmation</value>
|
||||
</data>
|
||||
<data name="dialog.type.multi_action" xml:space="preserve">
|
||||
<value>Multi Action</value>
|
||||
</data>
|
||||
<data name="dialog.type.dialog_list" xml:space="preserve">
|
||||
<value>Dialog list</value>
|
||||
</data>
|
||||
<data name="dialog.type.server_links" xml:space="preserve">
|
||||
<value>Server links</value>
|
||||
</data>
|
||||
<data name="dialog.type.unknown" xml:space="preserve">
|
||||
<value>Unknown</value>
|
||||
</data>
|
||||
<data name="dialog.input_kind.text" xml:space="preserve">
|
||||
<value>Text</value>
|
||||
</data>
|
||||
<data name="dialog.input_kind.boolean" xml:space="preserve">
|
||||
<value>Check box</value>
|
||||
</data>
|
||||
<data name="dialog.input_kind.options" xml:space="preserve">
|
||||
<value>Options</value>
|
||||
</data>
|
||||
<data name="dialog.input_kind.number" xml:space="preserve">
|
||||
<value>Number</value>
|
||||
</data>
|
||||
<data name="dialog.input_kind.unknown" xml:space="preserve">
|
||||
<value>Unknown</value>
|
||||
</data>
|
||||
<data name="dialog.render.help_hint" xml:space="preserve">
|
||||
<value>Use /dialog help for a list of commands.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
|
|
|||
449
MinecraftClient/Tui/DialogTuiHost.cs
Normal file
449
MinecraftClient/Tui/DialogTuiHost.cs
Normal file
|
|
@ -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<string, Control> _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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1647,6 +1647,120 @@ In scripts and remote control, no slash is needed to perform the command, eg. `q
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>dialog</code></summary>
|
||||
|
||||
- **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 <index>
|
||||
/dialog click-label <label>
|
||||
/dialog set <input> <value>
|
||||
/dialog input <input> <value>
|
||||
/dialog cancel
|
||||
/dialog dismiss
|
||||
```
|
||||
|
||||
- **Examples:**
|
||||
|
||||
```
|
||||
/dialog show
|
||||
/dialog click 1
|
||||
/dialog click-label Teleport
|
||||
/dialog set name MyPlayer
|
||||
/dialog dismiss
|
||||
```
|
||||
|
||||
See what dialog is currently shown, click the first button, click a button by its text, fill in an input, or close the dialog.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><code>debug</code></summary>
|
||||
|
||||
|
|
|
|||
238
tools/run-dialog-test.sh
Executable file
238
tools/run-dialog-test.sh
Executable file
|
|
@ -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 <mc-version>
|
||||
|
||||
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" <<TOML
|
||||
|
||||
[Debug]
|
||||
DebugMessages = false
|
||||
PacketDebugMessages = false
|
||||
TOML
|
||||
|
||||
# Launch MCC with MCC_FILE_INPUT=1 for maximum compatibility
|
||||
echo "ping" > "$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
|
||||
Loading…
Add table
Add a link
Reference in a new issue