- Move Init() and ServerDispatch hook from LoginComplete to Startup so event 0x0013 (character properties) is caught during login sequence - Add handler for message 0x02CF (PrivateUpdatePropertyInt64) to capture runtime luminance changes when player earns/spends luminance in-game - Uses RawData byte parsing for 0x02CF since Decal messages.xml may not define this message type Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
336 lines
12 KiB
C#
336 lines
12 KiB
C#
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 message 0x02CF - PrivateUpdatePropertyInt64.
|
|
/// Sent during gameplay when an Int64 property changes (e.g., luminance earned/spent).
|
|
/// Wire format after 4-byte type header: sequence(1) + key(4) + value(8).
|
|
/// Uses RawData since Decal's messages.xml may not define this message type.
|
|
/// </summary>
|
|
internal static void ProcessPropertyInt64Update(NetworkMessageEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
byte[] raw = e.Message.RawData;
|
|
if (raw.Length < 17) return; // 4 type + 1 seq + 4 key + 8 value
|
|
|
|
int key = BitConverter.ToInt32(raw, 5);
|
|
long value = BitConverter.ToInt64(raw, 9);
|
|
|
|
if (key == 6) // AvailableLuminance
|
|
luminanceEarned = value;
|
|
else if (key == 7) // MaximumLuminance
|
|
luminanceTotal = value;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
PluginCore.WriteToChat($"[CharStats] Int64 property update 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}");
|
|
}
|
|
}
|
|
}
|
|
}
|