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()); + } +}