From 5799ea956eb2dfb1e9efb67b6c63c40bdf5914b9 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:19:01 +0200 Subject: [PATCH] Main window: Main, Settings, Statistics and Tools tabs as a markup panel The original's notebook without the parts OpenAC or another plugin now owns (updater, VTank path, route visualisation, chest looter). Labels read live from the trackers each frame. Settings gains the backend URL and shared-secret fields, since the secret is no longer compiled in. Tools opens the Mossy Tracker and Metas windows through openers their features set. The WebSocket label now shows the real connection state rather than the setting. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 1 + .../Trackers/KillTracker.cs | 6 + .../Views/MainWindow.cs | 222 ++++++++++++++++++ .../mosswartmassacre-main.xml | 78 ++++++ .../Views/MarkupContractTests.cs | 72 ++++++ 5 files changed, 379 insertions(+) create mode 100644 src/OpenAC.MosswartMassacre/Views/MainWindow.cs create mode 100644 src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Views/MarkupContractTests.cs diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index 158a6f4..a8bbc08 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -27,6 +27,7 @@ internal static class FeatureCatalog features.Add(new VitalsStream(context)); features.Add(new SpawnPortalStream(context)); features.Add(new BotControl(context)); + features.Add(new Views.MainWindow(context, stats)); session.AddConnectionListener(telemetry); } } diff --git a/src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs b/src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs index f34e23b..b66f8c6 100644 --- a/src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs +++ b/src/OpenAC.MosswartMassacre/Trackers/KillTracker.cs @@ -69,6 +69,9 @@ internal sealed class KillTracker(IClock clock) public int RareCount { get; set; } + /// The highest kills-per-hour seen since the last reset, as the original's statistics page kept it. + public double BestKillsPerHour { get; private set; } + /// Counts a kill when is one of the player's kill messages. public bool CheckForKill(string text) { @@ -92,6 +95,7 @@ internal sealed class KillTracker(IClock clock) StatsStartTime = clock.Now; KillsPer5Min = 0; KillsPerHour = 0; + BestKillsPerHour = 0; } /// The once-a-second refresh the original ran on its stats timer. @@ -105,6 +109,8 @@ internal sealed class KillTracker(IClock clock) KillsPer5Min = (TotalKills / minutesElapsed) * 5; KillsPerHour = (TotalKills / minutesElapsed) * 60; } + if (KillsPerHour > BestKillsPerHour) + BestKillsPerHour = KillsPerHour; } private static bool IsKilledByMeMessage(string text) diff --git a/src/OpenAC.MosswartMassacre/Views/MainWindow.cs b/src/OpenAC.MosswartMassacre/Views/MainWindow.cs new file mode 100644 index 0000000..ee7a080 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Views/MainWindow.cs @@ -0,0 +1,222 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; +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. +/// +internal sealed class MainWindow : IMmFeature +{ + public const string MarkupFile = "mosswartmassacre-main.xml"; + + private readonly MmContext _context; + private readonly SessionStats _stats; + private IDisposable? _registration; + private Page _page = Page.Main; + + public MainWindow(MmContext context, SessionStats stats) + { + _context = context; + _stats = stats; + if (!context.Host.HasUi) + return; + + string directory = Path.GetDirectoryName(typeof(MainWindow).Assembly.Location) ?? "."; + _registration = context.Host.Ui.RegisterPanel( + new PluginPanelDescriptor("main", "Mosswart Massacre") + { + IconText = "MM", + StartVisible = false, + ShowInSidePanel = true, + }, + Path.Combine(directory, MarkupFile), + this); + } + + private enum Page + { + Main, + Settings, + Statistics, + 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; } + + // ── Tabs ── + + public bool MainSelected => _page == Page.Main; + public bool SettingsSelected => _page == Page.Settings; + public bool StatisticsSelected => _page == Page.Statistics; + 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 ShowTools => () => _page = Page.Tools; + + // ── Main ── + + public string TotalKillsText => $"Total Kills: {_stats.Kills.TotalKills}"; + public string KillsPer5MinText => InPlayerCulture($"Kills per 5 Min: {_stats.Kills.KillsPer5Min:F2}"); + public string KillsPerHourText => InPlayerCulture($"Kills per Hour: {_stats.Kills.KillsPerHour:F2}"); + public string ElapsedText => "Time: " + SessionTimeValue; + public string RareCountText => $"Rare Count: {_stats.Rares.RareCount}"; + + public string MetaStateText + { + get + { + string state = _context.MetaState() ?? string.Empty; + return state.Length == 0 ? "Meta State: Unknown" : $"Meta State: {state}"; + } + } + + public string WebSocketText => _context.Backend.IsConnected + ? "WebSocket: [CONNECTED]" + : "WebSocket: [DISCONNECTED]"; + + public uint WebSocketColor => _context.Backend.IsConnected ? 0xFF00C000u : 0xFFE04040u; + + public string VitalSharingText => _context.Settings.Current.VitalSharingEnabled + ? "Vital Sharing: [ON]" + : "Vital Sharing: [OFF]"; + + // ── Settings ── + + public bool WebSocketEnabled => _context.Settings.Current.WebSocketEnabled; + public Action ToggleWebSocket => () => + _context.Commands.Dispatch(WebSocketEnabled ? "ws disable" : "ws enable"); + + public bool VitalSharingEnabled => _context.Settings.Current.VitalSharingEnabled; + public Action ToggleVitalSharing => () => + _context.Commands.Dispatch(VitalSharingEnabled ? "vitalsharing off" : "vitalsharing on"); + public Action ToggleOverlay => () => _context.Commands.Dispatch("vitalsharing overlay"); + + public bool VerboseLogging => _context.Settings.Current.VerboseLogging; + public Action ToggleVerbose => () => _context.Commands.Dispatch("verbose"); + + public string CharTag => _context.Settings.Current.CharTag ?? string.Empty; + public Action SetCharTag => value => + { + _context.Settings.Current.CharTag = value ?? string.Empty; + _context.Settings.Save(); + }; + + public string BackendUrl => _context.Settings.Current.BackendUrl; + public Action SetBackendUrl => value => + { + if (string.IsNullOrWhiteSpace(value)) + return; + _context.Settings.Current.BackendUrl = value.Trim(); + _context.Settings.Save(); + Reconnect(); + }; + + public string SecretStatus => string.IsNullOrEmpty(_context.Settings.Current.BackendSecret) ? "(not set)" : "(set)"; + public Action SetBackendSecret => value => + { + if (string.IsNullOrWhiteSpace(value)) + return; + _context.Settings.Current.BackendSecret = value.Trim(); + _context.Settings.Save(); + Reconnect(); + }; + + // ── Statistics ── + + public string DetailedKillsValue => _stats.Kills.TotalKills.ToString(CultureInfo.CurrentCulture); + public string BestHourValue => InPlayerCulture($"{_stats.Kills.BestKillsPerHour:F2} kills/hr"); + public string AverageKillsValue => InPlayerCulture($"{_stats.Kills.KillsPerHour:F2} kills/hr"); + public string RareStatsValue => _stats.Rares.RareCount.ToString(CultureInfo.CurrentCulture); + + public string RareRateValue + { + get + { + int kills = _stats.Kills.TotalKills; + double rate = kills > 0 ? (double)_stats.Rares.RareCount / kills * 100 : 0; + return InPlayerCulture($"{rate:F2}%"); + } + } + + public string SessionTimeValue + { + get + { + TimeSpan elapsed = _context.Clock.Now - _stats.Kills.StatsStartTime; + return elapsed.Days > 0 + ? $"{elapsed.Days}d {elapsed.Hours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}" + : $"{elapsed.Hours:D2}:{elapsed.Minutes:D2}:{elapsed.Seconds:D2}"; + } + } + + public string LastKillValue + { + get + { + if (_stats.Kills.TotalKills == 0) + return "Never"; + TimeSpan since = _context.Clock.Now - _stats.Kills.LastKillTime; + return since.TotalMinutes < 60 + ? InPlayerCulture($"{since.TotalMinutes:F0} min ago") + : InPlayerCulture($"{since.TotalHours:F1} hr ago"); + } + } + + public Action ResetStats => () => _context.Commands.Dispatch("reset"); + + // ── Tools ── + + public Action OpenFlagTracker => () => + { + if (FlagTrackerOpener is { } open) + open(); + else + _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."); + }; + + public Action SendInventory => () => _context.Commands.Dispatch("sendinventory"); + + // ── Lifecycle ── + + public void OnLogin() + { + } + + public void OnLogoff() + { + } + + public void Dispose() + { + _registration?.Dispose(); + _registration = null; + } + + private void Reconnect() + { + if (!_context.Settings.Current.WebSocketEnabled || !_context.IsLoggedIn) + return; + _context.Commands.Dispatch("ws disable"); + _context.Commands.Dispatch("ws enable"); + } + + // The original formatted its labels in the player's own culture. + private static string InPlayerCulture(FormattableString text) => text.ToString(CultureInfo.CurrentCulture); +} diff --git a/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml b/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml new file mode 100644 index 0000000..de4fcf0 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/mosswartmassacre-main.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + +