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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:19:01 +02:00
parent 1ff0a40aeb
commit 5799ea956e
5 changed files with 379 additions and 0 deletions

View file

@ -27,6 +27,7 @@ internal static class FeatureCatalog
features.Add(new VitalsStream(context)); features.Add(new VitalsStream(context));
features.Add(new SpawnPortalStream(context)); features.Add(new SpawnPortalStream(context));
features.Add(new BotControl(context)); features.Add(new BotControl(context));
features.Add(new Views.MainWindow(context, stats));
session.AddConnectionListener(telemetry); session.AddConnectionListener(telemetry);
} }
} }

View file

@ -69,6 +69,9 @@ internal sealed class KillTracker(IClock clock)
public int RareCount { get; set; } public int RareCount { get; set; }
/// <summary>The highest kills-per-hour seen since the last reset, as the original's statistics page kept it.</summary>
public double BestKillsPerHour { get; private set; }
/// <summary>Counts a kill when <paramref name="text"/> is one of the player's kill messages.</summary> /// <summary>Counts a kill when <paramref name="text"/> is one of the player's kill messages.</summary>
public bool CheckForKill(string text) public bool CheckForKill(string text)
{ {
@ -92,6 +95,7 @@ internal sealed class KillTracker(IClock clock)
StatsStartTime = clock.Now; StatsStartTime = clock.Now;
KillsPer5Min = 0; KillsPer5Min = 0;
KillsPerHour = 0; KillsPerHour = 0;
BestKillsPerHour = 0;
} }
/// <summary>The once-a-second refresh the original ran on its stats timer.</summary> /// <summary>The once-a-second refresh the original ran on its stats timer.</summary>
@ -105,6 +109,8 @@ internal sealed class KillTracker(IClock clock)
KillsPer5Min = (TotalKills / minutesElapsed) * 5; KillsPer5Min = (TotalKills / minutesElapsed) * 5;
KillsPerHour = (TotalKills / minutesElapsed) * 60; KillsPerHour = (TotalKills / minutesElapsed) * 60;
} }
if (KillsPerHour > BestKillsPerHour)
BestKillsPerHour = KillsPerHour;
} }
private static bool IsKilledByMeMessage(string text) private static bool IsKilledByMeMessage(string text)

View file

@ -0,0 +1,222 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
using OpenAC.MosswartMassacre.Streams;
namespace OpenAC.MosswartMassacre.Views;
/// <summary>
/// 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.
/// </summary>
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,
}
/// <summary>Opens the flag tracker window; set by the feature that owns it.</summary>
public Action? FlagTrackerOpener { get; set; }
/// <summary>Opens the metas window; set by the feature that owns it.</summary>
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<string> SetCharTag => value =>
{
_context.Settings.Current.CharTag = value ?? string.Empty;
_context.Settings.Save();
};
public string BackendUrl => _context.Settings.Current.BackendUrl;
public Action<string> 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<string> 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);
}

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mosswart Massacre main window. The original's notebook, minus what OpenAC
or another plugin now owns: the updater (the launcher updates), the VTank
path (the host keeps the profile folder), route visualisation (MossTank
draws routes) and the chest looter (a looter comes later). -->
<panel x="60" y="60" w="420" h="320" title="Mosswart Massacre">
<tab x="6" y="22" w="56" h="18" text="Main"
selected="{MainSelected}" onclick="{ShowMain}" />
<tab x="62" y="22" w="62" h="18" text="Settings"
selected="{SettingsSelected}" onclick="{ShowSettings}" />
<tab x="124" y="22" w="70" h="18" text="Statistics"
selected="{StatisticsSelected}" onclick="{ShowStatistics}" />
<tab x="194" y="22" w="62" h="18" text="Tools"
selected="{ToolsSelected}" onclick="{ShowTools}" />
<!-- Main -->
<group x="6" y="44" w="408" h="270" visible="{MainSelected}">
<label x="4" y="4" text="{TotalKillsText}" />
<label x="4" y="24" text="{KillsPer5MinText}" />
<label x="4" y="44" text="{KillsPerHourText}" />
<label x="4" y="64" text="{ElapsedText}" />
<label x="4" y="84" text="{RareCountText}" />
<label x="4" y="114" text="{MetaStateText}" />
<label x="4" y="134" text="{WebSocketText}" color="{WebSocketColor}" />
<label x="4" y="154" text="{VitalSharingText}" />
</group>
<!-- Settings -->
<group x="6" y="44" w="408" h="270" visible="{SettingsSelected}">
<label x="4" y="4" text="Plugin Configuration" color="#FFE8D8B0" />
<toggle x="14" y="24" w="300" h="20" text="WebSocket streaming"
checked="{WebSocketEnabled}" onclick="{ToggleWebSocket}" />
<toggle x="14" y="46" w="200" h="20" text="Vital Sharing (cross-PC)"
checked="{VitalSharingEnabled}" onclick="{ToggleVitalSharing}" />
<button x="220" y="46" w="100" h="20" text="Toggle Overlay" onclick="{ToggleOverlay}" />
<toggle x="14" y="68" w="360" h="20" text="Verbose logging (vital sharing / VTank feed)"
checked="{VerboseLogging}" onclick="{ToggleVerbose}" />
<label x="14" y="98" text="Character Tag:" />
<field x="120" y="96" w="150" h="20" text="{CharTag}" onchange="{SetCharTag}" maxlength="40" />
<label x="14" y="118" text="Used for telemetry and vital sharing groups." color="#FFC7B98F" />
<label x="14" y="146" text="Backend URL:" />
<field x="120" y="144" w="280" h="20" text="{BackendUrl}" onsubmit="{SetBackendUrl}" />
<label x="14" y="170" text="Shared secret:" />
<field x="120" y="168" w="180" h="20" text="" onsubmit="{SetBackendSecret}" clearonsubmit="true" />
<label x="306" y="170" text="{SecretStatus}" color="#FFC7B98F" />
<label x="14" y="192" text="Press Enter to save; reconnects with the new values." color="#FFC7B98F" />
</group>
<!-- Statistics -->
<group x="6" y="44" w="408" h="270" visible="{StatisticsSelected}">
<label x="4" y="4" text="Detailed Statistics" color="#FFE8D8B0" />
<label x="4" y="28" text="Total Kills This Session:" />
<label x="194" y="28" text="{DetailedKillsValue}" />
<label x="4" y="48" text="Best Hour Performance:" />
<label x="194" y="48" text="{BestHourValue}" />
<label x="4" y="68" text="Average Kills/Hour:" />
<label x="194" y="68" text="{AverageKillsValue}" />
<label x="4" y="98" text="Rares Found:" />
<label x="194" y="98" text="{RareStatsValue}" />
<label x="4" y="118" text="Rare Drop Rate:" />
<label x="194" y="118" text="{RareRateValue}" />
<label x="4" y="148" text="Session Duration:" />
<label x="194" y="148" text="{SessionTimeValue}" />
<label x="4" y="168" text="Last Kill:" />
<label x="194" y="168" text="{LastKillValue}" />
<button x="4" y="198" w="80" h="22" text="Reset All" onclick="{ResetStats}" />
</group>
<!-- Tools -->
<group x="6" y="44" w="408" h="270" visible="{ToolsSelected}">
<button x="4" y="4" w="150" h="26" text="Open Mossy Tracker" onclick="{OpenFlagTracker}" />
<button x="4" y="36" w="150" h="26" text="Open Metas" onclick="{OpenMetas}" />
<button x="4" y="68" w="150" h="26" text="Send Inventory" onclick="{SendInventory}" />
</group>
</panel>

View file

@ -0,0 +1,72 @@
using System.Reflection;
using System.Xml.Linq;
using OpenAC.MosswartMassacre.Views;
namespace OpenAC.MosswartMassacre.Tests.Views;
/// <summary>
/// Every <c>{Binding}</c> 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.
/// </summary>
public sealed class MarkupContractTests
{
public static TheoryData<string, Type> 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<string, PropertyInfo> 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<string>),
("menu", "onchange") => typeof(Action<string>),
("list", "onchange") => typeof(Action<int>),
("slider", "onchange") => typeof(Action<float>),
(_, "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<MainWindow>();
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);
}