diff --git a/README.md b/README.md index 9909ad1..70d0c4a 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs b/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs index 025d17e..eda77b6 100644 --- a/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs +++ b/src/OpenAC.MosswartMassacre/Backend/BackendClient.cs @@ -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. /// +/// +/// 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 +/// . +/// /// 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 _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? outbound; Task? sendLoop; @@ -120,10 +130,16 @@ internal sealed class BackendClient : IDisposable } } run.Cts.Dispose(); - _log.Info("[WebSocket] DISABLED"); }); } + /// + /// 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. + /// + public bool TryTakeStatus(out string line) => _status.TryDequeue(out line!); + /// Queues a frame. Dropped when no socket is open. 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( 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); } } + /// Queues a line for chat, then logs it; queued first so a line seen in the log is already drainable. + 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); diff --git a/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs b/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs index d740abe..2699bce 100644 --- a/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs +++ b/src/OpenAC.MosswartMassacre/Backend/BackendSession.cs @@ -17,6 +17,12 @@ namespace OpenAC.MosswartMassacre.Backend; /// stop_radar 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. +/// +/// 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. +/// /// 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 /// Drains the socket. Public so tests can pump without a host tick. public void Pump() { + DrainStatus(); + while (_context.Backend.TryTakeConnection(out BackendConnection connection)) OnConnected(connection.ConnectedAtUtc); @@ -93,6 +103,13 @@ internal sealed class BackendSession : IMmFeature } } + /// Writes the socket's queued status lines to chat. Tick thread only. + private void DrainStatus() + { + while (_context.Backend.TryTakeStatus(out string line)) + _context.Chat.Write(line); + } + /// Commands queued from the backend and not yet run. 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) diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 5ace75f..de2ebc8 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -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). diff --git a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs index 4087620..6879694 100644 --- a/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs +++ b/src/OpenAC.MosswartMassacre/FlagTracker/FlagTrackerFeature.cs @@ -4,7 +4,8 @@ using OpenAC.MosswartMassacre.Quests; namespace OpenAC.MosswartMassacre.FlagTracker; /// -/// 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. /// @@ -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); diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs index 0fd528c..4031770 100644 --- a/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs +++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncFeature.cs @@ -6,7 +6,8 @@ namespace OpenAC.MosswartMassacre.Metas; /// /// Meta and nav sync from the club's repository: the /mm metasrefresh, /// metascheck, metaspull and metaspullfile 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. /// /// /// Network work runs on the thread pool; every result, chat line and window @@ -15,15 +16,11 @@ namespace OpenAC.MosswartMassacre.Metas; /// internal sealed class MetaSyncFeature : IMmFeature { - /// The panel's markup file, beside the plugin assembly. - public const string MarkupFile = "mosswartmassacre-metas.xml"; - private readonly MmContext _context; private readonly MetaSyncManager _manager; private readonly ConcurrentQueue _onTick = new(); private readonly CancellationTokenSource _shutdown = new(); private readonly Action _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; } /// - /// The window's binding object; set its IsVisible 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. /// 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)); + + /// + /// 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. + /// + 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) diff --git a/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs index c84a798..b72fa7a 100644 --- a/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs +++ b/src/OpenAC.MosswartMassacre/Metas/MetaSyncManager.cs @@ -105,9 +105,25 @@ internal sealed class MetaSyncManager : IDisposable public DateTime LastStatusCheckUtc { get; private set; } = DateTime.MinValue; - /// The storage root the files are written beneath, for the path label; null without storage. + /// The storage root the files are written beneath; null without storage. public string? StorageRoot => _storage.IsAvailable ? _storage.RootPath ?? string.Empty : null; + /// + /// The folder on disk a storage folder ( or + /// ) 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. + /// + 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 GetCachedFiles() { lock (_sync) diff --git a/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs index a72beea..a38c417 100644 --- a/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs +++ b/src/OpenAC.MosswartMassacre/Metas/MetasViewModel.cs @@ -3,10 +3,11 @@ using System.Globalization; namespace OpenAC.MosswartMassacre.Metas; /// -/// The binding object of the Metas window (mosswartmassacre-metas.xml), -/// 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. /// 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); } /// A button was pressed; the argument is its binding name. @@ -38,13 +40,17 @@ internal sealed class MetasViewModel /// The search text changed or was cleared. internal event Action? SearchRequested; - /// - /// 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. - /// - public bool IsVisible { get; set; } + /// The player entered a repository address; empty means the default. + internal event Action? RepositoryChanged; - public string PathText { get; set; } = "VTank Path: unresolved"; + public string MetaFolderText { get; set; } = UnresolvedMetaFolder; + + public string NavFolderText { get; set; } = UnresolvedNavFolder; + + /// The repository address the files come from. + public string RepositoryText { get; set; } = string.Empty; + + public Action 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}"; + /// Names the folders the metas and navs land in; null when there is no profile storage. + 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"; /// The status text colour: green current, orange outdated, blue missing, red failed. public static uint StatusColor(MetaFileSyncStatus status) => status switch diff --git a/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs b/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs index 6141a54..6baf92b 100644 --- a/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs +++ b/src/OpenAC.MosswartMassacre/Sharing/VitalSharing.cs @@ -140,16 +140,23 @@ internal sealed class VitalSharing : IMmFeature, IBackendConnectionListener, ISh /// public void OnBackendConnected() { - if (!_context.Settings.Current.VitalSharingEnabled || !_context.IsLoggedIn) - return; - _context.Send(new + try { - type = "share_subscribe", - timestamp = Wire.Timestamp(_context.Clock), - character_name = _context.CharacterName, - tags = Tags, - }); - _context.Verbose("[WebSocket] Vital sharing re-subscribed"); + if (!_context.Settings.Current.VitalSharingEnabled || !_context.IsLoggedIn) + return; + _context.Send(new + { + type = "share_subscribe", + 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 ── diff --git a/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs b/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs index 02f3a2d..a01a826 100644 --- a/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs +++ b/src/OpenAC.MosswartMassacre/Streams/TelemetryStream.cs @@ -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); diff --git a/src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs b/src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs new file mode 100644 index 0000000..327ba28 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs @@ -0,0 +1,59 @@ +using OpenAC.MosswartMassacre.Metas; + +namespace OpenAC.MosswartMassacre.Views; + +/// +/// The Metas tab's bindings. The markup binds one object per window, so each +/// is forwarded to the meta sync's ; an action +/// looks the tab up when it runs, so it works whenever the tab is set. +/// +internal sealed partial class MainWindow +{ + private static readonly IReadOnlyList NoItems = []; + + private static readonly IReadOnlyList NoColors = []; + + public string MetasFolderText => MetasTab?.MetaFolderText ?? string.Empty; + + public string NavsFolderText => MetasTab?.NavFolderText ?? string.Empty; + + public string MetasRepositoryText => MetasTab?.RepositoryText ?? string.Empty; + + public Action 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 MetasSearchChanged => text => MetasTab?.SearchChanged(text); + + public Action MetasClearSearch => () => MetasTab?.ClearSearch(); + + public int MetasNoRow => -1; + + public Action MetasIgnoreRow => _ => { }; + + public IReadOnlyList MetasFileTypes => MetasTab?.FileTypes ?? NoItems; + + public IReadOnlyList MetasFilePaths => MetasTab?.FilePaths ?? NoItems; + + public IReadOnlyList MetasFileStatus => MetasTab?.FileStatus ?? NoItems; + + public IReadOnlyList MetasFileStatusColors => MetasTab?.FileStatusColors ?? NoColors; + + public IReadOnlyList MetasSelectionItems => MetasTab?.SelectionItems ?? NoItems; + + public string MetasSelectedLabel => MetasTab?.SelectedLabel ?? string.Empty; + + public Action MetasSelectionChanged => label => MetasTab?.SelectionChanged(label); + + public string MetasLastCheckText => MetasTab?.LastCheckText ?? string.Empty; + + public string MetasStatusText => MetasTab?.StatusText ?? string.Empty; +} diff --git a/src/OpenAC.MosswartMassacre/Views/MainWindow.cs b/src/OpenAC.MosswartMassacre/Views/MainWindow.cs index ee7a080..06b3275 100644 --- a/src/OpenAC.MosswartMassacre/Views/MainWindow.cs +++ b/src/OpenAC.MosswartMassacre/Views/MainWindow.cs @@ -1,18 +1,23 @@ using System.Globalization; using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Metas; using OpenAC.MosswartMassacre.Streams; namespace OpenAC.MosswartMassacre.Views; /// -/// 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. /// -internal sealed class MainWindow : IMmFeature +internal sealed partial class MainWindow : IMmFeature { public const string MarkupFile = "mosswartmassacre-main.xml"; + /// The original window's icon, 7735 in the image table. + 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, } /// Opens the flag tracker window; set by the feature that owns it. public Action? FlagTrackerOpener { get; set; } - /// Opens the metas window; set by the feature that owns it. - public Action? MetasOpener { get; set; } + /// The Metas tab's state; set by the feature that owns it. Null on a host without a window. + 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"); diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml index de4fcf0..d28ad0c 100644 --- a/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml +++ b/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml @@ -1,21 +1,28 @@ - - + + - - - - + + - + - + - +