commit 5dbb294a968b0a13c2bc914cb9647fe4b9cf4ff5 Author: Erik Date: Fri Sep 25 12:03:00 2026 +0200 Skeleton: plugin root, backend socket, /mm router, settings, test harness The socket connects with the shared-secret header, registers first, reassembles incoming frames of any size (the original dropped anything over 4 KiB) and reconnects two seconds after a failure. Incoming share_* frames go to the vital-sharing handlers; any other frame is a chat-box command for this character, run one per tick through the chat bar as if typed. Frames are written with Newtonsoft.Json defaults, the original's serializer, so the backend sees the same bytes. The secret lives in the character's settings. Co-Authored-By: Claude Opus 5.5 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b7f7048 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Build output +bin/ +obj/ +out/ +**/TestResults/ + +# The local folder feed the plugin contract package is dropped into +packages-local/ + +# Release staging +artifacts/ + +# IDEs +.vs/ +.idea/ +*.user +*.suo + +# OS +.DS_Store +Thumbs.db + +# Local agent/session state +.claude/ +.codex/ +.agents/ +.worktrees/ diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..0f24f6d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + latest + latest + true + true + + + 0.1.0 + + + 0.1.18 + false + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..8c10e35 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,20 @@ + + + true + + + + + + + + + + + + + + + diff --git a/NuGet.Config b/NuGet.Config new file mode 100644 index 0000000..7a240fa --- /dev/null +++ b/NuGet.Config @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/OpenAC.MosswartMassacre.slnx b/OpenAC.MosswartMassacre.slnx new file mode 100644 index 0000000..bea352a --- /dev/null +++ b/OpenAC.MosswartMassacre.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a076e9a --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Mosswart Massacre for OpenAC + +A private OpenAC port of the Mosswart Massacre DECAL plugin: it streams +telemetry, vitals, chat, inventory, combat and character stats to the Mosswart +Overlord backend, shares vitals and debuffs across machines, runs the commands +typed into the Overlord chat box, keeps the flag tracker and syncs metas. + +**The backend contract is fixed.** Every frame this plugin sends or accepts is +byte-for-byte what the DECAL plugin sent and accepted; the golden tests under +`tests/` pin each one. Change a frame only together with the backend. + +## Building + +The plugin contract comes from the OpenAC release of the version named in +`Directory.Build.props` (`PluginApiPackageVersion`). Put that release's +`AcDream.Plugin.Abstractions..nupkg` in `packages-local/`: + +``` +gh release download v0.1.18 -R eriknihlen/OpenAC -p "AcDream.Plugin.Abstractions.0.1.18.nupkg" -D packages-local +dotnet build OpenAC.MosswartMassacre.slnx +dotnet test OpenAC.MosswartMassacre.slnx +``` + +## Installing + +Copy `src/OpenAC.MosswartMassacre/bin//net10.0/` (the dll, its +`.deps.json`, `Newtonsoft.Json.dll`, `plugin.json` and the markup) into +`%LOCALAPPDATA%\acdream\plugins\openac.mosswartmassacre\`. + +## First run + +Streaming is off until you turn it on. The shared secret is never compiled in: + +``` +/mm ws secret +/mm ws enable +``` + +`/mm help` lists every command. diff --git a/global.json b/global.json new file mode 100644 index 0000000..f7772fe --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.300", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs b/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs new file mode 100644 index 0000000..0ad1949 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs @@ -0,0 +1,260 @@ +using System.Collections.Concurrent; +using System.Net.WebSockets; +using System.Text; +using System.Threading.Channels; +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Backend; + +/// +/// The one WebSocket to the backend. It connects with the shared-secret +/// header, registers the character, then carries frames both ways until the +/// socket fails, and reconnects two seconds later, as the original did. +/// +/// +/// +/// Nothing here touches the game. Outgoing frames are built on the tick +/// thread and queued; incoming frames are handed back to the tick thread +/// through . A frame sent while the socket is down is +/// dropped, as the original dropped it. +/// +/// +/// Incoming frames are reassembled across receive calls; the original read a +/// single 4 KiB buffer and lost any larger frame. +/// +/// +internal sealed class BackendClient : IDisposable +{ + public const string SecretHeader = "X-Plugin-Secret"; + + private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(2); + + private readonly IPluginLogger _log; + private readonly ConcurrentQueue _inbound = new(); + private readonly ConcurrentQueue _connections = new(); + private readonly object _gate = new(); + private CancellationTokenSource? _cts; + private Task? _loop; + private Channel? _outbound; + + public BackendClient(IPluginLogger log) + { + _log = log; + } + + /// True between and . + public bool IsRunning + { + get + { + lock (_gate) + return _cts is not null; + } + } + + /// True while a registered socket is open. + public bool IsConnected + { + get + { + lock (_gate) + return _outbound is not null; + } + } + + public void Start(Uri endpoint, string secret, string playerName, Func utcNow) + { + lock (_gate) + { + if (_cts is not null) + return; + _cts = new CancellationTokenSource(); + CancellationToken token = _cts.Token; + _log.Info("[WebSocket] connecting"); + _loop = Task.Run(() => RunAsync(endpoint, secret, playerName, utcNow, token)); + } + } + + public void Stop() + { + Task? loop; + CancellationTokenSource? cts; + lock (_gate) + { + if (_cts is null) + return; + cts = _cts; + _cts = null; + cts.Cancel(); + _outbound?.Writer.TryComplete(); + _outbound = null; + loop = _loop; + _loop = null; + } + try + { + loop?.Wait(TimeSpan.FromSeconds(2)); + } + catch (AggregateException) + { + } + cts.Dispose(); + while (_inbound.TryDequeue(out _)) + { + } + while (_connections.TryDequeue(out _)) + { + } + _log.Info("[WebSocket] DISABLED"); + } + + /// Queues a frame. Dropped when no socket is open. + public bool Send(string json) + { + Channel? outbound; + lock (_gate) + outbound = _outbound; + return outbound is not null && outbound.Writer.TryWrite(json); + } + + /// The next frame the backend sent, trimmed, if any. + public bool TryReceive(out string frame) => _inbound.TryDequeue(out frame!); + + /// + /// The next completed connect-and-register, if any. The tick thread uses it + /// to run what the original ran right after registering. + /// + public bool TryTakeConnection(out BackendConnection connection) => + _connections.TryDequeue(out connection); + + public void Dispose() => Stop(); + + private async Task RunAsync( + Uri endpoint, string secret, string playerName, Func utcNow, CancellationToken token) + { + while (!token.IsCancellationRequested) + { + var socket = new ClientWebSocket(); + Channel? outbound = null; + try + { + socket.Options.SetRequestHeader(SecretHeader, secret); + await socket.ConnectAsync(endpoint, token).ConfigureAwait(false); + _log.Info("[WebSocket] CONNECTED"); + DateTime connectedAt = utcNow(); + + string register = Wire.Serialize(new { type = "register", player_name = playerName }); + await SendFrameAsync(socket, register, token).ConfigureAwait(false); + _log.Info("[WebSocket] REGISTERED"); + + outbound = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + lock (_gate) + { + if (token.IsCancellationRequested) + break; + _outbound = outbound; + } + _connections.Enqueue(new BackendConnection(connectedAt)); + + Task receive = ReceiveLoopAsync(socket, token); + Task send = SendLoopAsync(socket, outbound.Reader, token); + await Task.WhenAny(receive, send).ConfigureAwait(false); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _log.Warn($"[WebSocket] Connection error: {ex.Message}"); + } + finally + { + lock (_gate) + { + if (ReferenceEquals(_outbound, outbound)) + _outbound = null; + } + outbound?.Writer.TryComplete(); + socket.Abort(); + socket.Dispose(); + } + + if (token.IsCancellationRequested) + break; + _log.Info("[WebSocket] Reconnecting in 2 seconds..."); + try + { + await Task.Delay(ReconnectDelay, token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; + } + } + } + + private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken token) + { + var buffer = new byte[8192]; + using var message = new MemoryStream(); + while (socket.State == WebSocketState.Open && !token.IsCancellationRequested) + { + WebSocketReceiveResult result; + try + { + result = await socket.ReceiveAsync(new ArraySegment(buffer), token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + return; + } + catch (Exception ex) + { + _log.Warn($"[WebSocket] receive error: {ex.Message}"); + return; + } + + if (result.MessageType == WebSocketMessageType.Close) + return; + + message.Write(buffer, 0, result.Count); + if (!result.EndOfMessage) + continue; + + string text = Encoding.UTF8.GetString(message.GetBuffer(), 0, (int)message.Length).Trim(); + message.SetLength(0); + if (result.MessageType == WebSocketMessageType.Text && text.Length > 0) + _inbound.Enqueue(text); + } + } + + private async Task SendLoopAsync(ClientWebSocket socket, ChannelReader reader, CancellationToken token) + { + try + { + while (await reader.WaitToReadAsync(token).ConfigureAwait(false)) + { + while (reader.TryRead(out string? frame)) + await SendFrameAsync(socket, frame, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + _log.Warn($"[WebSocket] Send error: {ex.Message}"); + } + } + + private static Task SendFrameAsync(ClientWebSocket socket, string text, CancellationToken token) + { + byte[] bytes = Encoding.UTF8.GetBytes(text); + return socket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, token); + } +} + +/// One completed connect-and-register, stamped with the UTC time it connected. +internal readonly record struct BackendConnection(DateTime ConnectedAtUtc); diff --git a/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs b/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs new file mode 100644 index 0000000..d740abe --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs @@ -0,0 +1,222 @@ +using System.Globalization; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace OpenAC.MosswartMassacre.Backend; + +/// +/// Runs the backend socket for the character in the world: starts it at login +/// when streaming is on, stops it at logoff, and on every tick hands what the +/// socket received to the features. +/// +/// +/// Incoming frames route as the original routed them. A frame whose +/// type starts with share_ goes to the vital-sharing handlers. Any +/// other frame is a chat-box command; it runs only when its player_name +/// is this character (case-insensitive). start_radar and +/// stop_radar toggle the radar; any other text is run as if typed into +/// the chat bar. Commands run one per tick, as the original ran one per timer +/// tick. +/// +internal sealed class BackendSession : IMmFeature +{ + private readonly MmContext _context; + private readonly List _connectionListeners = []; + private readonly List _shareHandlers = []; + private readonly Queue _pendingCommands = new(); + private readonly Action _tick; + + public BackendSession(MmContext context) + { + _context = context; + _tick = _ => Pump(); + _context.Host.Events.Tick += _tick; + + context.Commands.Register("ws", HandleWsCommand, "Enable/disable WebSocket streaming"); + } + + /// Runs the radar on start_radar / stop_radar. + public Action? RadarToggle { get; set; } + + public void AddConnectionListener(IBackendConnectionListener listener) => + _connectionListeners.Add(listener); + + public void AddShareHandler(IShareFrameHandler handler) => _shareHandlers.Add(handler); + + public void OnLogin() + { + if (_context.Settings.Current.WebSocketEnabled) + StartSocket(); + } + + public void OnLogoff() + { + _context.Backend.Stop(); + _pendingCommands.Clear(); + } + + public void Dispose() + { + _context.Host.Events.Tick -= _tick; + _context.Backend.Stop(); + } + + /// Drains the socket. Public so tests can pump without a host tick. + public void Pump() + { + while (_context.Backend.TryTakeConnection(out BackendConnection connection)) + OnConnected(connection.ConnectedAtUtc); + + while (_context.Backend.TryReceive(out string frame)) + Route(frame); + + if (_pendingCommands.Count > 0) + RunCommand(_pendingCommands.Dequeue()); + } + + /// What the original ran right after registering: the new session id, then the listeners. + internal void OnConnected(DateTime connectedAtUtc) + { + _context.ConnectionSessionId = string.Create( + CultureInfo.InvariantCulture, + $"{_context.CharacterName}-{connectedAtUtc:yyyyMMdd-HHmmss}"); + foreach (IBackendConnectionListener listener in _connectionListeners) + { + try + { + listener.OnBackendConnected(); + } + catch (Exception ex) + { + _context.Host.Log.Error("A connection listener failed.", ex); + } + } + } + + /// Commands queued from the backend and not yet run. + internal int PendingCommandCount => _pendingCommands.Count; + + internal void Route(string frame) + { + JObject root; + try + { + root = JObject.Parse(frame); + } + catch (JsonException) + { + return; + } + + string? type = (string?)root["type"]; + if (!string.IsNullOrEmpty(type) && type.StartsWith("share_", StringComparison.Ordinal)) + { + foreach (IShareFrameHandler handler in _shareHandlers) + { + try + { + handler.HandleShareFrame(type, root); + } + catch (Exception ex) + { + _context.Chat.Write($"[VitalShare] HandleServerMessage error: {ex.Message}"); + } + } + return; + } + + CommandEnvelope? envelope; + try + { + envelope = root.ToObject(); + } + catch (JsonException) + { + return; + } + + if (envelope?.Command is { } command + && string.Equals(envelope.PlayerName, _context.CharacterName, StringComparison.OrdinalIgnoreCase)) + { + _pendingCommands.Enqueue(command); + } + } + + private void RunCommand(string command) + { + try + { + if (command == "start_radar") + { + RadarToggle?.Invoke(true); + return; + } + if (command == "stop_radar") + { + RadarToggle?.Invoke(false); + return; + } + _context.Automation.Chat.Submit(command); + } + catch (Exception ex) + { + _context.Chat.Write($"[WS] Command execution error: {ex.Message}"); + } + } + + private void StartSocket() + { + string url = _context.Settings.Current.BackendUrl; + if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? endpoint)) + { + _context.Chat.Write($"[WS] Invalid backend url: {url}"); + return; + } + _context.Backend.Start( + endpoint, + _context.Settings.Current.BackendSecret ?? string.Empty, + _context.CharacterName, + () => _context.Clock.UtcNow); + } + + private void HandleWsCommand(string[] args) + { + string sub = args.Length > 1 ? args[1] : string.Empty; + if (sub.Equals("enable", StringComparison.OrdinalIgnoreCase)) + { + _context.Settings.Current.WebSocketEnabled = true; + _context.Settings.Save(); + if (_context.IsLoggedIn) + StartSocket(); + _context.Chat.Write("WS streaming ENABLED."); + } + else if (sub.Equals("disable", StringComparison.OrdinalIgnoreCase)) + { + _context.Settings.Current.WebSocketEnabled = false; + _context.Settings.Save(); + _context.Backend.Stop(); + _context.Chat.Write("WS streaming DISABLED."); + } + else if (sub.Equals("url", StringComparison.OrdinalIgnoreCase) && args.Length > 2) + { + _context.Settings.Current.BackendUrl = string.Join(" ", args.Skip(2)).Trim(); + _context.Settings.Save(); + _context.Chat.Write($"WS url set to {_context.Settings.Current.BackendUrl}. Reconnect with /mm ws disable, /mm ws enable."); + } + else if (sub.Equals("secret", StringComparison.OrdinalIgnoreCase) && args.Length > 2) + { + _context.Settings.Current.BackendSecret = string.Join(" ", args.Skip(2)).Trim(); + _context.Settings.Save(); + _context.Chat.Write("WS secret saved. Reconnect with /mm ws disable, /mm ws enable."); + } + else if (sub.Equals("status", StringComparison.OrdinalIgnoreCase)) + { + _context.Chat.Write( + $"WS enabled={_context.Settings.Current.WebSocketEnabled} connected={_context.Backend.IsConnected} url={_context.Settings.Current.BackendUrl}"); + } + else + { + _context.Chat.Write("Usage: /mm ws |secret >"); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/Backend/CommandEnvelope.cs b/src/OpenAC.MosswartMassacre/Backend/CommandEnvelope.cs new file mode 100644 index 0000000..8630a15 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Backend/CommandEnvelope.cs @@ -0,0 +1,16 @@ +using Newtonsoft.Json; + +namespace OpenAC.MosswartMassacre.Backend; + +/// +/// A command typed into the backend's chat box for one character: +/// {"player_name": ..., "command": ...}. +/// +internal sealed class CommandEnvelope +{ + [JsonProperty("player_name")] + public string? PlayerName { get; set; } + + [JsonProperty("command")] + public string? Command { get; set; } +} diff --git a/src/OpenAC.MosswartMassacre/Backend/Wire.cs b/src/OpenAC.MosswartMassacre/Backend/Wire.cs new file mode 100644 index 0000000..69061cb --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Backend/Wire.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using System.Text; +using Newtonsoft.Json; +using OpenAC.MosswartMassacre.Infrastructure; + +namespace OpenAC.MosswartMassacre.Backend; + +/// +/// How every frame to the backend is written: Newtonsoft.Json with its default +/// settings, exactly as the original plugin wrote them. Property order is +/// declaration order, nulls are written, and numbers keep Newtonsoft's own +/// round-trip formatting; the backend parses exactly this. +/// +internal static class Wire +{ + // A private serializer rather than JsonConvert.SerializeObject: that one + // picks up JsonConvert.DefaultSettings, which any code in this load + // context could change. + private static readonly JsonSerializer Serializer = JsonSerializer.Create(); + + public static string Serialize(object? payload) + { + var builder = new StringBuilder(256); + using var writer = new StringWriter(builder, CultureInfo.InvariantCulture); + using (var json = new JsonTextWriter(writer) { Formatting = Formatting.None }) + { + Serializer.Serialize(json, payload); + } + return builder.ToString(); + } + + /// The frame timestamp: UTC, ISO-8601 round-trip ("o"), as the original sent it. + public static string Timestamp(IClock clock) => + clock.UtcNow.ToString("o", CultureInfo.InvariantCulture); +} diff --git a/src/OpenAC.MosswartMassacre/Commands/MmCommandRouter.cs b/src/OpenAC.MosswartMassacre/Commands/MmCommandRouter.cs new file mode 100644 index 0000000..4169c18 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Commands/MmCommandRouter.cs @@ -0,0 +1,68 @@ +using OpenAC.MosswartMassacre.Infrastructure; + +namespace OpenAC.MosswartMassacre.Commands; + +/// +/// The /mm verb and its subcommands, dispatched by name as the original +/// did: arguments split on single spaces, the subcommand first, case-insensitive. +/// +internal sealed class MmCommandRouter(ChatOutput chat) +{ + private readonly Dictionary Handler, string Description)> _commands = + new(StringComparer.OrdinalIgnoreCase); + + // Registration order, which is the order help lists them in. + private readonly List _order = []; + + public void Register(string name, Action handler, string description) + { + if (!_commands.ContainsKey(name)) + _order.Add(name); + _commands[name] = (handler, description); + } + + /// Runs one /mm line; is everything after the verb. + public bool Dispatch(string arguments) + { + string[] args = (arguments ?? string.Empty).Trim().Split(' '); + if (args.Length == 0 || string.IsNullOrEmpty(args[0])) + { + chat.Write("Usage: /mm . Try /mm help"); + return true; + } + + string subCommand = args[0].ToLowerInvariant(); + if (subCommand == "help") + { + PrintHelp(); + return true; + } + + if (_commands.TryGetValue(subCommand, out var entry)) + { + try + { + entry.Handler(args); + } + catch (Exception ex) + { + chat.Write($"[Error] Failed to process /mm command: {ex.Message}"); + } + return true; + } + + chat.Write($"Unknown /mm command: {subCommand}. Try /mm help"); + return false; + } + + private void PrintHelp() + { + chat.Write("Mosswart Massacre Commands:"); + foreach (string name in _order) + { + string description = _commands[name].Description; + if (!string.IsNullOrEmpty(description)) + chat.Write($"/mm {name,-18} - {description}"); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs new file mode 100644 index 0000000..697ab4e --- /dev/null +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -0,0 +1,17 @@ +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre; + +/// +/// Every feature the plugin runs, in the order the root logs them in. Each +/// slice of the port adds its features here and nowhere else. +/// +internal static class FeatureCatalog +{ + public static void AddFeatures(MmContext context, BackendSession session, List features) + { + _ = context; + _ = session; + _ = features; + } +} diff --git a/src/OpenAC.MosswartMassacre/IMmFeature.cs b/src/OpenAC.MosswartMassacre/IMmFeature.cs new file mode 100644 index 0000000..b270e46 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/IMmFeature.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json.Linq; + +namespace OpenAC.MosswartMassacre; + +/// +/// One feature of the plugin. The root calls it at every login and logoff; a +/// relog to another character is a logoff followed by a login. +/// +internal interface IMmFeature : IDisposable +{ + /// A character is in the world and its settings are loaded. + void OnLogin(); + + /// The character left the world, or the plugin is being disabled. + void OnLogoff(); +} + +/// A feature that acts right after the socket connects and registers. +internal interface IBackendConnectionListener +{ + void OnBackendConnected(); +} + +/// A feature that consumes the backend's share_* frames. +internal interface IShareFrameHandler +{ + void HandleShareFrame(string type, JObject frame); +} diff --git a/src/OpenAC.MosswartMassacre/Infrastructure/ChatOutput.cs b/src/OpenAC.MosswartMassacre/Infrastructure/ChatOutput.cs new file mode 100644 index 0000000..571c18a --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Infrastructure/ChatOutput.cs @@ -0,0 +1,21 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Infrastructure; + +/// The plugin's own lines in the chat window, prefixed the way the original printed them. +internal sealed class ChatOutput(IPluginHost host) +{ + public const string Prefix = "[Mosswart Massacre] "; + + public void Write(string message) + { + try + { + host.Automation.Chat.PostSystemMessage(Prefix + message); + } + catch (Exception ex) + { + host.Log.Warn($"Could not write to chat: {ex.Message}"); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/Infrastructure/IClock.cs b/src/OpenAC.MosswartMassacre/Infrastructure/IClock.cs new file mode 100644 index 0000000..13e91c6 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Infrastructure/IClock.cs @@ -0,0 +1,26 @@ +namespace OpenAC.MosswartMassacre.Infrastructure; + +/// +/// Wall-clock time for everything the plugin stamps or measures. The backend +/// frames carry UTC timestamps and the kill-rate maths uses local time, the +/// same split the original plugin had; tests substitute a fixed clock. +/// +internal interface IClock +{ + DateTime UtcNow { get; } + + DateTime Now { get; } +} + +internal sealed class SystemClock : IClock +{ + public static SystemClock Instance { get; } = new(); + + private SystemClock() + { + } + + public DateTime UtcNow => DateTime.UtcNow; + + public DateTime Now => DateTime.Now; +} diff --git a/src/OpenAC.MosswartMassacre/Infrastructure/TickScheduler.cs b/src/OpenAC.MosswartMassacre/Infrastructure/TickScheduler.cs new file mode 100644 index 0000000..a9690e8 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Infrastructure/TickScheduler.cs @@ -0,0 +1,121 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Infrastructure; + +/// +/// The plugin's only timer. Every periodic job and every deferred action runs +/// from the host's tick, on the thread that may touch the game; nothing here +/// starts a thread or a system timer. +/// +/// +/// A callback that throws is logged and left scheduled, so one failing job +/// never stops the others. +/// +internal sealed class TickScheduler : IDisposable +{ + private readonly IEvents _events; + private readonly IPluginLogger _log; + private readonly Action _handler; + private readonly List _entries = []; + private readonly List _due = []; + private bool _disposed; + + public TickScheduler(IEvents events, IPluginLogger log) + { + _events = events; + _log = log; + _handler = Tick; + _events.Tick += _handler; + } + + /// Seconds of ticks seen since construction. + public double ElapsedSeconds { get; private set; } + + /// Runs every , first after one interval. + public IDisposable Every(TimeSpan interval, Action callback) + { + if (interval <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(interval)); + return Add(new Entry(this, interval.TotalSeconds, callback, repeat: true)); + } + + /// Runs once, from now. + public IDisposable Delay(TimeSpan delay, Action callback) + { + if (delay < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(delay)); + return Add(new Entry(this, delay.TotalSeconds, callback, repeat: false)); + } + + /// Advances time. The host calls this through its tick; tests call it directly. + public void Tick(double deltaSeconds) + { + if (_disposed) + return; + if (deltaSeconds > 0 && !double.IsNaN(deltaSeconds) && !double.IsInfinity(deltaSeconds)) + ElapsedSeconds += deltaSeconds; + + _due.Clear(); + foreach (Entry entry in _entries) + { + if (!entry.Cancelled && entry.DueAt <= ElapsedSeconds) + _due.Add(entry); + } + + foreach (Entry entry in _due) + { + if (entry.Cancelled) + continue; + if (entry.Repeat) + entry.DueAt = ElapsedSeconds + entry.IntervalSeconds; + else + entry.Cancelled = true; + try + { + entry.Callback(); + } + catch (Exception ex) + { + _log.Error("A scheduled job failed.", ex); + } + } + + _entries.RemoveAll(static entry => entry.Cancelled); + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _events.Tick -= _handler; + _entries.Clear(); + } + + private IDisposable Add(Entry entry) + { + entry.DueAt = ElapsedSeconds + entry.IntervalSeconds; + _entries.Add(entry); + return entry; + } + + private sealed class Entry(TickScheduler owner, double intervalSeconds, Action callback, bool repeat) + : IDisposable + { + public double IntervalSeconds { get; } = intervalSeconds; + + public Action Callback { get; } = callback; + + public bool Repeat { get; } = repeat; + + public double DueAt { get; set; } + + public bool Cancelled { get; set; } + + public void Dispose() + { + Cancelled = true; + _ = owner; + } + } +} diff --git a/src/OpenAC.MosswartMassacre/MmContext.cs b/src/OpenAC.MosswartMassacre/MmContext.cs new file mode 100644 index 0000000..05f3dd2 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/MmContext.cs @@ -0,0 +1,97 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Commands; +using OpenAC.MosswartMassacre.Infrastructure; +using OpenAC.MosswartMassacre.Settings; + +namespace OpenAC.MosswartMassacre; + +/// +/// What every feature shares: the host, the clock, the one scheduler, the chat +/// output, the settings of the character in the world and the backend socket. +/// +internal sealed class MmContext +{ + public MmContext( + IPluginHost host, + IClock clock, + TickScheduler scheduler, + ChatOutput chat, + SettingsStore settings, + BackendClient backend, + MmCommandRouter commands) + { + Host = host; + Clock = clock; + Scheduler = scheduler; + Chat = chat; + Settings = settings; + Backend = backend; + Commands = commands; + } + + public IPluginHost Host { get; } + + public IAutomationSurface Automation => Host.Automation; + + public IClock Clock { get; } + + public TickScheduler Scheduler { get; } + + public ChatOutput Chat { get; } + + public SettingsStore Settings { get; } + + public BackendClient Backend { get; } + + public MmCommandRouter Commands { get; } + + /// True while a character is logged in and its settings are loaded. + public bool IsLoggedIn { get; set; } + + /// + /// The logged-in character's name, read once at login the way the original + /// read its character filter's name. + /// + public string CharacterName { get; set; } = string.Empty; + + /// + /// The per-connection session id the original set on every connect: + /// "<Name>-yyyyMMdd-HHmmss" (UTC). It is the session_id of + /// combat_stats; the telemetry session id is a different, per-process value. + /// + public string ConnectionSessionId { get; set; } = string.Empty; + + /// The per-process id the original put in every telemetry frame. + public static string ProcessSessionId { get; } = Guid.NewGuid().ToString("N"); + + /// + /// Sees every frame the plugin hands to the socket, before the socket + /// decides whether it is open. Tests read the wire through it. + /// + internal Action? FrameTap { get; set; } + + /// Writes a frame to the backend; dropped when the socket is down or streaming is off. + public void Send(object payload) + { + if (!Settings.Current.WebSocketEnabled) + return; + SendRaw(Wire.Serialize(payload)); + } + + /// Writes an already-serialized frame, with the same gate as . + public void SendRaw(string json) + { + if (!Settings.Current.WebSocketEnabled) + return; + FrameTap?.Invoke(json); + Backend.Send(json); + } + + /// A line in chat only when verbose logging is on. + public void Verbose(string message) + { + if (Settings.Current.VerboseLogging) + Chat.Write(message); + } +} diff --git a/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs b/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs new file mode 100644 index 0000000..6ab0d43 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/MosswartMassacrePlugin.cs @@ -0,0 +1,148 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Commands; +using OpenAC.MosswartMassacre.Infrastructure; +using OpenAC.MosswartMassacre.Settings; + +namespace OpenAC.MosswartMassacre; + +/// +/// The composition root. It builds the shared context, the features and the +/// /mm verb, and runs every feature's login and logoff. +/// +/// +/// The host raises login again after a reconnect and when the player relogs to +/// another character, so a login while one is already running is treated as a +/// logoff followed by a fresh login: settings, counters and the socket all +/// belong to one character at a time. +/// +public sealed class MosswartMassacrePlugin : IAcDreamPlugin +{ + private IPluginHost? _host; + private MmContext? _context; + private readonly List _features = []; + private IDisposable? _commandRegistration; + private Action? _onLogin; + private Action? _onLogoff; + private bool _enabled; + + internal MmContext? Context => _context; + + internal IReadOnlyList Features => _features; + + public void Initialize(IPluginHost host) + { + ArgumentNullException.ThrowIfNull(host); + _host = host; + } + + public void Enable() + { + if (_host is null || _enabled) + return; + _enabled = true; + Build(_host, SystemClock.Instance); + + _onLogin = OnLogin; + _onLogoff = OnLogoff; + _host.Events.LoginComplete += _onLogin; + _host.Events.Logoff += _onLogoff; + _commandRegistration = _host.Commands.Register("mm", command => _context!.Commands.Dispatch(command.Arguments)); + + // Enabled while a character is already in the world: that is a login. + if (_host.Automation.IsAvailable && _host.Automation.Character.IsInWorld) + OnLogin(); + + _host.Log.Info("Mosswart Massacre enabled"); + } + + public void Disable() + { + if (_host is null || !_enabled) + return; + _enabled = false; + OnLogoff(); + if (_onLogin is not null) + _host.Events.LoginComplete -= _onLogin; + if (_onLogoff is not null) + _host.Events.Logoff -= _onLogoff; + _commandRegistration?.Dispose(); + _commandRegistration = null; + foreach (IMmFeature feature in _features) + { + try + { + feature.Dispose(); + } + catch (Exception ex) + { + _host.Log.Error("A feature failed to shut down.", ex); + } + } + _features.Clear(); + _context?.Scheduler.Dispose(); + _context?.Backend.Dispose(); + _context = null; + } + + /// Builds the context and the features. Internal so tests can build with a fake clock. + internal void Build(IPluginHost host, IClock clock) + { + _host = host; + var chat = new ChatOutput(host); + var scheduler = new TickScheduler(host.Events, host.Log); + var settings = new SettingsStore(host.Storage, host.Log); + var backend = new BackendClient(host.Log); + var commands = new MmCommandRouter(chat); + _context = new MmContext(host, clock, scheduler, chat, settings, backend, commands); + + var session = new BackendSession(_context); + _features.Add(session); + FeatureCatalog.AddFeatures(_context, session, _features); + } + + internal void OnLogin() + { + if (_context is null || _host is null) + return; + if (_context.IsLoggedIn) + OnLogoff(); + + string name = _host.Automation.Character.Name; + _context.CharacterName = name; + _context.Settings.Load(name); + _context.IsLoggedIn = true; + _context.Chat.Write("Mosswart Massacre has started!"); + + foreach (IMmFeature feature in _features) + { + try + { + feature.OnLogin(); + } + catch (Exception ex) + { + _host.Log.Error($"{feature.GetType().Name} failed at login.", ex); + } + } + } + + internal void OnLogoff() + { + if (_context is null || _host is null || !_context.IsLoggedIn) + return; + foreach (IMmFeature feature in _features) + { + try + { + feature.OnLogoff(); + } + catch (Exception ex) + { + _host.Log.Error($"{feature.GetType().Name} failed at logoff.", ex); + } + } + _context.IsLoggedIn = false; + _context.Settings.Save(); + } +} diff --git a/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj b/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj new file mode 100644 index 0000000..ef71e2a --- /dev/null +++ b/src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + diff --git a/src/OpenAC.MosswartMassacre/Settings/MmSettings.cs b/src/OpenAC.MosswartMassacre/Settings/MmSettings.cs new file mode 100644 index 0000000..fd12190 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Settings/MmSettings.cs @@ -0,0 +1,52 @@ +using Newtonsoft.Json; + +namespace OpenAC.MosswartMassacre.Settings; + +/// +/// One character's settings. The names follow the original plugin's YAML +/// keys so a reader of either file sees the same words. +/// +internal sealed class MmSettings +{ + /// The backend the original plugin compiled in. + public const string DefaultBackendUrl = "wss://overlord.snakedesert.se/websocket/"; + + [JsonProperty("web_socket_enabled")] + public bool WebSocketEnabled { get; set; } + + [JsonProperty("char_tag")] + public string CharTag { get; set; } = "default"; + + [JsonProperty("inventory_log")] + public bool InventoryLog { get; set; } = true; + + [JsonProperty("verbose_logging")] + public bool VerboseLogging { get; set; } + + [JsonProperty("vital_sharing_enabled")] + public bool VitalSharingEnabled { get; set; } + + [JsonProperty("vital_sharing_overlay_x")] + public int VitalSharingOverlayX { get; set; } = 50; + + [JsonProperty("vital_sharing_overlay_y")] + public int VitalSharingOverlayY { get; set; } = 200; + + [JsonProperty("vital_sharing_overlay_visible")] + public bool VitalSharingOverlayVisible { get; set; } = true; + + /// Where the backend listens. + [JsonProperty("backend_url")] + public string BackendUrl { get; set; } = DefaultBackendUrl; + + /// + /// The shared secret sent in the X-Plugin-Secret header. It lives + /// here, never in the assembly. + /// + [JsonProperty("backend_secret")] + public string BackendSecret { get; set; } = string.Empty; + + /// The git server the meta and nav files are synced from. + [JsonProperty("metas_repository_url")] + public string MetasRepositoryUrl { get; set; } = string.Empty; +} diff --git a/src/OpenAC.MosswartMassacre/Settings/SettingsStore.cs b/src/OpenAC.MosswartMassacre/Settings/SettingsStore.cs new file mode 100644 index 0000000..fccf22e --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Settings/SettingsStore.cs @@ -0,0 +1,64 @@ +using AcDream.Plugin.Abstractions; +using Newtonsoft.Json; + +namespace OpenAC.MosswartMassacre.Settings; + +/// +/// Loads and saves in the character's own storage +/// scope, one JSON file per character, as the original kept one YAML file per +/// character. +/// +internal sealed class SettingsStore(IPluginStorage storage, IPluginLogger log) +{ + public const string Key = "settings.json"; + + private IPluginStorage? _scope; + + public MmSettings Current { get; private set; } = new(); + + public string CharacterName { get; private set; } = string.Empty; + + /// Opens the character's scope and reads its file, writing defaults when there is none. + public MmSettings Load(string characterName) + { + CharacterName = characterName; + _scope = storage.IsAvailable + ? storage.OpenScope(PluginStorageScope.Character(characterName)) + : null; + + MmSettings? loaded = null; + if (_scope is not null) + { + try + { + string? text = _scope.ReadText(Key); + if (!string.IsNullOrWhiteSpace(text)) + loaded = JsonConvert.DeserializeObject(text); + } + catch (Exception ex) + { + log.Warn($"Settings for {characterName} could not be read, using defaults: {ex.Message}"); + } + } + + Current = loaded ?? new MmSettings(); + if (string.IsNullOrWhiteSpace(Current.BackendUrl)) + Current.BackendUrl = MmSettings.DefaultBackendUrl; + Save(); + return Current; + } + + public void Save() + { + if (_scope is null) + return; + try + { + _scope.WriteText(Key, JsonConvert.SerializeObject(Current, Formatting.Indented)); + } + catch (Exception ex) + { + log.Warn($"Settings could not be saved: {ex.Message}"); + } + } +} diff --git a/src/OpenAC.MosswartMassacre/plugin.json b/src/OpenAC.MosswartMassacre/plugin.json new file mode 100644 index 0000000..8587c2c --- /dev/null +++ b/src/OpenAC.MosswartMassacre/plugin.json @@ -0,0 +1,15 @@ +{ + "id": "openac.mosswartmassacre", + "displayName": "Mosswart Massacre", + "version": "0.1.0", + "entryDll": "OpenAC.MosswartMassacre.dll", + "apiVersion": 1, + "kinds": ["Gameplay"], + "hosts": ["Graphical", "Headless"], + "minHostVersion": "0.1.18", + "capabilitiesVersion": 1, + "capabilities": [ + { "name": "network", "note": "Streams telemetry, vitals, chat, inventory and stats to the Mosswart Overlord backend, and receives shared vitals and chat-box commands from it." }, + { "name": "chat", "note": "Reads chat for kills, rares and combat stats, forwards it to the backend, and runs commands typed into the Overlord chat box." } + ] +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendClientTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendClientTests.cs new file mode 100644 index 0000000..36aff2a --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendClientTests.cs @@ -0,0 +1,106 @@ +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests.Backend; + +public sealed class BackendClientTests +{ + private static readonly DateTime Connected = new(2026, 9, 25, 8, 30, 15, DateTimeKind.Utc); + + [Fact] + public async Task Connect_sends_the_secret_header_then_registers_first() + { + using var server = new TestSocketServer(); + using var client = new BackendClient(new RecordingLogger()); + + client.Start(server.Uri, "s3cret", "Mossy", () => Connected); + + Assert.Equal("{\"type\":\"register\",\"player_name\":\"Mossy\"}", server.NextFrame()); + Assert.True(server.Secrets.TryPeek(out string? secret)); + Assert.Equal("s3cret", secret); + Assert.True(await WaitAsync(() => client.TryTakeConnection(out _))); + } + + [Fact] + public async Task Frames_sent_after_connect_arrive_in_order() + { + using var server = new TestSocketServer(); + using var client = new BackendClient(new RecordingLogger()); + client.Start(server.Uri, "", "Mossy", () => Connected); + server.NextFrame(); + Assert.True(await WaitAsync(() => client.IsConnected)); + + client.Send("{\"n\":1}"); + client.Send("{\"n\":2}"); + + Assert.Equal("{\"n\":1}", server.NextFrame()); + Assert.Equal("{\"n\":2}", server.NextFrame()); + } + + [Fact] + public void A_frame_sent_while_disconnected_is_dropped() + { + using var client = new BackendClient(new RecordingLogger()); + Assert.False(client.Send("{}")); + } + + [Fact] + public async Task A_frame_larger_than_one_receive_buffer_is_reassembled() + { + using var server = new TestSocketServer(); + using var client = new BackendClient(new RecordingLogger()); + client.Start(server.Uri, "", "Mossy", () => Connected); + server.NextFrame(); + Assert.True(await WaitAsync(() => client.IsConnected)); + + string big = "{\"type\":\"share_vital_update\",\"pad\":\"" + new string('x', 20000) + "\"}"; + await server.PushAsync(" " + big + "\n"); + + string? received = null; + Assert.True(await WaitAsync(() => client.TryReceive(out received))); + Assert.Equal(big, received); + } + + [Fact] + public async Task A_dropped_socket_reconnects_and_registers_again() + { + using var server = new TestSocketServer(); + using var client = new BackendClient(new RecordingLogger()); + client.Start(server.Uri, "", "Mossy", () => Connected); + server.NextFrame(); + Assert.True(await WaitAsync(() => client.IsConnected)); + + await server.DropAsync(); + + Assert.Equal("{\"type\":\"register\",\"player_name\":\"Mossy\"}", server.NextFrame(8000)); + Assert.Equal(2, server.Connections); + } + + [Fact] + public async Task Stop_closes_and_stops_reconnecting() + { + using var server = new TestSocketServer(); + using var client = new BackendClient(new RecordingLogger()); + client.Start(server.Uri, "", "Mossy", () => Connected); + server.NextFrame(); + Assert.True(await WaitAsync(() => client.IsConnected)); + + client.Stop(); + + Assert.False(client.IsRunning); + Assert.False(client.IsConnected); + await Task.Delay(2500); + Assert.Equal(1, server.Connections); + } + + internal static async Task WaitAsync(Func condition, int timeoutMs = 5000) + { + for (int waited = 0; waited < timeoutMs; waited += 20) + { + if (condition()) + return true; + await Task.Delay(20); + } + return condition(); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs new file mode 100644 index 0000000..6d188b3 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Backend/BackendSessionTests.cs @@ -0,0 +1,107 @@ +using Newtonsoft.Json.Linq; + +namespace OpenAC.MosswartMassacre.Tests.Backend; + +public sealed class BackendSessionTests +{ + [Fact] + public void A_command_for_this_character_runs_as_typed_on_the_next_tick() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + + harness.Receive("{\"player_name\":\"mossy\",\"command\":\"/a hello\"}"); + Assert.Empty(harness.Host.Automation.Chat.Submitted); + + harness.Tick(0.01); + + Assert.Equal(["/a hello"], harness.Host.Automation.Chat.Submitted); + } + + [Fact] + public void A_command_for_another_character_is_ignored() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + + harness.Receive("{\"player_name\":\"Horan\",\"command\":\"/a hello\"}"); + harness.Tick(0.01); + + Assert.Empty(harness.Host.Automation.Chat.Submitted); + } + + [Fact] + public void Commands_run_one_per_tick_in_arrival_order() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + + harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"/one\"}"); + harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"/two\"}"); + harness.Tick(0.01); + Assert.Equal(["/one"], harness.Host.Automation.Chat.Submitted); + harness.Tick(0.01); + Assert.Equal(["/one", "/two"], harness.Host.Automation.Chat.Submitted); + } + + [Fact] + public void Radar_commands_toggle_the_radar_instead_of_chat() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + var toggles = new List(); + harness.Session.RadarToggle = on => toggles.Add(on); + + harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"start_radar\"}"); + harness.Receive("{\"player_name\":\"Mossy\",\"command\":\"stop_radar\"}"); + harness.Tick(0.01); + harness.Tick(0.01); + + Assert.Equal([true, false], toggles); + Assert.Empty(harness.Host.Automation.Chat.Submitted); + } + + [Fact] + public void Share_frames_go_to_the_share_handlers_whoever_they_name() + { + using var harness = new PluginHarness("Mossy"); + var seen = new List(); + harness.Session.AddShareHandler(new RecordingShareHandler(seen)); + harness.Login(); + + harness.Receive("{\"type\":\"share_vital_update\",\"character_name\":\"Horan\"}"); + + Assert.Equal(["share_vital_update"], seen); + harness.Tick(0.01); + Assert.Empty(harness.Host.Automation.Chat.Submitted); + } + + [Fact] + public void Malformed_frames_are_skipped() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + + harness.Receive("not json"); + harness.Receive("[1,2,3]"); + harness.Tick(0.01); + + Assert.Empty(harness.Host.Automation.Chat.Submitted); + } + + [Fact] + public void Connecting_sets_the_per_connection_session_id() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + + harness.Session.OnConnected(new DateTime(2026, 9, 25, 8, 5, 9, DateTimeKind.Utc)); + + Assert.Equal("Mossy-20260925-080509", harness.Context.ConnectionSessionId); + } + + private sealed class RecordingShareHandler(List seen) : IShareFrameHandler + { + public void HandleShareFrame(string type, JObject frame) => seen.Add(type); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Backend/TestSocketServer.cs b/tests/OpenAC.MosswartMassacre.Tests/Backend/TestSocketServer.cs new file mode 100644 index 0000000..6ac58d6 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Backend/TestSocketServer.cs @@ -0,0 +1,149 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text; + +namespace OpenAC.MosswartMassacre.Tests.Backend; + +/// +/// A throwaway WebSocket server on a free local port, standing in for the +/// backend: it records the secret header and every frame, and can push frames +/// and drop the connection. +/// +internal sealed class TestSocketServer : IDisposable +{ + private readonly HttpListener _listener = new(); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _accept; + private WebSocket? _current; + + public TestSocketServer() + { + int port = FreePort(); + Uri = new Uri($"ws://localhost:{port}/websocket/"); + _listener.Prefixes.Add($"http://localhost:{port}/websocket/"); + _listener.Start(); + _accept = Task.Run(AcceptLoopAsync); + } + + public Uri Uri { get; } + + public BlockingCollection Frames { get; } = new(); + + public ConcurrentQueue Secrets { get; } = new(); + + public int Connections => Secrets.Count; + + public async Task PushAsync(string text) + { + WebSocket socket = await WaitForSocketAsync(); + byte[] bytes = Encoding.UTF8.GetBytes(text); + // Split into small fragments so the client has to reassemble. + const int chunk = 1000; + for (int offset = 0; offset < bytes.Length; offset += chunk) + { + int count = Math.Min(chunk, bytes.Length - offset); + bool last = offset + count >= bytes.Length; + await socket.SendAsync(new ArraySegment(bytes, offset, count), WebSocketMessageType.Text, last, CancellationToken.None); + } + } + + public async Task DropAsync() + { + WebSocket socket = await WaitForSocketAsync(); + socket.Abort(); + _current = null; + } + + public string NextFrame(int timeoutMs = 5000) + { + if (!Frames.TryTake(out string? frame, timeoutMs)) + throw new TimeoutException("No frame arrived."); + return frame; + } + + public void Dispose() + { + _cts.Cancel(); + _current?.Abort(); + _listener.Stop(); + _listener.Close(); + try + { + _accept.Wait(1000); + } + catch (AggregateException) + { + } + } + + private async Task WaitForSocketAsync() + { + for (int i = 0; i < 200; i++) + { + if (_current is { State: WebSocketState.Open } socket) + return socket; + await Task.Delay(25); + } + throw new TimeoutException("No client connected."); + } + + private async Task AcceptLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await _listener.GetContextAsync(); + } + catch + { + return; + } + if (!context.Request.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + context.Response.Close(); + continue; + } + Secrets.Enqueue(context.Request.Headers["X-Plugin-Secret"]); + HttpListenerWebSocketContext ws = await context.AcceptWebSocketAsync(null); + _current = ws.WebSocket; + _ = Task.Run(() => ReadLoopAsync(ws.WebSocket)); + } + } + + private async Task ReadLoopAsync(WebSocket socket) + { + var buffer = new byte[65536]; + using var message = new MemoryStream(); + try + { + while (socket.State == WebSocketState.Open) + { + WebSocketReceiveResult result = await socket.ReceiveAsync(new ArraySegment(buffer), _cts.Token); + if (result.MessageType == WebSocketMessageType.Close) + return; + message.Write(buffer, 0, result.Count); + if (!result.EndOfMessage) + continue; + Frames.Add(Encoding.UTF8.GetString(message.ToArray())); + message.SetLength(0); + } + } + catch + { + } + } + + private static int FreePort() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + int port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs new file mode 100644 index 0000000..c23551a --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs @@ -0,0 +1,936 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Tests.Fakes; + +/// +/// A settable automation surface. Every child defaults to an empty fake, so a +/// test fills in only the state it exercises. +/// +public sealed class FakeAutomation : IAutomationSurface +{ + public bool IsAvailable { get; set; } + + public FakeCharacter Character { get; } = new(); + + public RecordingChat Chat { get; } = new(); + + public FakeSpellCatalog Spells { get; } = new(); + + public FakeMagic Magic { get; } = new(); + + public FakeItems Items { get; } = new(); + + public FakeLoot Loot { get; } = new(); + + public FakeObjects Objects { get; } = new(); + + public FakeNavigation Navigation { get; } = new(); + + public FakeFellowship Fellowship { get; } = new(); + + public FakeCombat Combat { get; } = new(); + + public FakeEquipment Equipment { get; } = new(); + + public FakeDialogAutomation Dialogs { get; } = new(); + + public FakeTrade Trade { get; } = new(); + + public FakeVendor Vendor { get; } = new(); + + public FakeLogin Login { get; } = new(); + + ICharacterInfo IAutomationSurface.Character => Character; + ISpellCatalog IAutomationSurface.Spells => Spells; + IMagicCommands IAutomationSurface.Magic => Magic; + IPluginChat IAutomationSurface.Chat => Chat; + IItemAutomation IAutomationSurface.Items => Items; + ILootAutomation IAutomationSurface.Loot => Loot; + IWorldObjectAutomation IAutomationSurface.Objects => Objects; + INavigationAutomation IAutomationSurface.Navigation => Navigation; + IFellowshipAutomation IAutomationSurface.Fellowship => Fellowship; + ICombatAutomation IAutomationSurface.Combat => Combat; + IEquipmentAutomation IAutomationSurface.Equipment => Equipment; + IDialogAutomation IAutomationSurface.Dialogs => Dialogs; + ITradeAutomation IAutomationSurface.Trade => Trade; + IVendorAutomation IAutomationSurface.Vendor => Vendor; + ILoginAutomation IAutomationSurface.Login => Login; +} + +/// A settable : a test drives and inspects . +public sealed class FakeTrade : ITradeAutomation +{ + public bool IsAvailable { get; set; } = true; + public bool IsOpen { get; set; } + public uint PartnerObjectId { get; set; } + public string PartnerName { get; set; } = string.Empty; + public List MyItems { get; } = []; + public List PartnerItems { get; } = []; + public bool MyAccepted { get; set; } + public bool PartnerAccepted { get; set; } + + public List Calls { get; } = []; + + /// The status //// answer with. + public PluginTradeCommandStatus NextStatus { get; set; } = PluginTradeCommandStatus.Sent; + + IReadOnlyList ITradeAutomation.MyItems => MyItems; + IReadOnlyList ITradeAutomation.PartnerItems => PartnerItems; + + public event Action? Opened; + public event Action? Closed; + public event Action? PartnerTradeAccepted; + public event Action? ItemAdded; + + public void RaiseOpened(uint initiatorObjectId, uint partnerObjectId) + { + IsOpen = true; + PartnerObjectId = partnerObjectId; + Opened?.Invoke(new PluginTradeOpened(initiatorObjectId, partnerObjectId)); + } + + public void RaiseClosed() + { + IsOpen = false; + Closed?.Invoke(); + } + + public void RaisePartnerAccepted(uint partnerObjectId) + => PartnerTradeAccepted?.Invoke(partnerObjectId); + + public void RaiseItemAdded(uint itemObjectId, bool mine) + => ItemAdded?.Invoke(new PluginTradeItemAdded(itemObjectId, mine)); + + public PluginTradeCommandResult Add(uint itemObjectId) + { + Calls.Add("add:" + itemObjectId); + return new PluginTradeCommandResult(NextStatus); + } + + public PluginTradeCommandResult Accept() + { + Calls.Add("accept"); + return new PluginTradeCommandResult(NextStatus); + } + + public PluginTradeCommandResult Decline() + { + Calls.Add("decline"); + return new PluginTradeCommandResult(NextStatus); + } + + public PluginTradeCommandResult Reset() + { + Calls.Add("reset"); + return new PluginTradeCommandResult(NextStatus); + } + + public PluginTradeCommandResult End() + { + Calls.Add("end"); + return new PluginTradeCommandResult(NextStatus); + } + + event Action ITradeAutomation.Opened + { + add => Opened += value; + remove => Opened -= value; + } + + event Action ITradeAutomation.Closed + { + add => Closed += value; + remove => Closed -= value; + } + + event Action ITradeAutomation.PartnerTradeAccepted + { + add => PartnerTradeAccepted += value; + remove => PartnerTradeAccepted -= value; + } + + event Action ITradeAutomation.ItemAdded + { + add => ItemAdded += value; + remove => ItemAdded -= value; + } +} + +/// A settable . +public sealed class FakeVendor : IVendorAutomation +{ + public bool IsAvailable { get; set; } = true; + public bool IsOpen { get; set; } + public uint VendorObjectId { get; set; } + public string VendorName { get; set; } = string.Empty; + public List Items { get; } = []; + public bool IsBusy { get; set; } + + public Dictionary Properties { get; } = []; + + public List<(uint TemplateObjectId, int Count)> BuyList { get; } = []; + public List SellList { get; } = []; + + public List Calls { get; } = []; + + public PluginVendorCommandStatus NextStatus { get; set; } = PluginVendorCommandStatus.Sent; + + IReadOnlyList IVendorAutomation.Items => Items; + IReadOnlyList<(uint TemplateObjectId, int Count)> IVendorAutomation.BuyList => BuyList; + IReadOnlyList IVendorAutomation.SellList => SellList; + + public event Action? Opened; + public event Action? Closed; + public event Action? TransactionCompleted; + + public void RaiseOpened(uint vendorObjectId) + { + IsOpen = true; + VendorObjectId = vendorObjectId; + Opened?.Invoke(vendorObjectId); + } + + public void RaiseClosed() + { + IsOpen = false; + Closed?.Invoke(); + } + + public void RaiseTransactionCompleted(PluginVendorTransactionKind kind, bool success = true, string? notice = null) + => TransactionCompleted?.Invoke(new PluginVendorTransaction(kind, success, notice)); + + public bool TryCaptureProperties(uint templateObjectId, out PluginItemProperties properties) + => Properties.TryGetValue(templateObjectId, out properties); + + public PluginVendorCommandResult AddToBuyList(uint templateObjectId, int count) + { + Calls.Add("addbuy:" + templateObjectId + ":" + count); + BuyList.Add((templateObjectId, count)); + return new PluginVendorCommandResult(NextStatus); + } + + public PluginVendorCommandResult AddToSellList(uint itemObjectId) + { + Calls.Add("addsell:" + itemObjectId); + SellList.Add(itemObjectId); + return new PluginVendorCommandResult(NextStatus); + } + + public PluginVendorCommandResult RemoveFromBuyList(uint templateObjectId) + { + Calls.Add("removebuy:" + templateObjectId); + return new PluginVendorCommandResult(NextStatus); + } + + public PluginVendorCommandResult RemoveFromSellList(uint itemObjectId) + { + Calls.Add("removesell:" + itemObjectId); + return new PluginVendorCommandResult(NextStatus); + } + + public PluginVendorCommandResult ClearBuyList() + { + Calls.Add("clearbuy"); + BuyList.Clear(); + return new PluginVendorCommandResult(NextStatus); + } + + public PluginVendorCommandResult ClearSellList() + { + Calls.Add("clearsell"); + SellList.Clear(); + return new PluginVendorCommandResult(NextStatus); + } + + public PluginVendorCommandResult BuyAll() + { + Calls.Add("buyall"); + return new PluginVendorCommandResult(NextStatus); + } + + public PluginVendorCommandResult SellAll() + { + Calls.Add("sellall"); + return new PluginVendorCommandResult(NextStatus); + } + + event Action IVendorAutomation.Opened + { + add => Opened += value; + remove => Opened -= value; + } + + event Action IVendorAutomation.Closed + { + add => Closed += value; + remove => Closed -= value; + } + + event Action IVendorAutomation.TransactionCompleted + { + add => TransactionCompleted += value; + remove => TransactionCompleted -= value; + } +} + +/// A settable . +public sealed class FakeLogin : ILoginAutomation +{ + public bool IsAvailable { get; set; } = true; + public bool LogoutResult { get; set; } = true; + public int LogoutCalls { get; private set; } + + public bool Logout() + { + LogoutCalls++; + return LogoutResult; + } +} + +/// Records every call; a test can pre-arm which context ids it accepts. +public sealed class FakeDialogAutomation : IDialogAutomation +{ + public List<(uint ContextId, bool Accept)> Answers { get; } = []; + + /// Context ids reports as outstanding (returns true for). + public HashSet OutstandingContextIds { get; } = []; + + public bool Answer(uint contextId, bool accept) + { + Answers.Add((contextId, accept)); + return OutstandingContextIds.Contains(contextId); + } +} + +public sealed class FakeCharacter : ICharacterInfo +{ + public bool IsInWorld { get; set; } + public string Name { get; set; } = string.Empty; + public string WorldName { get; set; } = string.Empty; + public int ServerPopulation { get; set; } = -1; + public string AccountName { get; set; } = string.Empty; + public int MainPackFreeSlots { get; set; } + public uint ObjectId { get; set; } + public uint CurrentHealth { get; set; } + public uint MaxHealth { get; set; } + public uint CurrentStamina { get; set; } + public uint MaxStamina { get; set; } + public uint CurrentMana { get; set; } + public uint MaxMana { get; set; } + + public List Skills { get; } = []; + public IReadOnlyList Attributes { get; set; } = []; + public IReadOnlyList ActiveEnchantments { get; set; } = []; + + IReadOnlyList ICharacterInfo.Skills => Skills; + + public bool TryGetSkill(uint skillId, out PluginSkillInfo skill) + { + foreach (PluginSkillInfo candidate in Skills) + { + if (candidate.SkillId != skillId) + continue; + skill = candidate; + return true; + } + + skill = default; + return false; + } +} + +/// +/// Records every posted line and every submitted command, and can raise +/// and drive registered filters the way the real host +/// does — before a line is added to or delivered to +/// , every registered filter gets a chance to eat it. +/// +public sealed class RecordingChat : IPluginChat +{ + private readonly List> _filters = []; + private ulong _nextSequence = 1; + + public List Posted { get; } = []; + + public List Submitted { get; } = []; + + public List<(string Text, int LogTextType)> PostedTyped { get; } = []; + + /// Every message offered to , filtered or not. + public List Offered { get; } = []; + + /// Messages that survived every filter and reached . + public List Delivered { get; } = []; + + public IReadOnlyList Messages { get; set; } = []; + + public IReadOnlyList CaptureMessages(ulong afterSequence) + => [.. Messages.Where(message => message.Sequence > afterSequence)]; + + public event Action? Received; + + /// + /// How many distinct handlers are currently subscribed to + /// — used to prove a shared fan-out + /// (ChatClassificationDispatcher) adds exactly one subscription + /// to the raw feed no matter how many of ITS OWN consumers subscribe to + /// it in turn. + /// + public int ReceivedSubscriberCount => Received?.GetInvocationList().Length ?? 0; + + public IDisposable RegisterFilter(Func suppress) + { + _filters.Add(suppress); + return new FilterRegistration(this, suppress); + } + + /// Builds and offers a message the way the real client would, honoring filters. + public PluginChatMessage Deliver( + string text, + int kind = 0, + string sender = "", + string channelName = "", + int logTextType = 0, + int combatKind = 0, + uint senderObjectId = 0u) + { + var message = new PluginChatMessage( + _nextSequence++, senderObjectId, kind, sender, text, channelName) + { + LogTextType = logTextType, + CombatKind = combatKind, + Received = DateTimeOffset.UtcNow, + }; + + Offered.Add(message); + + foreach (Func filter in _filters) + { + if (filter(message)) + return message; + } + + Delivered.Add(message); + Received?.Invoke(message); + return message; + } + + public void PostSystemMessage(string text) => Posted.Add(text); + + public void PostMessage(string text, int logTextType) + { + Posted.Add(text); + PostedTyped.Add((text, logTextType)); + } + + public bool Submit(string text) + { + Submitted.Add(text); + return true; + } + + private sealed class FilterRegistration( + RecordingChat owner, + Func filter) : IDisposable + { + public void Dispose() => owner._filters.Remove(filter); + } +} + +public sealed class FakeSpellCatalog : ISpellCatalog +{ + public IReadOnlyList KnownSelfBuffs { get; set; } = []; + public IReadOnlyList KnownAttackSpells { get; set; } = []; + public IReadOnlyList KnownCombatSpells { get; set; } = []; + + /// The full spell table, independent of what the character knows. + public IReadOnlyList All { get; set; } = []; + + public bool TryGet(uint spellId, out PluginSpellInfo info) + { + foreach (PluginSpellInfo spell in KnownSelfBuffs + .Concat(KnownAttackSpells).Concat(KnownCombatSpells).Concat(All)) + { + if (spell.SpellId != spellId) + continue; + info = spell; + return true; + } + + info = default; + return false; + } + + public bool TryFindByName(string name, bool partialMatch, out PluginSpellInfo spell) + { + spell = default; + if (string.IsNullOrWhiteSpace(name)) + return false; + + foreach (PluginSpellInfo candidate in All) + { + if (string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase)) + { + spell = candidate; + return true; + } + } + + if (!partialMatch) + return false; + + foreach (PluginSpellInfo candidate in All) + { + if (candidate.Name.Contains(name, StringComparison.OrdinalIgnoreCase)) + { + spell = candidate; + return true; + } + } + + return false; + } + + /// Builds a minimal spell record for a test. + public static PluginSpellInfo Spell(uint spellId, string name) + => new(spellId, name, 0u, 1, 0, 0, 0f, 0u, string.Empty, true, true); +} + +public sealed class FakeMagic : IMagicCommands +{ + public bool IsCasting { get; set; } + + public List<(uint SpellId, uint TargetObjectId)> Casts { get; } = []; + + /// What / answer -- a test sets this to simulate a refusal that isn't a plain Cast()==false. + public PluginCastRequestResult RequestResult { get; set; } = PluginCastRequestResult.Sent; + + public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Ready; + + public PluginCastGate EvaluateGate(uint spellId, uint targetObjectId) + => PluginCastGate.Ready; + + public bool Cast(uint spellId) + { + Casts.Add((spellId, 0u)); + return RequestResult == PluginCastRequestResult.Sent; + } + + public bool Cast(uint spellId, uint targetObjectId) + { + Casts.Add((spellId, targetObjectId)); + return RequestResult == PluginCastRequestResult.Sent; + } + + public PluginCastRequestResult RequestCast(uint spellId) + { + Casts.Add((spellId, 0u)); + return RequestResult; + } + + public PluginCastRequestResult RequestCast(uint spellId, uint targetObjectId) + { + Casts.Add((spellId, targetObjectId)); + return RequestResult; + } +} + +public sealed class FakeItems : IItemAutomation +{ + public bool IsAvailable { get; set; } = true; + public bool IsBusy { get; set; } + + /// What returns -- a test sets this to simulate the host rejecting the command. + public PluginItemCommandResult UseResult { get; set; } = + new(PluginItemCommandStatus.Started); + + public List Owned { get; } = []; + + public List<(string Command, uint ObjectId, uint TargetObjectId)> Calls { get; } = []; + + /// Keyed by object id; set by a test to make answer. + public Dictionary Properties { get; } = []; + + /// + /// How many times has been called — used + /// to prove a coalescing host (H1/H3) captures at most once per tick no + /// matter how many ObjectChanged events arrived inside it. + /// + public int CaptureCount { get; private set; } + + /// Same counter as under the name the HUD-gate tests use. + public int CaptureOwnedItemsCalls => CaptureCount; + + public IReadOnlyList CaptureOwnedItems() + { + CaptureCount++; + // LOW-C (P10 re-review): the real host's CaptureOwnedItems + // (AppAutomationSurface.cs:2573's `built`) allocates a fresh list + // every call rather than exposing a live view -- return a snapshot + // here too, so a caller that stores the result (InventoryLogger's + // _lastOwnedSnapshot) is not silently aliased to this mutable list + // and later corrupted by an unrelated Owned.Add/Clear. + return Owned.ToList(); + } + + public bool TryCaptureProperties(uint objectId, out PluginItemProperties properties) + => Properties.TryGetValue(objectId, out properties); + + public PluginItemCommandResult Use(uint objectId) + { + Calls.Add(("use", objectId, 0u)); + return UseResult; + } + + /// What returns -- a test sets this to simulate the host rejecting the command. + public PluginItemCommandResult ApplyResult { get; set; } = + new(PluginItemCommandStatus.Started); + + public PluginItemCommandResult Apply(uint objectId, uint targetObjectId) + { + Calls.Add(("apply", objectId, targetObjectId)); + return ApplyResult; + } + + public PluginItemCommandResult Drop(uint objectId, uint amount = 0u) + { + Calls.Add(("drop", objectId, 0u)); + return new PluginItemCommandResult(PluginItemCommandStatus.Started); + } + + public PluginItemCommandResult Give( + uint objectId, + uint targetObjectId, + uint amount = 0u) + { + Calls.Add(("give", objectId, targetObjectId)); + return new PluginItemCommandResult(PluginItemCommandStatus.Started); + } + + /// Every call, in order. + public List<(uint ObjectId, uint ContainerObjectId, uint Amount, int Placement)> Moves { get; } = []; + + /// Every call, in order. + public List<(uint SourceObjectId, uint TargetObjectId, uint Amount)> Merges { get; } = []; + + public PluginItemCommandResult MoveToContainer( + uint objectId, + uint containerObjectId, + uint amount = 0u, + int placement = 0) + { + Calls.Add(("move", objectId, containerObjectId)); + Moves.Add((objectId, containerObjectId, amount, placement)); + return new PluginItemCommandResult(PluginItemCommandStatus.Started); + } + + public PluginItemCommandResult Merge( + uint sourceObjectId, + uint targetObjectId, + uint amount = 0u) + { + Calls.Add(("merge", sourceObjectId, targetObjectId)); + Merges.Add((sourceObjectId, targetObjectId, amount)); + return new PluginItemCommandResult(PluginItemCommandStatus.Started); + } + + /// Builds a minimal owned item for a test. + public static PluginInventoryItem Item( + uint objectId, + string name, + uint equippedLocation = 0u) + => new( + objectId, 0u, name, 0u, 0u, 0u, 0u, equippedLocation, 0u, 0u, 0u, + 1, 0, 0, 0u, 0, 0, 0u, false, 0d, 0, 0, 0, 0d, 0, 0, 0); +} + +public sealed class FakeLoot : ILootAutomation +{ + public bool IsAvailable { get; set; } = true; + + public bool IsBusy { get; set; } + + /// + /// The shared appraisal slot's state (MEDIUM-2, P14 review) -- a test + /// sets AwaitingObjectId to a DIFFERENT nonzero id than what it + /// asked + /// for to simulate that request having been displaced by a later one. + /// + public PluginAppraisalState Appraisal { get; set; } + + public List Contents { get; } = []; + + public List PickedUp { get; } = []; + + public List Opened { get; } = []; + + public List IdentifyRequests { get; } = []; + + public Dictionary Properties { get; } = []; + + public IReadOnlyList CaptureCurrentContents() => Contents; + + public bool TryCaptureProperties(uint objectId, out PluginItemProperties properties) + => Properties.TryGetValue(objectId, out properties); + + public PluginItemCommandResult Open(uint containerObjectId) + { + Opened.Add(containerObjectId); + return new PluginItemCommandResult(PluginItemCommandStatus.Started); + } + + public PluginItemCommandResult Identify(uint objectId) + { + IdentifyRequests.Add(objectId); + return new PluginItemCommandResult(PluginItemCommandStatus.Started); + } + + public PluginItemCommandResult Pickup(uint objectId, bool mainPack = false) + { + PickedUp.Add(objectId); + return new PluginItemCommandResult(PluginItemCommandStatus.Started); + } +} + +public sealed class FakeObjects : IWorldObjectAutomation +{ + public bool IsAvailable { get; set; } = true; + + public uint OpenContainerObjectId { get; set; } + + public List Objects { get; } = []; + + public Dictionary Properties { get; } = []; + + public List IdentifyRequests { get; } = []; + + /// What returns by default for any object id not covered by . + public PluginItemCommandStatus IdentifyResult { get; set; } = PluginItemCommandStatus.Started; + + /// Per-object-id override for 's returned status, checked before . + public Dictionary IdentifyResultOverrides { get; } = []; + + /// + /// Per-object-id remaining count of consecutive Busy responses + /// should return before falling through to + /// / -- + /// simulates the host's single-in-flight-request gate + /// (InventoryTransactionState.CanBeginRequest) staying busy for a + /// bounded number of retries. Checked before, and decremented ahead of, + /// . + /// + public Dictionary IdentifyBusyForCalls { get; } = []; + + /// + /// How many times has been called — used to + /// prove a headless host's HUD updater (a UI-only presenter) never runs + /// its 1 Hz full-landscape scan when is + /// false. + /// + public int CaptureObjectsCalls { get; private set; } + + public IReadOnlyList CaptureObjects() + { + CaptureObjectsCalls++; + return Objects; + } + + public bool TryGet(uint objectId, out PluginWorldObject value) + { + foreach (PluginWorldObject candidate in Objects) + { + if (candidate.ObjectId != objectId) + continue; + value = candidate; + return true; + } + + value = default; + return false; + } + + public bool TryCaptureProperties(uint objectId, out PluginItemProperties properties) + => Properties.TryGetValue(objectId, out properties); + + public PluginItemCommandResult Identify(uint objectId) + { + IdentifyRequests.Add(objectId); + + if (IdentifyBusyForCalls.TryGetValue(objectId, out int remaining) && remaining > 0) + { + IdentifyBusyForCalls[objectId] = remaining - 1; + return new PluginItemCommandResult(PluginItemCommandStatus.Busy); + } + + PluginItemCommandStatus status = IdentifyResultOverrides.TryGetValue(objectId, out PluginItemCommandStatus overridden) + ? overridden + : IdentifyResult; + return new PluginItemCommandResult(status); + } + + /// Replaces a tracked object's snapshot (e.g. to flip HasAppraisalData). + public void Replace(PluginWorldObject value) + { + for (int index = 0; index < Objects.Count; index++) + { + if (Objects[index].ObjectId != value.ObjectId) + continue; + Objects[index] = value; + return; + } + + Objects.Add(value); + } + + /// + /// Builds a landscape object at east of the + /// origin the fake navigation reports. + /// + public static PluginWorldObject Landscape( + uint objectId, + string name, + PluginObjectClass objectClass, + double metres) + => new(objectId, 0u, name, objectClass, 0u, 0u, 0u) + { + IsLandscape = true, + HasPosition = true, + Position = new PluginNavigationPosition( + 1u, metres / 240d, 0d, 0d, 0f, true), + }; +} + +public sealed class FakeNavigation : INavigationAutomation +{ + public PluginNavigationSnapshot Snapshot { get; set; } = + new(true, false, 1u, default, false, false); + + public List Headings { get; } = []; + + /// Backs for tests that need a nearby landscape object (e.g. a chest). + public List Objects { get; } = []; + + public IReadOnlyList CaptureObjects() => Objects; + + public PluginNavigationCommandStatus FaceHeading(float headingDegrees) + { + Headings.Add(headingDegrees); + return PluginNavigationCommandStatus.Accepted; + } + + public bool TryGetObject(uint objectId, out PluginNavigationObject value) + { + value = default; + return false; + } + + public PluginNavigationCommandStatus SetMovementIntent( + in PluginMovementIntent intent) + => PluginNavigationCommandStatus.Accepted; + + public PluginNavigationCommandStatus ClearMovementIntent() + => PluginNavigationCommandStatus.Accepted; +} + +public sealed class FakeFellowship : IFellowshipAutomation +{ + public List Calls { get; } = []; + + public bool IsInFellowship { get; set; } + + /// What every command below answers -- a test sets this to simulate a server refusal. + public PluginFellowshipCommandResult Result { get; set; } = + new(PluginFellowshipCommandStatus.Accepted); + + public PluginFellowshipCommandResult Create(string name, bool shareExperience) + { + Calls.Add("create:" + name + ":" + shareExperience); + return Result; + } + + public PluginFellowshipCommandResult SetOpen(bool isOpen) + { + Calls.Add("setopen:" + isOpen); + return Result; + } + + public PluginFellowshipCommandResult Quit(bool disband) + { + Calls.Add("quit:" + disband); + return Result; + } + + public PluginFellowshipCommandResult Recruit(uint targetObjectId) + { + Calls.Add("recruit:" + targetObjectId); + return Result; + } +} + +public sealed class FakeCombat : ICombatAutomation +{ + public PluginCombatSnapshot Snapshot { get; set; } = + new(0u, PluginCombatMode.Peace, PluginAttackHeight.Medium, 1f, 0f, + false, false, false, false); + + public List Calls { get; } = []; + + /// Every BeginPhysicalAttack call, with the height/power it used. + public List<(uint TargetObjectId, PluginAttackHeight Height, float Power)> + BeginAttacks { get; } = []; + + public PluginCombatCommandResult EnterDefaultMode() + { + Calls.Add("mode:default"); + return new PluginCombatCommandResult( + PluginCombatCommandStatus.ModeChangeSent); + } + + public PluginCombatCommandResult EnterMode(PluginCombatMode mode) + { + Calls.Add("mode:" + mode); + return new PluginCombatCommandResult( + PluginCombatCommandStatus.ModeChangeSent); + } + + public PluginCombatCommandResult BeginPhysicalAttack( + uint targetObjectId, + PluginAttackHeight height, + float power) + { + Calls.Add("attack:" + targetObjectId); + BeginAttacks.Add((targetObjectId, height, power)); + return new PluginCombatCommandResult(PluginCombatCommandStatus.Started); + } + + public IReadOnlyList CaptureHostileTargets( + float maximumDistance) => []; + + public PluginCombatCommandResult ReleasePhysicalAttack() + { + Calls.Add("release"); + return new PluginCombatCommandResult(PluginCombatCommandStatus.Released); + } + + public PluginCombatCommandResult AbortPhysicalAttack() + { + Calls.Add("abort"); + return new PluginCombatCommandResult(PluginCombatCommandStatus.Stopped); + } +} + +public sealed class FakeEquipment : IEquipmentAutomation +{ + public bool IsAvailable { get; set; } = true; + + public List Equipped { get; } = []; + + public PluginEquipmentCommandResult Equip( + uint objectId, + uint requestedLocation = 0u) + { + Equipped.Add(objectId); + return new PluginEquipmentCommandResult( + PluginEquipmentCommandStatus.Started); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs new file mode 100644 index 0000000..60bf405 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs @@ -0,0 +1,575 @@ +using AcDream.Plugin.Abstractions; + +namespace OpenAC.MosswartMassacre.Tests.Fakes; + +/// +/// The one fake host every test uses. Each surface is settable so a test names +/// only the state it cares about. +/// +public sealed class FakeHost : IPluginHost +{ + public bool HasUi { get; set; } + + public IPluginLogger Log { get; } = new RecordingLogger(); + + public IGameState State { get; } = new FakeGameState(); + + public FakeEvents Events { get; } = new(); + + public FakeSelection Selection { get; } = new(); + + public FakeUiRegistry Ui { get; } = new(); + + public FakeCommandRegistry Commands { get; } = new(); + + public MemoryStorage Storage { get; } = new(); + + public FakeAutomation Automation { get; } = new(); + + public FakeLootClassifierRegistry LootClassifiers { get; } = new(); + + public FakeClipboard Clipboard { get; } = new(); + + public FakeHotkeyRegistry Hotkeys { get; } = new(); + + public FakeHostWindow Window { get; } = new(); + + IEvents IPluginHost.Events => Events; + + ISelectionService IPluginHost.Selection => Selection; + + IUiRegistry IPluginHost.Ui => Ui; + + IPluginCommandRegistry IPluginHost.Commands => Commands; + + IPluginStorage IPluginHost.Storage => Storage; + + IAutomationSurface IPluginHost.Automation => Automation; + + IPluginLootClassifierRegistry IPluginHost.LootClassifiers => LootClassifiers; + + IPluginClipboard IPluginHost.Clipboard => Clipboard; + + IHotkeyRegistry IPluginHost.Hotkeys => Hotkeys; + + IHostWindow IPluginHost.Window => Window; + + /// The chat lines the plugin posted, in order. + public IReadOnlyList ChatLines => Automation.Chat.Posted; +} + +/// +/// A settable : records every call, defaults to +/// like a graphical host with a +/// responsive window, and a test can flip / +/// / to simulate a +/// refusal (a headless host, or an unconfirmed platform outcome such as +/// Wayland's always-Unavailable Minimize). +/// +public sealed class FakeHostWindow : IHostWindow +{ + public int MinimizeCalls { get; private set; } + + public int RestoreCalls { get; private set; } + + public int RequestCloseCalls { get; private set; } + + public bool IsMinimized { get; set; } + + public HostWindowResult MinimizeResult { get; set; } = new(HostWindowStatus.Done); + + public HostWindowResult RestoreResult { get; set; } = new(HostWindowStatus.Done); + + public HostWindowResult RequestCloseResult { get; set; } = new(HostWindowStatus.Done); + + public HostWindowResult Minimize() + { + MinimizeCalls++; + if (MinimizeResult.Succeeded) + IsMinimized = true; + return MinimizeResult; + } + + public HostWindowResult Restore() + { + RestoreCalls++; + if (RestoreResult.Succeeded) + IsMinimized = false; + return RestoreResult; + } + + public HostWindowResult RequestClose() + { + RequestCloseCalls++; + return RequestCloseResult; + } +} + +/// A settable : records every write, can fail on demand. +public sealed class FakeClipboard : IPluginClipboard +{ + public List Written { get; } = []; + + public bool Available { get; set; } = true; + + public bool TrySetText(string text) + { + if (!Available) + return false; + Written.Add(text); + return true; + } +} + +/// +/// A settable : one test-controlled +/// classifier answers // +/// , or the registry reports no classifier +/// registered at all (the "VTank isn't loaded" degradation path) when +/// is left empty. +/// +public sealed class FakeLootClassifierRegistry : IPluginLootClassifierRegistry +{ + public List Available { get; } = []; + + /// What returns for any classifier id. + public PluginLootClassification? ClassificationResult { get; set; } + + /// What returns for any classifier id. + public bool NeedsIdentificationResult { get; set; } + + /// + /// When set, overrides with a + /// per-context verdict -- for a test where only SOME items in a + /// container still need identification. + /// + public Func? NeedsIdentificationHandler { get; set; } + + /// What returns for any classifier id. + public PluginLootClassification? ProfileClassificationResult { get; set; } + + /// + /// When set, overrides with a + /// per-context verdict — for a test that needs different items to + /// classify differently. Returning null from the function means "no + /// match" (mirrors the live registry's false return). + /// + public Func? ClassifyHandler { get; set; } + + /// + /// When set, overrides with a + /// per-profile, per-context verdict. Returning null means "no profile, + /// or no match" (both collapse to the registry's false). + /// + public Func? ProfileClassifyHandler { get; set; } + + /// Every profile name ever probed/classified via . + public List ProfileNamesSeen { get; } = []; + + public List ClassifyCalls { get; } = []; + + IReadOnlyList IPluginLootClassifierRegistry.Available => Available; + + public bool TryClassify( + string classifierId, + in PluginLootClassificationContext context, + out PluginLootClassification classification) + { + ClassifyCalls.Add(classifierId); + + if (ClassifyHandler is { } handler) + { + PluginLootClassification? result = handler(context); + if (result is { } found) + { + classification = found; + return true; + } + + classification = default; + return false; + } + + if (ClassificationResult is { } fixedResult) + { + classification = fixedResult; + return true; + } + + classification = default; + return false; + } + + public bool TryNeedsIdentification( + string classifierId, + in PluginLootClassificationContext context) + => NeedsIdentificationHandler is { } handler ? handler(context) : NeedsIdentificationResult; + + public bool TryClassifyWithProfile( + string classifierId, + string profileName, + in PluginLootClassificationContext context, + out PluginLootClassification classification) + { + ProfileNamesSeen.Add(profileName); + + if (ProfileClassifyHandler is { } handler) + { + PluginLootClassification? result = handler(profileName, context); + if (result is { } found) + { + classification = found; + return true; + } + + classification = default; + return false; + } + + if (ProfileClassificationResult is { } fixedResult) + { + classification = fixedResult; + return true; + } + + classification = default; + return false; + } +} + +/// Records every registration and lets a test fire the handler or flip . +public sealed class FakeHotkeyRegistry : IHotkeyRegistry +{ + public List<(string Id, string DisplayName, PluginKeyChord DefaultChord, Action Handler)> Registrations { get; } = []; + + /// The most recently returned handle for each id, so a test can assert on its Disposed/IsBound state. + public Dictionary Handles { get; } = new(StringComparer.Ordinal); + + public IPluginHotkeyRegistration Register( + string id, + string displayName, + PluginKeyChord defaultChord, + Action handler) + { + Registrations.Add((id, displayName, defaultChord, handler)); + var registration = new FakeHotkeyRegistration(defaultChord); + Handles[id] = registration; + return registration; + } + + /// Invokes the handler registered under , as if the chord had just fired. + public void Fire(string id) + { + foreach ((string registeredId, _, _, Action handler) in Registrations) + { + if (string.Equals(registeredId, id, StringComparison.Ordinal)) + { + handler(); + return; + } + } + } +} + +public sealed class FakeHotkeyRegistration(PluginKeyChord chord) : IPluginHotkeyRegistration +{ + public bool IsBound { get; set; } = true; + public PluginKeyChord EffectiveChord { get; private set; } = chord; + public bool Disposed { get; private set; } + + public void Rebind(PluginKeyChord newChord) => EffectiveChord = newChord; + + public void Dispose() => Disposed = true; +} + +public sealed class RecordingLogger : IPluginLogger +{ + public List Messages { get; } = []; + + public void Info(string message) => Messages.Add("info: " + message); + + public void Warn(string message) => Messages.Add("warn: " + message); + + public void Error(string message, Exception? exception = null) + => Messages.Add("error: " + message); +} + +public sealed class FakeGameState : IGameState +{ + public IReadOnlyList Entities { get; set; } = []; +} + +/// Drives from a test. +public sealed class FakeEvents : IEvents +{ + public event Action? EntitySpawned; + + public event Action? Tick; + + public event Action? LoginComplete; + + public event Action? Logoff; + + public event Action? LocalPlayerDied; + + public event Action? ObjectChanged; + + public event Action? ContainerOpened; + + public event Action? ContainerClosed; + + public event Action? ConfirmationRequested; + + public int TickSubscriberCount => Tick?.GetInvocationList().Length ?? 0; + + public int LoginCompleteSubscriberCount => + LoginComplete?.GetInvocationList().Length ?? 0; + + public int LogoffSubscriberCount => Logoff?.GetInvocationList().Length ?? 0; + + public void RaiseTick(double elapsedSeconds) => Tick?.Invoke(elapsedSeconds); + + public void RaiseEntitySpawned(WorldEntitySnapshot snapshot) + => EntitySpawned?.Invoke(snapshot); + + public void RaiseLoginComplete() => LoginComplete?.Invoke(); + + public void RaiseLogoff() => Logoff?.Invoke(); + + public void RaiseLocalPlayerDied(string deathMessage) + => LocalPlayerDied?.Invoke(deathMessage); + + public void RaiseObjectChanged(PluginObjectChange change) => ObjectChanged?.Invoke(change); + + public void RaiseObjectChanged(uint objectId, PluginObjectChangeKind kind) + => ObjectChanged?.Invoke(new PluginObjectChange(objectId, kind)); + + public void RaiseContainerOpened(uint containerObjectId) => ContainerOpened?.Invoke(containerObjectId); + + public void RaiseContainerClosed(uint containerObjectId) => ContainerClosed?.Invoke(containerObjectId); + + public void RaiseConfirmationRequested(PluginConfirmation confirmation) + => ConfirmationRequested?.Invoke(confirmation); + + event Action IEvents.ConfirmationRequested + { + add => ConfirmationRequested += value; + remove => ConfirmationRequested -= value; + } + + event Action IEvents.ObjectChanged + { + add => ObjectChanged += value; + remove => ObjectChanged -= value; + } + + event Action IEvents.ContainerOpened + { + add => ContainerOpened += value; + remove => ContainerOpened -= value; + } + + event Action IEvents.ContainerClosed + { + add => ContainerClosed += value; + remove => ContainerClosed -= value; + } + + event Action IEvents.EntitySpawned + { + add => EntitySpawned += value; + remove => EntitySpawned -= value; + } + + event Action IEvents.Tick + { + add => Tick += value; + remove => Tick -= value; + } + + event Action IEvents.LoginComplete + { + add => LoginComplete += value; + remove => LoginComplete -= value; + } + + event Action IEvents.Logoff + { + add => Logoff += value; + remove => Logoff -= value; + } + + event Action IEvents.LocalPlayerDied + { + add => LocalPlayerDied += value; + remove => LocalPlayerDied -= value; + } +} + +public sealed class FakeSelection : ISelectionService +{ + public uint? SelectedObjectId { get; private set; } + + public uint? PreviousObjectId { get; private set; } + + public event Action? Changed; + + public bool Select(uint objectId) + { + PreviousObjectId = SelectedObjectId; + SelectedObjectId = objectId; + Changed?.Invoke(new SelectionChangedEvent(PreviousObjectId, objectId)); + return true; + } + + public bool Clear() + { + PreviousObjectId = SelectedObjectId; + SelectedObjectId = null; + Changed?.Invoke(new SelectionChangedEvent(PreviousObjectId, null)); + return true; + } +} + +public sealed record RegisteredPanel( + PluginPanelDescriptor Descriptor, + string MarkupPath, + object Binding); + +public sealed class FakeUiRegistry : IUiRegistry +{ + public List Panels { get; } = []; + + /// Every call, in order -- a test sets to simulate the host refusing it. + public List ShowClientWindowCalls { get; } = []; + + public bool ShowClientWindowResult { get; set; } = true; + + public bool ShowClientWindow(PluginClientWindow window) + { + ShowClientWindowCalls.Add(window); + return ShowClientWindowResult; + } + + public List HideClientWindowCalls { get; } = []; + + public bool HideClientWindow(PluginClientWindow window) + { + HideClientWindowCalls.Add(window); + return true; + } + + public List ToggleClientWindowCalls { get; } = []; + + public bool ToggleClientWindowResult { get; set; } = true; + + public bool ToggleClientWindow(PluginClientWindow window) + { + ToggleClientWindowCalls.Add(window); + return ToggleClientWindowResult; + } + + public HashSet VisibleClientWindows { get; } = []; + + public bool IsClientWindowVisible(PluginClientWindow window) + => VisibleClientWindows.Contains(window); + + public void AddMarkupPanel(string markupPath, object binding) + => Panels.Add(new RegisteredPanel( + new PluginPanelDescriptor( + Path.GetFileNameWithoutExtension(markupPath), + "Mag-Tools"), + markupPath, + binding)); + + public void AddPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + => Panels.Add(new RegisteredPanel(descriptor, markupPath, binding)); + + public IDisposable RegisterPanel( + PluginPanelDescriptor descriptor, + string markupPath, + object binding) + { + var panel = new RegisteredPanel(descriptor, markupPath, binding); + Panels.Add(panel); + return new PanelRegistration(this, panel); + } + + private sealed class PanelRegistration(FakeUiRegistry owner, RegisteredPanel panel) + : IDisposable + { + public bool Disposed { get; private set; } + + public void Dispose() + { + Disposed = true; + owner.Panels.Remove(panel); + } + } +} + +public sealed class FakeCommandRegistry : IPluginCommandRegistry +{ + public Dictionary> Handlers { get; } = + new(StringComparer.OrdinalIgnoreCase); + + public IDisposable Register(string verb, Action handler) + { + Handlers[verb] = handler; + return new Registration(this, verb); + } + + private sealed class Registration(FakeCommandRegistry owner, string verb) + : IDisposable + { + public void Dispose() => owner.Handlers.Remove(verb); + } +} + +/// An in-memory . +public sealed class MemoryStorage : IPluginStorage +{ + private readonly Dictionary _files = + new(StringComparer.OrdinalIgnoreCase); + + public bool IsAvailable { get; set; } = true; + + public string? RootPath { get; set; } = "memory://storage"; + + public int WriteCount { get; private set; } + + /// Makes the next (and every subsequent) write throw. + public bool ThrowOnWrite { get; set; } + + /// + /// The message throws with while + /// is set. Change it between calls to make + /// consecutive throws distinct EXCEPTION SHAPES for a test — the default + /// keeps every throw identical, for a test pinning same-shape dedup. + /// + public string ThrowMessage { get; set; } = "Storage write failed (test fault)."; + + public string? ReadText(string key) + => _files.TryGetValue(key, out string? value) ? value : null; + + public IReadOnlyList List(string prefix) + => [.. _files.Keys.Where(key => + key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))]; + + public void WriteText(string key, string content) + { + if (ThrowOnWrite) + throw new InvalidOperationException(ThrowMessage); + + _files[key] = content; + WriteCount++; + } + + public bool Delete(string key) => _files.Remove(key); + + /// Seeds a file as if it had been copied in by hand. + public void Seed(string key, string content) => _files[key] = content; +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/OpenAC.MosswartMassacre.Tests.csproj b/tests/OpenAC.MosswartMassacre.Tests/OpenAC.MosswartMassacre.Tests.csproj new file mode 100644 index 0000000..afd5a87 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/OpenAC.MosswartMassacre.Tests.csproj @@ -0,0 +1,25 @@ + + + false + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/OpenAC.MosswartMassacre.Tests/PluginHarness.cs b/tests/OpenAC.MosswartMassacre.Tests/PluginHarness.cs new file mode 100644 index 0000000..2f0dc8e --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/PluginHarness.cs @@ -0,0 +1,101 @@ +using Newtonsoft.Json.Linq; +using OpenAC.MosswartMassacre.Infrastructure; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests; + +/// A clock a test sets by hand. +internal sealed class FixedClock : IClock +{ + public DateTime UtcNow { get; set; } = new(2026, 9, 25, 8, 30, 15, 123, DateTimeKind.Utc); + + public DateTime Now { get; set; } = new(2026, 9, 25, 10, 30, 15, 123, DateTimeKind.Local); + + public void Advance(TimeSpan by) + { + UtcNow += by; + Now += by; + } +} + +/// +/// The plugin built on a fake host and a fixed clock, logged in as a named +/// character with streaming on, and every frame it sends recorded. +/// +internal sealed class PluginHarness : IDisposable +{ + public PluginHarness(string characterName = "Mossy", bool webSocketEnabled = true, Action? arrange = null) + { + Host.Automation.IsAvailable = true; + Host.Automation.Character.Name = characterName; + Host.Automation.Character.IsInWorld = false; + Host.Automation.Character.ObjectId = 0x50000001; + arrange?.Invoke(Host); + Plugin.Initialize(Host); + Plugin.Build(Host, Clock); + Context.FrameTap = frame => Frames.Add(frame); + // The stored settings keep streaming off so a login never opens a real + // socket; Login() turns it on in memory afterwards, which is all the + // frame gate reads. + Context.Settings.Load(characterName); + Context.Settings.Current.WebSocketEnabled = false; + Context.Settings.Current.BackendUrl = "ws://127.0.0.1:1/unused/"; + Context.Settings.Save(); + _webSocketEnabled = webSocketEnabled; + } + + private readonly bool _webSocketEnabled; + + public FakeHost Host { get; } = new(); + + public FixedClock Clock { get; } = new(); + + public MosswartMassacrePlugin Plugin { get; } = new(); + + public MmContext Context => Plugin.Context!; + + public List Frames { get; } = []; + + public IReadOnlyList ChatLines => Host.ChatLines; + + /// Logs in without starting the socket. + public void Login() + { + Host.Automation.Character.IsInWorld = true; + Plugin.OnLogin(); + Context.Settings.Current.WebSocketEnabled = _webSocketEnabled; + } + + /// Runs what the plugin runs right after the socket connects and registers. + public void SimulateConnected() => Session.OnConnected(Clock.UtcNow); + + public OpenAC.MosswartMassacre.Backend.BackendSession Session => + Plugin.Features.OfType().Single(); + + /// A frame as if the backend had sent it; routed on the next tick. + public void Receive(string frame) => Session.Route(frame); + + public T Feature() where T : IMmFeature => Plugin.Features.OfType().Single(); + + public void Tick(double seconds) => Host.Events.RaiseTick(seconds); + + /// Advances both the scheduler and the clock by in small steps. + public void Run(double seconds, double step = 0.1) + { + for (double t = 0; t < seconds - 1e-9; t += step) + { + Clock.Advance(TimeSpan.FromSeconds(step)); + Tick(step); + } + } + + public IEnumerable FramesOfType(string type) => + Frames.Select(JObject.Parse).Where(frame => (string?)frame["type"] == type); + + public void Dispose() + { + Plugin.OnLogoff(); + foreach (IMmFeature feature in Plugin.Features) + feature.Dispose(); + } +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/PluginLifecycleTests.cs b/tests/OpenAC.MosswartMassacre.Tests/PluginLifecycleTests.cs new file mode 100644 index 0000000..17466ee --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/PluginLifecycleTests.cs @@ -0,0 +1,99 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Tests.Fakes; + +namespace OpenAC.MosswartMassacre.Tests; + +public sealed class PluginLifecycleTests +{ + [Fact] + public void Enable_registers_the_mm_verb_and_login_loads_that_characters_settings() + { + var host = new FakeHost(); + host.Automation.IsAvailable = true; + host.Automation.Character.Name = "Mossy"; + var plugin = new MosswartMassacrePlugin(); + plugin.Initialize(host); + plugin.Enable(); + + Assert.True(host.Commands.Handlers.ContainsKey("mm")); + + host.Automation.Character.IsInWorld = true; + host.Events.RaiseLoginComplete(); + + Assert.True(plugin.Context!.IsLoggedIn); + Assert.Equal("Mossy", plugin.Context.CharacterName); + Assert.Contains("[Mosswart Massacre] Mosswart Massacre has started!", host.ChatLines); + Assert.NotNull(host.Storage.ReadText("settings.json")); + + plugin.Disable(); + Assert.False(host.Commands.Handlers.ContainsKey("mm")); + } + + [Fact] + public void A_second_login_is_a_relog_that_switches_character() + { + using var harness = new PluginHarness("Mossy"); + harness.Login(); + harness.Host.Automation.Character.Name = "Horan"; + + harness.Plugin.OnLogin(); + + Assert.Equal("Horan", harness.Context.CharacterName); + Assert.Equal("Horan", harness.Context.Settings.CharacterName); + } + + [Fact] + public void Mm_without_a_subcommand_prints_usage_and_unknown_is_reported() + { + using var harness = new PluginHarness(); + harness.Login(); + + harness.Context.Commands.Dispatch(""); + harness.Context.Commands.Dispatch("bogus"); + + Assert.Contains("[Mosswart Massacre] Usage: /mm . Try /mm help", harness.ChatLines); + Assert.Contains("[Mosswart Massacre] Unknown /mm command: bogus. Try /mm help", harness.ChatLines); + } + + [Fact] + public void Ws_secret_and_url_are_stored_in_settings_not_code() + { + using var harness = new PluginHarness(); + harness.Login(); + + harness.Context.Commands.Dispatch("ws secret abc 123"); + harness.Context.Commands.Dispatch("ws url wss://example.test/websocket/"); + + Assert.Equal("abc 123", harness.Context.Settings.Current.BackendSecret); + Assert.Equal("wss://example.test/websocket/", harness.Context.Settings.Current.BackendUrl); + string stored = harness.Host.Storage.ReadText("settings.json")!; + Assert.Contains("\"backend_secret\": \"abc 123\"", stored); + } + + [Fact] + public void Frames_are_not_sent_while_streaming_is_off() + { + using var harness = new PluginHarness(webSocketEnabled: false); + harness.Login(); + + harness.Context.Send(new { type = "x" }); + + Assert.Empty(harness.Frames); + } + + [Fact] + public void The_mm_verb_routes_through_the_host_command_registry() + { + var host = new FakeHost(); + host.Automation.IsAvailable = true; + host.Automation.Character.Name = "Mossy"; + var plugin = new MosswartMassacrePlugin(); + plugin.Initialize(host); + plugin.Enable(); + + host.Commands.Handlers["mm"](new PluginCommand("mm", "help", "/mm help")); + + Assert.Contains("[Mosswart Massacre] Mosswart Massacre Commands:", host.ChatLines); + plugin.Disable(); + } +}