From 9c91ed0afb860fc22d5f88afd81de061c58999f8 Mon Sep 17 00:00:00 2001 From: erik Date: Thu, 26 Feb 2026 15:30:37 +0000 Subject: [PATCH] Add plugin character stats streaming design document 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 --- ...026-02-26-plugin-character-stats-design.md | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 docs/plans/2026-02-26-plugin-character-stats-design.md diff --git a/docs/plans/2026-02-26-plugin-character-stats-design.md b/docs/plans/2026-02-26-plugin-character-stats-design.md new file mode 100644 index 00000000..62614bd6 --- /dev/null +++ b/docs/plans/2026-02-26-plugin-character-stats-design.md @@ -0,0 +1,201 @@ +# 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: + +1. **CharacterFilter API** — level, XP, deaths, race, gender, birth, attributes, vitals, skills +2. **Network message interception** — allegiance (event 0x0020), luminance & title (event 0x0013) +3. **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: + +```csharp +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: + +```csharp +void ProcessAllegianceInfo(NetworkMessageEventArgs e) +{ + allegianceName = e.Message.Value("allegianceName"); + allegianceSize = e.Message.Value("allegianceSize"); + followers = e.Message.Value("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: + +```csharp +// 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: + +```json +{ + "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 `SkillInfo` from `get_Skill()` must be released with `Marshal.ReleaseComObject()` in a `finally` block +- **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 `LoginComplete` before first send to let CharacterFilter fully populate +- **Timer**: New `System.Timers.Timer` at 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)