From 655bfd51635b198661bc8fe72157e9970e05ec2e Mon Sep 17 00:00:00 2001 From: erik Date: Thu, 26 Feb 2026 15:38:27 +0000 Subject: [PATCH] feat: add CharacterStats data collection and network message handlers Co-Authored-By: Claude Opus 4.6 --- MosswartMassacre/CharacterStats.cs | 309 +++++++++++++++++++++++ MosswartMassacre/MosswartMassacre.csproj | 1 + 2 files changed, 310 insertions(+) create mode 100644 MosswartMassacre/CharacterStats.cs diff --git a/MosswartMassacre/CharacterStats.cs b/MosswartMassacre/CharacterStats.cs new file mode 100644 index 0000000..6d16f03 --- /dev/null +++ b/MosswartMassacre/CharacterStats.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.InteropServices; +using Decal.Adapter; +using Decal.Adapter.Wrappers; +using Newtonsoft.Json; + +namespace MosswartMassacre +{ + public struct AllegianceInfoRecord + { + public string name; + public int rank; + public int race; + public int gender; + + public AllegianceInfoRecord(string _name, int _rank, int _race, int _gender) + { + name = _name; + rank = _rank; + race = _race; + gender = _gender; + } + } + + public static class CharacterStats + { + // Cached allegiance data (populated from network messages) + private static string allegianceName; + private static int allegianceSize; + private static int followers; + private static AllegianceInfoRecord monarch; + private static AllegianceInfoRecord patron; + private static int allegianceRank; + + // Cached luminance data (populated from network messages) + private static long luminanceEarned = -1; + private static long luminanceTotal = -1; + + // Cached title data (populated from network messages) + private static int currentTitle = -1; + + /// + /// Reset all cached data. Call on plugin init. + /// + internal static void Init() + { + allegianceName = null; + allegianceSize = 0; + followers = 0; + monarch = new AllegianceInfoRecord(); + patron = new AllegianceInfoRecord(); + allegianceRank = 0; + luminanceEarned = -1; + luminanceTotal = -1; + currentTitle = -1; + } + + /// + /// Process game event 0x0020 - Allegiance info. + /// Extracts monarch, patron, rank, followers from the allegiance tree. + /// Reference: TreeStats Character.cs:642-745 + /// + internal static void ProcessAllegianceInfoMessage(NetworkMessageEventArgs e) + { + try + { + allegianceName = e.Message.Value("allegianceName"); + allegianceSize = e.Message.Value("allegianceSize"); + followers = e.Message.Value("followers"); + + monarch = new AllegianceInfoRecord(); + patron = new AllegianceInfoRecord(); + + MessageStruct records = e.Message.Struct("records"); + int currentId = CoreManager.Current.CharacterFilter.Id; + var parentMap = new Dictionary(); + var recordMap = new Dictionary(); + + for (int i = 0; i < records.Count; i++) + { + var record = records.Struct(i); + int charId = record.Value("character"); + int treeParent = record.Value("treeParent"); + + parentMap[charId] = treeParent; + recordMap[charId] = new AllegianceInfoRecord( + record.Value("name"), + record.Value("rank"), + record.Value("race"), + record.Value("gender")); + + // Monarch: treeParent <= 1 + if (treeParent <= 1) + { + monarch = recordMap[charId]; + } + } + + // Patron: parent of current character + if (parentMap.ContainsKey(currentId) && recordMap.ContainsKey(parentMap[currentId])) + { + patron = recordMap[parentMap[currentId]]; + } + + // Our rank from the record + if (recordMap.ContainsKey(currentId)) + { + allegianceRank = recordMap[currentId].rank; + } + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[CharStats] Allegiance processing error: {ex.Message}"); + } + } + + /// + /// Process game event 0x0013 - Character property data. + /// Extracts luminance from QWORD keys 6 and 7. + /// Reference: TreeStats Character.cs:582-640 + /// + internal static void ProcessCharacterPropertyData(NetworkMessageEventArgs e) + { + try + { + MessageStruct props = e.Message.Struct("properties"); + MessageStruct qwords = props.Struct("qwords"); + + for (int i = 0; i < qwords.Count; i++) + { + var tmpStruct = qwords.Struct(i); + long key = tmpStruct.Value("key"); + long value = tmpStruct.Value("value"); + + if (key == 6) // AvailableLuminance + luminanceEarned = value; + else if (key == 7) // MaximumLuminance + luminanceTotal = value; + } + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[CharStats] Property processing error: {ex.Message}"); + } + } + + /// + /// Process game event 0x0029 - Titles list. + /// Extracts current title ID. + /// Reference: TreeStats Character.cs:551-580 + /// + internal static void ProcessTitlesMessage(NetworkMessageEventArgs e) + { + try + { + currentTitle = e.Message.Value("current"); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[CharStats] Title processing error: {ex.Message}"); + } + } + + /// + /// Process game event 0x002b - Set title (when player changes title). + /// + internal static void ProcessSetTitleMessage(NetworkMessageEventArgs e) + { + try + { + currentTitle = e.Message.Value("title"); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[CharStats] Set title error: {ex.Message}"); + } + } + + /// + /// Collect all character data and send via WebSocket. + /// Called on login (after delay) and every 10 minutes. + /// + internal static void CollectAndSend() + { + if (!PluginCore.WebSocketEnabled) + return; + + try + { + var cf = CoreManager.Current.CharacterFilter; + var culture = new CultureInfo("en-US"); + + // --- Attributes --- + var attributes = new Dictionary(); + foreach (var attr in cf.Attributes) + { + attributes[attr.Name.ToLower()] = new + { + @base = attr.Base, + creation = attr.Creation + }; + } + + // --- Vitals (base values) --- + var vitals = new Dictionary(); + foreach (var vital in cf.Vitals) + { + vitals[vital.Name.ToLower()] = new + { + @base = vital.Base + }; + } + + // --- Skills --- + var skills = new Dictionary(); + Decal.Filters.FileService fs = CoreManager.Current.FileService as Decal.Filters.FileService; + if (fs != null) + { + for (int i = 0; i < fs.SkillTable.Length; i++) + { + Decal.Interop.Filters.SkillInfo skillinfo = null; + try + { + skillinfo = cf.Underlying.get_Skill( + (Decal.Interop.Filters.eSkillID)fs.SkillTable[i].Id); + + string name = skillinfo.Name.ToLower().Replace(" ", "_"); + string training = skillinfo.Training.ToString(); + // Training enum returns "eTrainSpecialized" etc, strip "eTrain" prefix + if (training.Length > 6) + training = training.Substring(6); + + skills[name] = new + { + @base = skillinfo.Base, + training = training + }; + } + finally + { + if (skillinfo != null) + { + Marshal.ReleaseComObject(skillinfo); + skillinfo = null; + } + } + } + } + + // --- Allegiance --- + object allegiance = null; + if (allegianceName != null) + { + allegiance = new + { + name = allegianceName, + monarch = monarch.name != null ? new + { + name = monarch.name, + race = monarch.race, + rank = monarch.rank, + gender = monarch.gender + } : null, + patron = patron.name != null ? new + { + name = patron.name, + race = patron.race, + rank = patron.rank, + gender = patron.gender + } : null, + rank = allegianceRank, + followers = followers + }; + } + + // --- Build payload --- + var payload = new + { + type = "character_stats", + timestamp = DateTime.UtcNow.ToString("o"), + character_name = cf.Name, + level = cf.Level, + race = cf.Race, + gender = cf.Gender, + birth = cf.Birth.ToString(culture), + total_xp = cf.TotalXP, + unassigned_xp = cf.UnassignedXP, + skill_credits = cf.SkillPoints, + deaths = cf.Deaths, + luminance_earned = luminanceEarned >= 0 ? (long?)luminanceEarned : null, + luminance_total = luminanceTotal >= 0 ? (long?)luminanceTotal : null, + current_title = currentTitle >= 0 ? (int?)currentTitle : null, + attributes = attributes, + vitals = vitals, + skills = skills, + allegiance = allegiance + }; + + _ = WebSocket.SendCharacterStatsAsync(payload); + } + catch (Exception ex) + { + PluginCore.WriteToChat($"[CharStats] Error collecting stats: {ex.Message}"); + } + } + } +} diff --git a/MosswartMassacre/MosswartMassacre.csproj b/MosswartMassacre/MosswartMassacre.csproj index 0c1df39..c181a65 100644 --- a/MosswartMassacre/MosswartMassacre.csproj +++ b/MosswartMassacre/MosswartMassacre.csproj @@ -333,6 +333,7 @@ +