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