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:
commit
dde4411958
21 changed files with 685 additions and 145 deletions
|
|
@ -39,6 +39,11 @@ Streaming is off until you turn it on. The shared secret is never compiled in:
|
|||
|
||||
`/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
|
||||
|
||||
`tools/FrameRecorder` accepts the plugin's socket like the backend does,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ namespace OpenAC.MosswartMassacre.Backend;
|
|||
/// ten seconds, and only then closes. Whatever that run still receives goes
|
||||
/// nowhere, so a relog never hears the previous character's frames.
|
||||
/// </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>
|
||||
internal sealed class BackendClient : IDisposable
|
||||
{
|
||||
|
|
@ -39,6 +46,7 @@ internal sealed class BackendClient : IDisposable
|
|||
|
||||
private readonly IPluginLogger _log;
|
||||
private readonly object _gate = new();
|
||||
private readonly ConcurrentQueue<string> _status = new();
|
||||
private Run? _run;
|
||||
|
||||
public BackendClient(IPluginLogger log)
|
||||
|
|
@ -77,7 +85,7 @@ internal sealed class BackendClient : IDisposable
|
|||
return;
|
||||
var run = new Run();
|
||||
_run = run;
|
||||
_log.Info("[WebSocket] connecting");
|
||||
Status("[WebSocket] connecting…");
|
||||
run.Loop = Task.Run(() => RunAsync(run, endpoint, secret, playerName, utcNow));
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +100,8 @@ internal sealed class BackendClient : IDisposable
|
|||
}
|
||||
if (run is null)
|
||||
return;
|
||||
// Said at once, as the original did, while the run finishes behind it.
|
||||
Status("[WebSocket] DISABLED");
|
||||
|
||||
Channel<string>? outbound;
|
||||
Task? sendLoop;
|
||||
|
|
@ -120,10 +130,16 @@ internal sealed class BackendClient : IDisposable
|
|||
}
|
||||
}
|
||||
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>
|
||||
public bool Send(string json)
|
||||
{
|
||||
|
|
@ -173,12 +189,12 @@ internal sealed class BackendClient : IDisposable
|
|||
{
|
||||
socket.Options.SetRequestHeader(SecretHeader, secret);
|
||||
await socket.ConnectAsync(endpoint, token).ConfigureAwait(false);
|
||||
_log.Info("[WebSocket] CONNECTED");
|
||||
Status("[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");
|
||||
Status("[WebSocket] REGISTERED");
|
||||
|
||||
outbound = Channel.CreateUnbounded<string>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
|
|
@ -197,17 +213,27 @@ internal sealed class BackendClient : IDisposable
|
|||
|
||||
Task receive = ReceiveLoopAsync(socket, run.Inbound, token);
|
||||
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)
|
||||
{
|
||||
Status("[WebSocket] Connection cancelled");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warn($"[WebSocket] Connection error: {ex.Message}");
|
||||
Status($"[WebSocket] Connection error: {ex.Message}", warn: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Status($"[WebSocket] Cleaning up connection - Final state: {socket.State}");
|
||||
lock (run)
|
||||
{
|
||||
if (ReferenceEquals(run.Outbound, outbound))
|
||||
|
|
@ -220,7 +246,7 @@ internal sealed class BackendClient : IDisposable
|
|||
|
||||
if (token.IsCancellationRequested || run.Stopping)
|
||||
break;
|
||||
_log.Info("[WebSocket] Reconnecting in 2 seconds...");
|
||||
Status("[WebSocket] Reconnecting in 2 seconds...");
|
||||
try
|
||||
{
|
||||
await Task.Delay(ReconnectDelay, token).ConfigureAwait(false);
|
||||
|
|
@ -249,7 +275,7 @@ internal sealed class BackendClient : IDisposable
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Warn($"[WebSocket] receive error: {ex.Message}");
|
||||
Status($"[WebSocket] receive error: {ex.Message}", warn: true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -282,10 +308,20 @@ internal sealed class BackendClient : IDisposable
|
|||
}
|
||||
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)
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(text);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ namespace OpenAC.MosswartMassacre.Backend;
|
|||
/// <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.
|
||||
/// <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>
|
||||
internal sealed class BackendSession : IMmFeature
|
||||
{
|
||||
|
|
@ -47,11 +53,13 @@ internal sealed class BackendSession : IMmFeature
|
|||
{
|
||||
if (_context.Settings.Current.WebSocketEnabled)
|
||||
StartSocket();
|
||||
DrainStatus();
|
||||
}
|
||||
|
||||
public void OnLogoff()
|
||||
{
|
||||
_context.Backend.Stop();
|
||||
DrainStatus();
|
||||
_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>
|
||||
public void Pump()
|
||||
{
|
||||
DrainStatus();
|
||||
|
||||
while (_context.Backend.TryTakeConnection(out BackendConnection connection))
|
||||
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>
|
||||
internal int PendingCommandCount => _pendingCommands.Count;
|
||||
|
||||
|
|
@ -188,6 +205,7 @@ internal sealed class BackendSession : IMmFeature
|
|||
_context.Settings.Save();
|
||||
if (_context.IsLoggedIn)
|
||||
StartSocket();
|
||||
DrainStatus();
|
||||
_context.Chat.Write("WS streaming ENABLED.");
|
||||
}
|
||||
else if (sub.Equals("disable", StringComparison.OrdinalIgnoreCase))
|
||||
|
|
@ -195,6 +213,7 @@ internal sealed class BackendSession : IMmFeature
|
|||
_context.Settings.Current.WebSocketEnabled = false;
|
||||
_context.Settings.Save();
|
||||
_context.Backend.Stop();
|
||||
DrainStatus();
|
||||
_context.Chat.Write("WS streaming DISABLED.");
|
||||
}
|
||||
else if (sub.Equals("url", StringComparison.OrdinalIgnoreCase) && args.Length > 2)
|
||||
|
|
|
|||
|
|
@ -79,14 +79,7 @@ internal static class FeatureCatalog
|
|||
context.Host.Ui.ShowPanel("flagtracker");
|
||||
};
|
||||
}
|
||||
if (metas.Panel is { } metasPanel)
|
||||
{
|
||||
mainWindow.MetasOpener = () =>
|
||||
{
|
||||
metasPanel.IsVisible = true;
|
||||
context.Host.Ui.ShowPanel("metas");
|
||||
};
|
||||
}
|
||||
mainWindow.MetasTab = metas.Panel;
|
||||
|
||||
// Inventory: full_inventory, inventory_delta, equipment_cantrip_state,
|
||||
// and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ using OpenAC.MosswartMassacre.Quests;
|
|||
namespace OpenAC.MosswartMassacre.FlagTracker;
|
||||
|
||||
/// <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.
|
||||
/// Nothing here reaches the backend.
|
||||
/// </summary>
|
||||
|
|
@ -47,13 +48,12 @@ internal sealed class FlagTrackerFeature : IMmFeature
|
|||
_panel = context.Host.Ui.RegisterPanel(
|
||||
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 state starts true so that flag alone opens it.
|
||||
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),
|
||||
Panel);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ namespace OpenAC.MosswartMassacre.Metas;
|
|||
/// <summary>
|
||||
/// 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
|
||||
/// 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>
|
||||
/// <remarks>
|
||||
/// Network work runs on the thread pool; every result, chat line and window
|
||||
|
|
@ -15,15 +16,11 @@ namespace OpenAC.MosswartMassacre.Metas;
|
|||
/// </remarks>
|
||||
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 MetaSyncManager _manager;
|
||||
private readonly ConcurrentQueue<Action> _onTick = new();
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
private readonly Action<double> _tick;
|
||||
private readonly IDisposable? _panel;
|
||||
private Task _lastOperation = Task.CompletedTask;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -55,28 +52,17 @@ internal sealed class MetaSyncFeature : IMmFeature
|
|||
return;
|
||||
|
||||
Panel = new MetasViewModel();
|
||||
Panel.ShowPath(_manager.StorageRoot);
|
||||
ShowFolders();
|
||||
Panel.RepositoryText = RepositoryUrl;
|
||||
Panel.ShowFiles([]);
|
||||
Panel.ButtonPressed += OnButton;
|
||||
Panel.SearchRequested += ShowFiles;
|
||||
|
||||
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);
|
||||
Panel.RepositoryChanged += OnRepositoryChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The window's binding object; set its <c>IsVisible</c> to open the
|
||||
/// window. Null on a host without a window.
|
||||
/// The Metas tab's state, which the main window binds to. Null on a host
|
||||
/// without a window.
|
||||
/// </summary>
|
||||
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()
|
||||
{
|
||||
|
|
@ -112,8 +105,8 @@ internal sealed class MetaSyncFeature : IMmFeature
|
|||
{
|
||||
Panel.ButtonPressed -= OnButton;
|
||||
Panel.SearchRequested -= ShowFiles;
|
||||
Panel.RepositoryChanged -= OnRepositoryChanged;
|
||||
}
|
||||
_panel?.Dispose();
|
||||
_onTick.Clear();
|
||||
// Operations run one after another, so the manager goes once the
|
||||
// last one, cancelled above, has finished.
|
||||
|
|
@ -239,7 +232,7 @@ internal sealed class MetaSyncFeature : IMmFeature
|
|||
switch (button)
|
||||
{
|
||||
case nameof(MetasViewModel.RefreshList):
|
||||
panel.ShowPath(_manager.StorageRoot);
|
||||
ShowFolders();
|
||||
panel.StatusText = "Status: refreshing remote file list...";
|
||||
RunRefresh(
|
||||
count => panel.StatusText = $"Status: loaded {count} remote files",
|
||||
|
|
@ -251,7 +244,7 @@ internal sealed class MetaSyncFeature : IMmFeature
|
|||
break;
|
||||
|
||||
case nameof(MetasViewModel.CheckUpdates):
|
||||
panel.ShowPath(_manager.StorageRoot);
|
||||
ShowFolders();
|
||||
panel.StatusText = "Status: checking remote updates...";
|
||||
RunCheck(
|
||||
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()
|
||||
{
|
||||
if (Panel is null)
|
||||
|
|
|
|||
|
|
@ -105,9 +105,25 @@ internal sealed class MetaSyncManager : IDisposable
|
|||
|
||||
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;
|
||||
|
||||
/// <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()
|
||||
{
|
||||
lock (_sync)
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ using System.Globalization;
|
|||
namespace OpenAC.MosswartMassacre.Metas;
|
||||
|
||||
/// <summary>
|
||||
/// The binding object of the Metas window (<c>mosswartmassacre-metas.xml</c>),
|
||||
/// the original's Metas tab: the repository's files with their status, a
|
||||
/// live search, a pick list for one file, and the refresh, check and
|
||||
/// download buttons. Assigned on the tick thread only.
|
||||
/// The state behind the main window's Metas tab: the repository's files
|
||||
/// with their status, a live search, a pick list for one file, the refresh,
|
||||
/// check and download buttons, the repository address and the folders the
|
||||
/// files land in. The main window forwards its Metas bindings here.
|
||||
/// Assigned on the tick thread only.
|
||||
/// </summary>
|
||||
internal sealed class MetasViewModel
|
||||
{
|
||||
|
|
@ -30,6 +31,7 @@ internal sealed class MetasViewModel
|
|||
CheckUpdates = () => ButtonPressed?.Invoke(nameof(CheckUpdates));
|
||||
DownloadOutdated = () => ButtonPressed?.Invoke(nameof(DownloadOutdated));
|
||||
DownloadSelected = () => ButtonPressed?.Invoke(nameof(DownloadSelected));
|
||||
SetRepository = address => RepositoryChanged?.Invoke(address ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
internal event Action? SearchRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the window is open. The root panel's visibility is bound to it,
|
||||
/// so the main window opens this one by setting it; it starts closed.
|
||||
/// </summary>
|
||||
public bool IsVisible { get; set; }
|
||||
/// <summary>The player entered a repository address; empty means the default.</summary>
|
||||
internal event Action<string>? RepositoryChanged;
|
||||
|
||||
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";
|
||||
|
||||
|
|
@ -147,8 +153,16 @@ internal sealed class MetasViewModel
|
|||
$"Last Check: {DateTime.SpecifyKind(stamp, DateTimeKind.Utc).ToLocalTime():yyyy-MM-dd HH:mm:ss}");
|
||||
}
|
||||
|
||||
public void ShowPath(string? storageRoot) =>
|
||||
PathText = storageRoot is null ? "VTank Path: unresolved" : $"VTank Path: {storageRoot}";
|
||||
/// <summary>Names the folders the metas and navs land in; null when there is no profile storage.</summary>
|
||||
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>
|
||||
public static uint StatusColor(MetaFileSyncStatus status) => status switch
|
||||
|
|
|
|||
|
|
@ -139,6 +139,8 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
|
|||
/// no player id, unlike the first; the backend accepts both.
|
||||
/// </summary>
|
||||
public void OnBackendConnected()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_context.Settings.Current.VitalSharingEnabled || !_context.IsLoggedIn)
|
||||
return;
|
||||
|
|
@ -149,7 +151,12 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh
|
|||
character_name = _context.CharacterName,
|
||||
tags = Tags,
|
||||
});
|
||||
_context.Verbose("[WebSocket] Vital sharing re-subscribed");
|
||||
_context.Chat.Write("[WebSocket] Vital sharing re-subscribed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_context.Chat.Write($"[WebSocket] Re-subscribe error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Outgoing ──
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ internal sealed class TelemetryStream : IMmFeature, IBackendConnectionListener
|
|||
{
|
||||
if (!_context.IsLoggedIn)
|
||||
return;
|
||||
_context.Chat.Write("[WebSocket] Starting telemetry loop");
|
||||
SendNow();
|
||||
_timer?.Dispose();
|
||||
_timer = _context.Scheduler.Every(Interval, SendNow);
|
||||
|
|
|
|||
59
src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs
Normal file
59
src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -1,18 +1,23 @@
|
|||
using System.Globalization;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.Metas;
|
||||
using OpenAC.MosswartMassacre.Streams;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The main window (graphical host only) and its binding object. Every value
|
||||
/// is read live from the trackers each frame, so nothing here needs to be told
|
||||
/// that a number changed.
|
||||
/// The main window (graphical host only) and its binding object: the one
|
||||
/// plugin-bar button, "MM" with the original window's icon, and the
|
||||
/// original's tabbed window. Every value is read live each frame, so nothing
|
||||
/// here needs to be told that a number changed.
|
||||
/// </summary>
|
||||
internal sealed class MainWindow : IMmFeature
|
||||
internal sealed partial class MainWindow : IMmFeature
|
||||
{
|
||||
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 SessionStats _stats;
|
||||
private IDisposable? _registration;
|
||||
|
|
@ -30,6 +35,8 @@ internal sealed class MainWindow : IMmFeature
|
|||
new PluginPanelDescriptor("main", "Mosswart Massacre")
|
||||
{
|
||||
IconText = "MM",
|
||||
// The icon the original window carried (7735).
|
||||
IconSurfaceId = OriginalIcon,
|
||||
StartVisible = false,
|
||||
ShowInSidePanel = true,
|
||||
},
|
||||
|
|
@ -42,24 +49,30 @@ internal sealed class MainWindow : IMmFeature
|
|||
Main,
|
||||
Settings,
|
||||
Statistics,
|
||||
MossyTracker,
|
||||
Metas,
|
||||
Tools,
|
||||
}
|
||||
|
||||
/// <summary>Opens the flag tracker window; set by the feature that owns it.</summary>
|
||||
public Action? FlagTrackerOpener { get; set; }
|
||||
|
||||
/// <summary>Opens the metas window; set by the feature that owns it.</summary>
|
||||
public Action? MetasOpener { get; set; }
|
||||
/// <summary>The Metas tab's state; set by the feature that owns it. Null on a host without a window.</summary>
|
||||
public MetasViewModel? MetasTab { get; set; }
|
||||
|
||||
// ── Tabs ──
|
||||
|
||||
public bool MainSelected => _page == Page.Main;
|
||||
public bool SettingsSelected => _page == Page.Settings;
|
||||
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 Action ShowMain => () => _page = Page.Main;
|
||||
public Action ShowSettings => () => _page = Page.Settings;
|
||||
public Action ShowStatistics => () => _page = Page.Statistics;
|
||||
public Action ShowMossyTracker => () => _page = Page.MossyTracker;
|
||||
public Action ShowMetas => () => _page = Page.Metas;
|
||||
public Action ShowTools => () => _page = Page.Tools;
|
||||
|
||||
// ── Main ──
|
||||
|
|
@ -173,7 +186,7 @@ internal sealed class MainWindow : IMmFeature
|
|||
|
||||
public Action ResetStats => () => _context.Commands.Dispatch("reset");
|
||||
|
||||
// ── Tools ──
|
||||
// ── Mossy Tracker ──
|
||||
|
||||
public Action OpenFlagTracker => () =>
|
||||
{
|
||||
|
|
@ -183,13 +196,7 @@ internal sealed class MainWindow : IMmFeature
|
|||
_context.Chat.Write("The Mossy Tracker is not available.");
|
||||
};
|
||||
|
||||
public Action OpenMetas => () =>
|
||||
{
|
||||
if (MetasOpener is { } open)
|
||||
open();
|
||||
else
|
||||
_context.Chat.Write("The metas window is not available.");
|
||||
};
|
||||
// ── Tools ──
|
||||
|
||||
public Action SendInventory => () => _context.Commands.Dispatch("sendinventory");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Mosswart Massacre main window. The original's notebook, minus what OpenAC
|
||||
or another plugin now owns: the updater (the launcher updates), the VTank
|
||||
path (the host keeps the profile folder), route visualisation (MossTank
|
||||
draws routes) and the chest looter (a looter comes later). -->
|
||||
<panel x="60" y="60" w="420" h="320" title="Mosswart Massacre">
|
||||
<!-- Mosswart Massacre main window, the plugin's one bar button. The original's
|
||||
notebook, minus what OpenAC or another plugin now owns: the updater (the
|
||||
launcher updates), the VTank path (the host keeps the profile folder),
|
||||
route visualisation (MossTank draws routes) and the chest looter (a looter
|
||||
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}" />
|
||||
<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}" />
|
||||
<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}" />
|
||||
<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}" />
|
||||
|
||||
<!-- 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="24" text="{KillsPer5MinText}" />
|
||||
<label x="4" y="44" text="{KillsPerHourText}" />
|
||||
|
|
@ -27,7 +34,7 @@
|
|||
</group>
|
||||
|
||||
<!-- 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" />
|
||||
<toggle x="14" y="24" w="300" h="20" text="WebSocket streaming"
|
||||
checked="{WebSocketEnabled}" onclick="{ToggleWebSocket}" />
|
||||
|
|
@ -50,7 +57,7 @@
|
|||
</group>
|
||||
|
||||
<!-- 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="28" text="Total Kills This Session:" />
|
||||
<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}" />
|
||||
</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 -->
|
||||
<group x="6" y="44" w="408" h="270" visible="{ToolsSelected}">
|
||||
<button x="4" y="4" w="150" h="26" text="Open Mossy Tracker" onclick="{OpenFlagTracker}" />
|
||||
<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 x="6" y="44" w="408" h="310" visible="{ToolsSelected}">
|
||||
<button x="4" y="4" w="150" h="26" text="Send Inventory" onclick="{SendInventory}" />
|
||||
</group>
|
||||
</panel>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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))];
|
||||
}
|
||||
|
|
@ -431,7 +431,14 @@ public sealed class RecordingChat : IPluginChat
|
|||
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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ public sealed class FakeHost : IPluginHost
|
|||
|
||||
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 FakeLootClassifierRegistry LootClassifiers { get; } = new();
|
||||
|
|
@ -48,6 +51,8 @@ public sealed class FakeHost : IPluginHost
|
|||
|
||||
IPluginStorage IPluginHost.Storage => Storage;
|
||||
|
||||
IPluginStorage IPluginHost.VtankProfiles => VtankProfiles;
|
||||
|
||||
IAutomationSurface IPluginHost.Automation => Automation;
|
||||
|
||||
IPluginLootClassifierRegistry IPluginHost.LootClassifiers => LootClassifiers;
|
||||
|
|
@ -290,12 +295,25 @@ public sealed class RecordingLogger : IPluginLogger
|
|||
{
|
||||
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)
|
||||
=> 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
|
||||
|
|
@ -444,6 +462,15 @@ public sealed class FakeUiRegistry : IUiRegistry
|
|||
{
|
||||
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>
|
||||
public List<PluginClientWindow> ShowClientWindowCalls { get; } = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ public sealed class FlagTrackerTests
|
|||
RegisteredPanel panel = Assert.Single(harness.Host.Ui.Panels, registered => registered.Descriptor.WindowId == "flagtracker");
|
||||
Assert.Equal("flagtracker", panel.Descriptor.WindowId);
|
||||
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.Same(Panel(harness), panel.Binding);
|
||||
Assert.False(Panel(harness).IsVisible);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
public void A_headless_host_gets_the_verbs_but_no_window()
|
||||
{
|
||||
|
|
@ -310,8 +339,8 @@ public sealed class MetaSyncTests
|
|||
using (feature)
|
||||
{
|
||||
MetasViewModel panel = feature.Panel!;
|
||||
Assert.False(panel.IsVisible);
|
||||
Assert.Equal("VTank Path: C:/profiles", panel.PathText);
|
||||
Assert.Equal($"Metas: {Path.Combine("C:/profiles", "mosstank", "metas")}", panel.MetaFolderText);
|
||||
Assert.Equal($"Navs: {Path.Combine("C:/profiles", "mosstank", "navs")}", panel.NavFolderText);
|
||||
Assert.Equal(["No files loaded"], panel.FilePaths);
|
||||
storage.Seed("mosstank/metas/x.MET", "stale");
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
using System.Reflection;
|
||||
using System.Xml.Linq;
|
||||
using OpenAC.MosswartMassacre.FlagTracker;
|
||||
using OpenAC.MosswartMassacre.Metas;
|
||||
using OpenAC.MosswartMassacre.Views;
|
||||
|
||||
namespace OpenAC.MosswartMassacre.Tests.Ui;
|
||||
|
||||
/// <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
|
||||
/// host would otherwise only make when the window is built in game.
|
||||
/// </summary>
|
||||
public sealed class TrackerAndMetasMarkupContractTests
|
||||
public sealed class WindowMarkupContractTests
|
||||
{
|
||||
private static readonly string[] InteractiveElementNames =
|
||||
[
|
||||
|
|
@ -20,7 +20,13 @@ public sealed class TrackerAndMetasMarkupContractTests
|
|||
public static TheoryData<string, Type> MarkupFiles => new()
|
||||
{
|
||||
{ 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]
|
||||
|
|
@ -79,7 +85,7 @@ public sealed class TrackerAndMetasMarkupContractTests
|
|||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(MarkupFiles))]
|
||||
[MemberData(nameof(OpenedWindows))]
|
||||
public void TheRootPanelVisibilityIsTheSettableIsVisible(string file, Type bindingType)
|
||||
{
|
||||
XElement root = LoadRoot(file);
|
||||
164
tests/OpenAC.MosswartMassacre.Tests/Views/OneWindowTests.cs
Normal file
164
tests/OpenAC.MosswartMassacre.Tests/Views/OneWindowTests.cs
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue