Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/SharpMUTerm.Core/Commands/CommandCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,11 @@ public static IReadOnlyList<CommandItem> 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.
Expand Down
7 changes: 7 additions & 0 deletions src/SharpMUTerm.Core/Configuration/PreferenceSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ public sealed class TextSettings
/// <summary>Ten minutes. Past this a boundary marker is not a marker, it is a pin.</summary>
public const int MaxActivityBarSeconds = 600;

/// <summary>
/// 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.
/// </summary>
public List<StatusMeter> 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
Expand Down
165 changes: 165 additions & 0 deletions src/SharpMUTerm.Core/Configuration/StatusMeter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using System.Globalization;
using System.Text;
using SharpMUTerm.Core.Protocols;

namespace SharpMUTerm.Core.Configuration;

/// <summary>
/// One reading on the status row: a short label and the game data it shows, optionally out of a maximum —
/// <c>hp 450/500</c>. Each path may list alternatives separated by <c>|</c>, tried in order, so one meter reads
/// a GMCP game and an MSDP game alike.
/// </summary>
public sealed class StatusMeter
{
/// <summary>What the row prints before the value. Kept short; it is clipped to <see cref="MaxLabelLength"/>.</summary>
public string Label { get; set; } = string.Empty;

/// <summary>The value's path (<c>Char.Vitals.hp|MSDP.HEALTH</c>).</summary>
public string Value { get; set; } = string.Empty;

/// <summary>The maximum's path, or null for a meter with none (<c>gold</c>).</summary>
public string? Max { get; set; }

/// <summary>The longest label the row draws.</summary>
public const int MaxLabelLength = 8;

/// <summary>
/// The default row: health, mana and movement, by the names IRE, Aardwolf and the MSDP convention use.
/// </summary>
public static List<StatusMeter> 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",
},
];

/// <summary>The first alternative of <paramref name="paths"/> the store holds a value for, as text.</summary>
public static string? Read(GameDataStore data, string? paths)
{
foreach (var path in Alternatives(paths))
{
if (data.GetText(path) is { } text)
{
return text;
}
}

return null;
}

/// <summary>The alternatives in a path list, trimmed, blanks dropped.</summary>
public static IEnumerable<string> Alternatives(string? paths) =>
(paths ?? string.Empty).Split('|', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);

/// <summary>
/// The MSDP variables a list of meters reads — what a session asks an MSDP server to <c>REPORT</c>.
/// </summary>
public static IReadOnlyList<string> MsdpVariables(IEnumerable<StatusMeter> 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();

/// <summary>
/// Writes meters as the one-line form F7 edits: <c>hp=Char.Vitals.hp/Char.Vitals.maxhp; gold=Char.Status.gold</c>.
/// </summary>
public static string Format(IEnumerable<StatusMeter> 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();
}

/// <summary>
/// Reads the one-line form back. A part with no <c>=</c>, no label or no value is skipped, so a half-typed
/// entry costs nothing; the label is clipped to <see cref="MaxLabelLength"/>.
/// </summary>
public static List<StatusMeter> Parse(string? text)
{
var meters = new List<StatusMeter>();
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;
}

/// <summary>
/// A reading fitted to <paramref name="width"/> cells without wrapping a number past recognition: a whole
/// number too long is shown in thousands or millions (<c>123k</c>, <c>12M</c>), anything else is clipped
/// with an ellipsis. Never longer than the width, which is what lets the row reserve a fixed field.
/// </summary>
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)] + "…";
}
}
7 changes: 7 additions & 0 deletions src/SharpMUTerm.Core/Configuration/WorldDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ public sealed class WorldDefinition
/// <summary>Emoji/emoticon substitution for inbound text.</summary>
public EmojiSettings Emoji { get; set; } = new();

/// <summary>
/// GMCP modules to ask this world for beyond the client's defaults, each <c>Name Version</c>
/// (<c>IRE.Rift 1</c>) — sent in <c>Core.Supports.Set</c> once the server's GMCP is agreed.
/// </summary>
public List<string> GmcpModules { get; set; } = new();

/// <summary>
/// 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.
Expand Down Expand Up @@ -138,6 +144,7 @@ public sealed class WorldDefinition
Shortcodes = Emoji.Shortcodes,
},
Accent = Accent,
GmcpModules = GmcpModules.ToList(),
Characters = Characters.Select(c => c.Clone()).ToList(),
};

Expand Down
Loading