From 80fc2e6e1cfaab62f22995d078e6647f7cf6650e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 04:57:50 +0000 Subject: [PATCH] Ask GMCP/MSDP games for data and show it The client now introduces itself over GMCP once the server agrees (Core.Hello, Core.Supports.Set) and asks MSDP games to REPORT what the status row reads. Game data is held per session in GameDataStore and shown three ways: status-row meters (F7 meters), Ctrl+P > Show game data, and Lua gmcp.get / msdp.get / gmcp.send. F5 gains a per-world gmcp modules field. GmcpStats, which collected nine keys nothing drew, is removed. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WXyHEoxwX8GurTHxwPinQY --- CHANGELOG.md | 11 + CLAUDE.md | 12 + .../Commands/CommandCatalog.cs | 5 + .../Configuration/PreferenceSettings.cs | 7 + .../Configuration/StatusMeter.cs | 165 ++++++++++++ .../Configuration/WorldDefinition.cs | 7 + .../Protocols/GameDataStore.cs | 255 ++++++++++++++++++ src/SharpMUTerm.Core/Session/WorldSession.cs | 70 ++++- src/SharpMUTerm.Core/Telnet/GmcpHandshake.cs | 100 +++++++ src/SharpMUTerm.Core/Telnet/ITelnetSession.cs | 9 + src/SharpMUTerm.Core/Telnet/TelnetSession.cs | 79 +++++- src/SharpMUTerm.Scripting/IScriptWorld.cs | 11 + src/SharpMUTerm.Scripting/LuaJson.cs | 120 +++++++++ src/SharpMUTerm.Scripting/ScriptHost.cs | 30 ++- .../WorldSessionScriptBridge.cs | 4 + src/SharpMUTerm.Tui/GameDataOverlay.cs | 200 ++++++++++++++ src/SharpMUTerm.Tui/GmcpStats.cs | 111 -------- src/SharpMUTerm.Tui/OptionsScreenRenderer.cs | 18 ++ src/SharpMUTerm.Tui/SharpMUTermApp.cs | 102 ++++++- src/SharpMUTerm.Tui/SnapshotViews.cs | 2 +- src/SharpMUTerm.Tui/StatusMeterRenderer.cs | 64 +++++ src/SharpMUTerm.Tui/WorldsScreenRenderer.cs | 37 ++- .../Protocols/GameDataStoreTests.cs | 138 ++++++++++ .../Session/FakeTelnetSession.cs | 24 +- .../Telnet/GmcpHandshakeTests.cs | 148 ++++++++++ .../FakeScriptWorld.cs | 9 + .../FakeTelnetSession.cs | 24 +- .../GameDataApiTests.cs | 67 +++++ .../FrameContrastTests.cs | 4 + tests/SharpMUTerm.Tui.Tests/GameDataTests.cs | 169 ++++++++++++ .../RecordingTelnetSession.cs | 39 ++- .../SharpMUTerm.Tui.Tests/ScreenModelTests.cs | 9 +- 32 files changed, 1913 insertions(+), 137 deletions(-) create mode 100644 src/SharpMUTerm.Core/Configuration/StatusMeter.cs create mode 100644 src/SharpMUTerm.Core/Protocols/GameDataStore.cs create mode 100644 src/SharpMUTerm.Core/Telnet/GmcpHandshake.cs create mode 100644 src/SharpMUTerm.Scripting/LuaJson.cs create mode 100644 src/SharpMUTerm.Tui/GameDataOverlay.cs delete mode 100644 src/SharpMUTerm.Tui/GmcpStats.cs create mode 100644 src/SharpMUTerm.Tui/StatusMeterRenderer.cs create mode 100644 tests/SharpMUTerm.Core.Tests/Protocols/GameDataStoreTests.cs create mode 100644 tests/SharpMUTerm.Core.Tests/Telnet/GmcpHandshakeTests.cs create mode 100644 tests/SharpMUTerm.Scripting.Tests/GameDataApiTests.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/GameDataTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 51716a48..62b77a9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] along with errors the UI framework used to swallow without a trace. - A `scripts` field on each trigger set in Worlds & Characters (F5), and Ctrl+P ▸ *Reload scripts*, which re-reads the focused character's files. +- GMCP games now send their data. The client introduces itself (`Core.Hello`) and asks for the common + modules (`Core.Supports.Set`: Char, Char.Skills, Char.Items, Room, Comm.Channel) once GMCP is agreed, and + asks an MSDP game to report the variables the status row reads. Worlds & Characters (F5) has a + `gmcp modules` field for a world that needs more, such as `IRE.Rift 1`. +- Health, mana and movement on the status row, read from GMCP or MSDP. A meter shows only once its value + arrives, keeps its width as the numbers change, and turns to the warning colour below a quarter. Which + values the row shows is the `meters` setting in Text & ANSI (F7). +- Ctrl+P ▸ *Show game data* lists every GMCP package and MSDP variable the focused connection holds, and + shows the selected one in full. +- Lua scripts can read and send game data: `gmcp.get('Char.Vitals')`, `msdp.get('HEALTH')` and + `gmcp.send('Char.Skills.Get', { group = 'combat' })`. ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 222c8f7c..1d8a72b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,18 @@ fallbacks) for inline images/maps. - **A rebuild clears the engines' runtime rules first** (`ClearRuntime`), or a reload doubles every `trigger.add`. `SaveConfiguration` rebuilds only when a session's file list changed (`SyncFiles`), since a rebuild restarts script timers; ⌃P ▸ *Reload scripts* always rebuilds and re-reads. +- **GMCP and MSDP data is held per session in `GameDataStore`, and three things read it**: the status row's + meters (`StatusMeter`, F7 ▸ `meters`), ⌃P ▸ *Show game data* (`GameDataOverlay`) and Lua (`gmcp.get`, + `msdp.get`). GMCP packages are stored by name with objects merged key by key, because IRE and Aardwolf send + partial `Char.Vitals` updates; MSDP variables are stored as `MSDP.NAME`, so one path syntax with `|` + alternatives reads both. Payloads over 64 KiB and packages past 512 are dropped at the door. + - **The client says `Core.Hello` and `Core.Supports.Set` only after the server agrees to GMCP** + (`OnGMCPNegotiated`, `GmcpHandshake`). Before that a GMCP frame would be the unsolicited-option bytes + the login rule above forbids. MSDP has no negotiated callback in the library, so `IAC DO MSDP` is + spotted on the write path and the `REPORT`s follow it. + - **A meter is drawn only once its value has arrived, and from then its fields are reserved** (value and + max five cells each, `StatusMeter.Fit` scales past that). A world with no game data costs the row + nothing; one with it never reflows the row as numbers change. - **Tui** — **SharpConsoleUI** app: a `TabControl` of output windows (main + trigger-routed **spawn windows** + web view, with unread badges), each a `MarkupControl` in a `ScrollablePanelControl` viewport (PgUp/PgDn, Shift+↑/↓, ⌃Home/⌃End, wheel; unread badges count output arriving below a diff --git a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs index 63cd9007..dd8e85bb 100644 --- a/src/SharpMUTerm.Core/Commands/CommandCatalog.cs +++ b/src/SharpMUTerm.Core/Commands/CommandCatalog.cs @@ -226,6 +226,11 @@ public static IReadOnlyList Build( items.Add(new CommandItem( CommandGroup.Terminal, "Show client messages", "term:messages", "status-line notices")); + // What the server is sending out of band. Listed unconditionally: a meter or a script is written + // against a GMCP path, and this is where a player finds out which paths their game has. + items.Add(new CommandItem( + CommandGroup.Terminal, "Show game data", "term:gamedata", "GMCP and MSDP this connection holds")); + // The command line's own history, browsable and searchable. Named here as well as bound to ⌃R // because a chord nothing mentions is a chord nobody finds — the same reason every settings screen // has a row even though each has an F-key. diff --git a/src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs b/src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs index a47ea39f..fad55718 100644 --- a/src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs +++ b/src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs @@ -161,6 +161,13 @@ public sealed class TextSettings /// Ten minutes. Past this a boundary marker is not a marker, it is a pin. public const int MaxActivityBarSeconds = 600; + /// + /// The readings the status row shows from the session's GMCP and MSDP data (F7 ▸ STATUS ROW). A meter + /// whose value the server has not sent is not drawn, so the defaults cost nothing on a world without + /// either protocol. + /// + public List StatusMeters { get; set; } = StatusMeter.Defaults(); + // There is deliberately no "ambiguous width" here. It was a setting with nothing behind it: every // column measurement in this app is SharpConsoleUI's (Helpers/UnicodeWidth.cs), which asks the // Wcwidth tables and offers no East-Asian-ambiguous policy to set. Honouring it needs an upstream diff --git a/src/SharpMUTerm.Core/Configuration/StatusMeter.cs b/src/SharpMUTerm.Core/Configuration/StatusMeter.cs new file mode 100644 index 00000000..9b990d13 --- /dev/null +++ b/src/SharpMUTerm.Core/Configuration/StatusMeter.cs @@ -0,0 +1,165 @@ +using System.Globalization; +using System.Text; +using SharpMUTerm.Core.Protocols; + +namespace SharpMUTerm.Core.Configuration; + +/// +/// One reading on the status row: a short label and the game data it shows, optionally out of a maximum — +/// hp 450/500. Each path may list alternatives separated by |, tried in order, so one meter reads +/// a GMCP game and an MSDP game alike. +/// +public sealed class StatusMeter +{ + /// What the row prints before the value. Kept short; it is clipped to . + public string Label { get; set; } = string.Empty; + + /// The value's path (Char.Vitals.hp|MSDP.HEALTH). + public string Value { get; set; } = string.Empty; + + /// The maximum's path, or null for a meter with none (gold). + public string? Max { get; set; } + + /// The longest label the row draws. + public const int MaxLabelLength = 8; + + /// + /// The default row: health, mana and movement, by the names IRE, Aardwolf and the MSDP convention use. + /// + public static List Defaults() => + [ + new() + { + Label = "hp", + Value = "Char.Vitals.hp|MSDP.HEALTH", + Max = "Char.Vitals.maxhp|Char.MaxStats.maxhp|MSDP.HEALTH_MAX", + }, + new() + { + Label = "mp", + Value = "Char.Vitals.mp|Char.Vitals.mana|MSDP.MANA", + Max = "Char.Vitals.maxmp|Char.MaxStats.maxmana|MSDP.MANA_MAX", + }, + new() + { + Label = "mv", + Value = "Char.Vitals.moves|MSDP.MOVEMENT", + Max = "Char.MaxStats.maxmoves|MSDP.MOVEMENT_MAX", + }, + ]; + + /// The first alternative of the store holds a value for, as text. + public static string? Read(GameDataStore data, string? paths) + { + foreach (var path in Alternatives(paths)) + { + if (data.GetText(path) is { } text) + { + return text; + } + } + + return null; + } + + /// The alternatives in a path list, trimmed, blanks dropped. + public static IEnumerable Alternatives(string? paths) => + (paths ?? string.Empty).Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + + /// + /// The MSDP variables a list of meters reads — what a session asks an MSDP server to REPORT. + /// + public static IReadOnlyList MsdpVariables(IEnumerable meters) => meters + .SelectMany(m => Alternatives(m.Value).Concat(Alternatives(m.Max))) + .Where(p => p.StartsWith(GameDataStore.MsdpPrefix + ".", StringComparison.OrdinalIgnoreCase)) + .Select(p => p[(GameDataStore.MsdpPrefix.Length + 1)..]) + .Where(v => v.Length > 0 && !v.Contains('.', StringComparison.Ordinal)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + /// + /// Writes meters as the one-line form F7 edits: hp=Char.Vitals.hp/Char.Vitals.maxhp; gold=Char.Status.gold. + /// + public static string Format(IEnumerable meters) + { + var sb = new StringBuilder(); + foreach (var meter in meters) + { + if (sb.Length > 0) + { + sb.Append("; "); + } + + sb.Append(meter.Label).Append('=').Append(meter.Value); + if (!string.IsNullOrWhiteSpace(meter.Max)) + { + sb.Append('/').Append(meter.Max); + } + } + + return sb.ToString(); + } + + /// + /// Reads the one-line form back. A part with no =, no label or no value is skipped, so a half-typed + /// entry costs nothing; the label is clipped to . + /// + public static List Parse(string? text) + { + var meters = new List(); + foreach (var part in (text ?? string.Empty).Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + var eq = part.IndexOf('=', StringComparison.Ordinal); + if (eq <= 0) + { + continue; + } + + var label = part[..eq].Trim(); + var paths = part[(eq + 1)..]; + var slash = paths.IndexOf('/', StringComparison.Ordinal); + var value = (slash < 0 ? paths : paths[..slash]).Trim(); + var max = slash < 0 ? null : paths[(slash + 1)..].Trim(); + if (label.Length == 0 || value.Length == 0) + { + continue; + } + + meters.Add(new StatusMeter + { + Label = label.Length > MaxLabelLength ? label[..MaxLabelLength] : label, + Value = value, + Max = string.IsNullOrEmpty(max) ? null : max, + }); + } + + return meters; + } + + /// + /// A reading fitted to cells without wrapping a number past recognition: a whole + /// number too long is shown in thousands or millions (123k, 12M), anything else is clipped + /// with an ellipsis. Never longer than the width, which is what lets the row reserve a fixed field. + /// + public static string Fit(string text, int width) + { + if (text.Length <= width) + { + return text; + } + + if (long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var n)) + { + foreach (var (scale, suffix) in new[] { (1_000L, "k"), (1_000_000L, "M"), (1_000_000_000L, "G") }) + { + var scaled = (n / scale).ToString(CultureInfo.InvariantCulture) + suffix; + if (scaled.Length <= width) + { + return scaled; + } + } + } + + return width <= 1 ? "…" : text[..(width - 1)] + "…"; + } +} diff --git a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs index dd8f956f..5f57c761 100644 --- a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs +++ b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs @@ -106,6 +106,12 @@ public sealed class WorldDefinition /// Emoji/emoticon substitution for inbound text. public EmojiSettings Emoji { get; set; } = new(); + /// + /// GMCP modules to ask this world for beyond the client's defaults, each Name Version + /// (IRE.Rift 1) — sent in Core.Supports.Set once the server's GMCP is agreed. + /// + public List GmcpModules { get; set; } = new(); + /// /// The world's accent colour, used to keep its windows traceable to their owner once they /// scatter across panes. Default resolves to a theme-derived accent. @@ -138,6 +144,7 @@ public sealed class WorldDefinition Shortcodes = Emoji.Shortcodes, }, Accent = Accent, + GmcpModules = GmcpModules.ToList(), Characters = Characters.Select(c => c.Clone()).ToList(), }; diff --git a/src/SharpMUTerm.Core/Protocols/GameDataStore.cs b/src/SharpMUTerm.Core/Protocols/GameDataStore.cs new file mode 100644 index 00000000..fbb7fb8f --- /dev/null +++ b/src/SharpMUTerm.Core/Protocols/GameDataStore.cs @@ -0,0 +1,255 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace SharpMUTerm.Core.Protocols; + +/// One package the store holds: its name as the server spelt it, when it last changed, and its data. +public sealed record GameDataEntry(string Package, DateTimeOffset UpdatedAt, JsonNode? Data); + +/// +/// The latest out-of-band game data a session has received: every GMCP package by name, and every MSDP +/// variable under MSDP.<NAME>. Read by dotted path — Char.Vitals.hp, MSDP.HEALTH — +/// by the status row's meters, the ⌃P inspector and Lua. +/// +/// +/// +/// An object payload is merged into what is held, key by key, the way Mudlet's gmcp table is: +/// Char.Status and its relatives send only what changed, and replacing the package would forget every +/// field the latest message did not repeat. Any other payload replaces the package. +/// +/// +/// Names are case-insensitive because servers disagree: IRE sends Char.Vitals, Aardwolf +/// char.vitals, and a meter configured once should read both. +/// +/// +/// It is bounded at the door: a payload over is refused, and once +/// names are held a new one is refused (an existing one still updates). The server +/// chooses both, so neither may grow memory without limit. +/// +/// Writes arrive on the telnet read loop and reads come from the UI thread; one lock covers both. +/// +public sealed class GameDataStore +{ + /// The largest payload kept: 64 KiB of JSON text. + public const int MaxPayloadBytes = 64 * 1024; + + /// How many package names may be held at once. + public const int MaxPackages = 512; + + /// The prefix MSDP variables are kept under. + public const string MsdpPrefix = "MSDP"; + + private readonly Lock _gate = new(); + private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); + private readonly TimeProvider _time; + + public GameDataStore(TimeProvider? time = null) => _time = time ?? TimeProvider.System; + + /// Raised after a package changes, with its name. On the writer's thread. + public event EventHandler? Changed; + + /// How many packages are held. + public int Count + { + get + { + lock (_gate) + { + return _entries.Count; + } + } + } + + /// Every package held, sorted by name. + public IReadOnlyList Entries + { + get + { + lock (_gate) + { + return _entries.Values + .OrderBy(e => e.Package, StringComparer.OrdinalIgnoreCase) + .Select(e => e with { Data = e.Data?.DeepClone() }) + .ToArray(); + } + } + } + + /// + /// Takes one GMCP message. An empty body (GMCP allows a package with no data) is held as null. Returns + /// whether it was kept. + /// + public bool SetGmcp(string package, string? json) + { + if (string.IsNullOrWhiteSpace(package) || package.Length > 128 || (json?.Length ?? 0) > MaxPayloadBytes) + { + return false; + } + + JsonNode? data = null; + if (!string.IsNullOrWhiteSpace(json)) + { + try + { + data = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { MaxDepth = 32 }); + } + catch (JsonException) + { + return false; + } + } + + return Store(package.Trim(), data); + } + + /// + /// Takes one MSDP message, as the telnet library renders it: a JSON object of variable → value. Each + /// variable is held as its own package, MSDP.<NAME>. Returns whether anything was kept. + /// + public bool SetMsdp(string? json) + { + if (string.IsNullOrWhiteSpace(json) || json.Length > MaxPayloadBytes) + { + return false; + } + + JsonNode? root; + try + { + root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { MaxDepth = 32 }); + } + catch (JsonException) + { + return false; + } + + if (root is not JsonObject variables) + { + return false; + } + + var kept = false; + foreach (var (name, value) in variables.ToArray()) + { + if (name.Length is > 0 and <= 64) + { + kept |= Store($"{MsdpPrefix}.{name}", value?.DeepClone()); + } + } + + return kept; + } + + /// + /// The value at a dotted path, or null. The longest package name that prefixes the path wins, and the rest + /// of the path walks into its data: Char.Vitals.hp is field hp of package Char.Vitals. + /// A numeric segment indexes an array. + /// + public JsonNode? Get(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var parts = path.Trim().Split('.'); + lock (_gate) + { + for (var take = parts.Length; take > 0; take--) + { + if (!_entries.TryGetValue(string.Join('.', parts, 0, take), out var entry)) + { + continue; + } + + var node = entry.Data; + for (var i = take; i < parts.Length && node is not null; i++) + { + node = Step(node, parts[i]); + } + + return node?.DeepClone(); + } + } + + return null; + } + + /// + /// The value at a path as display text: a string as itself, a number in invariant culture, a boolean as + /// true/false. Null for a missing value or one that is an object or an array. + /// + public string? GetText(string? path) => Get(path) switch + { + JsonValue value when value.TryGetValue(out string? text) => text, + JsonValue value when value.TryGetValue(out bool flag) => flag ? "true" : "false", + JsonValue value => value.ToJsonString(), + _ => null, + }; + + /// The value at a path as a number, reading numeric strings too (IRE sends "hp": "450"). + public double? GetNumber(string? path) => GetText(path) is { } text + && double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var n) + && double.IsFinite(n) + ? n + : null; + + /// Forgets everything, as a new connection does. + public void Clear() + { + lock (_gate) + { + _entries.Clear(); + } + } + + /// The data a package holds, pretty-printed, for the inspector. + public static string Pretty(JsonNode? data) => + data is null ? "(no data)" : data.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + + private bool Store(string package, JsonNode? data) + { + lock (_gate) + { + if (_entries.TryGetValue(package, out var existing)) + { + if (existing.Data is JsonObject held && data is JsonObject update) + { + var merged = (JsonObject)held.DeepClone(); + foreach (var (key, value) in update.ToArray()) + { + merged[key] = value?.DeepClone(); + } + + if (Size(merged) > MaxPayloadBytes) + { + return false; + } + + data = merged; + } + } + else if (_entries.Count >= MaxPackages) + { + return false; + } + + _entries[package] = new GameDataEntry(existing?.Package ?? package, _time.GetUtcNow(), data); + } + + Changed?.Invoke(this, package); + return true; + } + + private static int Size(JsonNode node) => Encoding.UTF8.GetByteCount(node.ToJsonString()); + + private static JsonNode? Step(JsonNode node, string part) => node switch + { + JsonObject obj => obj.FirstOrDefault(p => string.Equals(p.Key, part, StringComparison.OrdinalIgnoreCase)).Value, + JsonArray array when int.TryParse(part, NumberStyles.None, CultureInfo.InvariantCulture, out var i) + && i < array.Count => array[i], + _ => null, + }; +} diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index ac949518..802595a7 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -219,6 +219,48 @@ async Task Send() /// what it does cover, and is the right call to keep regardless. /// /// + /// + /// Asks an MSDP server for the variables the meters read. MSDP servers commonly send nothing until a + /// client REPORTs, so without this a meter pointed at MSDP.HEALTH stayed empty for ever. + /// Raised from the read loop; the sends are fire-and-forget because a refused one changes nothing. + /// + private void OnMsdpEnabled(object? sender, EventArgs e) + { + if (sender is not ITelnetSession telnet) + { + return; + } + + foreach (var variable in MsdpReports.Distinct(StringComparer.OrdinalIgnoreCase)) + { + _ = SendMsdpQuietly(telnet, "REPORT", variable); + } + } + + private async Task SendMsdpQuietly(ITelnetSession telnet, string variable, string value) + { + try + { + await telnet.SendMsdpAsync(variable, value).ConfigureAwait(false); + } + catch (Exception ex) when (ex is InvalidOperationException or IOException or ObjectDisposedException) + { + Logger.LogDebug(ex, "MSDP {Variable} {Value} was not sent.", variable, value); + } + } + + /// Sends a GMCP message on this connection; refused (false) when nothing is connected. + public async Task SendGmcpAsync(string package, string json) + { + if (_telnet is not { IsConnected: true } telnet) + { + return false; + } + + await telnet.SendGmcpAsync(package, json).ConfigureAwait(false); + return true; + } + private void OnMxpEnabled(object? sender, EventArgs e) { if (World.ContentFormat != ContentFormat.Ansi || _parser is MxpParser) @@ -294,6 +336,18 @@ public ILogger Logger public event EventHandler? MsdpReceived; + /// + /// The latest GMCP and MSDP data this connection has received. Emptied on connect, because a package a + /// previous connection held says nothing about this one. + /// + public GameDataStore GameData { get; } = new(); + + /// + /// The MSDP variables to ask for with REPORT once MSDP is agreed — the ones the status row's meters + /// read. Set by the app; read at that moment, so a change applies from the next connection. + /// + public IReadOnlyList MsdpReports { get; set; } = []; + public event EventHandler? MsspReceived; public event EventHandler? SpawnLine; @@ -381,8 +435,19 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) var telnet = _sessionFactory(World.ToConnectionOptions()); _telnet = telnet; telnet.OutputReceived += OnOutputReceived; - telnet.GmcpReceived += (_, e) => GmcpReceived?.Invoke(this, e); - telnet.MsdpReceived += (_, e) => MsdpReceived?.Invoke(this, e); + GameData.Clear(); + telnet.GmcpReceived += (_, e) => + { + // Stored before the event, so a handler reading GameData sees the message that raised it. + GameData.SetGmcp(e.Package, e.Json); + GmcpReceived?.Invoke(this, e); + }; + telnet.MsdpReceived += (_, e) => + { + GameData.SetMsdp(e.Json); + MsdpReceived?.Invoke(this, e); + }; + telnet.MsdpEnabled += OnMsdpEnabled; telnet.MsspReceived += (_, e) => MsspReceived?.Invoke(this, e); telnet.EncodingChanged += OnEncodingChanged; telnet.Disconnected += OnDisconnected; @@ -876,6 +941,7 @@ private ITelnetSession DefaultSessionFactory(ConnectionOptions options) Logger, options: new TelnetSessionOptions { + GmcpModules = World.GmcpModules.ToArray(), CharsetOrder = order, EncodingOverride = encodingOverride, KeepaliveInterval = TelnetSessionOptions.ResolveKeepalive(World.KeepaliveSeconds), diff --git a/src/SharpMUTerm.Core/Telnet/GmcpHandshake.cs b/src/SharpMUTerm.Core/Telnet/GmcpHandshake.cs new file mode 100644 index 00000000..0e0d272b --- /dev/null +++ b/src/SharpMUTerm.Core/Telnet/GmcpHandshake.cs @@ -0,0 +1,100 @@ +using System.Globalization; +using System.Reflection; +using TelnetNegotiationCore.Gmcp; + +namespace SharpMUTerm.Core.Telnet; + +/// +/// What this client says once GMCP is agreed: Core.Hello with its name and version, then +/// Core.Supports.Set with the modules it wants. Many servers send nothing until the second arrives — +/// IRE games and every server built on their convention among them — and this client sent neither, so on +/// those servers GMCP was negotiated and then silent. +/// +/// +/// Nothing here is sent unless the server offered GMCP (IAC WILL GMCP) and the library agreed to it: +/// the handshake rides OnGMCPNegotiated, so the rule that this client asks no server to enable an +/// option it has not offered still holds. +/// +public static class GmcpHandshake +{ + /// The name this client introduces itself by. + public const string ClientName = "SharpMUTerm"; + + /// + /// The modules asked for on every world, Mudlet's common set. A world adds to these on F5; it cannot + /// remove them, because a server ignores a module it does not have. + /// + public static IReadOnlyList DefaultModules { get; } = + ["Char 1", "Char.Skills 1", "Char.Items 1", "Room 1", "Comm.Channel 1"]; + + /// + /// This build's version, as MinVer stamped it, without the commit suffix a server has no use for. + /// + public static string ClientVersion { get; } = ReadVersion(); + + /// + /// The modules for one world: the defaults, then the world's own, each parsed from Name Version + /// (the version defaults to 1). A later entry for a module already listed replaces its version; an entry + /// that does not parse is dropped. + /// + public static IReadOnlyList Modules(IEnumerable? extra = null) + { + var modules = new List(); + foreach (var spec in DefaultModules.Concat(extra ?? [])) + { + if (Parse(spec) is not { } module) + { + continue; + } + + var at = modules.FindIndex(m => string.Equals(m.Name, module.Name, StringComparison.OrdinalIgnoreCase)); + if (at >= 0) + { + modules[at] = module; + } + else + { + modules.Add(module); + } + } + + return modules; + } + + /// Parses Char.Vitals 1 or Char.Vitals; null for anything else. + public static GmcpModule? Parse(string? spec) + { + var parts = (spec ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length is 0 or > 2 || !IsName(parts[0])) + { + return null; + } + + var version = 1; + if (parts.Length == 2 + && (!int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out version) || version < 1)) + { + return null; + } + + return new GmcpModule(parts[0], version); + } + + private static bool IsName(string name) => + name.Length is > 0 and <= 64 + && name[0] != '.' && name[^1] != '.' + && name.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '_' or '-'); + + private static string ReadVersion() + { + var info = typeof(GmcpHandshake).Assembly + .GetCustomAttribute()?.InformationalVersion; + if (string.IsNullOrEmpty(info)) + { + return "0.0.0"; + } + + var plus = info.IndexOf('+', StringComparison.Ordinal); + return plus < 0 ? info : info[..plus]; + } +} diff --git a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs index 405f67cc..b40ca485 100644 --- a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs @@ -39,6 +39,12 @@ public interface ITelnetSession : IAsyncDisposable /// event EventHandler? MxpEnabled; + /// + /// Raised once the client has agreed to the server's MSDP, so a consumer can ask it to REPORT the + /// variables it wants. MSDP servers commonly send nothing until asked. + /// + event EventHandler? MsdpEnabled; + /// Connects the transport and begins negotiation and the receive loop. Task ConnectAsync(CancellationToken cancellationToken = default); @@ -51,6 +57,9 @@ public interface ITelnetSession : IAsyncDisposable /// Sends a GMCP message. ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default); + /// Sends one MSDP variable and value, e.g. REPORT HEALTH. + ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default); + /// /// Reports the terminal window size via NAWS. Before the server enables NAWS, retains the latest /// size and reports it automatically when negotiation completes; unchanged sizes are not resent. diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index 6a449115..fdbf72a7 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -5,6 +5,7 @@ using SharpMUTerm.Core.Telnet.Mssp; using SharpMUTerm.Core.Transport; using TelnetNegotiationCore.Builders; +using TelnetNegotiationCore.Gmcp; using TelnetNegotiationCore.Interpreters; using TelnetNegotiationCore.Models; using TelnetNegotiationCore.Protocols; @@ -97,6 +98,19 @@ public sealed class TelnetSessionOptions /// public TimeSpan? KeepaliveInterval { get; init; } + /// + /// Whether to send Core.Hello and Core.Supports.Set once the server's GMCP is agreed + /// (). On by default: it is what makes a server that waits to be told which + /// modules a client wants send anything at all. + /// + public bool IntroduceOverGmcp { get; init; } = true; + + /// + /// Modules asked for beyond , each Name Version — a + /// world's . + /// + public IReadOnlyList GmcpModules { get; init; } = []; + /// /// Turns a world's configured keepalive seconds into an interval the telnet library will accept, /// or null when there should be none. @@ -357,6 +371,7 @@ private Encoding? Negotiated public event EventHandler? MsspReceived; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? MsdpEnabled; public async Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -368,6 +383,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // Transport must be open before building: the interpreter emits initial negotiation // (e.g. WILL NAWS) during BuildAsync, which is written straight to the transport. await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); + _msdpAgreed = false; try { @@ -407,7 +423,7 @@ private Task BuildInterpreterAsync() // Explicit defaults let NAWS observe negotiation completion, so an early pane report // is retained and sent on DO NAWS rather than silently discarded by the base plugin. .AddPlugin(new WindowSizeProtocol().OnNAWS(static (_, _) => ValueTask.CompletedTask)) - .AddPlugin().OnGMCPMessage(OnGmcpAsync) + .AddPlugin().OnGMCPMessage(OnGmcpAsync).OnGMCPNegotiated(OnGmcpNegotiatedAsync) .AddPlugin().OnMSDPMessage(OnMsdpAsync) .AddPlugin().OnMSSP(OnMsspAsync).WithMSSPConfig(static () => new MSSPConfig()) .AddPlugin() @@ -532,8 +548,58 @@ private void ReportEncodingIfChanged() EncodingChanged?.Invoke(this, new SessionEncodingEventArgs(current)); } - private ValueTask WriteToTransportAsync(ReadOnlyMemory data) => - _transport.SendAsync(data); + /// + /// The library's negotiation output, written as-is. It is also where this session learns MSDP was + /// agreed: MSDP has no negotiated callback, and the library's IAC DO MSDP answer to a server's + /// WILL is the moment it is. Only negotiation passes through here, never data, so the three bytes + /// cannot be a literal in someone's text. + /// + private ValueTask WriteToTransportAsync(ReadOnlyMemory data) + { + if (!_msdpAgreed && Contains(data.Span, [Iac, DoCommand, MsdpOption])) + { + _msdpAgreed = true; + _logger.LogInformation("MSDP negotiated."); + var transportWrite = _transport.SendAsync(data); + MsdpEnabled?.Invoke(this, EventArgs.Empty); + return transportWrite; + } + + return _transport.SendAsync(data); + } + + private const byte Iac = 255; + private const byte DoCommand = 253; + private const byte MsdpOption = 69; + + /// Whether this connection has agreed MSDP; reset on every connect. + private volatile bool _msdpAgreed; + + private static bool Contains(ReadOnlySpan haystack, ReadOnlySpan needle) => + haystack.IndexOf(needle) >= 0; + + /// + /// Introduces the client once GMCP is agreed — Mudlet's order, Core.Hello first. A failure here + /// is logged and swallowed: a server that drops the handshake still has a working connection. + /// + private async ValueTask OnGmcpNegotiatedAsync(bool agreed) + { + if (!agreed || !_options.IntroduceOverGmcp || _interpreter is not { } interpreter) + { + return; + } + + try + { + var gmcp = new GmcpClientSession((package, data) => interpreter.SendGMCPCommand(package, data)); + await gmcp.HelloAsync(GmcpHandshake.ClientName, GmcpHandshake.ClientVersion).ConfigureAwait(false); + await gmcp.SetSupportsAsync(GmcpHandshake.Modules(_options.GmcpModules)).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + _logger.LogWarning(ex, "Could not send the GMCP handshake."); + } + } private ValueTask OnByteAsync(byte value, Encoding encoding) { @@ -689,6 +755,13 @@ public ValueTask SendGmcpAsync(string package, string json, CancellationToken ca return interpreter.SendGMCPCommand(package, json ?? string.Empty); } + public ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected."); + return interpreter.SendMSDPCommand(variable, value ?? string.Empty); + } + public ValueTask SetWindowSizeAsync(int width, int height) { var interpreter = _interpreter ?? throw new InvalidOperationException("Session is not connected."); diff --git a/src/SharpMUTerm.Scripting/IScriptWorld.cs b/src/SharpMUTerm.Scripting/IScriptWorld.cs index 514cb80a..89306fbc 100644 --- a/src/SharpMUTerm.Scripting/IScriptWorld.cs +++ b/src/SharpMUTerm.Scripting/IScriptWorld.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Nodes; + namespace SharpMUTerm.Scripting; /// @@ -33,6 +35,15 @@ public interface IScriptWorld /// Schedules a one-shot callback; disposing the handle cancels it. IDisposable ScheduleAfter(TimeSpan delay, Action callback); + /// + /// The GMCP or MSDP data at a dotted path (Char.Vitals.hp, MSDP.HEALTH), or null — what + /// gmcp.get and msdp.get read. + /// + JsonNode? GameData(string path); + + /// Sends a GMCP message; is the body, empty for none. + void SendGmcp(string package, string json); + /// The display name of the world, exposed to scripts as world.name. string WorldName { get; } } diff --git a/src/SharpMUTerm.Scripting/LuaJson.cs b/src/SharpMUTerm.Scripting/LuaJson.cs new file mode 100644 index 00000000..a9cc916a --- /dev/null +++ b/src/SharpMUTerm.Scripting/LuaJson.cs @@ -0,0 +1,120 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using MoonSharp.Interpreter; + +namespace SharpMUTerm.Scripting; + +/// +/// Converts between game data (JSON) and Lua values, for gmcp.get, msdp.get and +/// gmcp.send. An object is a table with string keys, an array a table indexed from 1. +/// +internal static class LuaJson +{ + /// Deeper than this and a Lua table is refused: a table can contain itself. + private const int MaxDepth = 32; + + public static DynValue ToLua(Script script, JsonNode? node) => node switch + { + null => DynValue.Nil, + JsonObject obj => Table(script, obj), + JsonArray array => Table(script, array), + JsonValue value => Scalar(value), + _ => DynValue.Nil, + }; + + /// A Lua value as JSON, or null for nil. Throws for a function, a cycle or too much nesting. + public static JsonNode? FromLua(DynValue value, int depth = 0) + { + if (depth > MaxDepth) + { + throw new ScriptRuntimeException("gmcp.send: the table is nested too deeply (or contains itself)."); + } + + switch (value.Type) + { + case DataType.Nil: + case DataType.Void: + return null; + case DataType.Boolean: + return JsonValue.Create(value.Boolean); + case DataType.Number: + var n = value.Number; + return n == Math.Floor(n) && Math.Abs(n) < 9e15 ? JsonValue.Create((long)n) : JsonValue.Create(n); + case DataType.String: + return JsonValue.Create(value.String); + case DataType.Table: + return FromTable(value.Table, depth); + default: + throw new ScriptRuntimeException($"gmcp.send: a {value.Type.ToLuaTypeString()} cannot be sent."); + } + } + + private static JsonNode FromTable(Table table, int depth) + { + var length = table.Length; + var count = table.Pairs.Count(); + if (length > 0 && length == count) + { + var array = new JsonArray(); + for (var i = 1; i <= length; i++) + { + array.Add(FromLua(table.Get(i), depth + 1)); + } + + return array; + } + + var obj = new JsonObject(); + foreach (var pair in table.Pairs) + { + var key = pair.Key.Type switch + { + DataType.String => pair.Key.String, + DataType.Number => pair.Key.Number.ToString(CultureInfo.InvariantCulture), + _ => throw new ScriptRuntimeException("gmcp.send: a table key must be a string or a number."), + }; + obj[key] = FromLua(pair.Value, depth + 1); + } + + return obj; + } + + private static DynValue Table(Script script, JsonObject obj) + { + var table = new Table(script); + foreach (var (key, child) in obj) + { + table[key] = ToLua(script, child); + } + + return DynValue.NewTable(table); + } + + private static DynValue Table(Script script, JsonArray array) + { + var table = new Table(script); + for (var i = 0; i < array.Count; i++) + { + table[i + 1] = ToLua(script, array[i]); + } + + return DynValue.NewTable(table); + } + + private static DynValue Scalar(JsonValue value) + { + if (value.TryGetValue(out string? text)) + { + return DynValue.NewString(text); + } + + if (value.TryGetValue(out bool flag)) + { + return DynValue.NewBoolean(flag); + } + + return double.TryParse(value.ToJsonString(), NumberStyles.Float, CultureInfo.InvariantCulture, out var n) + ? DynValue.NewNumber(n) + : DynValue.Nil; + } +} diff --git a/src/SharpMUTerm.Scripting/ScriptHost.cs b/src/SharpMUTerm.Scripting/ScriptHost.cs index 15f61096..75a68e3e 100644 --- a/src/SharpMUTerm.Scripting/ScriptHost.cs +++ b/src/SharpMUTerm.Scripting/ScriptHost.cs @@ -411,13 +411,23 @@ private void InjectApi(Script script) }; globals["timer"] = timer; - // gmcp.* + // gmcp.* — `on` hears a package arrive, `get` reads what the session holds, `send` writes one. var gmcp = new Table(script) { ["on"] = (Action)OnGmcp, + ["get"] = (Func)(path => LuaJson.ToLua(script, _world.GameData(path ?? string.Empty))), + ["send"] = (Action)SendGmcp, }; globals["gmcp"] = gmcp; + // msdp.* — read only: what to REPORT is the status row's to ask for, at the moment MSDP is agreed. + var msdp = new Table(script) + { + ["get"] = (Func)(name => + LuaJson.ToLua(script, _world.GameData("MSDP." + (name ?? string.Empty)))), + }; + globals["msdp"] = msdp; + // log.* var log = new Table(script) { @@ -446,6 +456,24 @@ private void InjectApi(Script script) }); } + /// + /// gmcp.send(package, value). A string is sent as it is — raw JSON, Mudlet's sendGMCP + /// convention — and anything else is serialised, so gmcp.send("Char.Skills.Get", {group = "combat"}) + /// works without a JSON library in the sandbox. + /// + private void SendGmcp(string package, DynValue value) + { + if (string.IsNullOrWhiteSpace(package)) + { + throw new ScriptRuntimeException("gmcp.send: a package name is required."); + } + + var body = value.Type == DataType.String + ? value.String + : LuaJson.FromLua(value)?.ToJsonString() ?? string.Empty; + _world.SendGmcp(package.Trim(), body); + } + private void AddTrigger(string pattern, DynValue fn) { RequireFunction(fn, "trigger.add"); diff --git a/src/SharpMUTerm.Scripting/WorldSessionScriptBridge.cs b/src/SharpMUTerm.Scripting/WorldSessionScriptBridge.cs index f201366b..9e5f2216 100644 --- a/src/SharpMUTerm.Scripting/WorldSessionScriptBridge.cs +++ b/src/SharpMUTerm.Scripting/WorldSessionScriptBridge.cs @@ -60,6 +60,10 @@ public void AddAlias(string pattern, string substitution, string callbackId) => ScriptCallback = string.IsNullOrEmpty(callbackId) ? null : callbackId, }); + public System.Text.Json.Nodes.JsonNode? GameData(string path) => _session.GameData.Get(path); + + public void SendGmcp(string package, string json) => _ = _session.SendGmcpAsync(package, json); + public IDisposable ScheduleEvery(TimeSpan interval, Action callback) => _session.Scheduler.Every(interval, callback); diff --git a/src/SharpMUTerm.Tui/GameDataOverlay.cs b/src/SharpMUTerm.Tui/GameDataOverlay.cs new file mode 100644 index 00000000..6b3df9e4 --- /dev/null +++ b/src/SharpMUTerm.Tui/GameDataOverlay.cs @@ -0,0 +1,200 @@ +using System.Globalization; +using SharpMUTerm.Core.Protocols; +using SharpConsoleUI; +using SharpConsoleUI.Builders; +using SharpConsoleUI.Controls; + +namespace SharpMUTerm.Tui; + +/// +/// The game data inspector (⌃P ▸ Show game data): every GMCP package and MSDP variable the focused +/// character's connection holds, how long ago each last changed, and the selected one's data in full. +/// ↑↓ select, Esc closes. +/// +/// It exists because a meter or a script is written against a path, and the only way to know the path is to +/// see what the server sends. Mudlet's equivalent is a tree; a list and one pretty-printed payload answer the +/// same question in the space a terminal has. +/// +/// +internal sealed class GameDataOverlay +{ + private readonly ConsoleWindowSystem _system; + private readonly TimeProvider _time; + + private Window? _window; + private MarkupControl? _content; + private IReadOnlyList _entries = []; + private string _owner = string.Empty; + private int _selected; + + public GameDataOverlay(ConsoleWindowSystem system, TimeProvider time) + { + _system = system; + _time = time; + } + + public bool IsOpen => _window is not null; + + /// Opens the inspector on if closed, closes it if open. + public void Toggle(GameDataStore data, string owner) + { + if (_window is not null) + { + Close(); + return; + } + + _entries = data.Entries; + _owner = owner; + _selected = 0; + + var desktop = _system.DesktopDimensions; + var width = Math.Max(44, desktop.Width - 8); + var height = Math.Max(10, desktop.Height - 4); + _content = new MarkupControl(Lines(width - 3, height - 2)); + + // Centered() after WithSize(), for CommandPalette's reason. + _window = new WindowBuilder(_system) + .WithTitle("Game data") + .AsModal() + .WithBorderStyle(BorderStyle.Single) + .WithBackgroundColor(new Color(ScreenPalette.MenuBg)) + .HideTitleButtons() + .Resizable(false) + .WithSize(width, height) + .Centered() + .AddControl(_content) + .OnClosed((_, _) => Reset()) + .Build(); + + _window.PreviewKeyPressed += OnKey; + _system.AddWindow(_window); + } + + /// + /// The header, one row per package, a rule, and the selected package's data — as many of its lines as fit. + /// Internal so the layout is testable without a window. + /// + internal List Lines(int width, int height) + { + var lines = new List + { + $"[{ScreenPalette.Accent}]{Escape(_owner)}[/] [{ScreenPalette.Label}]· {Count(_entries.Count)} · ↑↓ select · Esc close[/]", + string.Empty, + }; + + if (_entries.Count == 0) + { + lines.Add($"[{ScreenPalette.Label}]nothing yet — this connection has received no GMCP or MSDP data.[/]"); + return lines; + } + + // The list takes at most half the room, so the payload under it is always readable; it scrolls to + // keep the selection in view. + var listRows = Math.Clamp(_entries.Count, 1, Math.Max(3, (height - 4) / 2)); + var first = Math.Clamp(_selected - (listRows - 1), 0, Math.Max(0, _entries.Count - listRows)); + var nameWidth = Math.Min(32, _entries.Max(e => e.Package.Length)); + var now = _time.GetUtcNow(); + for (var i = first; i < first + listRows && i < _entries.Count; i++) + { + var entry = _entries[i]; + var marker = i == _selected ? $"[bold {ScreenPalette.Accent}]▸[/]" : " "; + var name = Elide(entry.Package, nameWidth).PadRight(nameWidth); + var age = Age(now - entry.UpdatedAt).PadLeft(7); + var preview = Elide(entry.Data?.ToJsonString() ?? "(no data)", Math.Max(4, width - nameWidth - 12)); + lines.Add($"{marker} [{(i == _selected ? "bold " : string.Empty)}{ScreenPalette.Value}]{Escape(name)}[/]" + + $" [{ScreenPalette.Label}]{age} {Escape(preview)}[/]"); + } + + lines.Add($"[{ScreenPalette.Label}]{new string('─', Math.Max(1, width))}[/]"); + var payload = GameDataStore.Pretty(_entries[_selected].Data).Split('\n'); + var room = Math.Max(1, height - lines.Count); + foreach (var row in payload.Take(room)) + { + lines.Add($"[{ScreenPalette.Value}]{Escape(Elide(row.TrimEnd('\r'), width))}[/]"); + } + + if (payload.Length > room) + { + lines[^1] = $"[{ScreenPalette.Label}]… {payload.Length - room + 1} more lines[/]"; + } + + return lines; + } + + internal void Select(int delta) + { + if (_entries.Count == 0) + { + return; + } + + _selected = Math.Clamp(_selected + delta, 0, _entries.Count - 1); + Repaint(); + } + + private void Repaint() + { + if (_window is { } window) + { + _content?.SetContent(Lines(Math.Max(20, window.Width - 3), Math.Max(6, window.Height - 2))); + } + } + + private void OnKey(object? sender, KeyPressedEventArgs e) + { + switch (e.KeyInfo.Key) + { + case ConsoleKey.UpArrow: + Select(-1); + e.Handled = true; + return; + case ConsoleKey.DownArrow: + Select(1); + e.Handled = true; + return; + case ConsoleKey.PageUp: + Select(-10); + e.Handled = true; + return; + case ConsoleKey.PageDown: + Select(10); + e.Handled = true; + return; + case ConsoleKey.Escape: + Close(); + e.Handled = true; + return; + } + } + + private void Close() + { + if (_window is { } window) + { + _system.CloseModalWindow(window); + } + } + + private void Reset() + { + _window = null; + _content = null; + _entries = []; + } + + private static string Count(int n) => n == 1 ? "1 package" : $"{n.ToString(CultureInfo.InvariantCulture)} packages"; + + private static string Age(TimeSpan age) => age.TotalSeconds switch + { + < 1 => "now", + < 60 => $"{(int)age.TotalSeconds}s ago", + < 3600 => $"{(int)age.TotalMinutes}m ago", + _ => $"{(int)age.TotalHours}h ago", + }; + + private static string Elide(string text, int width) => + text.Length <= width ? text : width <= 1 ? "…" : text[..(width - 1)] + "…"; + + private static string Escape(string text) => MarkupText.Escape(text); +} diff --git a/src/SharpMUTerm.Tui/GmcpStats.cs b/src/SharpMUTerm.Tui/GmcpStats.cs deleted file mode 100644 index 0141c180..00000000 --- a/src/SharpMUTerm.Tui/GmcpStats.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Text; -using System.Text.Json; - -namespace SharpMUTerm.Tui; - -/// -/// Accumulates GMCP character stats (e.g. Char.Vitals, Char.Stats) into a flat -/// key/value model and renders a compact one-line summary for the stat pane. Deliberately -/// generic: any flat JSON object's scalar fields are captured. -/// -internal sealed class GmcpStats -{ - private static readonly string[] PreferredOrder = - ["hp", "maxhp", "mp", "maxmp", "sp", "maxsp", "level", "xp", "gold"]; - - private readonly Dictionary _values = new(StringComparer.OrdinalIgnoreCase); - - /// Merges a GMCP message. Returns true if any stat changed. - public bool Update(string package, string json) - { - if (!package.StartsWith("Char", StringComparison.OrdinalIgnoreCase) || string.IsNullOrWhiteSpace(json)) - { - return false; - } - - JsonElement root; - try - { - using var doc = JsonDocument.Parse(json); - root = doc.RootElement.Clone(); - } - catch (JsonException) - { - return false; - } - - if (root.ValueKind != JsonValueKind.Object) - { - return false; - } - - var changed = false; - foreach (var property in root.EnumerateObject()) - { - if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array) - { - continue; - } - - // JsonElement.ToString() yields "True"/"False" for booleans; normalise to JSON casing - // so booleans read consistently with numeric/string fields. - var value = property.Value.ValueKind switch - { - JsonValueKind.True => "true", - JsonValueKind.False => "false", - _ => property.Value.ToString(), - }; - if (!_values.TryGetValue(property.Name, out var existing) || existing != value) - { - _values[property.Name] = value; - changed = true; - } - } - - return changed; - } - - public bool HasData => _values.Count > 0; - - /// Reads an integer stat (e.g. hp, maxhp), or null if absent/non-numeric. - public int? GetInt(string key) => - _values.TryGetValue(key, out var value) && int.TryParse(value, out var n) ? n : null; - - /// A compact one-line summary, preferred vitals first. - public string Summarize() - { - if (_values.Count == 0) - { - return string.Empty; - } - - var sb = new StringBuilder(); - foreach (var key in PreferredOrder) - { - if (_values.TryGetValue(key, out var value)) - { - Append(sb, key, value); - } - } - - foreach (var (key, value) in _values) - { - if (Array.IndexOf(PreferredOrder, key.ToLowerInvariant()) < 0) - { - Append(sb, key, value); - } - } - - return sb.ToString(); - } - - private static void Append(StringBuilder sb, string key, string value) - { - if (sb.Length > 0) - { - sb.Append(" "); - } - - sb.Append(key).Append(':').Append(value); - } -} diff --git a/src/SharpMUTerm.Tui/OptionsScreenRenderer.cs b/src/SharpMUTerm.Tui/OptionsScreenRenderer.cs index 30179d70..1726eda3 100644 --- a/src/SharpMUTerm.Tui/OptionsScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/OptionsScreenRenderer.cs @@ -318,9 +318,27 @@ internal static OptionsScreen TextAnsiScreen(TextSettings? text = null) "activity bar holds for (seconds)", () => settings.ActivityBarSeconds, v => settings.ActivityBarSeconds = v, 0, TextSettings.MaxActivityBarSeconds)), + new(string.Empty, null, null), + new("├ STATUS ROW", null, null), + + // One line, `label=value/max; …`, each path allowed `|` alternatives. A list editor would be + // friendlier and is a screen of its own; this is the whole of the setting, typed where it is read, + // and a part that does not parse is dropped rather than refused so a half-typed meter costs nothing. + new("meters", MeterSummary(settings.StatusMeters), null, null, null, + ScreenField.Optional( + "meters", + () => settings.StatusMeters.Count == 0 ? null : StatusMeter.Format(settings.StatusMeters), + v => settings.StatusMeters = StatusMeter.Parse(v))), }); } + /// + /// What the meters row shows when it is not being edited: the labels, in order. The full line is long — + /// every alternative path of every meter — and a row clipped at the screen's edge says less than this. + /// + private static string MeterSummary(IReadOnlyList meters) => + meters.Count == 0 ? "none" : string.Join(" · ", meters.Select(m => m.Label)) + " (⏎ edits the paths)"; + /// /// The F8 "Input" screen, reflecting — and writing back to — the app's /// . diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs index dcf1d4dd..866e627f 100644 --- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs +++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs @@ -212,7 +212,13 @@ private sealed class SizeReport private readonly InputBarControl _input = new(); private readonly InputBarControl _second = new(); private InputBarControl _armed; - private readonly GmcpStats _stats = new(); + + /// + /// The game data the demo scene shows, standing in for a session's own + /// in the views that draw it. Written through the same the wire uses, + /// and empty in every other frame, so no frame but those carries a meter. + /// + private readonly GameDataStore _demoGameData = new(); private readonly SharpMUTerm.Web.WebPageFetcher _fetcher = new(); private readonly WebImageLoader _imageLoader = new(); @@ -297,6 +303,7 @@ private sealed class SizeReport /// The ⌃P ▸ Show client messages viewer over the diagnostics log. private readonly MessageLogOverlay _messageLog; + private readonly GameDataOverlay _gameData; /// The ⌃Q confirmation. Nothing ends the loop except a yes it collected. private readonly QuitOverlay _quit; @@ -836,6 +843,7 @@ public SharpMUTermApp( _palette = new CommandPalette(_system, BuildCatalog, () => _active?.SessionKey, id => DispatchCommand(id)); _messageLog = new MessageLogOverlay(_system, _diagnostics); + _gameData = new GameDataOverlay(_system, _time); _settings = new SettingsOverlay(_system); _composer = new ComposeOverlay(_system); _composer.Send += (_, result) => SendComposed(result); @@ -1594,6 +1602,19 @@ public string RenderSnapshot(string? view = null) _messageLog.Toggle(); } + // The status row's meters and the game data inspector, over the demo's game data — written through + // GameDataStore.SetGmcp, the call the wire makes, so the frame shows what a server's messages produce. + // `meters` is the row alone; `gamedata` opens the inspector through the real ⌃P entry. + if (string.Equals(view, "meters", StringComparison.OrdinalIgnoreCase) + || string.Equals(view, "gamedata", StringComparison.OrdinalIgnoreCase)) + { + LoadDemoGameData(); + if (string.Equals(view, "gamedata", StringComparison.OrdinalIgnoreCase)) + { + DispatchCommand("term:gamedata"); + } + } + // The ⌃Q confirmation, over a workspace that has something to lose: a second world marked // connected (the demo scene's own way of saying so, and what the header's "n/m characters" reads), // a line typed into the command line through the bar's real change notification, and then the @@ -2585,11 +2606,13 @@ private void BindSession(WorldSession session, string? windowId = null) // cache so the snapshot's demo report is written by the same code the wire is (see DemoScene's // remarks on state a live session writes). session.MsspReceived += (_, e) => OnUi(() => CaptureMssp(session.World, e.Data)); - session.GmcpReceived += (_, e) => OnUi(() => + // The meters read the session's game data, which the session has already stored by the time this is + // raised. Only the session the status row describes repaints it. + session.GameData.Changed += (_, _) => OnUi(() => { - if (_stats.Update(e.Package, e.Json)) + if (ReferenceEquals(session, _active)) { - UpdateStatus(); + RefreshStatusBar(); } }); session.SpawnLine += (_, e) => OnUi(() => OnSpawnLine(session, e.Target, e.Line)); @@ -4827,7 +4850,7 @@ private void ToggleHistorySearch() /// private bool AnyOverlayOpen => _palette.IsOpen || _settings.IsOpen || _quit.IsOpen || _messageLog.IsOpen || _historySearch.IsOpen - || _prefixPanel.IsOpen || _composer.IsOpen || _search.IsOpen; + || _prefixPanel.IsOpen || _composer.IsOpen || _search.IsOpen || _gameData.IsOpen; /// /// Opens the ⌃F search surface, or closes it when the chord arrives again. @@ -4940,6 +4963,7 @@ private string OpenOverlayName() => : _palette.IsOpen ? "the command surface" : _quit.IsOpen ? "the quit prompt" : _messageLog.IsOpen ? "the client messages" + : _gameData.IsOpen ? "the game data" : _historySearch.IsOpen ? "the history search" : _search.IsOpen ? "the search surface" : _prefixPanel.IsOpen ? "the pane keys panel" @@ -6554,6 +6578,7 @@ private void ReloadAutomation() { foreach (var session in _sessions.Sessions) { + session.MsdpReports = StatusMeter.MsdpVariables(_config.Text.StatusMeters); if (session.Character is not { } character) { continue; // an anonymous connection has no character, so nothing resolves for it @@ -7847,6 +7872,13 @@ internal bool DispatchCommand(string id) _messageLog.Toggle(); } + return true; + case "term:gamedata": + if (!ComposerIsInTheWay("the game data")) + { + ShowGameData(); + } + return true; case "term:restore-purge": PurgeRestoreLog(); @@ -7953,6 +7985,7 @@ internal bool DispatchCommand(string id) /// private void OnSessionAdded(object? sender, WorldSession session) { + session.MsdpReports = StatusMeter.MsdpVariables(_config.Text.StatusMeters); if (_scriptsRoot is null || _scripts.ContainsKey(session)) { return; @@ -8026,6 +8059,51 @@ private void ReloadScripts() ? scripts.Defines(name) : null; + /// + /// ⌃P ▸ Show game data: the inspector, over the focused window's connection — , + /// for 's reason. A window with no connection refuses out loud. + /// + private void ShowGameData() + { + if (_gameData.IsOpen) + { + _gameData.Toggle(_demoGameData, string.Empty); + return; + } + + if (SendTarget() is { } session) + { + _gameData.Toggle(session.GameData, SessionTitle(session)); + return; + } + + if (_sessions.Sessions.Count == 0 && _demoGameData.Count > 0) + { + _gameData.Toggle(_demoGameData, "Corvid"); + return; + } + + RefuseCommand("no game data here — pick a character first (⌃P ▸ Switch to …)"); + } + + internal bool GameDataIsOpen => _gameData.IsOpen; + + /// What an IRE-style server sends after the handshake, for the meters and gamedata views. + private void LoadDemoGameData() + { + _demoGameData.SetGmcp("Char.Vitals", """{"hp":"412","maxhp":"500","mp":"23","maxmp":"120","moves":"88"}"""); + _demoGameData.SetGmcp("Char.MaxStats", """{"maxmoves":"100"}"""); + _demoGameData.SetGmcp("Char.Status", """{"name":"Corvid","level":"42","class":"Sentinel","gold":"1275"}"""); + _demoGameData.SetGmcp( + "Room.Info", + """{"num":4201,"name":"The Grand Plaza","area":"Aetherfall","environment":"urban","exits":{"n":4202,"e":4210,"s":4199}}"""); + _demoGameData.SetGmcp("Comm.Channel.List", """[{"name":"ooc","caption":"Out of character"},{"name":"trade","caption":"Trade"}]"""); + RefreshStatusBar(); + } + + /// The game data the status row's meters read: the active session's, or the demo's. + private GameDataStore StatusData() => _active?.GameData ?? _demoGameData; + private void Reconnect() { // Which connection this acts on is decided by the window in front of you — SendTarget, the same @@ -12319,10 +12397,14 @@ private string[] FocusHints() /// private SessionEncoding? EffectiveEncoding() => _active?.CurrentEncoding ?? _demoEncoding; + /// The cells the meters leave free for the typed-character count to grow into. + private const int MeterSlack = 4; + private string StatusBarMarkup(string character, string state) { var accent = ActiveWorld() is { } world ? AccentHex(world.Accent) : _ink.Accent; var left = $"[{accent}]●[/] [bold]{Escape(character)}[/] [dim]{Escape(state)}[/]"; + var meters = StatusMeterRenderer.Render(_config.Text.StatusMeters, StatusData(), $"{_ink.Notice}"); var right = new List(); @@ -12376,6 +12458,16 @@ private string StatusBarMarkup(string character, string state) // worse trade than a hint that is only there when there is room for it, and this is the same // measurement the header already makes to right-align its own cluster. var rightBar = string.Join(" ", right); + + // The meters sit beside the identity they describe, and only when the row has room for them with a + // few cells to spare: the character count on the right grows as a line is typed, and a cluster that + // came and went with it would be worse than one that is absent on a narrow terminal. + if (meters.Length > 0 + && MarkupWidth(left) + 3 + MarkupWidth(meters) + MarkupWidth(rightBar) + MinStatusGap + MeterSlack <= HeaderWidth()) + { + left = $"{left} {meters}"; + } + var room = HeaderWidth() - MarkupWidth(left) - MarkupWidth(rightBar) - MinStatusGap; foreach (var focusHint in FocusHints()) { diff --git a/src/SharpMUTerm.Tui/SnapshotViews.cs b/src/SharpMUTerm.Tui/SnapshotViews.cs index b858b409..e6819cf3 100644 --- a/src/SharpMUTerm.Tui/SnapshotViews.cs +++ b/src/SharpMUTerm.Tui/SnapshotViews.cs @@ -23,6 +23,6 @@ internal static class SnapshotViews "search", "search-regex", "search-all", "search-landed", "activity-bar", "mxp-image", "mxp-figure", "input-selection", "selection", "prefix", "prefix-panel", "focus", "focus-moved", "tabs", "tabs-many", - "collapsed", "timestamps", "timestamps-toggled", "prompt", + "collapsed", "timestamps", "timestamps-toggled", "prompt", "meters", "gamedata", ]; } diff --git a/src/SharpMUTerm.Tui/StatusMeterRenderer.cs b/src/SharpMUTerm.Tui/StatusMeterRenderer.cs new file mode 100644 index 00000000..e17a767c --- /dev/null +++ b/src/SharpMUTerm.Tui/StatusMeterRenderer.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Protocols; + +namespace SharpMUTerm.Tui; + +/// +/// The status row's meters: hp 450/500 mp 80/120, drawn from the session's game data. +/// +/// Every field is reserved. A value and its maximum each take cells whatever +/// they hold, because they arrive from the wire: a meter that grew a cell at 99 → 100 would widen the row, +/// and a status row past the width wraps and takes a row off every pane (the scrollback distance learned that +/// first). A number too long for its field is scaled (123k) rather than allowed to grow. +/// +/// +/// A meter with nothing to read is not drawn, so a world with no GMCP or MSDP costs the row nothing. +/// It appears once, when the first value arrives — a one-off change of the same kind as a spawn window +/// opening — and from then on its width is fixed. +/// +/// +internal static class StatusMeterRenderer +{ + /// The cells a value, and separately its maximum, always take. + internal const int ValueWidth = 5; + + /// Below this share of its maximum a value is drawn in the warning ink. + internal const double LowShare = 0.25; + + /// The meters that have a value, as one markup run, or empty when none does. + internal static string Render(IReadOnlyList meters, GameDataStore data, string lowInk) + { + var cells = new List(); + foreach (var meter in meters) + { + if (StatusMeter.Read(data, meter.Value) is not { } value) + { + continue; + } + + var max = string.IsNullOrWhiteSpace(meter.Max) ? null : StatusMeter.Read(data, meter.Max); + var low = IsLow(value, max); + var label = MarkupText.Escape(meter.Label); + var shown = MarkupText.Escape(StatusMeter.Fit(value, ValueWidth).PadLeft(ValueWidth)); + var cell = low + ? $"[dim]{label}[/] [bold {lowInk}]{shown}[/]" + : $"[dim]{label}[/] [bold]{shown}[/]"; + if (!string.IsNullOrWhiteSpace(meter.Max)) + { + cell += $"[dim]/{MarkupText.Escape(StatusMeter.Fit(max ?? string.Empty, ValueWidth).PadRight(ValueWidth))}[/]"; + } + + cells.Add(cell); + } + + return string.Join(" ", cells); + } + + private static bool IsLow(string value, string? max) => + max is not null + && double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) + && double.TryParse(max, NumberStyles.Float, CultureInfo.InvariantCulture, out var m) + && m > 0 + && v / m < LowShare; +} diff --git a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs index f74097e4..c1f74aac 100644 --- a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs +++ b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs @@ -62,6 +62,12 @@ internal static class WorldsScreenRenderer internal const int KeepaliveField = 4; + /// + /// : the GMCP modules asked for beyond the client's own, as a + /// comma-separated list of Name Version. + /// + internal const int GmcpModulesField = 5; + /// /// The character row's field ordinals, in the order ⇥ steps through them — which is the order the /// CHARACTER form draws them, top to bottom. The name leads, as on every list screen. The password @@ -406,7 +412,8 @@ internal static ScreenModel Model( ScreenField.Text("host", () => w.Host, v => w.Host = v), ScreenField.Integer("port", () => w.Port, v => w.Port = v, 1, 65535), ScreenField.Choice("encoding", () => w.Encoding, v => w.Encoding = v, Encodings), - ScreenField.Integer("keepalive", () => w.KeepaliveSeconds, v => w.KeepaliveSeconds = v, 0, 86400))) + ScreenField.Integer("keepalive", () => w.KeepaliveSeconds, v => w.KeepaliveSeconds = v, 0, 86400), + ScreenField.Optional("gmcp modules", () => JoinModules(w), v => SplitModules(w, v)))) .Concat(WorldButtons(worlds, selectedWorld, info)) .ToArray(); @@ -505,6 +512,27 @@ internal static ScreenModel Model( } }); + /// A world's extra GMCP modules as the well shows and edits them, or null for none. + internal static string? JoinModules(WorldDefinition world) => + world.GmcpModules.Count == 0 ? null : string.Join(", ", world.GmcpModules); + + /// + /// Writes the well back: split on commas, and only entries accepts are + /// kept, normalised to Name Version, so what is saved is what will be sent. + /// + internal static void SplitModules(WorldDefinition world, string? value) + { + world.GmcpModules.Clear(); + foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + if (GmcpHandshake.Parse(part) is { } module + && !world.GmcpModules.Any(m => m.StartsWith(module.Name + " ", StringComparison.OrdinalIgnoreCase))) + { + world.GmcpModules.Add($"{module.Name} {module.Version.ToString(CultureInfo.InvariantCulture)}"); + } + } + } + /// A set's script files as the well shows and edits them, or null for none. internal static string? JoinScripts(TriggerSet set) => set.ScriptFiles.Count == 0 ? null : string.Join(", ", set.ScriptFiles); @@ -880,6 +908,13 @@ internal static List DetailColumn( cursor, selectedWorld, KeepaliveField)), + WorldField("gmcp", Field( + JoinModules(world) is { } modules + ? $"[{Value}]{Escape(modules)}[/]" + : $"[{Label}]defaults[/]", + cursor, + selectedWorld, + GmcpModulesField)), string.Empty, $"[{accent}]├ CHARACTERS[/] [{Label}]a character is a connection[/]", }); diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/GameDataStoreTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/GameDataStoreTests.cs new file mode 100644 index 00000000..9b383555 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Protocols/GameDataStoreTests.cs @@ -0,0 +1,138 @@ +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Protocols; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Tests.Session; + +namespace SharpMUTerm.Core.Tests.Protocols; + +/// +/// The session's out-of-band game data. Before it the client kept nine fixed Char.* keys, drew none +/// of them, and dropped MSDP on the floor. +/// +public class GameDataStoreTests +{ + [Test] + public async Task APathReadsAFieldOfTheLongestMatchingPackage() + { + var store = new GameDataStore(); + store.SetGmcp("Char", """{"name":"Ann"}"""); + store.SetGmcp("Char.Vitals", """{"hp":"450","maxhp":"500"}"""); + + await Assert.That(store.GetText("Char.Vitals.hp")).IsEqualTo("450"); + await Assert.That(store.GetNumber("Char.Vitals.maxhp")).IsEqualTo(500); + await Assert.That(store.GetText("Char.name")).IsEqualTo("Ann"); + await Assert.That(store.GetText("Char.Vitals.mp")).IsNull(); + } + + /// IRE spells it Char.Vitals, Aardwolf char.vitals; one meter reads both. + [Test] + public async Task NamesAndFieldsAreCaseInsensitive() + { + var store = new GameDataStore(); + store.SetGmcp("char.vitals", """{"HP":12}"""); + + await Assert.That(store.GetNumber("Char.Vitals.hp")).IsEqualTo(12); + } + + /// Char.Status sends only what changed; replacing the package would forget the rest. + [Test] + public async Task AnObjectIsMergedIntoWhatIsHeld() + { + var store = new GameDataStore(); + store.SetGmcp("Char.Status", """{"level":"10","race":"Elf"}"""); + store.SetGmcp("Char.Status", """{"level":"11"}"""); + + await Assert.That(store.GetText("Char.Status.level")).IsEqualTo("11"); + await Assert.That(store.GetText("Char.Status.race")).IsEqualTo("Elf"); + } + + [Test] + public async Task ArraysAreIndexedAndScalarsReplace() + { + var store = new GameDataStore(); + store.SetGmcp("Room.Players", """[{"name":"Bob"},{"name":"Cy"}]"""); + store.SetGmcp("Char.Count", "3"); + store.SetGmcp("Char.Count", "4"); + + await Assert.That(store.GetText("Room.Players.1.name")).IsEqualTo("Cy"); + await Assert.That(store.GetText("Char.Count")).IsEqualTo("4"); + } + + [Test] + public async Task MsdpVariablesAreHeldUnderTheirOwnNames() + { + var store = new GameDataStore(); + store.SetMsdp("""{"HEALTH":"90","HEALTH_MAX":"100"}"""); + + await Assert.That(store.GetNumber("MSDP.HEALTH")).IsEqualTo(90); + await Assert.That(store.GetNumber("msdp.health_max")).IsEqualTo(100); + await Assert.That(store.Count).IsEqualTo(2); + } + + [Test] + public async Task APayloadOverTheCapIsRefused() + { + var store = new GameDataStore(); + var big = "\"" + new string('x', GameDataStore.MaxPayloadBytes) + "\""; + + await Assert.That(store.SetGmcp("Big", big)).IsFalse(); + await Assert.That(store.Count).IsEqualTo(0); + } + + [Test] + public async Task NewPackagesStopAtTheCapAndHeldOnesStillUpdate() + { + var store = new GameDataStore(); + for (var i = 0; i < GameDataStore.MaxPackages; i++) + { + store.SetGmcp($"P{i}", "1"); + } + + await Assert.That(store.SetGmcp("OneTooMany", "1")).IsFalse(); + await Assert.That(store.SetGmcp("P0", "2")).IsTrue(); + await Assert.That(store.GetText("P0")).IsEqualTo("2"); + } + + [Test] + public async Task MalformedJsonIsRefusedAndAnEmptyBodyIsHeld() + { + var store = new GameDataStore(); + + await Assert.That(store.SetGmcp("Bad", "{nope")).IsFalse(); + await Assert.That(store.SetGmcp("Core.Ping", "")).IsTrue(); + await Assert.That(store.Entries.Select(e => e.Package)).IsEquivalentTo(new[] { "Core.Ping" }); + } + + [Test] + public async Task TheSessionStoresWhatArrivesAndClearsOnConnect() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(new WorldDefinition { Name = "T", Host = "h", Port = 1 }, sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.EmitGmcp("Char.Vitals", """{"hp":5}"""); + telnet.EmitMsdp("""{"MANA":"7"}"""); + await Assert.That(session.GameData.GetNumber("Char.Vitals.hp")).IsEqualTo(5); + await Assert.That(session.GameData.GetNumber("MSDP.MANA")).IsEqualTo(7); + + await session.DisconnectAsync(); + await session.ConnectAsync(); + await Assert.That(session.GameData.Count).IsEqualTo(0); + } + + /// An MSDP server sends nothing until asked; the meters' variables are asked for once it is agreed. + [Test] + public async Task AgreeingMsdpReportsTheVariablesTheSessionWants() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(new WorldDefinition { Name = "T", Host = "h", Port = 1 }, sessionFactory: _ => telnet) + { + MsdpReports = ["HEALTH", "HEALTH_MAX", "health"], + }; + await session.ConnectAsync(); + + telnet.RaiseMsdpEnabled(); + + await Assert.That(telnet.SentMsdp).IsEquivalentTo(new[] { ("REPORT", "HEALTH"), ("REPORT", "HEALTH_MAX") }); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs b/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs index 3afc5546..6424962b 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs @@ -16,13 +16,18 @@ internal sealed class FakeTelnetSession : ITelnetSession public event EventHandler? OutputReceived; public event EventHandler? GmcpReceived; -#pragma warning disable CS0067 // Required by the interface; not exercised by current tests. public event EventHandler? MsdpReceived; +#pragma warning disable CS0067 // Required by the interface; not exercised by current tests. public event EventHandler? MsspReceived; #pragma warning restore CS0067 public event EventHandler? EncodingChanged; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? MsdpEnabled; + + public List<(string Variable, string Value)> SentMsdp { get; } = new(); + + public List<(string Package, string Json)> SentGmcp { get; } = new(); public Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -39,8 +44,17 @@ public ValueTask SendLineAsync(string text, CancellationToken cancellationToken public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) => - ValueTask.CompletedTask; + public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) + { + SentGmcp.Add((package, json)); + return ValueTask.CompletedTask; + } + + public ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default) + { + SentMsdp.Add((variable, value)); + return ValueTask.CompletedTask; + } public ValueTask SetWindowSizeAsync(int width, int height) => ValueTask.CompletedTask; @@ -73,4 +87,8 @@ public void EmitEncoding(SessionEncoding encoding) } public void RaiseMxpEnabled() => MxpEnabled?.Invoke(this, EventArgs.Empty); + + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); + + public void EmitMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); } diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/GmcpHandshakeTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/GmcpHandshakeTests.cs new file mode 100644 index 00000000..c873f8e1 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/GmcpHandshakeTests.cs @@ -0,0 +1,148 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// +/// Once a server's GMCP is agreed the client introduces itself — Core.Hello, then +/// Core.Supports.Set — and not before. Servers in the IRE tradition send nothing until the second +/// arrives, and this client used to send neither. +/// +public class GmcpHandshakeTests +{ + private const byte Iac = 255; + private const byte Will = 251; + private const byte Sb = 250; + private const byte Se = 240; + private const byte Gmcp = 201; + private const byte Msdp = 69; + + [Test] + public async Task AnAgreedGmcpGetsHelloThenSupportsSet() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Gmcp] }; + await using var session = new TelnetSession( + transport, NullLogger.Instance, new TelnetSessionOptions { GmcpModules = ["IRE.Rift 1"] }); + await session.ConnectAsync(); + + var messages = await Eventually(() => GmcpMessages(transport.Sent), m => m.Count >= 2); + + await Assert.That(messages.Count).IsEqualTo(2); + await Assert.That(messages[0]).StartsWith("Core.Hello "); + await Assert.That(messages[0]).Contains("\"SharpMUTerm\""); + await Assert.That(messages[1]).StartsWith("Core.Supports.Set "); + await Assert.That(messages[1]).Contains("\"Char 1\""); + await Assert.That(messages[1]).Contains("\"Room 1\""); + await Assert.That(messages[1]).Contains("\"IRE.Rift 1\""); + } + + /// The handshake is an answer to the server's offer, never a question of the client's own. + [Test] + public async Task NoOfferMeansNoHandshake() + { + var transport = new ScriptedTransport(); + await using var session = new TelnetSession(transport, NullLogger.Instance); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(GmcpMessages(transport.Sent)).IsEmpty(); + } + + [Test] + public async Task TheHandshakeCanBeSwitchedOff() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Gmcp] }; + await using var session = new TelnetSession( + transport, NullLogger.Instance, new TelnetSessionOptions { IntroduceOverGmcp = false }); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(GmcpMessages(transport.Sent)).IsEmpty(); + } + + [Test] + public async Task AgreeingMsdpIsReported() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Msdp] }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + var raised = 0; + session.MsdpEnabled += (_, _) => Interlocked.Increment(ref raised); + await session.ConnectAsync(); + + await Eventually(() => Volatile.Read(ref raised), n => n > 0); + + await Assert.That(Volatile.Read(ref raised)).IsEqualTo(1); + } + + [Test] + [Arguments("Char 1", "Char", 1)] + [Arguments("IRE.Rift", "IRE.Rift", 1)] + [Arguments(" Comm.Channel 2 ", "Comm.Channel", 2)] + public async Task AModuleParses(string spec, string name, int version) + { + var module = GmcpHandshake.Parse(spec); + + await Assert.That(module).IsNotNull(); + await Assert.That(module!.Value.Name).IsEqualTo(name); + await Assert.That(module.Value.Version).IsEqualTo(version); + } + + [Test] + [Arguments("")] + [Arguments("Char one")] + [Arguments("Char 0")] + [Arguments("Char 1 2")] + [Arguments(".Char 1")] + [Arguments("Char\"; 1")] + public async Task ANonsenseModuleIsDropped(string spec) => + await Assert.That(GmcpHandshake.Parse(spec)).IsNull(); + + /// A world's own entry for a default module replaces its version rather than listing it twice. + [Test] + public async Task AWorldsModuleOverridesADefaultsVersion() + { + var modules = GmcpHandshake.Modules(["room 2", "IRE.Rift 1"]); + + await Assert.That(modules.Count(m => m.Name.Equals("Room", StringComparison.OrdinalIgnoreCase))).IsEqualTo(1); + await Assert.That(modules.Single(m => m.Name.Equals("room", StringComparison.OrdinalIgnoreCase)).Version).IsEqualTo(2); + await Assert.That(modules[^1].Name).IsEqualTo("IRE.Rift"); + } + + /// Every IAC SB GMCP … IAC SE payload in a byte stream, as text. + private static List GmcpMessages(byte[] wire) + { + var messages = new List(); + for (var i = 0; i + 2 < wire.Length; i++) + { + if (wire[i] != Iac || wire[i + 1] != Sb || wire[i + 2] != Gmcp) + { + continue; + } + + var start = i + 3; + var end = start; + while (end + 1 < wire.Length && !(wire[end] == Iac && wire[end + 1] == Se)) + { + end++; + } + + messages.Add(Encoding.UTF8.GetString(wire, start, end - start)); + i = end; + } + + return messages; + } + + private static async Task Eventually(Func read, Func done) + { + var value = read(); + for (var i = 0; i < 100 && !done(value); i++) + { + await Task.Delay(20); + value = read(); + } + + return value; + } +} diff --git a/tests/SharpMUTerm.Scripting.Tests/FakeScriptWorld.cs b/tests/SharpMUTerm.Scripting.Tests/FakeScriptWorld.cs index ff63e25b..296ad862 100644 --- a/tests/SharpMUTerm.Scripting.Tests/FakeScriptWorld.cs +++ b/tests/SharpMUTerm.Scripting.Tests/FakeScriptWorld.cs @@ -19,6 +19,15 @@ internal sealed class FakeScriptWorld : IScriptWorld public void Print(string text) => Printed.Add(text); + public Dictionary Data { get; } = new(StringComparer.OrdinalIgnoreCase); + + public List<(string Package, string Json)> SentGmcp { get; } = new(); + + public System.Text.Json.Nodes.JsonNode? GameData(string path) => + Data.TryGetValue(path, out var node) ? node?.DeepClone() : null; + + public void SendGmcp(string package, string json) => SentGmcp.Add((package, json)); + public void AddTrigger(string pattern, string callbackId) => Triggers.Add((pattern, callbackId)); public void AddAlias(string pattern, string substitution, string callbackId) => diff --git a/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs b/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs index 27e44dd1..efc455f7 100644 --- a/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs +++ b/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs @@ -16,13 +16,18 @@ internal sealed class FakeTelnetSession : ITelnetSession public event EventHandler? OutputReceived; public event EventHandler? GmcpReceived; -#pragma warning disable CS0067 // Required by the interface; not exercised by current tests. public event EventHandler? MsdpReceived; +#pragma warning disable CS0067 // Required by the interface; not exercised by current tests. public event EventHandler? MsspReceived; #pragma warning restore CS0067 public event EventHandler? EncodingChanged; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? MsdpEnabled; + + public List<(string Variable, string Value)> SentMsdp { get; } = new(); + + public List<(string Package, string Json)> SentGmcp { get; } = new(); public Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -39,8 +44,17 @@ public ValueTask SendLineAsync(string text, CancellationToken cancellationToken public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) => - ValueTask.CompletedTask; + public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) + { + SentGmcp.Add((package, json)); + return ValueTask.CompletedTask; + } + + public ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default) + { + SentMsdp.Add((variable, value)); + return ValueTask.CompletedTask; + } public ValueTask SetWindowSizeAsync(int width, int height) => ValueTask.CompletedTask; @@ -73,4 +87,8 @@ public void EmitEncoding(SessionEncoding encoding) } public void RaiseMxpEnabled() => MxpEnabled?.Invoke(this, EventArgs.Empty); + + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); + + public void EmitMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); } diff --git a/tests/SharpMUTerm.Scripting.Tests/GameDataApiTests.cs b/tests/SharpMUTerm.Scripting.Tests/GameDataApiTests.cs new file mode 100644 index 00000000..97446cd0 --- /dev/null +++ b/tests/SharpMUTerm.Scripting.Tests/GameDataApiTests.cs @@ -0,0 +1,67 @@ +using System.Text.Json.Nodes; + +namespace SharpMUTerm.Scripting.Tests; + +/// gmcp.get, msdp.get and gmcp.send: game data in and out of Lua. +public class GameDataApiTests +{ + [Test] + public async Task GmcpGetReturnsTablesAndScalars() + { + var world = new FakeScriptWorld(); + world.Data["Char.Vitals"] = JsonNode.Parse("""{"hp":"450","flags":[true,false],"lvl":12}"""); + using var host = new ScriptHost(world); + + host.Execute(""" + local v = gmcp.get('Char.Vitals') + world.send(v.hp .. ' ' .. tostring(v.flags[1]) .. ' ' .. (v.lvl + 1)) + world.send(tostring(gmcp.get('Nope'))) + """); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "450 true 13", "nil" }); + } + + [Test] + public async Task MsdpGetReadsTheMsdpPrefix() + { + var world = new FakeScriptWorld(); + world.Data["MSDP.HEALTH"] = JsonValue.Create("90"); + using var host = new ScriptHost(world); + + host.Execute("world.send(msdp.get('HEALTH'))"); + + await Assert.That(world.Sent).IsEquivalentTo(new[] { "90" }); + } + + [Test] + public async Task GmcpSendSerialisesATableAndPassesAStringThrough() + { + var world = new FakeScriptWorld(); + using var host = new ScriptHost(world); + + host.Execute(""" + gmcp.send('Char.Skills.Get', { group = 'combat' }) + gmcp.send('Char.Items.Inv', '') + gmcp.send('Comm.List', { 'a', 'b' }) + gmcp.send('Raw', '{"x":1}') + """); + + await Assert.That(world.SentGmcp).IsEquivalentTo(new[] + { + ("Char.Skills.Get", """{"group":"combat"}"""), + ("Char.Items.Inv", ""), + ("Comm.List", """["a","b"]"""), + ("Raw", """{"x":1}"""), + }); + } + + [Test] + public async Task ATableThatContainsItselfIsRefusedNotLooped() + { + var world = new FakeScriptWorld(); + using var host = new ScriptHost(world); + + await Assert.That(() => host.Execute("local t = {} t.me = t gmcp.send('X', t)")).Throws(); + await Assert.That(world.SentGmcp).IsEmpty(); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs b/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs index 1e44ebc0..2c554a04 100644 --- a/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/FrameContrastTests.cs @@ -48,6 +48,10 @@ public class FrameContrastTests // most to lose from a theme it was not measured against. "selection", "input-selection", + // The meters are the status row's only cells whose colour depends on what a server sent (a low + // value wears the notice ink), and the inspector is a modal of its own. + "meters", "gamedata", + // The prompt row is the only thing this client paints outside a pane that carries the // *game's* colours rather than its own, so the floor is applied against the idle input band // instead of the reading plane. A band nothing renders in a test is a band nobody checks. diff --git a/tests/SharpMUTerm.Tui.Tests/GameDataTests.cs b/tests/SharpMUTerm.Tui.Tests/GameDataTests.cs new file mode 100644 index 00000000..ae89ddf7 --- /dev/null +++ b/tests/SharpMUTerm.Tui.Tests/GameDataTests.cs @@ -0,0 +1,169 @@ +using SharpConsoleUI.Drivers; +using SharpMUTerm.Core.Commands; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Protocols; +using SharpMUTerm.Graphics; + +namespace SharpMUTerm.Tui.Tests; + +/// +/// GMCP and MSDP from the client's side: the status row's meters and the ⌃P inspector. The client used to +/// keep nine fixed Char.* keys from GMCP and draw none of them. +/// +[NotInParallel] +public class GameDataTests +{ + private const string Ann = "Convergence.Ann"; + + private static readonly TerminalCapabilities Headless = + new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false); + + private static AppConfiguration Config() + { + var config = new AppConfiguration(); + config.Worlds.Add(new WorldDefinition + { + Name = "Convergence", + Host = "convergence.example", + Port = 4201, + Characters = { new CharacterDefinition { Name = "Ann", Logging = new LoggingSettings() } }, + }); + return config; + } + + private static async Task<(SharpMUTermApp App, RecordingTelnetSession Wire)> Open(AppConfiguration? config = null) + { + Console.SetIn(TextReader.Null); + var wire = new RecordingTelnetSession(); + var clock = new ManualTimeProvider(); + var app = new SharpMUTermApp(config ?? Config(), Headless, new HeadlessConsoleDriver(160, 32), time: clock); + app.TelnetFactory = _ => wire; + if (!app.DispatchCommand(CommandIds.Character(Ann))) + { + throw new InvalidOperationException("the app would not switch to Ann"); + } + + await app.FindSession(Ann)!.ConnectAsync(); + + // The switch's notice holds the row; the meters are on the resting line under it. + clock.Advance(SharpMUTermApp.NoticeDuration + TimeSpan.FromSeconds(1)); + app.RenderNextFrame(); + return (app, wire); + } + + [Test] + public async Task AWorldWithNoGameDataShowsNoMeters() + { + var (app, _) = await Open(); + await using var _app = app; + + await Assert.That(MarkupText.Plain(app.StatusMarkup)).DoesNotContain("hp"); + } + + [Test] + public async Task GmcpVitalsAppearOnTheStatusRow() + { + var (app, wire) = await Open(); + await using var _app = app; + + wire.ReceiveGmcp("Char.Vitals", """{"hp":"412","maxhp":"500"}"""); + app.RenderNextFrame(); + + var row = MarkupText.Plain(app.StatusMarkup); + await Assert.That(row).Contains("hp 412/500"); + await Assert.That(row).DoesNotContain("mp"); // no value, no meter + } + + [Test] + public async Task AnMsdpGameFillsTheSameMeterAndIsAskedForIt() + { + var (app, wire) = await Open(); + await using var _app = app; + + wire.RaiseMsdpEnabled(); + wire.ReceiveMsdp("""{"HEALTH":"90","HEALTH_MAX":"100"}"""); + app.RenderNextFrame(); + + await Assert.That(MarkupText.Plain(app.StatusMarkup)).Contains("hp 90/100"); + await Assert.That(wire.SentMsdp).Contains(("REPORT", "HEALTH")); + await Assert.That(wire.SentMsdp).Contains(("REPORT", "HEALTH_MAX")); + } + + /// The 99 → 100 lesson: a value from the wire may not change how wide the row is. + [Test] + [Arguments("9")] + [Arguments("10")] + [Arguments("100")] + [Arguments("99999")] + [Arguments("123456789")] + public async Task AMetersWidthDoesNotDependOnItsValue(string hp) + { + var meters = new List { new() { Label = "hp", Value = "Char.Vitals.hp", Max = "Char.Vitals.maxhp" } }; + var baseline = new GameDataStore(); + baseline.SetGmcp("Char.Vitals", """{"hp":"1","maxhp":"1"}"""); + var store = new GameDataStore(); + store.SetGmcp("Char.Vitals", $$"""{"hp":"{{hp}}","maxhp":"{{hp}}"}"""); + + var width = MarkupText.VisibleLength(StatusMeterRenderer.Render(meters, store, "#ffff00")); + + await Assert.That(width).IsEqualTo(MarkupText.VisibleLength(StatusMeterRenderer.Render(meters, baseline, "#ffff00"))); + } + + [Test] + public async Task ALowValueWearsTheWarningInk() + { + var meters = new List { new() { Label = "hp", Value = "Char.Vitals.hp", Max = "Char.Vitals.maxhp" } }; + var store = new GameDataStore(); + store.SetGmcp("Char.Vitals", """{"hp":"20","maxhp":"100"}"""); + + await Assert.That(StatusMeterRenderer.Render(meters, store, "#abcdef")).Contains("#abcdef"); + + store.SetGmcp("Char.Vitals", """{"hp":"80"}"""); + await Assert.That(StatusMeterRenderer.Render(meters, store, "#abcdef")).DoesNotContain("#abcdef"); + } + + [Test] + public async Task TheInspectorOpensOnTheFocusedConnection() + { + var (app, wire) = await Open(); + await using var _app = app; + wire.ReceiveGmcp("Room.Info", """{"num":1,"name":"Hall"}"""); + + await Assert.That(app.BuildCatalog().Select(i => i.Id)).Contains("term:gamedata"); + await Assert.That(app.DispatchCommand("term:gamedata")).IsTrue(); + await Assert.That(app.GameDataIsOpen).IsTrue(); + + app.DispatchCommand("term:gamedata"); + await Assert.That(app.GameDataIsOpen).IsFalse(); + } + + [Test] + public async Task F7EditsTheMetersAsOneLine() + { + var text = new TextSettings(); + var screen = OptionsScreenRenderer.TextAnsiScreen(text); + var model = OptionsScreenRenderer.Model(screen); + var field = Enumerable.Range(0, model.PaneCount) + .SelectMany(p => Enumerable.Range(0, model.RowCount(p)).Select(r => model.RowAt(p, r))) + .SelectMany(r => Enumerable.Range(0, r.FieldCount).Select(i => r.FieldAt(i))) + .Where(f => f is not null) + .Select(f => f!.Value) + .Single(f => f.Label == "meters"); + + await Assert.That(new ScreenEdits().Apply(field, "hp=Char.Vitals.hp/Char.Vitals.maxhp; gold=Char.Status.gold; broken")).IsNull(); + + await Assert.That(text.StatusMeters.Select(m => m.Label)).IsEquivalentTo(new[] { "hp", "gold" }); + await Assert.That(text.StatusMeters[1].Max).IsNull(); + } + + [Test] + public async Task F5KeepsOnlyModulesThatParse() + { + var world = new WorldDefinition { Name = "W", Host = "h", Port = 1 }; + + WorldsScreenRenderer.SplitModules(world, "IRE.Rift 1, nonsense here, Room 2, ire.rift 3"); + + await Assert.That(world.GmcpModules).IsEquivalentTo(new[] { "IRE.Rift 1", "Room 2" }); + await Assert.That(WorldsScreenRenderer.JoinModules(world)).IsEqualTo("IRE.Rift 1, Room 2"); + } +} diff --git a/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs b/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs index 6c192f0a..8f592dfc 100644 --- a/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs +++ b/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs @@ -59,13 +59,29 @@ public IReadOnlyList Lines public event EventHandler? OutputReceived; -#pragma warning disable CS0067 // Required by the interface; these tests drive sizes and output, not these. public event EventHandler? GmcpReceived; public event EventHandler? MsdpReceived; + +#pragma warning disable CS0067 // Required by the interface; these tests drive sizes and output, not these. public event EventHandler? MsspReceived; public event EventHandler? MxpEnabled; #pragma warning restore CS0067 + public event EventHandler? MsdpEnabled; + + public void ReceiveGmcp(string package, string json) => + GmcpReceived?.Invoke(this, new GmcpMessageEventArgs(package, json)); + + public void ReceiveMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); + + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); + + /// Every GMCP message the client sent, oldest first. + public List<(string Package, string Json)> SentGmcp { get; } = new(); + + /// Every MSDP variable the client sent, oldest first. + public List<(string Variable, string Value)> SentMsdp { get; } = new(); + public event EventHandler? EncodingChanged; /// Announces whatever has been set to, as a live session would. @@ -109,8 +125,25 @@ public ValueTask SendLineAsync(string text, CancellationToken cancellationToken public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) => ValueTask.CompletedTask; - public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) => - ValueTask.CompletedTask; + public ValueTask SendGmcpAsync(string package, string json, CancellationToken cancellationToken = default) + { + lock (SentGmcp) + { + SentGmcp.Add((package, json)); + } + + return ValueTask.CompletedTask; + } + + public ValueTask SendMsdpAsync(string variable, string value, CancellationToken cancellationToken = default) + { + lock (SentMsdp) + { + SentMsdp.Add((variable, value)); + } + + return ValueTask.CompletedTask; + } public ValueTask SetWindowSizeAsync(int width, int height) { diff --git a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs index e5b8c964..80dd00c3 100644 --- a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs +++ b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs @@ -364,15 +364,16 @@ public async Task Options_NavigableRowsSkipSectionHeadersAndSpacers() var screen = OptionsScreenRenderer.TextAnsiScreen(); var model = OptionsScreenRenderer.Model(screen); - // 18 display rows: 5 section headers + 4 spacers + 9 options. It was 7/4 before WHITESPACE and + // 21 display rows: 6 section headers + 5 spacers + 10 options. It was 7/4 before WHITESPACE and // its "tab width (spaces)" row, which brought a header and a spacer with it, 10/5 before // "detect links in output" joined the COLOUR section, 11/6 before ACTIVITY and its // "activity bar holds for (seconds)" row brought a header and a spacer of their own, and 14/7 // before "keep text legible" joined COLOUR beneath the row it is the other half of, and 15/8 - // before BACKGROUND and its "background transparency (%)" row. - await Assert.That(screen.Rows.Count).IsEqualTo(18); + // before BACKGROUND and its "background transparency (%)" row, and 18/9 before STATUS ROW and its + // "meters" row. + await Assert.That(screen.Rows.Count).IsEqualTo(21); await Assert.That(model.PaneCount).IsEqualTo(1); - await Assert.That(model.Sizes[0]).IsEqualTo(9); + await Assert.That(model.Sizes[0]).IsEqualTo(10); } ///