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,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;
}
}