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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`]
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' })`.
- Worlds & Characters (F5) shows three settings that could only be changed in `config.json`: a world's
`content` (`auto`, `ansi`, `mxp` or `pueblo`; `ansi` keeps a world on plain colour even when the server
offers MXP), its `emoji` substitution (off, emoticons, shortcodes or both), and a character's
`disconnect` commands.

### Changed

Expand All @@ -33,6 +37,8 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`]

### Fixed

- A character's on-disconnect commands are now sent, before the client closes the connection itself
(Disconnect, Reconnect or quitting). They were saved and never used.
- Lua scripts now run. A trigger set's script files are loaded from the `scripts` folder beside
`config.json` when its character's session opens, and the `script` field of a trigger, alias, timer or
macro calls the Lua function it names, with the match and its groups. Until now no script was ever loaded,
Expand Down
5 changes: 4 additions & 1 deletion src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ public sealed class CharacterDefinition
/// <summary>Semicolon-separated commands sent after connecting.</summary>
public string? OnConnect { get; set; }

/// <summary>Semicolon-separated commands sent (or run locally) on disconnect.</summary>
/// <summary>
/// Semicolon-separated commands sent before the client closes this character's connection itself
/// (Disconnect, Reconnect, quitting) — <c>QUIT</c>, typically. A connection the server dropped gets none.
/// </summary>
public string? OnDisconnect { get; set; }

/// <summary>Names of the <see cref="TriggerSet"/>s that apply to this character.</summary>
Expand Down
12 changes: 11 additions & 1 deletion src/SharpMUTerm.Core/Configuration/WorldDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,24 @@ public enum LogFormat
/// <summary>How a world's inbound text is interpreted for styling and interaction.</summary>
public enum ContentFormat
{
/// <summary>Plain text with ANSI SGR colour (the default).</summary>
/// <summary>
/// Plain text with ANSI SGR colour, switched to MXP when the connection negotiates it — the default, and
/// so it means "nobody chose" (F5's <c>auto</c>).
/// </summary>
Ansi,

/// <summary>MXP markup (tags, SEND/A links) over ANSI.</summary>
Mxp,

/// <summary>Pueblo HTML-subset markup.</summary>
Pueblo,

/// <summary>
/// ANSI colour and nothing else, chosen on purpose: a negotiated MXP does not switch the parser (F5's
/// <c>ansi</c>). Appended, because the configuration stores these by name and <see cref="Ansi"/> has to
/// keep meaning what every saved world already means by it.
/// </summary>
AnsiOnly,
}

/// <summary>Emoji/emoticon substitution options.</summary>
Expand Down
47 changes: 43 additions & 4 deletions src/SharpMUTerm.Core/Session/WorldSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public sealed class WorldSession : IAsyncDisposable

private readonly Func<ConnectionOptions, ITelnetSession> _sessionFactory;
private ILineParser _parser;
private readonly EmojiSubstitutor? _emoji;
private EmojiSubstitutor? _emoji;
private ILogSink? _log;
private readonly TextSettings? _text;
private readonly InputSettings? _input;
Expand Down Expand Up @@ -82,9 +82,7 @@ public WorldSession(
_input = input;
_charsetOrder = charsetOrder;
_parser = NewParser(world.ContentFormat);
_emoji = world.Emoji.Enabled
? new EmojiSubstitutor(world.Emoji.Emoticons, world.Emoji.Shortcodes)
: null;
_emoji = NewEmoji(world.Emoji);
Scrollback = new ScrollbackBuffer(scrollbackCapacity, CreateSpill(spill ?? new ScrollbackSpillOptions()));

Triggers = new TriggerEngine();
Expand Down Expand Up @@ -136,6 +134,9 @@ public void ReloadAutomation(IReadOnlyList<TriggerSet> triggerSets)
private IScrollbackSpill? CreateSpill(ScrollbackSpillOptions options) =>
options.Enabled ? new FileScrollbackSpill(options, SessionKey) : null;

private static EmojiSubstitutor? NewEmoji(EmojiSettings emoji) =>
emoji.Enabled ? new EmojiSubstitutor(emoji.Emoticons, emoji.Shortcodes) : null;

private static ILineParser CreateParser(ContentFormat format) => format switch
{
ContentFormat.Mxp => new MxpParser(),
Expand Down Expand Up @@ -429,6 +430,9 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
// negotiated again to take it back off ANSI.
_parser = NewParser(World.ContentFormat);

// Re-read with the parser, so an F5 change to the world's emoji switches reaches the next connection.
_emoji = NewEmoji(World.Emoji);

SetState(ConnectionState.Connecting, null);
PrintSystem($"*** Connecting to {World.Host}:{World.Port}...");

Expand Down Expand Up @@ -891,10 +895,44 @@ public async Task DisconnectAsync()
{
if (_telnet is not null)
{
await SendLeavingAsync().ConfigureAwait(false);
await _telnet.DisconnectAsync().ConfigureAwait(false);
}
}

/// <summary>
/// Sends the character's <see cref="CharacterDefinition.OnDisconnect"/> commands (<c>QUIT</c>, say)
/// before the client closes a connection itself — on Disconnect, Reconnect and quitting the client. A
/// connection the server dropped gets none: there is nothing left to send them on.
/// <para>
/// Bounded by <see cref="LeavingTimeout"/> and never throws, because it runs on the way out of the client
/// and a hung socket must not hold the exit.
/// </para>
/// </summary>
private async Task SendLeavingAsync()
{
if (!IsConnected || Character is not { } character)
{
return;
}

using var timeout = new CancellationTokenSource(LeavingTimeout);
try
{
foreach (var command in SplitCommands(character.OnDisconnect))
{
await SendRawAsync(command, timeout.Token).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or InvalidOperationException)
{
Logger.LogDebug(ex, "{World}: the on-disconnect commands were not all sent", World.Name);
}
}

/// <summary>How long the on-disconnect commands may take before the connection is closed anyway.</summary>
internal static readonly TimeSpan LeavingTimeout = TimeSpan.FromSeconds(1);

private void SetState(ConnectionState state, Exception? error)
{
State = state;
Expand Down Expand Up @@ -956,6 +994,7 @@ public async ValueTask DisposeAsync()
Scrollback.Dispose(); // deletes this session's scrollback spill cache; the log above is kept
if (_telnet is not null)
{
await SendLeavingAsync().ConfigureAwait(false);
await _telnet.DisposeAsync().ConfigureAwait(false);
_telnet = null;
}
Expand Down
97 changes: 91 additions & 6 deletions src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ internal static class WorldsScreenRenderer
/// </summary>
internal const int GmcpModulesField = 5;

/// <summary><see cref="WorldDefinition.ContentFormat"/>, as <see cref="ContentChoices"/>.</summary>
internal const int ContentField = 6;

/// <summary><see cref="WorldDefinition.Emoji"/>'s three switches, as one of <see cref="EmojiChoices"/>.</summary>
internal const int EmojiField = 7;

/// <summary>
/// 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
Expand All @@ -83,6 +89,13 @@ internal static class WorldsScreenRenderer

internal const int OnConnectField = 3;

/// <summary>
/// <see cref="CharacterDefinition.OnDisconnect"/>, labelled <c>disconnect</c> as the counterpart of the
/// <c>connect</c> line (and because <c>on disconnect</c> is wider than the form's label column). Inserted
/// under the on-connect lines, not appended, so ⇥ reads the form top to bottom (the password's rule).
/// </summary>
internal const int OnDisconnectField = 4;

/// <summary>
/// <see cref="CharacterDefinition.ConnectAtStartup"/>, placed with the connection rows it belongs to:
/// the connect line, the on-connect lines and the login readout all answer "what happens when this
Expand All @@ -93,27 +106,27 @@ internal static class WorldsScreenRenderer
/// all. This one has a well, and ⏎ opens it.
/// </para>
/// </summary>
internal const int StartupField = 4;
internal const int StartupField = 5;

internal const int LogFormatField = 5;
internal const int LogFormatField = 6;

internal const int LogDirectoryField = 6;
internal const int LogDirectoryField = 7;

/// <summary>
/// <see cref="LoggingSettings.RestoreLog"/> — whether this character's panes come back holding their
/// previous session's content. Appended after the two log rows rather than inserted among them,
/// because it is the third answer to "what does this client write down about me". A field and not a
/// toggle for <see cref="StartupField"/>'s reason.
/// </summary>
internal const int RestoreLogField = 7;
internal const int RestoreLogField = 8;

/// <summary>
/// <see cref="CharacterDefinition.Tint"/> — the colour this character's panes are painted in.
/// Appended after the logging block rather than slotted in beside the connection rows, for the
/// numbering reason above and because it is the one row on this form that is about
/// <em>appearance</em>: it belongs at the end, where a reader stops looking for behaviour.
/// </summary>
internal const int PaneTintField = 8;
internal const int PaneTintField = 9;

/// <summary>
/// The trigger-set row's field ordinals. A set's name leads, as every list row's does — and here it
Expand Down Expand Up @@ -413,7 +426,9 @@ internal static ScreenModel Model(
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.Optional("gmcp modules", () => JoinModules(w), v => SplitModules(w, v))))
ScreenField.Optional("gmcp modules", () => JoinModules(w), v => SplitModules(w, v)),
ScreenField.Choice("content", () => ContentName(w.ContentFormat), v => w.ContentFormat = ContentOf(v), ContentChoices),
ScreenField.Choice("emoji", () => EmojiName(w.Emoji), v => SetEmoji(w.Emoji, v), EmojiChoices)))
.Concat(WorldButtons(worlds, selectedWorld, info))
.ToArray();

Expand All @@ -431,6 +446,7 @@ internal static ScreenModel Model(
ScreenField.Defaulted(
"connect", () => c.ConnectString, v => c.ConnectString = v, ConnectStringTemplate.Default),
ScreenField.Optional("on connect", () => c.OnConnect, v => c.OnConnect = v),
ScreenField.Optional("disconnect", () => c.OnDisconnect, v => c.OnDisconnect = v),
ScreenField.Choice(
StartupLabel,
() => c.ConnectAtStartup ? StartupOn : StartupOff,
Expand Down Expand Up @@ -915,6 +931,20 @@ internal static List<string> DetailColumn(
cursor,
selectedWorld,
GmcpModulesField)),
WorldField("content", Field(
world.ContentFormat == ContentFormat.Ansi
? $"[{Label}]{ContentName(world.ContentFormat)}[/]"
: $"[{Value}]{ContentName(world.ContentFormat)}[/]",
cursor,
selectedWorld,
ContentField)),
WorldField("emoji", Field(
world.Emoji.Enabled
? $"[{Value}]{EmojiName(world.Emoji)}[/]"
: $"[{Label}]{EmojiOff}[/]",
cursor,
selectedWorld,
EmojiField)),
string.Empty,
$"[{accent}]├ CHARACTERS[/] [{Label}]a character is a connection[/]",
});
Expand Down Expand Up @@ -1065,6 +1095,12 @@ internal static List<string> FormColumn(
selectedCharacter,
OnConnectField,
pane: CharactersPane)),
CharField("disconnect", Field(
$"[{Value}]{Escape(character.OnDisconnect ?? "—")}[/]",
cursor,
selectedCharacter,
OnDisconnectField,
pane: CharactersPane)),
CharField(StartupLabel, Field(
StartupDetail(character.ConnectAtStartup),
cursor,
Expand Down Expand Up @@ -1110,6 +1146,55 @@ internal static List<string> FormColumn(
return ScreenChrome.Choices(form, cursor.Edit, CharDetailColumnWidth);
}

/// <summary>
/// The <c>content</c> field's words. <c>auto</c> is <see cref="ContentFormat.Ansi"/>, the stored default,
/// which starts on ANSI and follows a negotiated MXP; <c>ansi</c> is the deliberate choice not to.
/// </summary>
internal static readonly IReadOnlyList<string> ContentChoices = ["auto", "ansi", "mxp", "pueblo"];

internal static string ContentName(ContentFormat format) => format switch
{
ContentFormat.AnsiOnly => "ansi",
ContentFormat.Mxp => "mxp",
ContentFormat.Pueblo => "pueblo",
_ => "auto",
};

internal static ContentFormat ContentOf(string name) => name.ToLowerInvariant() switch
{
"ansi" => ContentFormat.AnsiOnly,
"mxp" => ContentFormat.Mxp,
"pueblo" => ContentFormat.Pueblo,
_ => ContentFormat.Ansi,
};

internal const string EmojiOff = "off";

/// <summary>
/// The <c>emoji</c> field's words: which of the two substitutions run. Three switches in the
/// configuration, one field here, because "on, with neither kind" is not a state anybody wants.
/// </summary>
internal static readonly IReadOnlyList<string> EmojiChoices = [EmojiOff, "emoticons", "shortcodes", "both"];

internal static string EmojiName(EmojiSettings emoji) => (emoji.Enabled, emoji.Emoticons, emoji.Shortcodes) switch
{
(false, _, _) or (true, false, false) => EmojiOff,
(true, true, false) => "emoticons",
(true, false, true) => "shortcodes",
_ => "both",
};

internal static void SetEmoji(EmojiSettings emoji, string name)
{
var choice = name.ToLowerInvariant();
emoji.Enabled = choice != EmojiOff;
if (emoji.Enabled)
{
emoji.Emoticons = choice is "emoticons" or "both";
emoji.Shortcodes = choice is "shortcodes" or "both";
}
}

/// <summary>
/// What an unset log directory reads as. The field itself holds null — the logger picks a
/// per-session folder under the config directory — so the row names the state rather than showing
Expand Down
Loading