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