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 @@ + + + + + + + + + + + + + + + +