diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
index a4580a1..d56f699 100644
--- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
+++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs
@@ -31,7 +31,15 @@ internal static class FeatureCatalog
features.Add(new VitalsStream(context));
features.Add(new SpawnPortalStream(context));
features.Add(new BotControl(context));
- features.Add(new CharacterStatsStream(context));
+ var characterStats = new CharacterStatsStream(context)
+ {
+ CurrentTitle = () => HostReadings.CurrentTitle(context.Automation.Character),
+ Titles = () => HostReadings.Titles(context.Automation.Character),
+ Allegiance = () => HostReadings.Allegiance(context.Automation),
+ };
+ features.Add(characterStats);
+ context.Vitae = () => HostReadings.VitaePercent(context.Automation.Character.VitaeMultiplier);
+ context.MetaState = () => HostReadings.MetaState(context.Host);
var mainWindow = new Views.MainWindow(context, stats);
features.Add(mainWindow);
session.AddConnectionListener(telemetry);
@@ -44,7 +52,7 @@ internal static class FeatureCatalog
features.Add(new Sharing.VitalSharingOverlay(context, sharing));
// The radar the backend turns on and off.
- var radar = new Radar.RadarStream(context, cells: null);
+ var radar = new Radar.RadarStream(context, new HostDungeonCells(context.Automation));
features.Add(radar);
session.RadarToggle = on =>
{
@@ -62,9 +70,21 @@ internal static class FeatureCatalog
var metas = new MetaSyncFeature(context);
features.Add(metas);
if (flagTracker.Panel is { } trackerPanel)
- mainWindow.FlagTrackerOpener = () => trackerPanel.IsVisible = true;
+ {
+ mainWindow.FlagTrackerOpener = () =>
+ {
+ trackerPanel.IsVisible = true;
+ context.Host.Ui.ShowPanel("flagtracker");
+ };
+ }
if (metas.Panel is { } metasPanel)
- mainWindow.MetasOpener = () => metasPanel.IsVisible = true;
+ {
+ mainWindow.MetasOpener = () =>
+ {
+ metasPanel.IsVisible = true;
+ context.Host.Ui.ShowPanel("metas");
+ };
+ }
// Inventory: full_inventory, inventory_delta, equipment_cantrip_state,
// and the taper count telemetry reads (InventoryFeature.PrismaticTaperCount).
diff --git a/src/OpenAC.MosswartMassacre/HostReadings.cs b/src/OpenAC.MosswartMassacre/HostReadings.cs
new file mode 100644
index 0000000..4f1db23
--- /dev/null
+++ b/src/OpenAC.MosswartMassacre/HostReadings.cs
@@ -0,0 +1,88 @@
+using AcDream.Plugin.Abstractions;
+using OpenAC.MosswartMassacre.Radar;
+using OpenAC.MosswartMassacre.Streams;
+
+namespace OpenAC.MosswartMassacre;
+
+///
+/// The values the original read from its client library that the host now
+/// reports, turned into the numbers the original put on the wire.
+///
+internal static class HostReadings
+{
+ /// The bot plugin whose meta state the telemetry frame carries.
+ public const string BotPluginId = "acdream.mosstank";
+
+ /// The status line the bot publishes its current meta state under.
+ public const string MetaStateKey = "metaState";
+
+ ///
+ /// The vitae penalty in percent as the original's client library computed
+ /// it: the vitae multiplier as a float, nudged up by a ten-millionth
+ /// against float error, then (long)((1.00 - v) * 100 + 0.5f). With
+ /// no vitae the multiplier is exactly 1 and the penalty 0.
+ ///
+ public static int VitaePercent(float multiplier)
+ {
+ if (!float.IsFinite(multiplier) || multiplier >= 1f)
+ return 0;
+ float vitae = multiplier + 0.0000001f;
+ return (int)(long)(((1.00 - vitae) * 100) + 0.5f);
+ }
+
+ /// The bot's current meta state, or empty when no bot publishes one.
+ public static string MetaState(IPluginHost host) =>
+ host.StatusBoard.TryRead(BotPluginId, MetaStateKey, out string value) ? value : string.Empty;
+
+ /// The allegiance part of the character-stats frame, once the server has described it.
+ public static AllegianceSummary? Allegiance(IAutomationSurface automation)
+ {
+ if (!automation.Allegiance.IsAvailable)
+ return null;
+ PluginAllegianceSnapshot snapshot = automation.Allegiance.Snapshot;
+ if (!snapshot.IsKnown)
+ return null;
+ return new AllegianceSummary(
+ snapshot.Name ?? string.Empty,
+ Member(snapshot.Monarch),
+ Member(snapshot.Patron),
+ (int)snapshot.Rank,
+ (int)snapshot.VassalCount);
+ }
+
+ private static AllegianceMember? Member(PluginAllegianceMember? member) =>
+ member is { } m && m.Name is not null
+ ? new AllegianceMember(m.Name, m.HeritageGroup, (int)m.Rank, m.Gender)
+ : null;
+
+ /// The character's titles by name, in the server's order; null before the server has listed them.
+ public static IReadOnlyList? Titles(ICharacterInfo character)
+ {
+ IReadOnlyList titles = character.Titles;
+ return titles.Count == 0 ? null : [.. titles.Select(static title => title.Name)];
+ }
+
+ /// The current title id once the server has listed titles; null before.
+ public static int? CurrentTitle(ICharacterInfo character) =>
+ character.Titles.Count == 0 ? null : unchecked((int)character.CurrentTitleId);
+}
+
+/// A landblock's indoor cells from the host's game data.
+internal sealed class HostDungeonCells(IAutomationSurface automation) : IDungeonCellSource
+{
+ public IReadOnlyList? ReadCells(uint landblock)
+ {
+ IReadOnlyList cells = automation.DungeonMap.CaptureIndoorCells(landblock);
+ if (cells.Count == 0)
+ return null;
+ return
+ [
+ .. cells.Select(static cell => new DungeonCellRecord(
+ (ushort)cell.EnvironmentId,
+ cell.Origin.X,
+ cell.Origin.Y,
+ cell.Origin.Z,
+ cell.Orientation.W)),
+ ];
+ }
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
index 5f3b822..9315a5d 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs
@@ -44,6 +44,8 @@ public sealed class FakeAutomation : IAutomationSurface
public FakeDungeonMap DungeonMap { get; } = new();
+ public FakeAllegiance Allegiance { get; } = new();
+
ICharacterInfo IAutomationSurface.Character => Character;
ISpellCatalog IAutomationSurface.Spells => Spells;
IMagicCommands IAutomationSurface.Magic => Magic;
@@ -61,6 +63,7 @@ public sealed class FakeAutomation : IAutomationSurface
ILoginAutomation IAutomationSurface.Login => Login;
INetworkAutomation IAutomationSurface.Network => Network;
IDungeonMapAutomation IAutomationSurface.DungeonMap => DungeonMap;
+ IAllegianceAutomation IAutomationSurface.Allegiance => Allegiance;
}
/// A settable : a test drives and inspects .
@@ -310,6 +313,9 @@ public sealed class FakeDialogAutomation : IDialogAutomation
public sealed class FakeCharacter : ICharacterInfo
{
+ public float VitaeMultiplier { get; set; } = 1f;
+ public uint CurrentTitleId { get; set; }
+ public IReadOnlyList Titles { get; set; } = [];
public bool IsInWorld { get; set; }
public int Level { get; set; }
public IReadOnlyList Vitals { get; set; } = [];
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs
index 60bf405..808dbc3 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeHost.cs
@@ -34,6 +34,10 @@ public sealed class FakeHost : IPluginHost
public FakeHostWindow Window { get; } = new();
+ public FakeStatusBoard StatusBoard { get; } = new();
+
+ IPluginStatusBoard IPluginHost.StatusBoard => StatusBoard;
+
IEvents IPluginHost.Events => Events;
ISelectionService IPluginHost.Selection => Selection;
diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs
index 0ff33ee..e3a7c88 100644
--- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs
+++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeNetwork.cs
@@ -52,3 +52,31 @@ public sealed class FakeDungeonMap : IDungeonMapAutomation
public bool IsSealedDungeon(uint cellId) =>
(cellId & 0xFFFFu) >= 0x100u && SealedLandblocks.Contains(cellId & 0xFFFF0000u);
}
+
+/// An allegiance surface a test fills in.
+public sealed class FakeAllegiance : IAllegianceAutomation
+{
+ public bool IsAvailable { get; set; } = true;
+
+ public PluginAllegianceSnapshot Snapshot { get; set; }
+}
+
+/// A status board whose lines a test sets.
+public sealed class FakeStatusBoard : IPluginStatusBoard
+{
+ public Dictionary<(string PluginId, string Key), string> Lines { get; } = [];
+
+ public bool IsAvailable => true;
+
+ public bool Publish(string key, string? value)
+ {
+ if (value is null)
+ Lines.Remove(("openac.mosswartmassacre", key));
+ else
+ Lines[("openac.mosswartmassacre", key)] = value;
+ return true;
+ }
+
+ public bool TryRead(string pluginId, string key, out string value) =>
+ Lines.TryGetValue((pluginId, key), out value!);
+}
diff --git a/tests/OpenAC.MosswartMassacre.Tests/HostReadingsTests.cs b/tests/OpenAC.MosswartMassacre.Tests/HostReadingsTests.cs
new file mode 100644
index 0000000..3432388
--- /dev/null
+++ b/tests/OpenAC.MosswartMassacre.Tests/HostReadingsTests.cs
@@ -0,0 +1,73 @@
+using AcDream.Plugin.Abstractions;
+
+namespace OpenAC.MosswartMassacre.Tests;
+
+public sealed class HostReadingsTests
+{
+ [Theory]
+ // The original's (long)((1.00 - (v + 0.0000001f)) * 100 + 0.5f).
+ [InlineData(1f, 0)]
+ [InlineData(0.95f, 5)]
+ [InlineData(0.9f, 10)]
+ [InlineData(0.6f, 40)]
+ // 0.995 plus the nudge is just over 0.995 as a float: the rounding lands on 0.
+ [InlineData(0.995f, 0)]
+ public void Vitae_is_the_penalty_in_percent_as_the_original_rounded_it(float multiplier, int expected) =>
+ Assert.Equal(expected, HostReadings.VitaePercent(multiplier));
+
+ [Fact]
+ public void The_vitals_frame_carries_the_vitae_penalty()
+ {
+ using var harness = new PluginHarness("Mossy");
+ harness.Host.Automation.Character.VitaeMultiplier = 0.95f;
+ harness.Host.Automation.Character.MaxHealth = 10;
+ harness.Login();
+ harness.Frames.Clear();
+
+ harness.Feature().SendNow();
+
+ Assert.EndsWith(",\"vitae\":5}", Assert.Single(harness.Frames), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Titles_are_null_until_listed_then_names_in_order_and_the_current_id()
+ {
+ var character = new Fakes.FakeCharacter();
+ Assert.Null(HostReadings.Titles(character));
+ Assert.Null(HostReadings.CurrentTitle(character));
+
+ character.Titles = [new PluginCharacterTitle(7, "Adventurer"), new PluginCharacterTitle(3, "Sawato Resident")];
+ character.CurrentTitleId = 3;
+
+ Assert.Equal(["Adventurer", "Sawato Resident"], HostReadings.Titles(character));
+ Assert.Equal(3, HostReadings.CurrentTitle(character));
+ }
+
+ [Fact]
+ public void Allegiance_maps_monarch_patron_rank_and_followers()
+ {
+ var automation = new Fakes.FakeAutomation();
+ Assert.Null(HostReadings.Allegiance(automation));
+
+ automation.Allegiance.Snapshot = new PluginAllegianceSnapshot(1, true, "Mossy Club", 3u, 40u, 17u, 5u)
+ {
+ Monarch = new PluginAllegianceMember(5u, "King", 8u, 200u, 1, 2, true),
+ };
+
+ var summary = HostReadings.Allegiance(automation)!.Value;
+ Assert.Equal("Mossy Club", summary.Name);
+ Assert.Equal(new OpenAC.MosswartMassacre.Streams.AllegianceMember("King", 1, 8, 2), summary.Monarch);
+ Assert.Null(summary.Patron);
+ Assert.Equal(3, summary.Rank);
+ Assert.Equal(17, summary.Followers);
+ }
+
+ [Fact]
+ public void Meta_state_comes_from_the_bots_status_line()
+ {
+ var host = new Fakes.FakeHost();
+ Assert.Equal(string.Empty, HostReadings.MetaState(host));
+ host.StatusBoard.Lines[("acdream.mosstank", "metaState")] = "Combat";
+ Assert.Equal("Combat", HostReadings.MetaState(host));
+ }
+}