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

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