Design for adding character_stats event to MosswartMassacre plugin, covering data collection from CharacterFilter API, network message interception for allegiance/luminance, and 10-minute timer send. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7 KiB
7 KiB
Plugin Character Stats Streaming - Design Document
Overview
Add character stats streaming to the MosswartMassacre Decal plugin. Sends a character_stats JSON payload via the existing WebSocket connection to MosswartOverlord on login and every 10 minutes.
Scope: MosswartMassacre plugin only. The backend (MosswartOverlord) already handles this event type.
Architecture
A new CharacterStats class collects data from three sources and sends it via the existing WebSocket:
- CharacterFilter API — level, XP, deaths, race, gender, birth, attributes, vitals, skills
- Network message interception — allegiance (event 0x0020), luminance & title (event 0x0013)
- Separate 10-minute timer — triggers the send, plus an immediate send on login
Uses Newtonsoft.Json with anonymous objects (same as existing SendVitalsAsync, SendChatTextAsync).
Data Sources
| Data | Decal API |
|---|---|
| Level | CharacterFilter.Level |
| Deaths | CharacterFilter.Deaths |
| Skill Credits | CharacterFilter.SkillPoints |
| Total XP | CharacterFilter.TotalXP (Int64) |
| Unassigned XP | CharacterFilter.UnassignedXP (Int64) |
| Race | CharacterFilter.Race (string) |
| Gender | CharacterFilter.Gender (string) |
| Birth | CharacterFilter.Birth (DateTime) |
| Attributes (6) | CharacterFilter.Attributes collection — .Name, .Base, .Creation |
| Vitals (3) base | CharacterFilter.Vitals collection — .Name, .Base |
| Skills (all) | CharacterFilter.Underlying.get_Skill((eSkillID)fs.SkillTable[i].Id) — .Name, .Base, .Training |
| Allegiance | EchoFilter.ServerDispatch → game event 0x0020 message fields |
| Luminance | EchoFilter.ServerDispatch → game event 0x0013 QWORD keys 6, 7 |
| Current Title | EchoFilter.ServerDispatch → game event 0x0029 or 0x002b |
Skill Access Pattern (from TreeStats reference)
Skills require COM interop with careful cleanup:
Decal.Filters.FileService fs = CoreManager.Current.FileService as Decal.Filters.FileService;
Decal.Interop.Filters.SkillInfo skillinfo = null;
for (int i = 0; i < fs.SkillTable.Length; ++i)
{
try
{
skillinfo = CoreManager.Current.CharacterFilter.Underlying.get_Skill(
(Decal.Interop.Filters.eSkillID)fs.SkillTable[i].Id);
string name = skillinfo.Name.ToLower().Replace(" ", "_");
string training = skillinfo.Training.ToString().Substring(6); // Strip "eTrain" prefix
int baseValue = skillinfo.Base;
}
finally
{
if (skillinfo != null)
{
System.Runtime.InteropServices.Marshal.ReleaseComObject(skillinfo);
skillinfo = null;
}
}
}
Allegiance Message Processing (from TreeStats reference)
Game event 0x0020 contains allegiance tree:
void ProcessAllegianceInfo(NetworkMessageEventArgs e)
{
allegianceName = e.Message.Value<string>("allegianceName");
allegianceSize = e.Message.Value<Int32>("allegianceSize");
followers = e.Message.Value<Int32>("followers");
MessageStruct records = e.Message.Struct("records");
// Walk tree to find monarch (treeParent == 0) and patron (parent of current char)
}
Luminance from Character Properties (from TreeStats reference)
Game event 0x0013 contains QWORD properties:
// QWORD key 6 = AvailableLuminance (luminance_earned)
// QWORD key 7 = MaximumLuminance (luminance_total)
Data Flow
Login Complete
├──▶ Hook EchoFilter.ServerDispatch (allegiance + luminance/title)
├──▶ Start 10-minute characterStatsTimer
└──▶ Send first stats after 5-second delay (let CharacterFilter populate)
Every 10 minutes (+ on login):
CharacterStats.CollectAndSend()
├── CharacterFilter: level, XP, deaths, race, attributes, vitals
├── FileService + get_Skill(): all skills with training levels
├── Cached network data: allegiance, luminance, title
├── Build anonymous object with Newtonsoft.Json
└── WebSocket.SendCharacterStatsAsync(payload)
└── Existing WebSocket → /ws/position → MosswartOverlord
Network Messages (event-driven, cached for next stats send):
0x0020 → allegiance name, monarch, patron, rank, followers
0x0013 → luminance_earned, luminance_total
0x0029 → current_title
JSON Payload
Matches the MosswartOverlord backend contract:
{
"type": "character_stats",
"timestamp": "2026-02-26T12:34:56Z",
"character_name": "Barris",
"level": 275,
"race": "Aluvian",
"gender": "Male",
"birth": "2018-03-15 14:22:33",
"total_xp": 191226310247,
"unassigned_xp": 0,
"skill_credits": 0,
"luminance_earned": 500000,
"luminance_total": 1500000,
"deaths": 3175,
"current_title": 42,
"attributes": {
"strength": {"base": 290, "creation": 100},
"endurance": {"base": 200, "creation": 100},
"coordination": {"base": 240, "creation": 100},
"quickness": {"base": 220, "creation": 10},
"focus": {"base": 250, "creation": 100},
"self": {"base": 200, "creation": 100}
},
"vitals": {
"health": {"base": 341},
"stamina": {"base": 400},
"mana": {"base": 300}
},
"skills": {
"war_magic": {"base": 533, "training": "Specialized"},
"melee_defense": {"base": 488, "training": "Specialized"},
"life_magic": {"base": 440, "training": "Trained"},
"arcane_lore": {"base": 10, "training": "Untrained"}
},
"allegiance": {
"name": "Knights of Dereth",
"monarch": {"name": "KingName", "race": 1, "rank": 0, "gender": 0},
"patron": {"name": "PatronName", "race": 2, "rank": 5, "gender": 1},
"rank": 10,
"followers": 5
}
}
Files Modified
| File | Changes |
|---|---|
New: CharacterStats.cs |
Data collection, network message processing, allegiance/luminance caching |
PluginCore.cs |
Hook EchoFilter.ServerDispatch, create 10-min timer, initial send on login, cleanup on shutdown |
WebSocket.cs |
Add SendCharacterStatsAsync() method |
Implementation Details
- Skill COM cleanup: Every
SkillInfofromget_Skill()must be released withMarshal.ReleaseComObject()in afinallyblock - Allegiance timing: Network message 0x0020 arrives asynchronously after login. First stats send may have null allegiance; subsequent sends will include it
- Login delay: Wait 5 seconds after
LoginCompletebefore first send to let CharacterFilter fully populate - Timer: New
System.Timers.Timerat 600,000ms (10 min), separate from vitals timer - Error handling: Try/catch around entire collection — log errors, don't crash the plugin
- Training string:
skillinfo.Training.ToString()returns values like"eTrainSpecialized"— strip first 6 chars to get"Specialized"
What's NOT in Scope
- UI changes in the plugin (no new tab/view)
- Vassal list (just monarch + patron + rank + follower count)
- Buffed skill values (base only, matching TreeStats)
- Historical tracking (backend supports it, not a plugin concern)