From 9b5371c2e41eb6462c7268ae695d195922c7ea8a Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 13:00:03 +0200 Subject: [PATCH] Wire the host's new readings into the frames vitals.vitae is the penalty percent computed exactly as the original's client library did from the vitae multiplier; character_stats carries the titles (by name, server order), the current title id and the allegiance (monarch, patron, rank, followers); telemetry.vt_state reads the bot's published meta state; dungeon_map reads the landblock's indoor cells; the main window's Tools buttons reopen the Mossy Tracker and Metas windows even after the player closed them. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 28 +++++- src/OpenAC.MosswartMassacre/HostReadings.cs | 88 +++++++++++++++++++ .../Fakes/FakeAutomation.cs | 6 ++ .../Fakes/FakeHost.cs | 4 + .../Fakes/FakeNetwork.cs | 28 ++++++ .../HostReadingsTests.cs | 73 +++++++++++++++ 6 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 src/OpenAC.MosswartMassacre/HostReadings.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/HostReadingsTests.cs 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)); + } +}