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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:03:00 +02:00
commit 5dbb294a96
31 changed files with 3442 additions and 0 deletions

27
.gitignore vendored Normal file
View file

@ -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/

21
Directory.Build.props Normal file
View file

@ -0,0 +1,21 @@
<Project>
<PropertyGroup>
<!-- Repository-wide language and warning policy, matching OpenAC. -->
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<AnalysisLevel>latest</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Deterministic>true</Deterministic>
<!-- The plugin's own version; plugin.json names the same number. -->
<Version>0.1.0</Version>
<!-- The plugin contract, compile-only, from the .nupkg in ./packages-local
(downloaded from the OpenAC release of the same version). The host
supplies the contract assembly at runtime. -->
<PluginApiPackageVersion>0.1.18</PluginApiPackageVersion>
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>

20
Directory.Packages.props Normal file
View file

@ -0,0 +1,20 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<!-- Version comes from PluginApiPackageVersion via VersionOverride. -->
<PackageVersion Include="AcDream.Plugin.Abstractions" Version="0.1.18" />
<!-- The backend expects exactly the JSON Newtonsoft.Json writes with its
default settings (the original plugin's serializer), so the wire is
written with it and nothing else. -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
</Project>

12
NuGet.Config Normal file
View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<!-- Where the plugin contract package lands: CI downloads the
`AcDream.Plugin.Abstractions` .nupkg from the OpenAC release into this
folder before it restores, and a local build packs it there by hand
(see README, "Building from source"). -->
<add key="packages-local" value="./packages-local" />
</packageSources>
</configuration>

View file

@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/OpenAC.MosswartMassacre/OpenAC.MosswartMassacre.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/OpenAC.MosswartMassacre.Tests/OpenAC.MosswartMassacre.Tests.csproj" />
</Folder>
</Solution>

39
README.md Normal file
View file

@ -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.<version>.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/<config>/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 <the backend's shared secret>
/mm ws enable
```
`/mm help` lists every command.

7
global.json Normal file
View file

@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.300",
"rollForward": "latestPatch",
"allowPrerelease": false
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="TryReceive"/>. A frame sent while the socket is down is
/// dropped, as the original dropped it.
/// </para>
/// <para>
/// Incoming frames are reassembled across receive calls; the original read a
/// single 4 KiB buffer and lost any larger frame.
/// </para>
/// </remarks>
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<string> _inbound = new();
private readonly ConcurrentQueue<BackendConnection> _connections = new();
private readonly object _gate = new();
private CancellationTokenSource? _cts;
private Task? _loop;
private Channel<string>? _outbound;
public BackendClient(IPluginLogger log)
{
_log = log;
}
/// <summary>True between <see cref="Start"/> and <see cref="Stop"/>.</summary>
public bool IsRunning
{
get
{
lock (_gate)
return _cts is not null;
}
}
/// <summary>True while a registered socket is open.</summary>
public bool IsConnected
{
get
{
lock (_gate)
return _outbound is not null;
}
}
public void Start(Uri endpoint, string secret, string playerName, Func<DateTime> 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");
}
/// <summary>Queues a frame. Dropped when no socket is open.</summary>
public bool Send(string json)
{
Channel<string>? outbound;
lock (_gate)
outbound = _outbound;
return outbound is not null && outbound.Writer.TryWrite(json);
}
/// <summary>The next frame the backend sent, trimmed, if any.</summary>
public bool TryReceive(out string frame) => _inbound.TryDequeue(out frame!);
/// <summary>
/// The next completed connect-and-register, if any. The tick thread uses it
/// to run what the original ran right after registering.
/// </summary>
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<DateTime> utcNow, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
var socket = new ClientWebSocket();
Channel<string>? 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<string>(
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<byte>(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<string> 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<byte>(bytes), WebSocketMessageType.Text, true, token);
}
}
/// <summary>One completed connect-and-register, stamped with the UTC time it connected.</summary>
internal readonly record struct BackendConnection(DateTime ConnectedAtUtc);

View file

@ -0,0 +1,222 @@
using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace OpenAC.MosswartMassacre.Backend;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Incoming frames route as the original routed them. A frame whose
/// <c>type</c> starts with <c>share_</c> goes to the vital-sharing handlers. Any
/// other frame is a chat-box command; it runs only when its <c>player_name</c>
/// is this character (case-insensitive). <c>start_radar</c> and
/// <c>stop_radar</c> 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.
/// </remarks>
internal sealed class BackendSession : IMmFeature
{
private readonly MmContext _context;
private readonly List<IBackendConnectionListener> _connectionListeners = [];
private readonly List<IShareFrameHandler> _shareHandlers = [];
private readonly Queue<string> _pendingCommands = new();
private readonly Action<double> _tick;
public BackendSession(MmContext context)
{
_context = context;
_tick = _ => Pump();
_context.Host.Events.Tick += _tick;
context.Commands.Register("ws", HandleWsCommand, "Enable/disable WebSocket streaming");
}
/// <summary>Runs the radar on <c>start_radar</c> / <c>stop_radar</c>.</summary>
public Action<bool>? 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();
}
/// <summary>Drains the socket. Public so tests can pump without a host tick.</summary>
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());
}
/// <summary>What the original ran right after registering: the new session id, then the listeners.</summary>
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);
}
}
}
/// <summary>Commands queued from the backend and not yet run.</summary>
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<CommandEnvelope>();
}
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 <enable|disable|status|url <url>|secret <secret>>");
}
}
}

View file

@ -0,0 +1,16 @@
using Newtonsoft.Json;
namespace OpenAC.MosswartMassacre.Backend;
/// <summary>
/// A command typed into the backend's chat box for one character:
/// <c>{"player_name": ..., "command": ...}</c>.
/// </summary>
internal sealed class CommandEnvelope
{
[JsonProperty("player_name")]
public string? PlayerName { get; set; }
[JsonProperty("command")]
public string? Command { get; set; }
}

View file

@ -0,0 +1,35 @@
using System.Globalization;
using System.Text;
using Newtonsoft.Json;
using OpenAC.MosswartMassacre.Infrastructure;
namespace OpenAC.MosswartMassacre.Backend;
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>The frame timestamp: UTC, ISO-8601 round-trip ("o"), as the original sent it.</summary>
public static string Timestamp(IClock clock) =>
clock.UtcNow.ToString("o", CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,68 @@
using OpenAC.MosswartMassacre.Infrastructure;
namespace OpenAC.MosswartMassacre.Commands;
/// <summary>
/// The <c>/mm</c> verb and its subcommands, dispatched by name as the original
/// did: arguments split on single spaces, the subcommand first, case-insensitive.
/// </summary>
internal sealed class MmCommandRouter(ChatOutput chat)
{
private readonly Dictionary<string, (Action<string[]> Handler, string Description)> _commands =
new(StringComparer.OrdinalIgnoreCase);
// Registration order, which is the order help lists them in.
private readonly List<string> _order = [];
public void Register(string name, Action<string[]> handler, string description)
{
if (!_commands.ContainsKey(name))
_order.Add(name);
_commands[name] = (handler, description);
}
/// <summary>Runs one <c>/mm</c> line; <paramref name="arguments"/> is everything after the verb.</summary>
public bool Dispatch(string arguments)
{
string[] args = (arguments ?? string.Empty).Trim().Split(' ');
if (args.Length == 0 || string.IsNullOrEmpty(args[0]))
{
chat.Write("Usage: /mm <command>. 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}");
}
}
}

View file

@ -0,0 +1,17 @@
using OpenAC.MosswartMassacre.Backend;
namespace OpenAC.MosswartMassacre;
/// <summary>
/// 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.
/// </summary>
internal static class FeatureCatalog
{
public static void AddFeatures(MmContext context, BackendSession session, List<IMmFeature> features)
{
_ = context;
_ = session;
_ = features;
}
}

View file

@ -0,0 +1,28 @@
using Newtonsoft.Json.Linq;
namespace OpenAC.MosswartMassacre;
/// <summary>
/// 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.
/// </summary>
internal interface IMmFeature : IDisposable
{
/// <summary>A character is in the world and its settings are loaded.</summary>
void OnLogin();
/// <summary>The character left the world, or the plugin is being disabled.</summary>
void OnLogoff();
}
/// <summary>A feature that acts right after the socket connects and registers.</summary>
internal interface IBackendConnectionListener
{
void OnBackendConnected();
}
/// <summary>A feature that consumes the backend's <c>share_*</c> frames.</summary>
internal interface IShareFrameHandler
{
void HandleShareFrame(string type, JObject frame);
}

View file

@ -0,0 +1,21 @@
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Infrastructure;
/// <summary>The plugin's own lines in the chat window, prefixed the way the original printed them.</summary>
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}");
}
}
}

View file

@ -0,0 +1,26 @@
namespace OpenAC.MosswartMassacre.Infrastructure;
/// <summary>
/// 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.
/// </summary>
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;
}

View file

@ -0,0 +1,121 @@
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Infrastructure;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// A callback that throws is logged and left scheduled, so one failing job
/// never stops the others.
/// </remarks>
internal sealed class TickScheduler : IDisposable
{
private readonly IEvents _events;
private readonly IPluginLogger _log;
private readonly Action<double> _handler;
private readonly List<Entry> _entries = [];
private readonly List<Entry> _due = [];
private bool _disposed;
public TickScheduler(IEvents events, IPluginLogger log)
{
_events = events;
_log = log;
_handler = Tick;
_events.Tick += _handler;
}
/// <summary>Seconds of ticks seen since construction.</summary>
public double ElapsedSeconds { get; private set; }
/// <summary>Runs <paramref name="callback"/> every <paramref name="interval"/>, first after one interval.</summary>
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));
}
/// <summary>Runs <paramref name="callback"/> once, <paramref name="delay"/> from now.</summary>
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));
}
/// <summary>Advances time. The host calls this through its tick; tests call it directly.</summary>
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;
}
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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; }
/// <summary>True while a character is logged in and its settings are loaded.</summary>
public bool IsLoggedIn { get; set; }
/// <summary>
/// The logged-in character's name, read once at login the way the original
/// read its character filter's name.
/// </summary>
public string CharacterName { get; set; } = string.Empty;
/// <summary>
/// The per-connection session id the original set on every connect:
/// <c>"&lt;Name&gt;-yyyyMMdd-HHmmss"</c> (UTC). It is the <c>session_id</c> of
/// <c>combat_stats</c>; the telemetry session id is a different, per-process value.
/// </summary>
public string ConnectionSessionId { get; set; } = string.Empty;
/// <summary>The per-process id the original put in every telemetry frame.</summary>
public static string ProcessSessionId { get; } = Guid.NewGuid().ToString("N");
/// <summary>
/// Sees every frame the plugin hands to the socket, before the socket
/// decides whether it is open. Tests read the wire through it.
/// </summary>
internal Action<string>? FrameTap { get; set; }
/// <summary>Writes a frame to the backend; dropped when the socket is down or streaming is off.</summary>
public void Send(object payload)
{
if (!Settings.Current.WebSocketEnabled)
return;
SendRaw(Wire.Serialize(payload));
}
/// <summary>Writes an already-serialized frame, with the same gate as <see cref="Send"/>.</summary>
public void SendRaw(string json)
{
if (!Settings.Current.WebSocketEnabled)
return;
FrameTap?.Invoke(json);
Backend.Send(json);
}
/// <summary>A line in chat only when verbose logging is on.</summary>
public void Verbose(string message)
{
if (Settings.Current.VerboseLogging)
Chat.Write(message);
}
}

View file

@ -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;
/// <summary>
/// The composition root. It builds the shared context, the features and the
/// <c>/mm</c> verb, and runs every feature's login and logoff.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class MosswartMassacrePlugin : IAcDreamPlugin
{
private IPluginHost? _host;
private MmContext? _context;
private readonly List<IMmFeature> _features = [];
private IDisposable? _commandRegistration;
private Action? _onLogin;
private Action? _onLogoff;
private bool _enabled;
internal MmContext? Context => _context;
internal IReadOnlyList<IMmFeature> 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;
}
/// <summary>Builds the context and the features. Internal so tests can build with a fake clock.</summary>
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();
}
}

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<InternalsVisibleTo Include="OpenAC.MosswartMassacre.Tests" />
</ItemGroup>
<ItemGroup>
<!-- Compile-only: the host ships the contract. -->
<PackageReference Include="AcDream.Plugin.Abstractions"
VersionOverride="$(PluginApiPackageVersion)"
ExcludeAssets="runtime" />
<PackageReference Include="Newtonsoft.Json" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="mosswartmassacre*.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -0,0 +1,52 @@
using Newtonsoft.Json;
namespace OpenAC.MosswartMassacre.Settings;
/// <summary>
/// One character's settings. The names follow the original plugin's YAML
/// keys so a reader of either file sees the same words.
/// </summary>
internal sealed class MmSettings
{
/// <summary>The backend the original plugin compiled in.</summary>
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;
/// <summary>Where the backend listens.</summary>
[JsonProperty("backend_url")]
public string BackendUrl { get; set; } = DefaultBackendUrl;
/// <summary>
/// The shared secret sent in the <c>X-Plugin-Secret</c> header. It lives
/// here, never in the assembly.
/// </summary>
[JsonProperty("backend_secret")]
public string BackendSecret { get; set; } = string.Empty;
/// <summary>The git server the meta and nav files are synced from.</summary>
[JsonProperty("metas_repository_url")]
public string MetasRepositoryUrl { get; set; } = string.Empty;
}

View file

@ -0,0 +1,64 @@
using AcDream.Plugin.Abstractions;
using Newtonsoft.Json;
namespace OpenAC.MosswartMassacre.Settings;
/// <summary>
/// Loads and saves <see cref="MmSettings"/> in the character's own storage
/// scope, one JSON file per character, as the original kept one YAML file per
/// character.
/// </summary>
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;
/// <summary>Opens the character's scope and reads its file, writing defaults when there is none.</summary>
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<MmSettings>(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}");
}
}
}

View file

@ -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." }
]
}

View file

@ -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<bool> WaitAsync(Func<bool> condition, int timeoutMs = 5000)
{
for (int waited = 0; waited < timeoutMs; waited += 20)
{
if (condition())
return true;
await Task.Delay(20);
}
return condition();
}
}

View file

@ -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<bool>();
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<string>();
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<string> seen) : IShareFrameHandler
{
public void HandleShareFrame(string type, JObject frame) => seen.Add(type);
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<string> Frames { get; } = new();
public ConcurrentQueue<string?> 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<byte>(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<WebSocket> 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<byte>(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;
}
}

View file

@ -0,0 +1,936 @@
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Tests.Fakes;
/// <summary>
/// A settable automation surface. Every child defaults to an empty fake, so a
/// test fills in only the state it exercises.
/// </summary>
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;
}
/// <summary>A settable <see cref="ITradeAutomation"/>: a test drives <see cref="Raise*"/> and inspects <see cref="Calls"/>.</summary>
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<uint> MyItems { get; } = [];
public List<uint> PartnerItems { get; } = [];
public bool MyAccepted { get; set; }
public bool PartnerAccepted { get; set; }
public List<string> Calls { get; } = [];
/// <summary>The status <see cref="Add"/>/<see cref="Accept"/>/<see cref="Decline"/>/<see cref="Reset"/>/<see cref="End"/> answer with.</summary>
public PluginTradeCommandStatus NextStatus { get; set; } = PluginTradeCommandStatus.Sent;
IReadOnlyList<uint> ITradeAutomation.MyItems => MyItems;
IReadOnlyList<uint> ITradeAutomation.PartnerItems => PartnerItems;
public event Action<PluginTradeOpened>? Opened;
public event Action? Closed;
public event Action<uint>? PartnerTradeAccepted;
public event Action<PluginTradeItemAdded>? 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<PluginTradeOpened> ITradeAutomation.Opened
{
add => Opened += value;
remove => Opened -= value;
}
event Action ITradeAutomation.Closed
{
add => Closed += value;
remove => Closed -= value;
}
event Action<uint> ITradeAutomation.PartnerTradeAccepted
{
add => PartnerTradeAccepted += value;
remove => PartnerTradeAccepted -= value;
}
event Action<PluginTradeItemAdded> ITradeAutomation.ItemAdded
{
add => ItemAdded += value;
remove => ItemAdded -= value;
}
}
/// <summary>A settable <see cref="IVendorAutomation"/>.</summary>
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<PluginVendorItem> Items { get; } = [];
public bool IsBusy { get; set; }
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
public List<(uint TemplateObjectId, int Count)> BuyList { get; } = [];
public List<uint> SellList { get; } = [];
public List<string> Calls { get; } = [];
public PluginVendorCommandStatus NextStatus { get; set; } = PluginVendorCommandStatus.Sent;
IReadOnlyList<PluginVendorItem> IVendorAutomation.Items => Items;
IReadOnlyList<(uint TemplateObjectId, int Count)> IVendorAutomation.BuyList => BuyList;
IReadOnlyList<uint> IVendorAutomation.SellList => SellList;
public event Action<uint>? Opened;
public event Action? Closed;
public event Action<PluginVendorTransaction>? 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<uint> IVendorAutomation.Opened
{
add => Opened += value;
remove => Opened -= value;
}
event Action IVendorAutomation.Closed
{
add => Closed += value;
remove => Closed -= value;
}
event Action<PluginVendorTransaction> IVendorAutomation.TransactionCompleted
{
add => TransactionCompleted += value;
remove => TransactionCompleted -= value;
}
}
/// <summary>A settable <see cref="ILoginAutomation"/>.</summary>
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;
}
}
/// <summary>Records every <see cref="Answer"/> call; a test can pre-arm which context ids it accepts.</summary>
public sealed class FakeDialogAutomation : IDialogAutomation
{
public List<(uint ContextId, bool Accept)> Answers { get; } = [];
/// <summary>Context ids <see cref="Answer"/> reports as outstanding (returns true for).</summary>
public HashSet<uint> 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<PluginSkillInfo> Skills { get; } = [];
public IReadOnlyList<PluginAttributeInfo> Attributes { get; set; } = [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; set; } = [];
IReadOnlyList<PluginSkillInfo> 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;
}
}
/// <summary>
/// Records every posted line and every submitted command, and can raise
/// <see cref="Received"/> and drive registered filters the way the real host
/// does — before a line is added to <see cref="Posted"/> or delivered to
/// <see cref="Received"/>, every registered filter gets a chance to eat it.
/// </summary>
public sealed class RecordingChat : IPluginChat
{
private readonly List<Func<PluginChatMessage, bool>> _filters = [];
private ulong _nextSequence = 1;
public List<string> Posted { get; } = [];
public List<string> Submitted { get; } = [];
public List<(string Text, int LogTextType)> PostedTyped { get; } = [];
/// <summary>Every message offered to <see cref="Deliver"/>, filtered or not.</summary>
public List<PluginChatMessage> Offered { get; } = [];
/// <summary>Messages that survived every filter and reached <see cref="Received"/>.</summary>
public List<PluginChatMessage> Delivered { get; } = [];
public IReadOnlyList<PluginChatMessage> Messages { get; set; } = [];
public IReadOnlyList<PluginChatMessage> CaptureMessages(ulong afterSequence)
=> [.. Messages.Where(message => message.Sequence > afterSequence)];
public event Action<PluginChatMessage>? Received;
/// <summary>
/// How many distinct handlers are currently subscribed to
/// <see cref="Received"/> — used to prove a shared fan-out
/// (<c>ChatClassificationDispatcher</c>) adds exactly one subscription
/// to the raw feed no matter how many of ITS OWN consumers subscribe to
/// it in turn.
/// </summary>
public int ReceivedSubscriberCount => Received?.GetInvocationList().Length ?? 0;
public IDisposable RegisterFilter(Func<PluginChatMessage, bool> suppress)
{
_filters.Add(suppress);
return new FilterRegistration(this, suppress);
}
/// <summary>Builds and offers a message the way the real client would, honoring filters.</summary>
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<PluginChatMessage, bool> 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<PluginChatMessage, bool> filter) : IDisposable
{
public void Dispose() => owner._filters.Remove(filter);
}
}
public sealed class FakeSpellCatalog : ISpellCatalog
{
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; set; } = [];
public IReadOnlyList<PluginSpellInfo> KnownAttackSpells { get; set; } = [];
public IReadOnlyList<PluginSpellInfo> KnownCombatSpells { get; set; } = [];
/// <summary>The full spell table, independent of what the character knows.</summary>
public IReadOnlyList<PluginSpellInfo> 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;
}
/// <summary>Builds a minimal spell record for a test.</summary>
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; } = [];
/// <summary>What <see cref="RequestCast(uint)"/>/<see cref="RequestCast(uint,uint)"/> answer -- a test sets this to simulate a refusal that isn't a plain Cast()==false.</summary>
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; }
/// <summary>What <see cref="Use"/> returns -- a test sets this to simulate the host rejecting the command.</summary>
public PluginItemCommandResult UseResult { get; set; } =
new(PluginItemCommandStatus.Started);
public List<PluginInventoryItem> Owned { get; } = [];
public List<(string Command, uint ObjectId, uint TargetObjectId)> Calls { get; } = [];
/// <summary>Keyed by object id; set by a test to make <see cref="TryCaptureProperties"/> answer.</summary>
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
/// <summary>
/// How many times <see cref="CaptureOwnedItems"/> has been called — used
/// to prove a coalescing host (H1/H3) captures at most once per tick no
/// matter how many <c>ObjectChanged</c> events arrived inside it.
/// </summary>
public int CaptureCount { get; private set; }
/// <summary>Same counter as <see cref="CaptureCount"/> under the name the HUD-gate tests use.</summary>
public int CaptureOwnedItemsCalls => CaptureCount;
public IReadOnlyList<PluginInventoryItem> 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;
}
/// <summary>What <see cref="Apply"/> returns -- a test sets this to simulate the host rejecting the command.</summary>
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);
}
/// <summary>Every <see cref="MoveToContainer"/> call, in order.</summary>
public List<(uint ObjectId, uint ContainerObjectId, uint Amount, int Placement)> Moves { get; } = [];
/// <summary>Every <see cref="Merge"/> call, in order.</summary>
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);
}
/// <summary>Builds a minimal owned item for a test.</summary>
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; }
/// <summary>
/// The shared appraisal slot's state (MEDIUM-2, P14 review) -- a test
/// sets <c>AwaitingObjectId</c> to a DIFFERENT nonzero id than what it
/// asked <see cref="AcDream.Plugin.Abstractions.IWorldObjectAutomation.Identify"/>
/// for to simulate that request having been displaced by a later one.
/// </summary>
public PluginAppraisalState Appraisal { get; set; }
public List<PluginInventoryItem> Contents { get; } = [];
public List<uint> PickedUp { get; } = [];
public List<uint> Opened { get; } = [];
public List<uint> IdentifyRequests { get; } = [];
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
public IReadOnlyList<PluginInventoryItem> 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<PluginWorldObject> Objects { get; } = [];
public Dictionary<uint, PluginItemProperties> Properties { get; } = [];
public List<uint> IdentifyRequests { get; } = [];
/// <summary>What <see cref="Identify"/> returns by default for any object id not covered by <see cref="IdentifyResultOverrides"/>.</summary>
public PluginItemCommandStatus IdentifyResult { get; set; } = PluginItemCommandStatus.Started;
/// <summary>Per-object-id override for <see cref="Identify"/>'s returned status, checked before <see cref="IdentifyResult"/>.</summary>
public Dictionary<uint, PluginItemCommandStatus> IdentifyResultOverrides { get; } = [];
/// <summary>
/// Per-object-id remaining count of consecutive <c>Busy</c> responses
/// <see cref="Identify"/> should return before falling through to
/// <see cref="IdentifyResultOverrides"/>/<see cref="IdentifyResult"/> --
/// simulates the host's single-in-flight-request gate
/// (<c>InventoryTransactionState.CanBeginRequest</c>) staying busy for a
/// bounded number of retries. Checked before, and decremented ahead of,
/// <see cref="IdentifyResultOverrides"/>.
/// </summary>
public Dictionary<uint, int> IdentifyBusyForCalls { get; } = [];
/// <summary>
/// How many times <see cref="CaptureObjects"/> 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 <see cref="FakeHost.HasUi"/> is
/// false.
/// </summary>
public int CaptureObjectsCalls { get; private set; }
public IReadOnlyList<PluginWorldObject> 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);
}
/// <summary>Replaces a tracked object's snapshot (e.g. to flip <c>HasAppraisalData</c>).</summary>
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);
}
/// <summary>
/// Builds a landscape object at <paramref name="metres"/> east of the
/// origin the fake navigation reports.
/// </summary>
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<float> Headings { get; } = [];
/// <summary>Backs <see cref="CaptureObjects"/> for tests that need a nearby landscape object (e.g. a chest).</summary>
public List<PluginNavigationObject> Objects { get; } = [];
public IReadOnlyList<PluginNavigationObject> 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<string> Calls { get; } = [];
public bool IsInFellowship { get; set; }
/// <summary>What every command below answers -- a test sets this to simulate a server refusal.</summary>
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<string> Calls { get; } = [];
/// <summary>Every BeginPhysicalAttack call, with the height/power it used.</summary>
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<PluginCombatTarget> 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<uint> Equipped { get; } = [];
public PluginEquipmentCommandResult Equip(
uint objectId,
uint requestedLocation = 0u)
{
Equipped.Add(objectId);
return new PluginEquipmentCommandResult(
PluginEquipmentCommandStatus.Started);
}
}

View file

@ -0,0 +1,575 @@
using AcDream.Plugin.Abstractions;
namespace OpenAC.MosswartMassacre.Tests.Fakes;
/// <summary>
/// The one fake host every test uses. Each surface is settable so a test names
/// only the state it cares about.
/// </summary>
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;
/// <summary>The chat lines the plugin posted, in order.</summary>
public IReadOnlyList<string> ChatLines => Automation.Chat.Posted;
}
/// <summary>
/// A settable <see cref="IHostWindow"/>: records every call, defaults to
/// <see cref="HostWindowStatus.Done"/> like a graphical host with a
/// responsive window, and a test can flip <see cref="MinimizeResult"/>/
/// <see cref="RestoreResult"/>/<see cref="RequestCloseResult"/> to simulate a
/// refusal (a headless host, or an unconfirmed platform outcome such as
/// Wayland's always-Unavailable Minimize).
/// </summary>
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;
}
}
/// <summary>A settable <see cref="IPluginClipboard"/>: records every write, can fail on demand.</summary>
public sealed class FakeClipboard : IPluginClipboard
{
public List<string> Written { get; } = [];
public bool Available { get; set; } = true;
public bool TrySetText(string text)
{
if (!Available)
return false;
Written.Add(text);
return true;
}
}
/// <summary>
/// A settable <see cref="IPluginLootClassifierRegistry"/>: one test-controlled
/// classifier answers <see cref="TryClassify"/>/<see cref="TryNeedsIdentification"/>/
/// <see cref="TryClassifyWithProfile"/>, or the registry reports no classifier
/// registered at all (the "VTank isn't loaded" degradation path) when
/// <see cref="Available"/> is left empty.
/// </summary>
public sealed class FakeLootClassifierRegistry : IPluginLootClassifierRegistry
{
public List<PluginLootClassifierInfo> Available { get; } = [];
/// <summary>What <see cref="TryClassify"/> returns for any classifier id.</summary>
public PluginLootClassification? ClassificationResult { get; set; }
/// <summary>What <see cref="TryNeedsIdentification"/> returns for any classifier id.</summary>
public bool NeedsIdentificationResult { get; set; }
/// <summary>
/// When set, overrides <see cref="NeedsIdentificationResult"/> with a
/// per-context verdict -- for a test where only SOME items in a
/// container still need identification.
/// </summary>
public Func<PluginLootClassificationContext, bool>? NeedsIdentificationHandler { get; set; }
/// <summary>What <see cref="TryClassifyWithProfile"/> returns for any classifier id.</summary>
public PluginLootClassification? ProfileClassificationResult { get; set; }
/// <summary>
/// When set, overrides <see cref="ClassificationResult"/> 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 <c>false</c> return).
/// </summary>
public Func<PluginLootClassificationContext, PluginLootClassification?>? ClassifyHandler { get; set; }
/// <summary>
/// When set, overrides <see cref="ProfileClassificationResult"/> with a
/// per-profile, per-context verdict. Returning null means "no profile,
/// or no match" (both collapse to the registry's <c>false</c>).
/// </summary>
public Func<string, PluginLootClassificationContext, PluginLootClassification?>? ProfileClassifyHandler { get; set; }
/// <summary>Every profile name ever probed/classified via <see cref="TryClassifyWithProfile"/>.</summary>
public List<string> ProfileNamesSeen { get; } = [];
public List<string> ClassifyCalls { get; } = [];
IReadOnlyList<PluginLootClassifierInfo> 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;
}
}
/// <summary>Records every registration and lets a test fire the handler or flip <see cref="IPluginHotkeyRegistration.IsBound"/>.</summary>
public sealed class FakeHotkeyRegistry : IHotkeyRegistry
{
public List<(string Id, string DisplayName, PluginKeyChord DefaultChord, Action Handler)> Registrations { get; } = [];
/// <summary>The most recently returned handle for each id, so a test can assert on its Disposed/IsBound state.</summary>
public Dictionary<string, FakeHotkeyRegistration> 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;
}
/// <summary>Invokes the handler registered under <paramref name="id"/>, as if the chord had just fired.</summary>
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<string> 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<WorldEntitySnapshot> Entities { get; set; } = [];
}
/// <summary>Drives <see cref="IEvents"/> from a test.</summary>
public sealed class FakeEvents : IEvents
{
public event Action<WorldEntitySnapshot>? EntitySpawned;
public event Action<double>? Tick;
public event Action? LoginComplete;
public event Action? Logoff;
public event Action<string>? LocalPlayerDied;
public event Action<PluginObjectChange>? ObjectChanged;
public event Action<uint>? ContainerOpened;
public event Action<uint>? ContainerClosed;
public event Action<PluginConfirmation>? 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<PluginConfirmation> IEvents.ConfirmationRequested
{
add => ConfirmationRequested += value;
remove => ConfirmationRequested -= value;
}
event Action<PluginObjectChange> IEvents.ObjectChanged
{
add => ObjectChanged += value;
remove => ObjectChanged -= value;
}
event Action<uint> IEvents.ContainerOpened
{
add => ContainerOpened += value;
remove => ContainerOpened -= value;
}
event Action<uint> IEvents.ContainerClosed
{
add => ContainerClosed += value;
remove => ContainerClosed -= value;
}
event Action<WorldEntitySnapshot> IEvents.EntitySpawned
{
add => EntitySpawned += value;
remove => EntitySpawned -= value;
}
event Action<double> 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<string> 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<SelectionChangedEvent>? 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<RegisteredPanel> Panels { get; } = [];
/// <summary>Every <see cref="ShowClientWindow"/> call, in order -- a test sets <see cref="ShowClientWindowResult"/> to simulate the host refusing it.</summary>
public List<PluginClientWindow> ShowClientWindowCalls { get; } = [];
public bool ShowClientWindowResult { get; set; } = true;
public bool ShowClientWindow(PluginClientWindow window)
{
ShowClientWindowCalls.Add(window);
return ShowClientWindowResult;
}
public List<PluginClientWindow> HideClientWindowCalls { get; } = [];
public bool HideClientWindow(PluginClientWindow window)
{
HideClientWindowCalls.Add(window);
return true;
}
public List<PluginClientWindow> ToggleClientWindowCalls { get; } = [];
public bool ToggleClientWindowResult { get; set; } = true;
public bool ToggleClientWindow(PluginClientWindow window)
{
ToggleClientWindowCalls.Add(window);
return ToggleClientWindowResult;
}
public HashSet<PluginClientWindow> 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<string, Action<PluginCommand>> Handlers { get; } =
new(StringComparer.OrdinalIgnoreCase);
public IDisposable Register(string verb, Action<PluginCommand> 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);
}
}
/// <summary>An in-memory <see cref="IPluginStorage"/>.</summary>
public sealed class MemoryStorage : IPluginStorage
{
private readonly Dictionary<string, string> _files =
new(StringComparer.OrdinalIgnoreCase);
public bool IsAvailable { get; set; } = true;
public string? RootPath { get; set; } = "memory://storage";
public int WriteCount { get; private set; }
/// <summary>Makes the next (and every subsequent) write throw.</summary>
public bool ThrowOnWrite { get; set; }
/// <summary>
/// The message <see cref="WriteText"/> throws with while
/// <see cref="ThrowOnWrite"/> 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.
/// </summary>
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<string> 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);
/// <summary>Seeds a file as if it had been copied in by hand.</summary>
public void Seed(string key, string content) => _files[key] = content;
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\OpenAC.MosswartMassacre\OpenAC.MosswartMassacre.csproj" />
</ItemGroup>
<!-- A test run has no host, so it brings its own copy of the contract. -->
<ItemGroup>
<PackageReference Include="AcDream.Plugin.Abstractions"
VersionOverride="$(PluginApiPackageVersion)" />
</ItemGroup>
<ItemGroup>
<None Include="Golden\**\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,101 @@
using Newtonsoft.Json.Linq;
using OpenAC.MosswartMassacre.Infrastructure;
using OpenAC.MosswartMassacre.Tests.Fakes;
namespace OpenAC.MosswartMassacre.Tests;
/// <summary>A clock a test sets by hand.</summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
internal sealed class PluginHarness : IDisposable
{
public PluginHarness(string characterName = "Mossy", bool webSocketEnabled = true, Action<FakeHost>? 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<string> Frames { get; } = [];
public IReadOnlyList<string> ChatLines => Host.ChatLines;
/// <summary>Logs in without starting the socket.</summary>
public void Login()
{
Host.Automation.Character.IsInWorld = true;
Plugin.OnLogin();
Context.Settings.Current.WebSocketEnabled = _webSocketEnabled;
}
/// <summary>Runs what the plugin runs right after the socket connects and registers.</summary>
public void SimulateConnected() => Session.OnConnected(Clock.UtcNow);
public OpenAC.MosswartMassacre.Backend.BackendSession Session =>
Plugin.Features.OfType<OpenAC.MosswartMassacre.Backend.BackendSession>().Single();
/// <summary>A frame as if the backend had sent it; routed on the next tick.</summary>
public void Receive(string frame) => Session.Route(frame);
public T Feature<T>() where T : IMmFeature => Plugin.Features.OfType<T>().Single();
public void Tick(double seconds) => Host.Events.RaiseTick(seconds);
/// <summary>Advances both the scheduler and the clock by <paramref name="seconds"/> in small steps.</summary>
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<JObject> 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();
}
}

View file

@ -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 <command>. 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();
}
}