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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Views/MarkupContractTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Views/MarkupContractTests.cs
new file mode 100644
index 0000000..1997e88
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/Views/MarkupContractTests.cs
@@ -0,0 +1,72 @@
+using System.Reflection;
+using System.Xml.Linq;
+using OpenAC.MosswartMassacre.Views;
+
+namespace OpenAC.MosswartMassacre.Tests.Views;
+
+///
+/// Every {Binding} in a markup file names a public property of the
+/// shape the host expects, so a typo fails here instead of as a panel that
+/// refuses to build in the client.
+///
+public sealed class MarkupContractTests
+{
+ public static TheoryData MarkupFiles => new()
+ {
+ { MainWindow.MarkupFile, typeof(MainWindow) },
+ };
+
+ [Theory]
+ [MemberData(nameof(MarkupFiles))]
+ public void EveryBindingResolvesWithTheRightShape(string file, Type bindingType)
+ {
+ XElement root = XDocument.Load(MarkupPath(file)).Root!;
+ Dictionary properties = bindingType
+ .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+ .ToDictionary(property => property.Name, StringComparer.Ordinal);
+
+ foreach (XAttribute attribute in root.DescendantsAndSelf().Attributes())
+ {
+ string value = attribute.Value;
+ if (!value.StartsWith('{'))
+ continue;
+ Assert.Matches("^\\{[^{}]+\\}$", value);
+ string name = value[1..^1];
+ Assert.True(properties.TryGetValue(name, out PropertyInfo? property),
+ $"{file}: {value} on <{attribute.Parent!.Name}> names no public property.");
+
+ string element = attribute.Parent!.Name.LocalName;
+ Type expected = (element, attribute.Name.LocalName) switch
+ {
+ (_, "onclick") => typeof(Action),
+ ("field", "onchange" or "onsubmit") => typeof(Action),
+ ("menu", "onchange") => typeof(Action),
+ ("list", "onchange") => typeof(Action),
+ ("slider", "onchange") => typeof(Action),
+ (_, "checked" or "selected" or "visible" or "enabled") when element != "list" && element != "menu" => typeof(bool),
+ _ => typeof(object),
+ };
+ if (expected != typeof(object))
+ Assert.True(property!.PropertyType == expected,
+ $"{file}: {value} on <{element} {attribute.Name}> is {property.PropertyType.Name}, expected {expected.Name}.");
+ }
+ }
+
+ [Fact]
+ public void The_main_window_builds_its_bindings_without_a_host_ui()
+ {
+ using var harness = new PluginHarness();
+ harness.Login();
+ MainWindow window = harness.Feature();
+
+ Assert.Equal("Total Kills: 0", window.TotalKillsText);
+ Assert.Equal("Meta State: Unknown", window.MetaStateText);
+ Assert.Equal("WebSocket: [DISCONNECTED]", window.WebSocketText);
+ Assert.Equal("Never", window.LastKillValue);
+ window.ShowStatistics();
+ Assert.True(window.StatisticsSelected);
+ Assert.False(window.MainSelected);
+ }
+
+ private static string MarkupPath(string file) => Path.Combine(AppContext.BaseDirectory, file);
+}