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 <noreply@anthropic.com>
This commit is contained in:
parent
10bb6c30dd
commit
9c91ed0afb
1 changed files with 201 additions and 0 deletions
201
docs/plans/2026-02-26-plugin-character-stats-design.md
Normal file
201
docs/plans/2026-02-26-plugin-character-stats-design.md
Normal file
|
|
@ -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<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:
|
||||
|
||||
```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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue