diff --git a/docs/plans/2026-02-26-plugin-character-stats-plan.md b/docs/plans/2026-02-26-plugin-character-stats-plan.md new file mode 100644 index 00000000..1e95f065 --- /dev/null +++ b/docs/plans/2026-02-26-plugin-character-stats-plan.md @@ -0,0 +1,576 @@ +# 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 |