Merge pull request #1 from eriknihlen/claude/mm-ui-one-window

One MM button, tabbed window, and connection status in chat
This commit is contained in:
Erik Nihlén 2026-09-26 12:50:25 +02:00 • committed by GitHub
commit dde4411958
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 685 additions and 145 deletions

View file

@ -39,6 +39,11 @@ Streaming is off until you turn it on. The shared secret is never compiled in:
`/mm help` lists every command. `/mm help` lists every command.
The plugin has one button on the plugin bar, MM. Its Metas tab downloads the
club's metas and navs into the shared VTank profile folder the client
configures (by default `%LOCALAPPDATA%\OpenAC\vtank`), under `mosstank\metas`
and `mosstank\navs`, where MossTank loads them; the tab shows both folders.
## Testing against a stand-in backend ## Testing against a stand-in backend
`tools/FrameRecorder` accepts the plugin's socket like the backend does, `tools/FrameRecorder` accepts the plugin's socket like the backend does,

View file

@ -29,6 +29,13 @@ namespace OpenAC.MosswartMassacre.Backend;
/// ten seconds, and only then closes. Whatever that run still receives goes /// ten seconds, and only then closes. Whatever that run still receives goes
/// nowhere, so a relog never hears the previous character's frames. /// nowhere, so a relog never hears the previous character's frames.
/// </para> /// </para>
/// <para>
/// Every status and error line (connecting, connected, registered, errors,
/// reconnects, disabled) goes to the host log and is also queued for the
/// player's chat, worded as the original printed it there. The socket thread
/// never writes to chat; the tick thread drains the queue through
/// <see cref="TryTakeStatus"/>.
/// </para>
/// </remarks> /// </remarks>
internal sealed class BackendClient : IDisposable internal sealed class BackendClient : IDisposable
{ {
@ -39,6 +46,7 @@ internal sealed class BackendClient : IDisposable
private readonly IPluginLogger _log; private readonly IPluginLogger _log;
private readonly object _gate = new(); private readonly object _gate = new();
private readonly ConcurrentQueue<string> _status = new();
private Run? _run; private Run? _run;
public BackendClient(IPluginLogger log) public BackendClient(IPluginLogger log)
@ -77,7 +85,7 @@ internal sealed class BackendClient : IDisposable
return; return;
var run = new Run(); var run = new Run();
_run = run; _run = run;
_log.Info("[WebSocket] connecting"); Status("[WebSocket] connecting…");
run.Loop = Task.Run(() => RunAsync(run, endpoint, secret, playerName, utcNow)); run.Loop = Task.Run(() => RunAsync(run, endpoint, secret, playerName, utcNow));
} }
} }
@ -92,6 +100,8 @@ internal sealed class BackendClient : IDisposable
} }
if (run is null) if (run is null)
return; return;
// Said at once, as the original did, while the run finishes behind it.
Status("[WebSocket] DISABLED");
Channel<string>? outbound; Channel<string>? outbound;
Task? sendLoop; Task? sendLoop;
@ -120,10 +130,16 @@ internal sealed class BackendClient : IDisposable
} }
} }
run.Cts.Dispose(); run.Cts.Dispose();
_log.Info("[WebSocket] DISABLED");
}); });
} }
/// <summary>
/// The next status or error line for the player's chat, oldest first. Lines
/// of a stopped run still come out, as the original printed them after it
/// said DISABLED.
/// </summary>
public bool TryTakeStatus(out string line) => _status.TryDequeue(out line!);
/// <summary>Queues a frame. Dropped when no socket is open.</summary> /// <summary>Queues a frame. Dropped when no socket is open.</summary>
public bool Send(string json) public bool Send(string json)
{ {
@ -173,12 +189,12 @@ internal sealed class BackendClient : IDisposable
{ {
socket.Options.SetRequestHeader(SecretHeader, secret); socket.Options.SetRequestHeader(SecretHeader, secret);
await socket.ConnectAsync(endpoint, token).ConfigureAwait(false); await socket.ConnectAsync(endpoint, token).ConfigureAwait(false);
_log.Info("[WebSocket] CONNECTED"); Status("[WebSocket] CONNECTED");
DateTime connectedAt = utcNow(); DateTime connectedAt = utcNow();
string register = Wire.Serialize(new { type = "register", player_name = playerName }); string register = Wire.Serialize(new { type = "register", player_name = playerName });
await SendFrameAsync(socket, register, token).ConfigureAwait(false); await SendFrameAsync(socket, register, token).ConfigureAwait(false);
_log.Info("[WebSocket] REGISTERED"); Status("[WebSocket] REGISTERED");
outbound = Channel.CreateUnbounded<string>( outbound = Channel.CreateUnbounded<string>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
@ -197,17 +213,27 @@ internal sealed class BackendClient : IDisposable
Task receive = ReceiveLoopAsync(socket, run.Inbound, token); Task receive = ReceiveLoopAsync(socket, run.Inbound, token);
await Task.WhenAny(receive, send).ConfigureAwait(false); await Task.WhenAny(receive, send).ConfigureAwait(false);
// The original's telemetry loop ran for as long as the socket
// did, and said why it ended. A stop here is the stop flag
// first and the token later, so both count as cancelled.
bool cancelled = token.IsCancellationRequested || run.Stopping;
if (cancelled)
Status("[WebSocket] Telemetry loop cancelled");
Status($"[WebSocket] Telemetry loop ended - State: {socket.State}, Cancelled: {cancelled}");
} }
catch (OperationCanceledException) when (token.IsCancellationRequested) catch (OperationCanceledException) when (token.IsCancellationRequested)
{ {
Status("[WebSocket] Connection cancelled");
break; break;
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Warn($"[WebSocket] Connection error: {ex.Message}"); Status($"[WebSocket] Connection error: {ex.Message}", warn: true);
} }
finally finally
{ {
Status($"[WebSocket] Cleaning up connection - Final state: {socket.State}");
lock (run) lock (run)
{ {
if (ReferenceEquals(run.Outbound, outbound)) if (ReferenceEquals(run.Outbound, outbound))
@ -220,7 +246,7 @@ internal sealed class BackendClient : IDisposable
if (token.IsCancellationRequested || run.Stopping) if (token.IsCancellationRequested || run.Stopping)
break; break;
_log.Info("[WebSocket] Reconnecting in 2 seconds..."); Status("[WebSocket] Reconnecting in 2 seconds...");
try try
{ {
await Task.Delay(ReconnectDelay, token).ConfigureAwait(false); await Task.Delay(ReconnectDelay, token).ConfigureAwait(false);
@ -249,7 +275,7 @@ internal sealed class BackendClient : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Warn($"[WebSocket] receive error: {ex.Message}"); Status($"[WebSocket] receive error: {ex.Message}", warn: true);
return; return;
} }
@ -282,10 +308,20 @@ internal sealed class BackendClient : IDisposable
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.Warn($"[WebSocket] Send error: {ex.Message}"); Status($"[WebSocket] Send error: {ex.Message}", warn: true);
} }
} }
/// <summary>Queues a line for chat, then logs it; queued first so a line seen in the log is already drainable.</summary>
private void Status(string line, bool warn = false)
{
_status.Enqueue(line);
if (warn)
_log.Warn(line);
else
_log.Info(line);
}
private static Task SendFrameAsync(ClientWebSocket socket, string text, CancellationToken token) private static Task SendFrameAsync(ClientWebSocket socket, string text, CancellationToken token)
{ {
byte[] bytes = Encoding.UTF8.GetBytes(text); byte[] bytes = Encoding.UTF8.GetBytes(text);

View file

@ -17,6 +17,12 @@ namespace OpenAC.MosswartMassacre.Backend;
/// <c>stop_radar</c> toggle the radar; any other text is run as if typed into /// <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 /// the chat bar. Commands run one per tick, as the original ran one per timer
/// tick. /// tick.
/// <para>
/// The socket's status and error lines reach the chat here, on the tick
/// thread: each tick drains them before anything else, and a start or stop
/// made on this thread drains at once so its line comes out in the order the
/// original printed it.
/// </para>
/// </remarks> /// </remarks>
internal sealed class BackendSession : IMmFeature internal sealed class BackendSession : IMmFeature
{ {
@ -47,11 +53,13 @@ internal sealed class BackendSession : IMmFeature
{ {
if (_context.Settings.Current.WebSocketEnabled) if (_context.Settings.Current.WebSocketEnabled)
StartSocket(); StartSocket();
DrainStatus();
} }
public void OnLogoff() public void OnLogoff()
{ {
_context.Backend.Stop(); _context.Backend.Stop();
DrainStatus();
_pendingCommands.Clear(); _pendingCommands.Clear();
} }
@ -64,6 +72,8 @@ internal sealed class BackendSession : IMmFeature
/// <summary>Drains the socket. Public so tests can pump without a host tick.</summary> /// <summary>Drains the socket. Public so tests can pump without a host tick.</summary>
public void Pump() public void Pump()
{ {
DrainStatus();
while (_context.Backend.TryTakeConnection(out BackendConnection connection)) while (_context.Backend.TryTakeConnection(out BackendConnection connection))
OnConnected(connection.ConnectedAtUtc); OnConnected(connection.ConnectedAtUtc);
@ -93,6 +103,13 @@ internal sealed class BackendSession : IMmFeature
} }
} }
/// <summary>Writes the socket's queued status lines to chat. Tick thread only.</summary>
private void DrainStatus()
{
while (_context.Backend.TryTakeStatus(out string line))
_context.Chat.Write(line);
}
/// <summary>Commands queued from the backend and not yet run.</summary> /// <summary>Commands queued from the backend and not yet run.</summary>
internal int PendingCommandCount => _pendingCommands.Count; internal int PendingCommandCount => _pendingCommands.Count;
@ -188,6 +205,7 @@ internal sealed class BackendSession : IMmFeature
_context.Settings.Save(); _context.Settings.Save();
if (_context.IsLoggedIn) if (_context.IsLoggedIn)
StartSocket(); StartSocket();
DrainStatus();
_context.Chat.Write("WS streaming ENABLED."); _context.Chat.Write("WS streaming ENABLED.");
} }
else if (sub.Equals("disable", StringComparison.OrdinalIgnoreCase)) else if (sub.Equals("disable", StringComparison.OrdinalIgnoreCase))
@ -195,6 +213,7 @@ internal sealed class BackendSession : IMmFeature
_context.Settings.Current.WebSocketEnabled = false; _context.Settings.Current.WebSocketEnabled = false;
_context.Settings.Save(); _context.Settings.Save();
_context.Backend.Stop(); _context.Backend.Stop();
DrainStatus();
_context.Chat.Write("WS streaming DISABLED."); _context.Chat.Write("WS streaming DISABLED.");
} }
else if (sub.Equals("url", StringComparison.OrdinalIgnoreCase) && args.Length > 2) else if (sub.Equals("url", StringComparison.OrdinalIgnoreCase) && args.Length > 2)

View file

@ -79,14 +79,7 @@ internal static class FeatureCatalog
context.Host.Ui.ShowPanel("flagtracker"); context.Host.Ui.ShowPanel("flagtracker");
}; };
} }
if (metas.Panel is { } metasPanel) mainWindow.MetasTab = metas.Panel;
{
mainWindow.MetasOpener = () =>
{
metasPanel.IsVisible = true;
context.Host.Ui.ShowPanel("metas");
};
}
// Inventory: full_inventory, inventory_delta, equipment_cantrip_state, // Inventory: full_inventory, inventory_delta, equipment_cantrip_state,
// and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount). // and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount).

View file

@ -4,7 +4,8 @@ using OpenAC.MosswartMassacre.Quests;
namespace OpenAC.MosswartMassacre.FlagTracker; namespace OpenAC.MosswartMassacre.FlagTracker;
/// <summary> /// <summary>
/// The "Mossy Tracker" window: augmentations, luminance auras, recalls, /// The "Mossy Tracker" window, opened from the main window's Mossy Tracker
/// tab: augmentations, luminance auras, recalls,
/// cantrips, tracked weapons and quest timers for the character in the world. /// cantrips, tracked weapons and quest timers for the character in the world.
/// Nothing here reaches the backend. /// Nothing here reaches the backend.
/// </summary> /// </summary>
@ -47,13 +48,12 @@ internal sealed class FlagTrackerFeature : IMmFeature
_panel = context.Host.Ui.RegisterPanel( _panel = context.Host.Ui.RegisterPanel(
new PluginPanelDescriptor("flagtracker", "Mossy Tracker") new PluginPanelDescriptor("flagtracker", "Mossy Tracker")
{ {
IconText = "MT",
// The icon the original window carried (7735).
IconSurfaceId = 0x06001E37,
// Shown while the binding's IsVisible is set; the host's own // Shown while the binding's IsVisible is set; the host's own
// shown state starts true so that flag alone opens it. // shown state starts true so that flag alone opens it.
StartVisible = true, StartVisible = true,
ShowInSidePanel = true, // No bar button: as in the original, the main window's
// Mossy Tracker tab opens it.
ShowInSidePanel = false,
}, },
Path.Combine(directory, MarkupFile), Path.Combine(directory, MarkupFile),
Panel); Panel);

View file

@ -6,7 +6,8 @@ namespace OpenAC.MosswartMassacre.Metas;
/// <summary> /// <summary>
/// Meta and nav sync from the club's repository: the <c>/mm metasrefresh</c>, /// Meta and nav sync from the club's repository: the <c>/mm metasrefresh</c>,
/// <c>metascheck</c>, <c>metaspull</c> and <c>metaspullfile</c> verbs and, on a /// <c>metascheck</c>, <c>metaspull</c> and <c>metaspullfile</c> verbs and, on a
/// host with a window, the Metas window. Nothing here reaches the backend. /// host with a window, the state behind the main window's Metas tab. Nothing
/// here reaches the backend.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Network work runs on the thread pool; every result, chat line and window /// Network work runs on the thread pool; every result, chat line and window
@ -15,15 +16,11 @@ namespace OpenAC.MosswartMassacre.Metas;
/// </remarks> /// </remarks>
internal sealed class MetaSyncFeature : IMmFeature internal sealed class MetaSyncFeature : IMmFeature
{ {
/// <summary>The panel's markup file, beside the plugin assembly.</summary>
public const string MarkupFile = "mosswartmassacre-metas.xml";
private readonly MmContext _context; private readonly MmContext _context;
private readonly MetaSyncManager _manager; private readonly MetaSyncManager _manager;
private readonly ConcurrentQueue<Action> _onTick = new(); private readonly ConcurrentQueue<Action> _onTick = new();
private readonly CancellationTokenSource _shutdown = new(); private readonly CancellationTokenSource _shutdown = new();
private readonly Action<double> _tick; private readonly Action<double> _tick;
private readonly IDisposable? _panel;
private Task _lastOperation = Task.CompletedTask; private Task _lastOperation = Task.CompletedTask;
private bool _disposed; private bool _disposed;
@ -55,28 +52,17 @@ internal sealed class MetaSyncFeature : IMmFeature
return; return;
Panel = new MetasViewModel(); Panel = new MetasViewModel();
Panel.ShowPath(_manager.StorageRoot); ShowFolders();
Panel.RepositoryText = RepositoryUrl;
Panel.ShowFiles([]); Panel.ShowFiles([]);
Panel.ButtonPressed += OnButton; Panel.ButtonPressed += OnButton;
Panel.SearchRequested += ShowFiles; Panel.SearchRequested += ShowFiles;
Panel.RepositoryChanged += OnRepositoryChanged;
string directory = Path.GetDirectoryName(typeof(MetaSyncFeature).Assembly.Location) ?? ".";
_panel = context.Host.Ui.RegisterPanel(
new PluginPanelDescriptor("metas", "Metas")
{
IconText = "ME",
// Shown while the binding's IsVisible is set; the host's own
// shown state starts true so that flag alone opens it.
StartVisible = true,
ShowInSidePanel = true,
},
Path.Combine(directory, MarkupFile),
Panel);
} }
/// <summary> /// <summary>
/// The window's binding object; set its <c>IsVisible</c> to open the /// The Metas tab's state, which the main window binds to. Null on a host
/// window. Null on a host without a window. /// without a window.
/// </summary> /// </summary>
public MetasViewModel? Panel { get; } public MetasViewModel? Panel { get; }
@ -95,7 +81,14 @@ internal sealed class MetaSyncFeature : IMmFeature
} }
} }
public void OnLogin() => Panel?.ShowPath(_manager.StorageRoot); public void OnLogin()
{
if (Panel is null)
return;
// The repository is a per-character setting, loaded at login.
ShowFolders();
Panel.RepositoryText = RepositoryUrl;
}
public void OnLogoff() public void OnLogoff()
{ {
@ -112,8 +105,8 @@ internal sealed class MetaSyncFeature : IMmFeature
{ {
Panel.ButtonPressed -= OnButton; Panel.ButtonPressed -= OnButton;
Panel.SearchRequested -= ShowFiles; Panel.SearchRequested -= ShowFiles;
Panel.RepositoryChanged -= OnRepositoryChanged;
} }
_panel?.Dispose();
_onTick.Clear(); _onTick.Clear();
// Operations run one after another, so the manager goes once the // Operations run one after another, so the manager goes once the
// last one, cancelled above, has finished. // last one, cancelled above, has finished.
@ -239,7 +232,7 @@ internal sealed class MetaSyncFeature : IMmFeature
switch (button) switch (button)
{ {
case nameof(MetasViewModel.RefreshList): case nameof(MetasViewModel.RefreshList):
panel.ShowPath(_manager.StorageRoot); ShowFolders();
panel.StatusText = "Status: refreshing remote file list..."; panel.StatusText = "Status: refreshing remote file list...";
RunRefresh( RunRefresh(
count => panel.StatusText = $"Status: loaded {count} remote files", count => panel.StatusText = $"Status: loaded {count} remote files",
@ -251,7 +244,7 @@ internal sealed class MetaSyncFeature : IMmFeature
break; break;
case nameof(MetasViewModel.CheckUpdates): case nameof(MetasViewModel.CheckUpdates):
panel.ShowPath(_manager.StorageRoot); ShowFolders();
panel.StatusText = "Status: checking remote updates..."; panel.StatusText = "Status: checking remote updates...";
RunCheck( RunCheck(
summary => panel.StatusText = summary => panel.StatusText =
@ -299,6 +292,32 @@ internal sealed class MetaSyncFeature : IMmFeature
} }
} }
private void ShowFolders() =>
Panel?.ShowFolders(
_manager.FolderPath(MetaSyncManager.MetaFolder),
_manager.FolderPath(MetaSyncManager.NavFolder));
/// <summary>
/// Stores the address the player entered, or the default for an empty
/// one. The list already shown came from the old address, so the status
/// asks for a refresh.
/// </summary>
private void OnRepositoryChanged(string address)
{
if (Panel is null)
return;
string trimmed = address.Trim();
if (trimmed.Length > 0 && !Uri.TryCreate(trimmed, UriKind.Absolute, out _))
{
Panel.StatusText = "Status: not a repository address";
return;
}
_context.Settings.Current.MetasRepositoryUrl = trimmed;
_context.Settings.Save();
Panel.RepositoryText = RepositoryUrl;
Panel.StatusText = "Status: repository set; press Refresh List";
}
private void ShowFiles() private void ShowFiles()
{ {
if (Panel is null) if (Panel is null)

View file

@ -105,9 +105,25 @@ internal sealed class MetaSyncManager : IDisposable
public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue; public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue;
/// <summary>The storage root the files are written beneath, for the path label; null without storage.</summary> /// <summary>The storage root the files are written beneath; null without storage.</summary>
public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null; public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null;
/// <summary>
/// The folder on disk a storage folder (<see cref="MetaFolder"/> or
/// <see cref="NavFolder"/>) sits in, for telling the player where the
/// files went: the storage key itself when the storage has no folder on
/// disk, null without storage.
/// </summary>
public string? FolderPath(string folder)
{
string? root = StorageRoot;
if (root is null)
return null;
return root.Length == 0
? folder
: Path.Combine(root, folder.Replace('/', Path.DirectorySeparatorChar));
}
public IReadOnlyList<MetaFileEntry> GetCachedFiles() public IReadOnlyList<MetaFileEntry> GetCachedFiles()
{ {
lock (_sync) lock (_sync)

View file

@ -3,10 +3,11 @@ using System.Globalization;
namespace OpenAC.MosswartMassacre.Metas; namespace OpenAC.MosswartMassacre.Metas;
/// <summary> /// <summary>
/// The binding object of the Metas window (<c>mosswartmassacre-metas.xml</c>), /// The state behind the main window's Metas tab: the repository's files
/// the original's Metas tab: the repository's files with their status, a /// with their status, a live search, a pick list for one file, the refresh,
/// live search, a pick list for one file, and the refresh, check and /// check and download buttons, the repository address and the folders the
/// download buttons. Assigned on the tick thread only. /// files land in. The main window forwards its Metas bindings here.
/// Assigned on the tick thread only.
/// </summary> /// </summary>
internal sealed class MetasViewModel internal sealed class MetasViewModel
{ {
@ -30,6 +31,7 @@ internal sealed class MetasViewModel
CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates)); CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates));
DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated)); DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated));
DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected)); DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected));
SetRepository = address => RepositoryChanged?.Invoke(address ?? string.Empty);
} }
/// <summary>A button was pressed; the argument is its binding name.</summary> /// <summary>A button was pressed; the argument is its binding name.</summary>
@ -38,13 +40,17 @@ internal sealed class MetasViewModel
/// <summary>The search text changed or was cleared.</summary> /// <summary>The search text changed or was cleared.</summary>
internal event Action? SearchRequested; internal event Action? SearchRequested;
/// <summary> /// <summary>The player entered a repository address; empty means the default.</summary>
/// Whether the window is open. The root panel's visibility is bound to it, internal event Action<string>? RepositoryChanged;
/// so the main window opens this one by setting it; it starts closed.
/// </summary>
public bool IsVisible { get; set; }
public string PathText { get; set; } = "VTank Path: unresolved"; public string MetaFolderText { get; set; } = UnresolvedMetaFolder;
public string NavFolderText { get; set; } = UnresolvedNavFolder;
/// <summary>The repository address the files come from.</summary>
public string RepositoryText { get; set; } = string.Empty;
public Action<string> SetRepository { get; }
public string LastCheckText { get; set; } = "Last Check: never"; public string LastCheckText { get; set; } = "Last Check: never";
@ -147,8 +153,16 @@ internal sealed class MetasViewModel
$"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}"); $"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}");
} }
public void ShowPath(string? storageRoot) => /// <summary>Names the folders the metas and navs land in; null when there is no profile storage.</summary>
PathText = storageRoot is null ? "VTank Path: unresolved" : $"VTank Path: {storageRoot}"; public void ShowFolders(string? metaFolder, string? navFolder)
{
MetaFolderText = metaFolder is null ? UnresolvedMetaFolder : $"Metas: {metaFolder}";
NavFolderText = navFolder is null ? UnresolvedNavFolder : $"Navs: {navFolder}";
}
private const string UnresolvedMetaFolder = "Metas: no profile folder";
private const string UnresolvedNavFolder = "Navs: no profile folder";
/// <summary>The status text colour: green current, orange outdated, blue missing, red failed.</summary> /// <summary>The status text colour: green current, orange outdated, blue missing, red failed.</summary>
public static uint StatusColor(MetaFileSyncStatus status) => status switch public static uint StatusColor(MetaFileSyncStatus status) => status switch

View file

@ -140,16 +140,23 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
/// </summary> /// </summary>
public void OnBackendConnected() public void OnBackendConnected()
{ {
if (!_context.Settings.Current.VitalSharingEnabled || !_context.IsLoggedIn) try
return;
_context.Send(new
{ {
type = "share_subscribe", if (!_context.Settings.Current.VitalSharingEnabled || !_context.IsLoggedIn)
timestamp = Wire.Timestamp(_context.Clock), return;
character_name = _context.CharacterName, _context.Send(new
tags = Tags, {
}); type = "share_subscribe",
_context.Verbose("[WebSocket] Vital sharing re-subscribed"); timestamp = Wire.Timestamp(_context.Clock),
character_name = _context.CharacterName,
tags = Tags,
});
_context.Chat.Write("[WebSocket] Vital sharing re-subscribed");
}
catch (Exception ex)
{
_context.Chat.Write($"[WebSocket] Re-subscribe error: {ex.Message}");
}
} }
// ── Outgoing ── // ── Outgoing ──

View file

@ -31,6 +31,7 @@ internal sealed class TelemetryStream : IMmFeature, IBackendConnectionListener
{ {
if (!_context.IsLoggedIn) if (!_context.IsLoggedIn)
return; return;
_context.Chat.Write("[WebSocket] Starting telemetry loop");
SendNow(); SendNow();
_timer?.Dispose(); _timer?.Dispose();
_timer = _context.Scheduler.Every(Interval, SendNow); _timer = _context.Scheduler.Every(Interval, SendNow);

View file

@ -0,0 +1,59 @@
using OpenAC.MosswartMassacre.Metas;
namespace OpenAC.MosswartMassacre.Views;
/// <summary>
/// The Metas tab's bindings. The markup binds one object per window, so each
/// is forwarded to the meta sync's <see cref="MetasViewModel"/>; an action
/// looks the tab up when it runs, so it works whenever the tab is set.
/// </summary>
internal sealed partial class MainWindow
{
private static readonly IReadOnlyList<string> NoItems = [];
private static readonly IReadOnlyList<uint> NoColors = [];
public string MetasFolderText => MetasTab?.MetaFolderText ?? string.Empty;
public string NavsFolderText => MetasTab?.NavFolderText ?? string.Empty;
public string MetasRepositoryText => MetasTab?.RepositoryText ?? string.Empty;
public Action<string> SetMetasRepository => address => MetasTab?.SetRepository(address);
public Action MetasRefreshList => () => MetasTab?.RefreshList();
public Action MetasCheckUpdates => () => MetasTab?.CheckUpdates();
public Action MetasDownloadOutdated => () => MetasTab?.DownloadOutdated();
public Action MetasDownloadSelected => () => MetasTab?.DownloadSelected();
public string MetasSearchText => MetasTab?.SearchText ?? string.Empty;
public Action<string> MetasSearchChanged => text => MetasTab?.SearchChanged(text);
public Action MetasClearSearch => () => MetasTab?.ClearSearch();
public int MetasNoRow => -1;
public Action<int> MetasIgnoreRow => _ => { };
public IReadOnlyList<string> MetasFileTypes => MetasTab?.FileTypes ?? NoItems;
public IReadOnlyList<string> MetasFilePaths => MetasTab?.FilePaths ?? NoItems;
public IReadOnlyList<string> MetasFileStatus => MetasTab?.FileStatus ?? NoItems;
public IReadOnlyList<uint> MetasFileStatusColors => MetasTab?.FileStatusColors ?? NoColors;
public IReadOnlyList<string> MetasSelectionItems => MetasTab?.SelectionItems ?? NoItems;
public string MetasSelectedLabel => MetasTab?.SelectedLabel ?? string.Empty;
public Action<string> MetasSelectionChanged => label => MetasTab?.SelectionChanged(label);
public string MetasLastCheckText => MetasTab?.LastCheckText ?? string.Empty;
public string MetasStatusText => MetasTab?.StatusText ?? string.Empty;
}

View file

@ -1,18 +1,23 @@
using System.Globalization; using System.Globalization;
using AcDream.Plugin.Abstractions; using AcDream.Plugin.Abstractions;
using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Streams; using OpenAC.MosswartMassacre.Streams;
namespace OpenAC.MosswartMassacre.Views; namespace OpenAC.MosswartMassacre.Views;
/// <summary> /// <summary>
/// The main window (graphical host only) and its binding object. Every value /// The main window (graphical host only) and its binding object: the one
/// is read live from the trackers each frame, so nothing here needs to be told /// plugin-bar button, "MM" with the original window's icon, and the
/// that a number changed. /// original's tabbed window. Every value is read live each frame, so nothing
/// here needs to be told that a number changed.
/// </summary> /// </summary>
internal sealed class MainWindow : IMmFeature internal sealed partial class MainWindow : IMmFeature
{ {
public const string MarkupFile = "mosswartmassacre-main.xml"; public const string MarkupFile = "mosswartmassacre-main.xml";
/// <summary>The original window's icon, 7735 in the image table.</summary>
public const uint OriginalIcon = 0x06001E37;
private readonly MmContext _context; private readonly MmContext _context;
private readonly SessionStats _stats; private readonly SessionStats _stats;
private IDisposable? _registration; private IDisposable? _registration;
@ -30,6 +35,8 @@ internal sealed class MainWindow : IMmFeature
new PluginPanelDescriptor("main", "Mosswart Massacre") new PluginPanelDescriptor("main", "Mosswart Massacre")
{ {
IconText = "MM", IconText = "MM",
// The icon the original window carried (7735).
IconSurfaceId = OriginalIcon,
StartVisible = false, StartVisible = false,
ShowInSidePanel = true, ShowInSidePanel = true,
}, },
@ -42,24 +49,30 @@ internal sealed class MainWindow : IMmFeature
Main, Main,
Settings, Settings,
Statistics, Statistics,
MossyTracker,
Metas,
Tools, Tools,
} }
/// <summary>Opens the flag tracker window; set by the feature that owns it.</summary> /// <summary>Opens the flag tracker window; set by the feature that owns it.</summary>
public Action? FlagTrackerOpener { get; set; } public Action? FlagTrackerOpener { get; set; }
/// <summary>Opens the metas window; set by the feature that owns it.</summary> /// <summary>The Metas tab's state; set by the feature that owns it. Null on a host without a window.</summary>
public Action? MetasOpener { get; set; } public MetasViewModel? MetasTab { get; set; }
// ── Tabs ── // ── Tabs ──
public bool MainSelected => _page == Page.Main; public bool MainSelected => _page == Page.Main;
public bool SettingsSelected => _page == Page.Settings; public bool SettingsSelected => _page == Page.Settings;
public bool StatisticsSelected => _page == Page.Statistics; public bool StatisticsSelected => _page == Page.Statistics;
public bool MossyTrackerSelected => _page == Page.MossyTracker;
public bool MetasSelected => _page == Page.Metas;
public bool ToolsSelected => _page == Page.Tools; public bool ToolsSelected => _page == Page.Tools;
public Action ShowMain => () => _page = Page.Main; public Action ShowMain => () => _page = Page.Main;
public Action ShowSettings => () => _page = Page.Settings; public Action ShowSettings => () => _page = Page.Settings;
public Action ShowStatistics => () => _page = Page.Statistics; public Action ShowStatistics => () => _page = Page.Statistics;
public Action ShowMossyTracker => () => _page = Page.MossyTracker;
public Action ShowMetas => () => _page = Page.Metas;
public Action ShowTools => () => _page = Page.Tools; public Action ShowTools => () => _page = Page.Tools;
// ── Main ── // ── Main ──
@ -173,7 +186,7 @@ internal sealed class MainWindow : IMmFeature
public Action ResetStats => () => _context.Commands.Dispatch("reset"); public Action ResetStats => () => _context.Commands.Dispatch("reset");
// ── Tools ── // ── Mossy Tracker ──
public Action OpenFlagTracker => () => public Action OpenFlagTracker => () =>
{ {
@ -183,13 +196,7 @@ internal sealed class MainWindow : IMmFeature
_context.Chat.Write("The Mossy Tracker is not available."); _context.Chat.Write("The Mossy Tracker is not available.");
}; };
public Action OpenMetas => () => // ── Tools ──
{
if (MetasOpener is { } open)
open();
else
_context.Chat.Write("The metas window is not available.");
};
public Action SendInventory => () => _context.Commands.Dispatch("sendinventory"); public Action SendInventory => () => _context.Commands.Dispatch("sendinventory");

View file

@ -1,21 +1,28 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mosswart Massacre main window. The original's notebook, minus what OpenAC <!-- Mosswart Massacre main window, the plugin's one bar button. The original's
or another plugin now owns: the updater (the launcher updates), the VTank notebook, minus what OpenAC or another plugin now owns: the updater (the
path (the host keeps the profile folder), route visualisation (MossTank launcher updates), the VTank path (the host keeps the profile folder),
draws routes) and the chest looter (a looter comes later). --> route visualisation (MossTank draws routes) and the chest looter (a looter
<panel x="60" y="60" w="420" h="320" title="Mosswart Massacre"> comes later). Mossy Tracker opens the tracker window as the original did;
Metas downloads metas and navs into the folders MossTank loads them from;
Tools holds what has no page of its own. -->
<panel x="60" y="60" w="420" h="360" title="Mosswart Massacre">
<tab x="6" y="22" w="56" h="18" text="Main" <tab x="6" y="22" w="50" h="18" text="Main"
selected="{MainSelected}" onclick="{ShowMain}" /> selected="{MainSelected}" onclick="{ShowMain}" />
<tab x="62" y="22" w="62" h="18" text="Settings" <tab x="56" y="22" w="60" h="18" text="Settings"
selected="{SettingsSelected}" onclick="{ShowSettings}" /> selected="{SettingsSelected}" onclick="{ShowSettings}" />
<tab x="124" y="22" w="70" h="18" text="Statistics" <tab x="116" y="22" w="66" h="18" text="Statistics"
selected="{StatisticsSelected}" onclick="{ShowStatistics}" /> selected="{StatisticsSelected}" onclick="{ShowStatistics}" />
<tab x="194" y="22" w="62" h="18" text="Tools" <tab x="182" y="22" w="92" h="18" text="Mossy Tracker"
selected="{MossyTrackerSelected}" onclick="{ShowMossyTracker}" />
<tab x="274" y="22" w="48" h="18" text="Metas"
selected="{MetasSelected}" onclick="{ShowMetas}" />
<tab x="322" y="22" w="46" h="18" text="Tools"
selected="{ToolsSelected}" onclick="{ShowTools}" /> selected="{ToolsSelected}" onclick="{ShowTools}" />
<!-- Main --> <!-- Main -->
<group x="6" y="44" w="408" h="270" visible="{MainSelected}"> <group x="6" y="44" w="408" h="310" visible="{MainSelected}">
<label x="4" y="4" text="{TotalKillsText}" /> <label x="4" y="4" text="{TotalKillsText}" />
<label x="4" y="24" text="{KillsPer5MinText}" /> <label x="4" y="24" text="{KillsPer5MinText}" />
<label x="4" y="44" text="{KillsPerHourText}" /> <label x="4" y="44" text="{KillsPerHourText}" />
@ -27,7 +34,7 @@
</group> </group>
<!-- Settings --> <!-- Settings -->
<group x="6" y="44" w="408" h="270" visible="{SettingsSelected}"> <group x="6" y="44" w="408" h="310" visible="{SettingsSelected}">
<label x="4" y="4" text="Plugin Configuration" color="#FFE8D8B0" /> <label x="4" y="4" text="Plugin Configuration" color="#FFE8D8B0" />
<toggle x="14" y="24" w="300" h="20" text="WebSocket streaming" <toggle x="14" y="24" w="300" h="20" text="WebSocket streaming"
checked="{WebSocketEnabled}" onclick="{ToggleWebSocket}" /> checked="{WebSocketEnabled}" onclick="{ToggleWebSocket}" />
@ -50,7 +57,7 @@
</group> </group>
<!-- Statistics --> <!-- Statistics -->
<group x="6" y="44" w="408" h="270" visible="{StatisticsSelected}"> <group x="6" y="44" w="408" h="310" visible="{StatisticsSelected}">
<label x="4" y="4" text="Detailed Statistics" color="#FFE8D8B0" /> <label x="4" y="4" text="Detailed Statistics" color="#FFE8D8B0" />
<label x="4" y="28" text="Total Kills This Session:" /> <label x="4" y="28" text="Total Kills This Session:" />
<label x="194" y="28" text="{DetailedKillsValue}" /> <label x="194" y="28" text="{DetailedKillsValue}" />
@ -69,10 +76,46 @@
<button x="4" y="198" w="80" h="22" text="Reset All" onclick="{ResetStats}" /> <button x="4" y="198" w="80" h="22" text="Reset All" onclick="{ResetStats}" />
</group> </group>
<!-- Mossy Tracker: the original page, one button that opens the tracker window. -->
<group x="6" y="44" w="408" h="310" visible="{MossyTrackerSelected}">
<button x="4" y="4" w="150" h="30" text="Open Mossy Tracker" onclick="{OpenFlagTracker}" />
</group>
<!-- Metas: the repository's .met/.nav files with their status, a live
search, a pick list for one file, refresh, check and download. -->
<group x="6" y="44" w="408" h="310" visible="{MetasSelected}">
<label x="4" y="0" text="Remote .met/.nav files from the metas repository" color="#FFE8D8B0" />
<label x="4" y="18" text="Repository:" />
<field x="80" y="16" w="324" h="20" text="{MetasRepositoryText}" onsubmit="{SetMetasRepository}" />
<label x="4" y="40" text="{MetasFolderText}" color="#FFC7B98F" />
<label x="4" y="56" text="{NavsFolderText}" color="#FFC7B98F" />
<button x="4" y="74" w="95" h="22" text="Refresh List" onclick="{MetasRefreshList}" />
<button x="104" y="74" w="95" h="22" text="Check Updates" onclick="{MetasCheckUpdates}" />
<button x="204" y="74" w="120" h="22" text="Download Outdated" onclick="{MetasDownloadOutdated}" />
<label x="4" y="102" text="Search:" />
<field x="52" y="100" w="212" h="20" text="{MetasSearchText}" onchange="{MetasSearchChanged}" />
<button x="269" y="99" w="55" h="22" text="Clear" onclick="{MetasClearSearch}" />
<list x="4" y="124" w="400" h="112" rowheight="16"
selected="{MetasNoRow}" onchange="{MetasIgnoreRow}">
<column type="text" width="45" items="{MetasFileTypes}" />
<column type="text" width="230" items="{MetasFilePaths}" />
<column type="text" width="*" items="{MetasFileStatus}" colors="{MetasFileStatusColors}" />
</list>
<label x="4" y="244" text="Selected file:" />
<menu x="89" y="242" w="225" h="20"
items="{MetasSelectionItems}" selected="{MetasSelectedLabel}" onchange="{MetasSelectionChanged}" />
<button x="319" y="241" w="85" h="22" text="Download" onclick="{MetasDownloadSelected}" />
<label x="4" y="268" text="{MetasLastCheckText}" />
<label x="4" y="286" text="{MetasStatusText}" />
</group>
<!-- Tools --> <!-- Tools -->
<group x="6" y="44" w="408" h="270" visible="{ToolsSelected}"> <group x="6" y="44" w="408" h="310" visible="{ToolsSelected}">
<button x="4" y="4" w="150" h="26" text="Open Mossy Tracker" onclick="{OpenFlagTracker}" /> <button x="4" y="4" w="150" h="26" text="Send Inventory" onclick="{SendInventory}" />
<button x="4" y="36" w="150" h="26" text="Open Metas" onclick="{OpenMetas}" />
<button x="4" y="68" w="150" h="26" text="Send Inventory" onclick="{SendInventory}" />
</group> </group>
</panel> </panel>

View file

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Metas window: the original's Metas tab in a window of its own. The
repository's .met/.nav files with their status, a live search, a pick list
for one file, and refresh, check and download. -->
<panel x="80" y="80" w="420" h="320" title="Metas"
visible="{IsVisible}" resizable="true" minw="420" minh="320">
<label x="10" y="24" text="Remote .met/.nav files from metas repo" />
<label x="10" y="40" text="{PathText}" />
<button x="10" y="58" w="95" h="22" text="Refresh List" onclick="{RefreshList}" />
<button x="110" y="58" w="95" h="22" text="Check Updates" onclick="{CheckUpdates}" />
<button x="210" y="58" w="120" h="22" text="Download Outdated" onclick="{DownloadOutdated}" />
<label x="10" y="86" text="Search:" />
<field x="58" y="84" w="212" h="20" text="{SearchText}" onchange="{SearchChanged}" />
<button x="275" y="83" w="55" h="22" text="Clear" onclick="{ClearSearch}" />
<list x="10" y="110" w="400" h="128" rowheight="16"
anchor="left top right bottom"
selected="{NoRow}" onchange="{IgnoreRow}">
<column type="text" width="45" items="{FileTypes}" />
<column type="text" width="230" items="{FilePaths}" />
<column type="text" width="*" items="{FileStatus}" colors="{FileStatusColors}" />
</list>
<label x="10" y="246" text="Selected file:" anchor="left bottom" />
<menu x="95" y="244" w="225" h="20" anchor="left bottom"
items="{SelectionItems}" selected="{SelectedLabel}" onchange="{SelectionChanged}" />
<button x="325" y="243" w="85" h="22" text="Download" anchor="left bottom"
onclick="{DownloadSelected}" />
<label x="10" y="272" text="{LastCheckText}" anchor="left bottom" />
<label x="10" y="290" text="{StatusText}" anchor="left bottom" />
</panel>

View file

@ -0,0 +1,122 @@
using OpenAC.MosswartMassacre.Tests.Fakes;
namespace OpenAC.MosswartMassacre.Tests.Backend;
/// <summary>
/// The socket's status and error lines reach the player's chat, worded as the
/// original worded them, and only through the tick: the socket thread never
/// writes to chat itself.
/// </summary>
public sealed class BackendChatStatusTests
{
private const string P = "[Mosswart Massacre] ";
[Fact]
public async Task Enable_shows_connecting_then_connected_and_registered_on_the_next_tick()
{
using var server = new TestSocketServer();
using var harness = new PluginHarness("Mossy");
harness.Login();
harness.Context.Settings.Current.BackendUrl = server.Uri.ToString();
harness.Context.Commands.Dispatch("ws enable");
Assert.Equal([P + "[WebSocket] connecting…", P + "WS streaming ENABLED."], Tail(harness, 2));
server.NextFrame();
Assert.True(await BackendClientTests.WaitAsync(() => harness.Context.Backend.IsConnected));
Assert.DoesNotContain(P + "[WebSocket] CONNECTED", harness.ChatLines);
int before = harness.ChatLines.Count;
int tickThread = Environment.CurrentManagedThreadId;
harness.Tick(0.01);
Assert.Equal(
[P + "[WebSocket] CONNECTED", P + "[WebSocket] REGISTERED", P + "[WebSocket] Starting telemetry loop"],
Tail(harness, 3));
AssertPostedOn(harness, before, tickThread);
}
[Fact]
public async Task Streaming_saved_on_connects_at_login_and_says_so()
{
using var server = new TestSocketServer();
using var harness = new PluginHarness("Mossy");
harness.Context.Settings.Current.WebSocketEnabled = true;
harness.Context.Settings.Current.BackendUrl = server.Uri.ToString();
harness.Context.Settings.Save();
harness.Host.Automation.Character.IsInWorld = true;
harness.Plugin.OnLogin();
Assert.Contains(P + "[WebSocket] connecting…", harness.ChatLines);
Assert.Equal("{\"type\":\"register\",\"player_name\":\"Mossy\"}", server.NextFrame());
Assert.True(await BackendClientTests.WaitAsync(() => harness.Context.Backend.IsConnected));
harness.Tick(0.01);
Assert.Contains(P + "[WebSocket] REGISTERED", harness.ChatLines);
}
[Fact]
public async Task A_failed_connect_shows_the_error_cleanup_and_reconnect_on_the_next_tick()
{
using var harness = new PluginHarness("Mossy");
harness.Login();
// Nothing listens on port 1: the connect is refused.
harness.Context.Settings.Current.BackendUrl = "ws://127.0.0.1:1/websocket/";
var log = (RecordingLogger)harness.Host.Log;
harness.Context.Commands.Dispatch("ws enable");
Assert.True(await BackendClientTests.WaitAsync(
() => log.Any(line => line.Contains("[WebSocket] Reconnecting in 2 seconds...")), 15000));
Assert.DoesNotContain(harness.ChatLines, line => line.Contains("Connection error"));
int before = harness.ChatLines.Count;
int tickThread = Environment.CurrentManagedThreadId;
harness.Tick(0.01);
string[] tail = Tail(harness, 3);
Assert.StartsWith(P + "[WebSocket] Connection error: ", tail[0]);
Assert.StartsWith(P + "[WebSocket] Cleaning up connection - Final state: ", tail[1]);
Assert.Equal(P + "[WebSocket] Reconnecting in 2 seconds...", tail[2]);
AssertPostedOn(harness, before, tickThread);
}
[Fact]
public async Task Disable_shows_disabled_before_the_command_reply()
{
using var server = new TestSocketServer();
using var harness = new PluginHarness("Mossy");
harness.Login();
harness.Context.Settings.Current.BackendUrl = server.Uri.ToString();
harness.Context.Commands.Dispatch("ws enable");
server.NextFrame();
Assert.True(await BackendClientTests.WaitAsync(() => harness.Context.Backend.IsConnected));
harness.Tick(0.01);
harness.Context.Commands.Dispatch("ws disable");
Assert.Equal([P + "[WebSocket] DISABLED", P + "WS streaming DISABLED."], Tail(harness, 2));
}
[Fact]
public void A_reconnect_with_sharing_on_says_it_re_subscribed_even_without_verbose_logging()
{
using var harness = new PluginHarness("Mossy");
harness.Login();
harness.Context.Settings.Current.VitalSharingEnabled = true;
harness.Context.Settings.Current.VerboseLogging = false;
harness.SimulateConnected();
Assert.Equal(
[P + "[WebSocket] Vital sharing re-subscribed", P + "[WebSocket] Starting telemetry loop"],
Tail(harness, 2));
}
/// <summary>Every chat line from <paramref name="from"/> on was written on <paramref name="threadId"/>, the thread that ran the tick.</summary>
private static void AssertPostedOn(PluginHarness harness, int from, int threadId) =>
Assert.All(harness.Host.Automation.Chat.PostedThreadIds.Skip(from), id => Assert.Equal(threadId, id));
private static string[] Tail(PluginHarness harness, int count) =>
[.. harness.ChatLines.Skip(Math.Max(0, harness.ChatLines.Count - count))];
}

View file

@ -431,7 +431,14 @@ public sealed class RecordingChat : IPluginChat
return message; return message;
} }
public void PostSystemMessage(string text) => Posted.Add(text); /// <summary>The managed thread each <see cref="PostSystemMessage"/> ran on.</summary>
public List<int> PostedThreadIds { get; } = [];
public void PostSystemMessage(string text)
{
Posted.Add(text);
PostedThreadIds.Add(Environment.CurrentManagedThreadId);
}
public void PostMessage(string text, int logTextType) public void PostMessage(string text, int logTextType)
{ {

View file

@ -24,6 +24,9 @@ public sealed class FakeHost : IPluginHost
public MemoryStorage Storage { get; } = new(); public MemoryStorage Storage { get; } = new();
/// <summary>The shared automation profile storage; its own root so a test can tell the two apart.</summary>
public MemoryStorage VtankProfiles { get; } = new() { RootPath = @"C:\Users\player\AppData\Local\OpenAC\vtank" };
public FakeAutomation Automation { get; } = new(); public FakeAutomation Automation { get; } = new();
public FakeLootClassifierRegistry LootClassifiers { get; } = new(); public FakeLootClassifierRegistry LootClassifiers { get; } = new();
@ -48,6 +51,8 @@ public sealed class FakeHost : IPluginHost
IPluginStorage IPluginHost.Storage => Storage; IPluginStorage IPluginHost.Storage => Storage;
IPluginStorage IPluginHost.VtankProfiles => VtankProfiles;
IAutomationSurface IPluginHost.Automation => Automation; IAutomationSurface IPluginHost.Automation => Automation;
IPluginLootClassifierRegistry IPluginHost.LootClassifiers => LootClassifiers; IPluginLootClassifierRegistry IPluginHost.LootClassifiers => LootClassifiers;
@ -290,12 +295,25 @@ public sealed class RecordingLogger : IPluginLogger
{ {
public List<string> Messages { get; } = []; public List<string> Messages { get; } = [];
public void Info(string message) => Messages.Add("info: " + message); public void Info(string message) => Add("info: " + message);
public void Warn(string message) => Messages.Add("warn: " + message); public void Warn(string message) => Add("warn: " + message);
public void Error(string message, Exception? exception = null) public void Error(string message, Exception? exception = null)
=> Messages.Add("error: " + message); => Add("error: " + message);
/// <summary>True when a logged line matches; safe while a background thread logs.</summary>
public bool Any(Func<string, bool> match)
{
lock (Messages)
return Messages.Any(match);
}
private void Add(string line)
{
lock (Messages)
Messages.Add(line);
}
} }
public sealed class FakeGameState : IGameState public sealed class FakeGameState : IGameState
@ -444,6 +462,15 @@ public sealed class FakeUiRegistry : IUiRegistry
{ {
public List<RegisteredPanel> Panels { get; } = []; public List<RegisteredPanel> Panels { get; } = [];
/// <summary>Every <see cref="ShowPanel"/> call, in order.</summary>
public List<string> ShowPanelCalls { get; } = [];
public bool ShowPanel(string viewName)
{
ShowPanelCalls.Add(viewName);
return Panels.Exists(panel => panel.Descriptor.WindowId == viewName || panel.Descriptor.Title == viewName);
}
/// <summary>Every <see cref="ShowClientWindow"/> call, in order -- a test sets <see cref="ShowClientWindowResult"/> to simulate the host refusing it.</summary> /// <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 List<PluginClientWindow> ShowClientWindowCalls { get; } = [];

View file

@ -55,7 +55,8 @@ public sealed class FlagTrackerTests
RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels, registered => registered.Descriptor.WindowId == "flagtracker"); RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels, registered => registered.Descriptor.WindowId == "flagtracker");
Assert.Equal("flagtracker", panel.Descriptor.WindowId); Assert.Equal("flagtracker", panel.Descriptor.WindowId);
Assert.Equal("Mossy Tracker", panel.Descriptor.Title); Assert.Equal("Mossy Tracker", panel.Descriptor.Title);
Assert.True(panel.Descriptor.ShowInSidePanel); Assert.False(panel.Descriptor.ShowInSidePanel);
Assert.Equal(0u, panel.Descriptor.IconSurfaceId);
Assert.EndsWith(FlagTrackerFeature.MarkupFile, panel.MarkupPath, StringComparison.Ordinal); Assert.EndsWith(FlagTrackerFeature.MarkupFile, panel.MarkupPath, StringComparison.Ordinal);
Assert.Same(Panel(harness), panel.Binding); Assert.Same(Panel(harness), panel.Binding);
Assert.False(Panel(harness).IsVisible); Assert.False(Panel(harness).IsVisible);

View file

@ -289,6 +289,35 @@ public sealed class MetaSyncTests
} }
} }
[Fact]
public async Task Downloads_land_in_the_shared_profile_storage_the_automation_plugin_loads_from()
{
using var harness = new PluginHarness();
harness.Login();
MemoryStorage profiles = harness.Host.VtankProfiles;
// The plugin's own sync writes to the shared profile storage, not to
// the plugin's private storage.
Assert.Equal(profiles.RootPath, harness.Feature<MetaSyncFeature>().Manager.StorageRoot);
Assert.NotEqual(harness.Host.Storage.RootPath, harness.Feature<MetaSyncFeature>().Manager.StorageRoot);
var server = new FakeGitServer();
server.Text(TreeUrl, TreeJson);
server.Text(RawBase + "/hunting/Colo.met", ColoMet);
server.Text(RawBase + "/navs/Colo%20Loop.nav", LoopNav);
server.Text(RawBase + "/x.MET", XMet);
using var feature = new MetaSyncFeature(harness.Context, profiles, server);
harness.Context.Commands.Dispatch("metaspull");
await Settle(feature);
// Each file under its own name and extension, metas and navs apart,
// where a bare name in the automation plugin finds it.
Assert.Equal(ColoMet, profiles.ReadText("mosstank/metas/Colo.met"));
Assert.Equal(XMet, profiles.ReadText("mosstank/metas/x.MET"));
Assert.Equal(LoopNav, profiles.ReadText("mosstank/navs/Colo Loop.nav"));
Assert.Null(harness.Host.Storage.ReadText("mosstank/metas/Colo.met"));
}
[Fact] [Fact]
public void A_headless_host_gets_the_verbs_but_no_window() public void A_headless_host_gets_the_verbs_but_no_window()
{ {
@ -310,8 +339,8 @@ public sealed class MetaSyncTests
using (feature) using (feature)
{ {
MetasViewModel panel = feature.Panel!; MetasViewModel panel = feature.Panel!;
Assert.False(panel.IsVisible); Assert.Equal($"Metas: {Path.Combine("C:/profiles", "mosstank", "metas")}", panel.MetaFolderText);
Assert.Equal("VTank Path: C:/profiles", panel.PathText); Assert.Equal($"Navs: {Path.Combine("C:/profiles", "mosstank", "navs")}", panel.NavFolderText);
Assert.Equal(["No files loaded"], panel.FilePaths); Assert.Equal(["No files loaded"], panel.FilePaths);
storage.Seed("mosstank/metas/x.MET", "stale"); storage.Seed("mosstank/metas/x.MET", "stale");

View file

@ -1,16 +1,16 @@
using System.Reflection; using System.Reflection;
using System.Xml.Linq; using System.Xml.Linq;
using OpenAC.MosswartMassacre.FlagTracker; using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Metas; using OpenAC.MosswartMassacre.Views;
namespace OpenAC.MosswartMassacre.Tests.Ui; namespace OpenAC.MosswartMassacre.Tests.Ui;
/// <summary> /// <summary>
/// Proves every <c>{Binding}</c> in this slice's markup files resolves to a /// Proves every <c>{Binding}</c> in the plugin's window markup resolves to a
/// public property of the right shape on its binding object, the check the /// public property of the right shape on its binding object, the check the
/// host would otherwise only make when the window is built in game. /// host would otherwise only make when the window is built in game.
/// </summary> /// </summary>
public sealed class TrackerAndMetasMarkupContractTests public sealed class WindowMarkupContractTests
{ {
private static readonly string[] InteractiveElementNames = private static readonly string[] InteractiveElementNames =
[ [
@ -20,7 +20,13 @@ public sealed class TrackerAndMetasMarkupContractTests
public static TheoryData<string, Type> MarkupFiles => new() public static TheoryData<string, Type> MarkupFiles => new()
{ {
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) }, { FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
{ MetaSyncFeature.MarkupFile, typeof(MetasViewModel) }, { MainWindow.MarkupFile, typeof(MainWindow) },
};
/// <summary>The windows opened by a button of the plugin's own, whose visibility the plugin binds.</summary>
public static TheoryData<string, Type> OpenedWindows => new()
{
{ FlagTrackerFeature.MarkupFile, typeof(FlagTrackerViewModel) },
}; };
[Theory] [Theory]
@ -79,7 +85,7 @@ public sealed class TrackerAndMetasMarkupContractTests
} }
[Theory] [Theory]
[MemberData(nameof(MarkupFiles))] [MemberData(nameof(OpenedWindows))]
public void TheRootPanelVisibilityIsTheSettableIsVisible(string file, Type bindingType) public void TheRootPanelVisibilityIsTheSettableIsVisible(string file, Type bindingType)
{ {
XElement root = LoadRoot(file); XElement root = LoadRoot(file);

View file

@ -0,0 +1,164 @@
using System.Reflection;
using System.Xml.Linq;
using OpenAC.MosswartMassacre.FlagTracker;
using OpenAC.MosswartMassacre.Metas;
using OpenAC.MosswartMassacre.Tests.Fakes;
using OpenAC.MosswartMassacre.Views;
namespace OpenAC.MosswartMassacre.Tests.Views;
/// <summary>
/// The plugin shows as the original did: one button on the plugin bar, "MM"
/// with the original window's icon, opening one tabbed window. The Mossy
/// Tracker tab opens the tracker window, which has no bar button of its own,
/// and the Metas tab holds the meta and nav downloads.
/// </summary>
/// <remarks>
/// The tabs are driven the way the host drives them: each binding is looked
/// up by the name the markup gives it and read or invoked by reflection.
/// </remarks>
public sealed class OneWindowTests
{
private const uint OriginalIcon = 0x06001E37;
private static PluginHarness WithUi()
{
var harness = new PluginHarness(arrange: host => host.HasUi = true);
harness.Login();
return harness;
}
[Fact]
public void The_plugin_bar_gets_one_button_MM_with_the_original_icon()
{
using PluginHarness harness = WithUi();
RegisteredPanel shelf = Assert.Single(harness.Host.Ui.Panels, panel => panel.Descriptor.ShowInSidePanel);
Assert.Equal("main", shelf.Descriptor.WindowId);
Assert.Equal("MM", shelf.Descriptor.IconText);
Assert.Equal(OriginalIcon, shelf.Descriptor.IconSurfaceId);
Assert.EndsWith(MainWindow.MarkupFile, shelf.MarkupPath, StringComparison.Ordinal);
Assert.IsType<MainWindow>(shelf.Binding);
// The only other window is the tracker, opened from its tab.
RegisteredPanel other = Assert.Single(harness.Host.Ui.Panels, panel => !panel.Descriptor.ShowInSidePanel);
Assert.Equal("flagtracker", other.Descriptor.WindowId);
Assert.Equal(2, harness.Host.Ui.Panels.Count);
}
[Fact]
public void The_main_window_has_the_original_tabs_plus_metas()
{
XElement root = LoadMain();
string[] tabs = [.. root.Elements("tab").Select(tab => (string?)tab.Attribute("text") ?? string.Empty)];
Assert.Equal(["Main", "Settings", "Statistics", "Mossy Tracker", "Metas", "Tools"], tabs);
foreach (string tab in tabs)
Assert.NotNull(PageOf(root, tab));
}
[Fact]
public void The_mossy_tracker_tab_opens_the_tracker_window()
{
using PluginHarness harness = WithUi();
MainWindow window = harness.Feature<MainWindow>();
FlagTrackerViewModel tracker = harness.Feature<FlagTrackerFeature>().Panel!;
XElement page = PageOf(LoadMain(), "Mossy Tracker")!;
XElement tab = LoadMain().Elements("tab").Single(t => (string?)t.Attribute("text") == "Mossy Tracker");
Invoke(window, (string?)tab.Attribute("onclick"));
Assert.True(Read<bool>(window, (string?)page.Attribute("visible")));
XElement button = Assert.Single(page.Elements("button"), b => (string?)b.Attribute("text") == "Open Mossy Tracker");
Assert.False(tracker.IsVisible);
Invoke(window, (string?)button.Attribute("onclick"));
Assert.True(tracker.IsVisible);
Assert.Equal(["flagtracker"], harness.Host.Ui.ShowPanelCalls);
}
[Fact]
public void The_metas_tab_is_bound_to_the_meta_sync_and_names_the_real_folders()
{
using PluginHarness harness = WithUi();
MainWindow window = harness.Feature<MainWindow>();
MetasViewModel metas = harness.Feature<MetaSyncFeature>().Panel!;
XElement page = PageOf(LoadMain(), "Metas")!;
// The file list, its search and the download buttons are all here.
XElement list = Assert.Single(page.Elements("list"));
string[] columnItems = [.. list.Elements("column").Select(column => (string?)column.Attribute("items") ?? string.Empty)];
Assert.Equal(3, columnItems.Length);
Assert.Equal(["No files loaded"], Read<IReadOnlyList<string>>(window, columnItems[1]));
Assert.Single(page.Elements("field"), field => (string?)field.Attribute("onchange") is not null);
string[] buttons = [.. page.Elements("button").Select(b => (string?)b.Attribute("text") ?? string.Empty)];
Assert.Contains("Refresh List", buttons);
Assert.Contains("Check Updates", buttons);
Assert.Contains("Download Outdated", buttons);
Assert.Contains("Download", buttons);
// The labels name the folders the automation plugin loads from.
string vtankRoot = harness.Host.VtankProfiles.RootPath!;
string[] labels = [.. page.Elements("label").Select(label => Text(window, (string?)label.Attribute("text")))];
Assert.Contains(labels, text => text.EndsWith(Path.Combine(vtankRoot, "mosstank", "metas"), StringComparison.Ordinal));
Assert.Contains(labels, text => text.EndsWith(Path.Combine(vtankRoot, "mosstank", "navs"), StringComparison.Ordinal));
// Typing in the tab's search reaches the meta sync and redraws the list.
XElement search = page.Elements("field").Single(f => (string?)f.Attribute("onchange") is not null);
Invoke(window, (string?)search.Attribute("onchange"), "zzz");
Assert.Equal("zzz", metas.SearchText);
Assert.Equal(["No matches"], Read<IReadOnlyList<string>>(window, columnItems[1]));
Assert.Equal("zzz", Text(window, (string?)search.Attribute("text")));
}
[Fact]
public void The_metas_tab_changes_the_repository_address()
{
using PluginHarness harness = WithUi();
MainWindow window = harness.Feature<MainWindow>();
XElement page = PageOf(LoadMain(), "Metas")!;
XElement field = Assert.Single(page.Elements("field"), f => (string?)f.Attribute("onsubmit") is not null);
Assert.Equal(MetaSyncManager.DefaultRepositoryUrl, Text(window, (string?)field.Attribute("text")));
Invoke(window, (string?)field.Attribute("onsubmit"), " https://example.org/club/metas ");
Assert.Equal("https://example.org/club/metas", harness.Context.Settings.Current.MetasRepositoryUrl);
Assert.Equal("https://example.org/club/metas", Text(window, (string?)field.Attribute("text")));
}
// ── markup helpers ──
private static XElement LoadMain() =>
XDocument.Load(Path.Combine(AppContext.BaseDirectory, MainWindow.MarkupFile)).Root!;
/// <summary>The group a tab shows: the one whose visibility is the tab's selected binding.</summary>
private static XElement? PageOf(XElement root, string tabText)
{
XElement? tab = root.Elements("tab").SingleOrDefault(t => (string?)t.Attribute("text") == tabText);
string? selected = (string?)tab?.Attribute("selected");
return selected is null
? null
: root.Elements("group").SingleOrDefault(group => (string?)group.Attribute("visible") == selected);
}
private static PropertyInfo Property(object binding, string? expression)
{
Assert.NotNull(expression);
Assert.Matches("^\\{[^{}]+\\}$", expression);
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
Assert.True(property is not null, $"{expression} names no public property on {binding.GetType().Name}.");
return property;
}
private static T Read<T>(object binding, string? expression) =>
Assert.IsAssignableFrom<T>(Property(binding, expression).GetValue(binding));
/// <summary>A label's text: the literal, or the bound value.</summary>
private static string Text(object binding, string? text) =>
text is not null && text.StartsWith('{') ? Read<object>(binding, text).ToString() ?? string.Empty : text ?? string.Empty;
private static void Invoke(object binding, string? expression) =>
Read<Action>(binding, expression)();
private static void Invoke(object binding, string? expression, string argument) =>
Read<Action<string>>(binding, expression)(argument);
}