4-task plan covering WebSocket send method, CharacterStats.cs data collection class, PluginCore wiring (ServerDispatch, timer, login), and end-to-end testing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
21 KiB
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:
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
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:
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;
/// <summary>
/// Reset all cached data. Call on plugin init.
/// </summary>
internal static void Init()
{
allegianceName = null;
allegianceSize = 0;
followers = 0;
monarch = new AllegianceInfoRecord();
patron = new AllegianceInfoRecord();
allegianceRank = 0;
luminanceEarned = -1;
luminanceTotal = -1;
currentTitle = -1;
}
/// <summary>
/// Process game event 0x0020 - Allegiance info.
/// Extracts monarch, patron, rank, followers from the allegiance tree.
/// Reference: TreeStats Character.cs:642-745
/// </summary>
internal static void ProcessAllegianceInfoMessage(NetworkMessageEventArgs e)
{
try
{
allegianceName = e.Message.Value<string>("allegianceName");
allegianceSize = e.Message.Value<Int32>("allegianceSize");
followers = e.Message.Value<Int32>("followers");
monarch = new AllegianceInfoRecord();
patron = new AllegianceInfoRecord();
MessageStruct records = e.Message.Struct("records");
int currentId = CoreManager.Current.CharacterFilter.Id;
var parentMap = new Dictionary<int, int>();
var recordMap = new Dictionary<int, AllegianceInfoRecord>();
for (int i = 0; i < records.Count; i++)
{
var record = records.Struct(i);
int charId = record.Value<int>("character");
int treeParent = record.Value<int>("treeParent");
parentMap[charId] = treeParent;
recordMap[charId] = new AllegianceInfoRecord(
record.Value<string>("name"),
record.Value<int>("rank"),
record.Value<int>("race"),
record.Value<int>("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}");
}
}
/// <summary>
/// Process game event 0x0013 - Character property data.
/// Extracts luminance from QWORD keys 6 and 7.
/// Reference: TreeStats Character.cs:582-640
/// </summary>
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<Int64>("key");
long value = tmpStruct.Value<Int64>("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}");
}
}
/// <summary>
/// Process game event 0x0029 - Titles list.
/// Extracts current title ID.
/// Reference: TreeStats Character.cs:551-580
/// </summary>
internal static void ProcessTitlesMessage(NetworkMessageEventArgs e)
{
try
{
currentTitle = e.Message.Value<Int32>("current");
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Title processing error: {ex.Message}");
}
}
/// <summary>
/// Process game event 0x002b - Set title (when player changes title).
/// </summary>
internal static void ProcessSetTitleMessage(NetworkMessageEventArgs e)
{
try
{
currentTitle = e.Message.Value<Int32>("title");
}
catch (Exception ex)
{
PluginCore.WriteToChat($"[CharStats] Set title error: {ex.Message}");
}
}
/// <summary>
/// Collect all character data and send via WebSocket.
/// Called on login (after delay) and every 10 minutes.
/// </summary>
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<string, object>();
foreach (var attr in cf.Attributes)
{
attributes[attr.Name.ToLower()] = new
{
@base = attr.Base,
creation = attr.Creation
};
}
// --- Vitals (base values) ---
var vitals = new Dictionary<string, object>();
foreach (var vital in cf.Vitals)
{
vitals[vital.Name.ToLower()] = new
{
@base = vital.Base
};
}
// --- Skills ---
var skills = new Dictionary<string, object>();
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 (<Compile Include="WebSocket.cs" />) and add before it:
<Compile Include="CharacterStats.cs" />
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
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:
private static Timer characterStatsTimer;
Step 2: Hook EchoFilter.ServerDispatch in Startup()
In Startup(), after line 184 (CoreManager.Current.WorldFilter.ChangeObject += OnInventoryChange;), add:
// 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:
// 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:
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:
// 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:
// 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
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
- Launch a game client with the plugin loaded
- Watch for
[OK] Character stats streaming initialized (10-min interval)in chat - After ~5 seconds, check MosswartOverlord logs for the initial character_stats message:
docker logs mosswartoverlord-dereth-tracker-1 2>&1 | grep "character_stats\|character stats" | tail -5 - Open the web interface and click "Char" on the player that sent stats
- 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 <Compile Include="CharacterStats.cs" /> |
MosswartMassacre/PluginCore.cs |
Modify | Timer, ServerDispatch hook, login send, shutdown cleanup |