feat: add CharacterStats data collection and network message handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erik 2026-02-26 15:38:27 +00:00
parent 84462d7c92
commit 655bfd5163
2 changed files with 310 additions and 0 deletions

View file

@ -0,0 +1,309 @@
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}");
}
}
}
}

View file

@ -333,6 +333,7 @@
<Compile Include="Views\FlagTrackerView.cs" /> <Compile Include="Views\FlagTrackerView.cs" />
<Compile Include="Views\VVSBaseView.cs" /> <Compile Include="Views\VVSBaseView.cs" />
<Compile Include="Views\VVSTabbedMainView.cs" /> <Compile Include="Views\VVSTabbedMainView.cs" />
<Compile Include="CharacterStats.cs" />
<Compile Include="WebSocket.cs" /> <Compile Include="WebSocket.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>