From 5b3179dd755c7ba1af18093e196e2aa734556554 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 25 Sep 2026 12:33:15 +0200 Subject: [PATCH] character_stats: the full character frame, five seconds after login and every ten minutes Fields, order and types as the original sent them. Values follow the original's client library where it matters: race and gender are the player's string properties, birth the creation time as a US date (explicit pattern, so no newer-culture space quirk), burden the units over 150 times effective strength truncated to a percent, capacity with the carrying augmentation, and properties the player's integer properties minus the original's blacklist. Attributes, vitals and skills carry unbuffed values, creation being the value before experience was spent. Titles, the allegiance tree and luminance total come through seams that the plugin API additions fill; until then they are null, as the original sent them before the matching server message arrived. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/OpenAC.MosswartMassacre/FeatureCatalog.cs | 1 + .../Streams/CharacterStatsStream.cs | 244 ++++++++++++++++++ .../Fakes/FakeAutomation.cs | 2 + .../Streams/CharacterStatsTests.cs | 99 +++++++ 4 files changed, 346 insertions(+) create mode 100644 src/OpenAC.MosswartMassacre/Streams/CharacterStatsStream.cs create mode 100644 tests/OpenAC.MosswartMassacre.Tests/Streams/CharacterStatsTests.cs diff --git a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs index bb63c5d..b86fd7f 100644 --- a/src/OpenAC.MosswartMassacre/FeatureCatalog.cs +++ b/src/OpenAC.MosswartMassacre/FeatureCatalog.cs @@ -30,6 +30,7 @@ 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 mainWindow = new Views.MainWindow(context, stats); features.Add(mainWindow); session.AddConnectionListener(telemetry); diff --git a/src/OpenAC.MosswartMassacre/Streams/CharacterStatsStream.cs b/src/OpenAC.MosswartMassacre/Streams/CharacterStatsStream.cs new file mode 100644 index 0000000..b9af508 --- /dev/null +++ b/src/OpenAC.MosswartMassacre/Streams/CharacterStatsStream.cs @@ -0,0 +1,244 @@ +using System.Globalization; +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; + +namespace OpenAC.MosswartMassacre.Streams; + +/// An allegiance member as the character-stats frame names one. +internal readonly record struct AllegianceMember(string Name, int Race, int Rank, int Gender); + +/// The allegiance part of the character-stats frame. +internal readonly record struct AllegianceSummary( + string Name, AllegianceMember? Monarch, AllegianceMember? Patron, int Rank, int Followers); + +/// +/// The character_stats frame: five seconds after login, then every ten +/// minutes, with the original's fields in the original's order. +/// +/// +/// +/// Values follow the original's sources: race and gender are the player's +/// string properties, birth is the creation timestamp as a US-formatted date, +/// burden is the burden units over 150 times effective strength in percent +/// (truncated, no carrying augmentation, as the original's client library +/// computed it), and the capacity adds the augmentation. properties is +/// the player's integer properties minus the original's cosmetic blacklist. +/// +/// +/// Attributes, vitals and skills carry the unbuffed values; an attribute's +/// creation is its value before any experience was spent on it. +/// +/// +internal sealed class CharacterStatsStream : IMmFeature +{ + public static readonly TimeSpan FirstDelay = TimeSpan.FromSeconds(5); + public static readonly TimeSpan Interval = TimeSpan.FromMinutes(10); + + private const uint PropEncumbranceVal = 5; + private const uint PropAvailableSkillCredits = 24; + private const uint PropNumDeaths = 43; + private const uint PropCreationTimestamp = 98; + private const uint PropCarryingCapacityAugs = 230; + private const uint StrSex = 3; + private const uint StrHeritage = 4; + private const uint Int64TotalXp = 1; + private const uint Int64AvailableXp = 2; + private const uint Int64AvailableLuminance = 6; + private const uint Int64MaximumLuminance = 7; + + /// Cosmetic and internal properties the original left out (TreeStats' list). + internal static readonly HashSet PropertyBlacklist = + [ + 2, 5, 7, 10, 17, 19, 20, 24, 25, 26, 28, 30, 33, 35, 36, 38, 43, 45, + 86, 87, 88, 89, 90, 91, 92, 98, + 105, 106, 107, 108, 109, 110, 111, 113, 114, 115, 117, 125, 129, 131, 134, + 158, 159, 160, 166, 170, 171, 172, 174, 175, 176, 177, 178, 179, 188, 193, + 270, 271, 272, 293, + ]; + + private static readonly string[] AttributeNames = ["strength", "endurance", "quickness", "coordination", "focus", "self"]; + private static readonly string[] VitalNames = ["health", "stamina", "mana"]; + + private readonly MmContext _context; + private IDisposable? _first; + private IDisposable? _timer; + + public CharacterStatsStream(MmContext context) + { + _context = context; + } + + /// The character's current title id, or null when unknown; set once the host reports titles. + public Func CurrentTitle { get; set; } = static () => null; + + /// The character's titles in the server's order, or null when unknown. + public Func?> Titles { get; set; } = static () => null; + + /// The allegiance, or null when the character has none or it is unknown. + public Func Allegiance { get; set; } = static () => null; + + public void OnLogin() + { + _first = _context.Scheduler.Delay(FirstDelay, SendNow); + _timer = _context.Scheduler.Every(Interval, SendNow); + } + + public void OnLogoff() + { + _first?.Dispose(); + _first = null; + _timer?.Dispose(); + _timer = null; + } + + public void Dispose() => OnLogoff(); + + internal void SendNow() + { + if (!_context.Settings.Current.WebSocketEnabled || !_context.IsLoggedIn) + return; + try + { + _context.Send(Build()); + } + catch (Exception ex) + { + _context.Verbose($"[CharStats] Error collecting stats: {ex.Message}"); + } + } + + internal object Build() + { + IAutomationSurface automation = _context.Automation; + ICharacterInfo character = automation.Character; + PluginItemProperties props = automation.Objects.TryCaptureProperties(character.ObjectId, out PluginItemProperties captured) + ? captured + : Empty; + + int Int(uint key) => props.Ints is { } ints && ints.TryGetValue(key, out int value) ? value : 0; + long? Int64(uint key) => props.Int64s is { } longs && longs.TryGetValue(key, out long value) ? value : null; + string Str(uint key) => props.Strings is { } strings && strings.TryGetValue(key, out string? value) ? value ?? "" : ""; + + // Attributes, in the character's own order. + var attributes = new Dictionary(); + foreach (PluginAttributeInfo attribute in character.Attributes.OrderBy(static a => a.Kind)) + { + if (attribute.Kind < 0 || attribute.Kind >= AttributeNames.Length) + continue; + attributes[AttributeNames[attribute.Kind]] = new + { + @base = (int)attribute.Base, + creation = (int)(attribute.Base - Math.Min(attribute.Base, attribute.Ranks)), + }; + } + + var vitals = new Dictionary(); + foreach (PluginVitalInfo vital in character.Vitals.OrderBy(static v => v.Kind)) + { + if (vital.Kind < 0 || vital.Kind >= VitalNames.Length) + continue; + vitals[VitalNames[vital.Kind]] = new { @base = (int)vital.Base }; + } + + var skills = new Dictionary(); + foreach (PluginSkillInfo skill in character.Skills.OrderBy(static s => s.SkillId)) + { + if (string.IsNullOrEmpty(skill.Name)) + continue; + skills[skill.Name.ToLowerInvariant().Replace(" ", "_", StringComparison.Ordinal)] = new + { + @base = (int)skill.Base, + training = TrainingName(skill.Training), + }; + } + + object? allegiance = null; + if (Allegiance() is { } a) + { + allegiance = new + { + name = a.Name, + monarch = a.Monarch is { } m ? new { name = m.Name, race = m.Race, rank = m.Rank, gender = m.Gender } : null, + patron = a.Patron is { } p ? new { name = p.Name, race = p.Race, rank = p.Rank, gender = p.Gender } : null, + rank = a.Rank, + followers = a.Followers, + }; + } + + int effectiveStrength = EffectiveStrength(character); + int burdenUnits = Int(PropEncumbranceVal); + int legalBurden = effectiveStrength * 150; + int burden = legalBurden > 0 ? (int)((float)burdenUnits / legalBurden * 100) : 0; + int encumbranceCapacity = (150 * effectiveStrength) + (Int(PropCarryingCapacityAugs) * 30 * effectiveStrength); + + Dictionary? properties = null; + if (props.Ints is { Count: > 0 } allInts) + { + properties = []; + foreach (var pair in allInts) + { + if (!PropertyBlacklist.Contains(pair.Key)) + properties[unchecked((int)pair.Key)] = pair.Value; + } + if (properties.Count == 0) + properties = null; + } + + IReadOnlyList? titles = Titles(); + long? lumEarned = Int64(Int64AvailableLuminance); + long? lumTotal = Int64(Int64MaximumLuminance); + + return new + { + type = "character_stats", + timestamp = Wire.Timestamp(_context.Clock), + character_name = _context.CharacterName, + level = character.Level, + race = Str(StrHeritage), + gender = Str(StrSex), + birth = new DateTime(1970, 1, 1, 0, 0, 0) + .AddSeconds(Int(PropCreationTimestamp)) + .ToString("M/d/yyyy h:mm:ss tt", CultureInfo.GetCultureInfo("en-US")), + total_xp = Int64(Int64TotalXp) ?? 0L, + unassigned_xp = Int64(Int64AvailableXp) ?? 0L, + skill_credits = Int(PropAvailableSkillCredits), + deaths = Int(PropNumDeaths), + burden, + burden_units = burdenUnits, + encumbrance_capacity = encumbranceCapacity, + luminance_earned = lumEarned is >= 0 ? lumEarned : null, + luminance_total = lumTotal is >= 0 ? lumTotal : null, + current_title = CurrentTitle() is int title && title >= 0 ? (int?)title : null, + attributes, + vitals, + skills, + allegiance, + properties, + titles = titles is { Count: > 0 } ? new List(titles) : null, + }; + } + + private static int EffectiveStrength(ICharacterInfo character) + { + foreach (PluginAttributeInfo attribute in character.Attributes) + { + if (attribute.Kind == 0) + return (int)attribute.Current; + } + return 0; + } + + /// The original's training word: the client library's enum name without its "eTrain" prefix. + private static string TrainingName(PluginSkillTraining training) => training switch + { + PluginSkillTraining.Untrained => "Untrained", + PluginSkillTraining.Trained => "Trained", + PluginSkillTraining.Specialized => "Specialized", + _ => "Unusable", + }; + + private static readonly PluginItemProperties Empty = new( + new Dictionary(), new Dictionary(), new Dictionary(), + new Dictionary(), new Dictionary(), new Dictionary(), + new Dictionary()); +} diff --git a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs index 83250bc..daad549 100644 --- a/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs +++ b/tests/OpenAC.MosswartMassacre.Tests/Fakes/FakeAutomation.cs @@ -311,6 +311,8 @@ public sealed class FakeDialogAutomation : IDialogAutomation public sealed class FakeCharacter : ICharacterInfo { public bool IsInWorld { get; set; } + public int Level { get; set; } + public IReadOnlyList Vitals { get; set; } = []; public string Name { get; set; } = string.Empty; public string WorldName { get; set; } = string.Empty; public int ServerPopulation { get; set; } = -1; diff --git a/tests/OpenAC.MosswartMassacre.Tests/Streams/CharacterStatsTests.cs b/tests/OpenAC.MosswartMassacre.Tests/Streams/CharacterStatsTests.cs new file mode 100644 index 0000000..ad251d9 --- /dev/null +++ b/tests/OpenAC.MosswartMassacre.Tests/Streams/CharacterStatsTests.cs @@ -0,0 +1,99 @@ +using AcDream.Plugin.Abstractions; +using OpenAC.MosswartMassacre.Backend; +using OpenAC.MosswartMassacre.Streams; + +namespace OpenAC.MosswartMassacre.Tests.Streams; + +/// The character_stats frame as the original's CharacterStats.CollectAndSend wrote it. +public sealed class CharacterStatsTests +{ + private const string Stamp = "2026-09-25T08:30:15.1230000Z"; + + private static PluginHarness Arranged() + { + var harness = new PluginHarness("Mossy"); + var c = harness.Host.Automation.Character; + c.Level = 126; + c.Attributes = + [ + new PluginAttributeInfo(0, "Strength", 300u) { Base = 290u, Ranks = 190u }, + new PluginAttributeInfo(1, "Endurance", 200u) { Base = 200u, Ranks = 100u }, + ]; + c.Vitals = [new PluginVitalInfo(0, "Health", 300u, 341u) { Base = 331u }]; + c.Skills.Add(new PluginSkillInfo(34u, "War Magic", PluginSkillTraining.Specialized, 540u) { Base = 533u }); + c.Skills.Add(new PluginSkillInfo(6u, "Melee Defense", PluginSkillTraining.Trained, 400u) { Base = 388u }); + harness.Host.Automation.Objects.Properties[0x50000001] = new PluginItemProperties( + new Dictionary + { + [5] = 9000, // burden units (blacklisted from properties) + [24] = 3, // skill credits (blacklisted) + [43] = 12, // deaths (blacklisted) + [98] = 1_000_000_000, // creation time (blacklisted) + [230] = 2, // carrying-capacity augmentations + [218] = 1, // an augmentation: kept + }, + new Dictionary { [1] = 123_456_789_012L, [2] = 5_000L, [6] = 500_000L }, + new Dictionary(), + new Dictionary(), + new Dictionary { [3] = "Male", [4] = "Aluvian" }, + new Dictionary(), + new Dictionary()); + harness.Login(); + harness.Frames.Clear(); + return harness; + } + + [Fact] + public void The_frame_keeps_the_originals_fields_order_and_types() + { + // CharacterStats.cs:252-383. burden = (int)((float)9000 / (300*150) * 100) = 20; + // capacity = 150*300 + 2*30*300 = 63000; birth = 1970 + 1e9 s in en-US. + using var harness = Arranged(); + var stream = harness.Feature(); + stream.CurrentTitle = () => 42; + stream.Titles = () => ["Adventurer", "Sawato Resident"]; + stream.Allegiance = () => new AllegianceSummary( + "Mossy Club", new AllegianceMember("King", 1, 8, 1), null, 3, 17); + + string json = Wire.Serialize(stream.Build()); + + Assert.Equal( + "{\"type\":\"character_stats\",\"timestamp\":\"" + Stamp + "\",\"character_name\":\"Mossy\"," + + "\"level\":126,\"race\":\"Aluvian\",\"gender\":\"Male\",\"birth\":\"9/9/2001 1:46:40 AM\"," + + "\"total_xp\":123456789012,\"unassigned_xp\":5000,\"skill_credits\":3,\"deaths\":12," + + "\"burden\":20,\"burden_units\":9000,\"encumbrance_capacity\":63000," + + "\"luminance_earned\":500000,\"luminance_total\":null,\"current_title\":42," + + "\"attributes\":{\"strength\":{\"base\":290,\"creation\":100},\"endurance\":{\"base\":200,\"creation\":100}}," + + "\"vitals\":{\"health\":{\"base\":331}}," + + "\"skills\":{\"melee_defense\":{\"base\":388,\"training\":\"Trained\"},\"war_magic\":{\"base\":533,\"training\":\"Specialized\"}}," + + "\"allegiance\":{\"name\":\"Mossy Club\",\"monarch\":{\"name\":\"King\",\"race\":1,\"rank\":8,\"gender\":1},\"patron\":null,\"rank\":3,\"followers\":17}," + + "\"properties\":{\"230\":2,\"218\":1}," + + "\"titles\":[\"Adventurer\",\"Sawato Resident\"]}", + json); + } + + [Fact] + public void Unknown_title_and_allegiance_are_null() + { + using var harness = Arranged(); + + string json = Wire.Serialize(harness.Feature().Build()); + + Assert.Contains("\"current_title\":null,", json, StringComparison.Ordinal); + Assert.Contains("\"allegiance\":null,", json, StringComparison.Ordinal); + Assert.EndsWith("\"titles\":null}", json, StringComparison.Ordinal); + } + + [Fact] + public void Sent_five_seconds_after_login_then_every_ten_minutes() + { + using var harness = Arranged(); + + harness.Run(4.5, 0.5); + Assert.Empty(harness.FramesOfType("character_stats")); + harness.Run(0.6, 0.1); + Assert.Single(harness.FramesOfType("character_stats")); + harness.Run(600, 5); + Assert.Equal(2, harness.FramesOfType("character_stats").Count()); + } +}