From 336c5cfdf52a45b8e9dc591489e09607f625d98e Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 26 Sep 2026 06:56:10 +0200 Subject: [PATCH 1/2] One plugin-bar button and one tabbed window, as the original Live in 0.1.19 the plugin put three buttons on the plugin bar (MM, Mossy Tracker, Metas), the tracker carried the icon that belongs to the main window, and the metas downloads were in a window of their own. Now only the main window has a bar button: "MM" with the original window's icon (7735, 0x06001E37). Its tabs follow the original's notebook: Main, Settings, Statistics, Mossy Tracker, Metas, Tools. The Mossy Tracker tab is the original page, one "Open Mossy Tracker" button that opens the tracker window; that window is registered without a bar button and opened through the host's ShowPanel. The Metas tab holds everything the metas window had (list with search, refresh, check, download outdated, download one file, last check, status), plus the repository address and the two folders the files land in. The main window forwards those bindings to the meta sync's view model, since markup binds one object. Downloads already went to the host's VTank profile storage, the one MossTank reads, under mosstank/metas and mosstank/navs with each file's own name and extension; the tab now shows those full folder paths instead of the storage root. Backend frames are untouched. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 + src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 9 +- .../FlagTracker/FlagTrackerFeature.cs | 10 +- .../Metas/MetaSyncFeature.cs | 69 +++++--- .../Metas/MetaSyncManager.cs | 18 +- .../Metas/MetasViewModel.cs | 38 ++-- .../Views/MainWindow.Metas.cs | 59 +++++++ .../Views/MainWindow.cs | 35 ++-- .../mosswartmassacre-main.xml | 75 ++++++-- .../mosswartmassacre-metas.xml | 35 ---- .../Fakes/FakeHost.cs | 14 ++ .../FlagTracker/FlagTrackerTests.cs | 3 +- .../Metas/MetaSyncTests.cs | 33 +++- ...tTests.cs => WindowMarkupContractTests.cs} | 16 +- .../Views/OneWindowTests.cs | 164 ++++++++++++++++++ 15 files changed, 459 insertions(+), 124 deletions(-) create mode 100644 src/OpenAC.MosswartMassacre/Views/MainWindow.Metas.cs delete mode 100644 src/OpenAC.MosswartMassacre/mosswartmassacre-metas.xml rename tests/OpenAC.MosswartMassacre.Tests/Ui/{TrackerAndMetasMarkupContractTests.cs => WindowMarkupContractTests.cs} (94%) create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Views/OneWindowTests.cs 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/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/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 @@ - - + + - - - - + + - + - + - +