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.
|
`/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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ──
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
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 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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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; } = [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
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