Add plugin character stats streaming implementation plan
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>
This commit is contained in:
parent
9c91ed0afb
commit
45cedd0ec9
1 changed files with 576 additions and 0 deletions
576
docs/plans/2026-02-26-plugin-character-stats-plan.md
Normal file
576
docs/plans/2026-02-26-plugin-character-stats-plan.md
Normal file
|
|
@ -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;
|
||||
|
||||
/// <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:
|
||||
|
||||
```xml
|
||||
<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**
|
||||
|
||||
```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 `<Compile Include="CharacterStats.cs" />` |
|
||||
| `MosswartMassacre/PluginCore.cs` | Modify | Timer, ServerDispatch hook, login send, shutdown cleanup |
|
||||
Loading…
Add table
Add a link
Reference in a new issue