# Plugin Character Stats Streaming - Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add character stats streaming to the MosswartMassacre Decal plugin, sending level, XP, attributes, vitals, skills, allegiance, luminance, and title data via WebSocket every 10 minutes. **Architecture:** New `CharacterStats.cs` class handles data collection from Decal APIs and network message caching. PluginCore hooks `EchoFilter.ServerDispatch` for allegiance/luminance/title data, creates a 10-minute timer, and sends an initial update on login. WebSocket.cs gets one new send method. **Tech Stack:** C# / .NET Framework, Decal Adapter API, Newtonsoft.Json, COM Interop for skill access **Codebase:** `/home/erik/MosswartMassacre/` (spawn-detection branch) **Reference:** TreeStats plugin at `/home/erik/treestats/Character.cs` for Decal API patterns --- ### Task 1: Add SendCharacterStatsAsync to WebSocket.cs **Files:** - Modify: `MosswartMassacre/WebSocket.cs:293-297` **Step 1: Add the send method** Add after `SendVitalsAsync` (line 297), following the exact same pattern: ```csharp public static async Task SendCharacterStatsAsync(object statsData) { var json = JsonConvert.SerializeObject(statsData); await SendEncodedAsync(json, CancellationToken.None); } ``` **Step 2: Verify the file compiles** Open the solution and verify no syntax errors. The method follows the identical pattern as `SendVitalsAsync` at line 293-297. **Step 3: Commit** ```bash cd /home/erik/MosswartMassacre git add MosswartMassacre/WebSocket.cs git commit -m "feat: add SendCharacterStatsAsync to WebSocket" ``` --- ### Task 2: Create CharacterStats.cs - Data Structures and Network Message Handlers This is the core data collection class. We split it into two tasks: this one covers the static data structures and network message processing, the next covers the collection and send logic. **Files:** - Create: `MosswartMassacre/CharacterStats.cs` - Modify: `MosswartMassacre/MosswartMassacre.csproj:336` (add Compile Include) **Step 1: Create CharacterStats.cs with data structures and message handlers** Create `/home/erik/MosswartMassacre/MosswartMassacre/CharacterStats.cs`: ```csharp 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}"); } } } } ``` **Step 2: Add to .csproj** In `MosswartMassacre/MosswartMassacre.csproj`, find line 336 (``) and add before it: ```xml ``` **Step 3: Verify compilation** Build the solution. All Decal APIs used here are the same ones already referenced by PluginCore.cs (CharacterFilter, FileService). The only new interop type is `Decal.Interop.Filters.SkillInfo` which comes from the existing Decal.Interop.Filters reference. **Step 4: Commit** ```bash cd /home/erik/MosswartMassacre git add MosswartMassacre/CharacterStats.cs MosswartMassacre/MosswartMassacre.csproj git commit -m "feat: add CharacterStats data collection and network message handlers" ``` --- ### Task 3: Hook ServerDispatch and Timer in PluginCore.cs Wire up the network message interception, 10-minute timer, and initial login send. **Files:** - Modify: `MosswartMassacre/PluginCore.cs` **Step 1: Add the character stats timer field** At line 66 (after `private static System.Windows.Forms.Timer commandTimer;`), add: ```csharp private static Timer characterStatsTimer; ``` **Step 2: Hook EchoFilter.ServerDispatch in Startup()** In `Startup()`, after line 184 (`CoreManager.Current.WorldFilter.ChangeObject += OnInventoryChange;`), add: ```csharp // Subscribe to server messages for allegiance/luminance/title data Core.EchoFilter.ServerDispatch += EchoFilter_ServerDispatch; ``` **Step 3: Initialize CharacterStats and timer in LoginComplete()** In `CharacterFilter_LoginComplete()`, after the quest streaming initialization block (after line 404 `WriteToChat("[OK] Quest streaming initialized with full data refresh");`), add: ```csharp // Initialize character stats streaming try { CharacterStats.Init(); // Start 10-minute character stats timer characterStatsTimer = new Timer(600000); // 10 minutes characterStatsTimer.Elapsed += OnCharacterStatsUpdate; characterStatsTimer.AutoReset = true; characterStatsTimer.Start(); // Send initial stats after 5-second delay (let CharacterFilter populate) var initialDelay = new Timer(5000); initialDelay.AutoReset = false; initialDelay.Elapsed += (s, args) => { CharacterStats.CollectAndSend(); ((Timer)s).Dispose(); }; initialDelay.Start(); WriteToChat("[OK] Character stats streaming initialized (10-min interval)"); } catch (Exception ex) { WriteToChat($"[ERROR] Character stats initialization failed: {ex.Message}"); } ``` **Step 4: Add the timer handler and ServerDispatch handler** After the `SendVitalsUpdate` method (after line 1162), add: ```csharp private static void OnCharacterStatsUpdate(object sender, ElapsedEventArgs e) { try { CharacterStats.CollectAndSend(); } catch (Exception ex) { WriteToChat($"[CharStats] Timer error: {ex.Message}"); } } private void EchoFilter_ServerDispatch(object sender, NetworkMessageEventArgs e) { try { if (e.Message.Type == 0xF7B0) // Game Event { int eventId = (int)e.Message["event"]; if (eventId == 0x0020) // Allegiance info { CharacterStats.ProcessAllegianceInfoMessage(e); } else if (eventId == 0x0013) // Login Character (properties) { CharacterStats.ProcessCharacterPropertyData(e); } else if (eventId == 0x0029) // Titles list { CharacterStats.ProcessTitlesMessage(e); } else if (eventId == 0x002b) // Set title { CharacterStats.ProcessSetTitleMessage(e); } } } catch (Exception ex) { WriteToChat($"[CharStats] ServerDispatch error: {ex.Message}"); } } ``` **Step 5: Clean up in Shutdown()** In `Shutdown()`, after the quest streaming timer cleanup (after line 285), add: ```csharp // Stop and dispose character stats timer if (characterStatsTimer != null) { characterStatsTimer.Stop(); characterStatsTimer.Elapsed -= OnCharacterStatsUpdate; characterStatsTimer.Dispose(); characterStatsTimer = null; } ``` Also in `Shutdown()`, after unsubscribing from inventory events (after line 253), add: ```csharp // Unsubscribe from server dispatch Core.EchoFilter.ServerDispatch -= EchoFilter_ServerDispatch; ``` **Step 6: Verify compilation** Build the solution. All types used are already available: `NetworkMessageEventArgs` from `Decal.Adapter.Wrappers`, `Timer` from `System.Timers`. **Step 7: Commit** ```bash cd /home/erik/MosswartMassacre git add MosswartMassacre/PluginCore.cs git commit -m "feat: wire up character stats timer, ServerDispatch, and login send" ``` --- ### Task 4: Build, Deploy, and Test End-to-End **Step 1: Build the plugin** Build the MosswartMassacre solution in Release mode. Copy the output DLL to the Decal plugin directory. **Step 2: Test with a running game client** 1. Launch a game client with the plugin loaded 2. Watch for `[OK] Character stats streaming initialized (10-min interval)` in chat 3. After ~5 seconds, check MosswartOverlord logs for the initial character_stats message: ```bash docker logs mosswartoverlord-dereth-tracker-1 2>&1 | grep "character_stats\|character stats" | tail -5 ``` 4. Open the web interface and click "Char" on the player that sent stats 5. Verify the character window shows real data (level, attributes, skills, etc.) **Step 3: Verify allegiance data** Allegiance info arrives via a separate network message. It may not be available on the first send but should appear on the 10-minute update. To force it sooner, open the allegiance panel in-game (which triggers the 0x0020 message). **Step 4: Verify luminance data** Luminance comes from the character property message (0x0013) which fires on login. Check that `luminance_earned` and `luminance_total` appear in the character window. **Step 5: Wait for 10-minute update** Leave the client running for 10+ minutes and verify a second stats update appears in logs. Verify the character window updates with any changed data. --- ## Files Summary | File | Action | Description | |------|--------|-------------| | `MosswartMassacre/WebSocket.cs` | Modify | Add `SendCharacterStatsAsync()` | | `MosswartMassacre/CharacterStats.cs` | Create | Data collection, network message handlers, `CollectAndSend()` | | `MosswartMassacre/MosswartMassacre.csproj` | Modify | Add `` | | `MosswartMassacre/PluginCore.cs` | Modify | Timer, ServerDispatch hook, login send, shutdown cleanup |