diff --git a/CHANGELOG.md b/CHANGELOG.md index a01c4a3..cebe737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,8 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] captions*, which prints a dim `♪ …` line for each sound. Ctrl+P ▸ *Mute sound* silences everything until you unmute or restart, and the status row says `♪ muted` meanwhile. Worlds & Characters (F5) has a `sounds` field to stop a world downloading new files. +- MSP sound. A game that offers the MUD Sound Protocol now gets the client's agreement when sound can play, + and its `!!SOUND(…)` and `!!MUSIC(…)` lines play like MCMP's instead of showing as text. - Packages. A package is a `.zip` of trigger sets, Lua scripts and sound files for one or more games. Ctrl+P ▸ *Install package…* (or `/package install `) shows what it adds first, including how many of its rules send commands and which scripts it runs, and writes nothing until you diff --git a/CLAUDE.md b/CLAUDE.md index 95c62b1..a6b36fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,10 +166,10 @@ fallbacks) for inline images/maps. scrollback spill under the platform cache folder, not beside `config.json`. - **MSP triggers are read only on a connection that agreed option 90** (`MspTrigger`, `ITelnetSession.MspChanged`): a whole line `!!SOUND(…)`/`!!MUSIC(…)` becomes a `MediaCommand` and is taken - out before the triggers; anywhere else it is text a player typed. **It is inert until the library ships - `MSPProtocol`** (TelnetNegotiationCore PR #171): no release has it, so `TelnetSession` registers nothing, - the server's `WILL MSP` is refused, and `MspChanged` is never raised. The bump registers the plugin only - when `PlaysMedia` is true and raises the event from its two callbacks. + out before the triggers; anywhere else it is text a player typed. The library's `MSPProtocol` is + registered only when `PlaysMedia` is true (`TelnetSessionOptions.PlaysMsp`), because registering it is + the statement that this client plays sound; otherwise the server's `WILL MSP` is refused. Its two + callbacks raise `MspChanged`. - **Packages** (`Core/Packages`: `PackageManifest`, `PackageArchive`, `PackageStore`; Tui `PackageScreen`, `ListOverlay`, `SharpMUTermApp.Packages.cs`). A `.zip` with `package.json` (id, name, SemVer version, `games` as `host[:port]`, `*.host` or `*`), `sets/*.json`, `scripts/**.lua`, `media/**`; anything else is diff --git a/Directory.Packages.props b/Directory.Packages.props index 53fdfa6..33fb2cc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ - + diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 79e11b4..e4b1844 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -424,7 +424,8 @@ public ILogger Logger /// /// Whether this client will play what the server sends: set by the app when it has an audio device and game - /// sound is on. True adds Client.Media 1 to the GMCP modules, which is how MCMP asks; read on connect. + /// sound is on. True adds Client.Media 1 to the GMCP modules, which is how MCMP asks, and agrees MSP when + /// the server offers it; read on connect. /// public bool PlaysMedia { get; set; } @@ -1349,6 +1350,7 @@ private ITelnetSession DefaultSessionFactory(ConnectionOptions options) { GmcpModules = GmcpModulesFor(World, PlaysMedia), AnnouncePueblo = World.ContentFormat is ContentFormat.Ansi or ContentFormat.Pueblo, + PlaysMsp = PlaysMedia, CharsetOrder = order, EncodingOverride = encodingOverride, KeepaliveInterval = TelnetSessionOptions.ResolveKeepalive(World.KeepaliveSeconds), diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index 7975b15..f16b48b 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -147,6 +147,14 @@ public static IReadOnlyDictionary MnesIdentity(IReadOnlyList public bool AnnouncePueblo { get; init; } = true; + /// + /// Whether to agree MSP (option 90) when a server offers it. Registering the library's plugin is the + /// statement that this client plays sound, so a session with nothing to play leaves it off and the + /// server's WILL MSP is refused. Off by default; WorldSession sets it from + /// PlaysMedia. + /// + public bool PlaysMsp { get; init; } + /// The version this client announces as a Pueblo client; PennMUSH reads only the word. public const string PuebloVersion = "2.50"; @@ -402,6 +410,7 @@ private Encoding? Negotiated public event EventHandler? MxpEnabled; public event EventHandler? MsdpEnabled; public event EventHandler? PuebloEnabled; + public event EventHandler? MspChanged; public async Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -476,6 +485,13 @@ private Task BuildInterpreterAsync() .OnMcpMessage(MooEditing.AwnsStatus, OnMcpStatusAsync) .OnMcpNegotiationComplete(OnMcpAgreedAsync); + if (_options.PlaysMsp) + { + builder = builder.AddPlugin() + .OnMSPEnabled(() => OnMspChangedAsync(true)) + .OnMSPDisabled(() => OnMspChangedAsync(false)); + } + // An idle keepalive, when the world asks for one: IAC NOP after the configured silence, so a // NAT or load balancer doesn't evict a connection that is merely quiet. It is filler traffic, // not a liveness probe — it proves our write succeeded, not that the server answered. @@ -834,6 +850,13 @@ private ValueTask OnMxpEnabledAsync() return ValueTask.CompletedTask; } + private ValueTask OnMspChangedAsync(bool active) + { + _logger.LogInformation(active ? "MSP negotiated." : "MSP withdrawn."); + MspChanged?.Invoke(this, active); + return ValueTask.CompletedTask; + } + private async Task ReadLoopAsync(CancellationToken cancellationToken) { var buffer = new byte[_options.ReceiveBufferSize]; diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/MspNegotiationTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/MspNegotiationTests.cs new file mode 100644 index 0000000..b5e5ca2 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/MspNegotiationTests.cs @@ -0,0 +1,68 @@ +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// +/// MSP (option 90) from the client's side: agreed only by a session that plays sound, and reported through +/// both ways, because a !!SOUND line is a request only while the +/// option is agreed. +/// +public class MspNegotiationTests +{ + private const byte Iac = 255, Will = 251, Wont = 252, Do = 253, Dont = 254, Msp = 90; + + private static async Task WaitForAsync(Func condition) + { + for (var i = 0; i < 100 && !condition(); i++) + { + await Task.Delay(10); + } + + return condition(); + } + + [Test] + public async Task ASessionThatPlaysSoundAgreesAndReportsBothWays() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Msp] }; + await using var session = new TelnetSession( + transport, NullLogger.Instance, new TelnetSessionOptions { PlaysMsp = true }); + var changes = new List(); + session.MspChanged += (_, active) => { lock (changes) changes.Add(active); }; + await session.ConnectAsync(); + + await Assert.That(await transport.WaitForSentAsync([Iac, Do, Msp])).IsTrue(); + await Assert.That(await WaitForAsync(() => { lock (changes) return changes.Count == 1; })).IsTrue(); + + transport.SendToClient([Iac, Wont, Msp]); + await Assert.That(await transport.WaitForSentAsync([Iac, Dont, Msp])).IsTrue(); + await Assert.That(await WaitForAsync(() => { lock (changes) return changes.Count == 2; })).IsTrue(); + + bool[] seen; + lock (changes) + { + seen = [.. changes]; + } + + await Assert.That(seen).IsEquivalentTo(new[] { true, false }); + } + + [Test] + public async Task ASessionWithNothingToPlayRefusesAndSaysNothing() + { + var transport = new ScriptedTransport { Greeting = [Iac, Will, Msp] }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + var raised = 0; + session.MspChanged += (_, _) => Interlocked.Increment(ref raised); + await session.ConnectAsync(); + + await Assert.That(await transport.WaitForSentAsync([Iac, Dont, Msp])).IsTrue(); + await Task.Delay(50); + await Assert.That(Volatile.Read(ref raised)).IsEqualTo(0); + await Assert.That(Contains(transport.Sent, [Iac, Do, Msp])).IsFalse(); + } + + private static bool Contains(byte[] haystack, byte[] needle) => + haystack.AsSpan().IndexOf(needle) >= 0; +}