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) <noreply@anthropic.com>
This commit is contained in:
parent
50a173ef25
commit
9b5371c2e4
6 changed files with 223 additions and 4 deletions
|
|
@ -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).
|
||||
|
|
|
|||
88
src/OpenAC.MosswartMassacre/HostReadings.cs
Normal file
88
src/OpenAC.MosswartMassacre/HostReadings.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using AcDream.Plugin.Abstractions;
|
||||
using OpenAC.MosswartMassacre.Radar;
|
||||
using OpenAC.MosswartMassacre.Streams;
|
||||
|
||||
namespace OpenAC.MosswartMassacre;
|
||||
|
||||
/// <summary>
|
||||
/// The values the original read from its client library that the host now
|
||||
/// reports, turned into the numbers the original put on the wire.
|
||||
/// </summary>
|
||||
internal static class HostReadings
|
||||
{
|
||||
/// <summary>The bot plugin whose meta state the telemetry frame carries.</summary>
|
||||
public const string BotPluginId = "acdream.mosstank";
|
||||
|
||||
/// <summary>The status line the bot publishes its current meta state under.</summary>
|
||||
public const string MetaStateKey = "metaState";
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>(long)((1.00 - v) * 100 + 0.5f)</c>. With
|
||||
/// no vitae the multiplier is exactly 1 and the penalty 0.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The bot's current meta state, or empty when no bot publishes one.</summary>
|
||||
public static string MetaState(IPluginHost host) =>
|
||||
host.StatusBoard.TryRead(BotPluginId, MetaStateKey, out string value) ? value : string.Empty;
|
||||
|
||||
/// <summary>The allegiance part of the character-stats frame, once the server has described it.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>The character's titles by name, in the server's order; null before the server has listed them.</summary>
|
||||
public static IReadOnlyList<string>? Titles(ICharacterInfo character)
|
||||
{
|
||||
IReadOnlyList<PluginCharacterTitle> titles = character.Titles;
|
||||
return titles.Count == 0 ? null : [.. titles.Select(static title => title.Name)];
|
||||
}
|
||||
|
||||
/// <summary>The current title id once the server has listed titles; null before.</summary>
|
||||
public static int? CurrentTitle(ICharacterInfo character) =>
|
||||
character.Titles.Count == 0 ? null : unchecked((int)character.CurrentTitleId);
|
||||
}
|
||||
|
||||
/// <summary>A landblock's indoor cells from the host's game data.</summary>
|
||||
internal sealed class HostDungeonCells(IAutomationSurface automation) : IDungeonCellSource
|
||||
{
|
||||
public IReadOnlyList<DungeonCellRecord>? ReadCells(uint landblock)
|
||||
{
|
||||
IReadOnlyList<PluginIndoorCell> 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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>A settable <see cref="ITradeAutomation"/>: a test drives <see cref="Raise*"/> and inspects <see cref="Calls"/>.</summary>
|
||||
|
|
@ -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<PluginCharacterTitle> Titles { get; set; } = [];
|
||||
public bool IsInWorld { get; set; }
|
||||
public int Level { get; set; }
|
||||
public IReadOnlyList<PluginVitalInfo> Vitals { get; set; } = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -52,3 +52,31 @@ public sealed class FakeDungeonMap : IDungeonMapAutomation
|
|||
public bool IsSealedDungeon(uint cellId) =>
|
||||
(cellId & 0xFFFFu) >= 0x100u && SealedLandblocks.Contains(cellId & 0xFFFF0000u);
|
||||
}
|
||||
|
||||
/// <summary>An allegiance surface a test fills in.</summary>
|
||||
public sealed class FakeAllegiance : IAllegianceAutomation
|
||||
{
|
||||
public bool IsAvailable { get; set; } = true;
|
||||
|
||||
public PluginAllegianceSnapshot Snapshot { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A status board whose lines a test sets.</summary>
|
||||
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!);
|
||||
}
|
||||
|
|
|
|||
73
tests/OpenAC.MosswartMassacre.Tests/HostReadingsTests.cs
Normal file
73
tests/OpenAC.MosswartMassacre.Tests/HostReadingsTests.cs
Normal file
|
|
@ -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<OpenAC.MosswartMassacre.Streams.VitalsStream>().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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue