character_stats: the full character frame, five seconds after login and every ten minutes

Fields, order and types as the original sent them. Values follow the
original's client library where it matters: race and gender are the player's
string properties, birth the creation time as a US date (explicit pattern, so
no newer-culture space quirk), burden the units over 150 times effective
strength truncated to a percent, capacity with the carrying augmentation, and
properties the player's integer properties minus the original's blacklist.
Attributes, vitals and skills carry unbuffed values, creation being the value
before experience was spent. Titles, the allegiance tree and luminance total
come through seams that the plugin API additions fill; until then they are
null, as the original sent them before the matching server message arrived.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-25 12:33:15 +02:00
parent 2e5d768ca9
commit 5b3179dd75
4 changed files with 346 additions and 0 deletions

View file

@ -30,6 +30,7 @@ internal static class FeatureCatalog
features.Add(new VitalsStream(context));
features.Add(new SpawnPortalStream(context));
features.Add(new BotControl(context));
features.Add(new CharacterStatsStream(context));
var mainWindow = new Views.MainWindow(context, stats);
features.Add(mainWindow);
session.AddConnectionListener(telemetry);

View file

@ -0,0 +1,244 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
using OpenAC.MosswartMassacre.Backend;
namespace OpenAC.MosswartMassacre.Streams;
/// <summary>An allegiance member as the character-stats frame names one.</summary>
internal readonly record struct AllegianceMember(string Name, int Race, int Rank, int Gender);
/// <summary>The allegiance part of the character-stats frame.</summary>
internal readonly record struct AllegianceSummary(
string Name, AllegianceMember? Monarch, AllegianceMember? Patron, int Rank, int Followers);
/// <summary>
/// The <c>character_stats</c> frame: five seconds after login, then every ten
/// minutes, with the original's fields in the original's order.
/// </summary>
/// <remarks>
/// <para>
/// Values follow the original's sources: race and gender are the player's
/// string properties, birth is the creation timestamp as a US-formatted date,
/// burden is the burden units over 150 times effective strength in percent
/// (truncated, no carrying augmentation, as the original's client library
/// computed it), and the capacity adds the augmentation. <c>properties</c> is
/// the player's integer properties minus the original's cosmetic blacklist.
/// </para>
/// <para>
/// Attributes, vitals and skills carry the unbuffed values; an attribute's
/// <c>creation</c> is its value before any experience was spent on it.
/// </para>
/// </remarks>
internal sealed class CharacterStatsStream : IMmFeature
{
public static readonly TimeSpan FirstDelay = TimeSpan.FromSeconds(5);
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(10);
private const uint PropEncumbranceVal = 5;
private const uint PropAvailableSkillCredits = 24;
private const uint PropNumDeaths = 43;
private const uint PropCreationTimestamp = 98;
private const uint PropCarryingCapacityAugs = 230;
private const uint StrSex = 3;
private const uint StrHeritage = 4;
private const uint Int64TotalXp = 1;
private const uint Int64AvailableXp = 2;
private const uint Int64AvailableLuminance = 6;
private const uint Int64MaximumLuminance = 7;
/// <summary>Cosmetic and internal properties the original left out (TreeStats' list).</summary>
internal static readonly HashSet<uint> PropertyBlacklist =
[
2, 5, 7, 10, 17, 19, 20, 24, 25, 26, 28, 30, 33, 35, 36, 38, 43, 45,
86, 87, 88, 89, 90, 91, 92, 98,
105, 106, 107, 108, 109, 110, 111, 113, 114, 115, 117, 125, 129, 131, 134,
158, 159, 160, 166, 170, 171, 172, 174, 175, 176, 177, 178, 179, 188, 193,
270, 271, 272, 293,
];
private static readonly string[] AttributeNames = ["strength", "endurance", "quickness", "coordination", "focus", "self"];
private static readonly string[] VitalNames = ["health", "stamina", "mana"];
private readonly MmContext _context;
private IDisposable? _first;
private IDisposable? _timer;
public CharacterStatsStream(MmContext context)
{
_context = context;
}
/// <summary>The character's current title id, or null when unknown; set once the host reports titles.</summary>
public Func<int?> CurrentTitle { get; set; } = static () => null;
/// <summary>The character's titles in the server's order, or null when unknown.</summary>
public Func<IReadOnlyList<string>?> Titles { get; set; } = static () => null;
/// <summary>The allegiance, or null when the character has none or it is unknown.</summary>
public Func<AllegianceSummary?> Allegiance { get; set; } = static () => null;
public void OnLogin()
{
_first = _context.Scheduler.Delay(FirstDelay, SendNow);
_timer = _context.Scheduler.Every(Interval, SendNow);
}
public void OnLogoff()
{
_first?.Dispose();
_first = null;
_timer?.Dispose();
_timer = null;
}
public void Dispose() => OnLogoff();
internal void SendNow()
{
if (!_context.Settings.Current.WebSocketEnabled || !_context.IsLoggedIn)
return;
try
{
_context.Send(Build());
}
catch (Exception ex)
{
_context.Verbose($"[CharStats] Error collecting stats: {ex.Message}");
}
}
internal object Build()
{
IAutomationSurface automation = _context.Automation;
ICharacterInfo character = automation.Character;
PluginItemProperties props = automation.Objects.TryCaptureProperties(character.ObjectId, out PluginItemProperties captured)
? captured
: Empty;
int Int(uint key) => props.Ints is { } ints && ints.TryGetValue(key, out int value) ? value : 0;
long? Int64(uint key) => props.Int64s is { } longs && longs.TryGetValue(key, out long value) ? value : null;
string Str(uint key) => props.Strings is { } strings && strings.TryGetValue(key, out string? value) ? value ?? "" : "";
// Attributes, in the character's own order.
var attributes = new Dictionary<string, object>();
foreach (PluginAttributeInfo attribute in character.Attributes.OrderBy(static a => a.Kind))
{
if (attribute.Kind < 0 || attribute.Kind >= AttributeNames.Length)
continue;
attributes[AttributeNames[attribute.Kind]] = new
{
@base = (int)attribute.Base,
creation = (int)(attribute.Base - Math.Min(attribute.Base, attribute.Ranks)),
};
}
var vitals = new Dictionary<string, object>();
foreach (PluginVitalInfo vital in character.Vitals.OrderBy(static v => v.Kind))
{
if (vital.Kind < 0 || vital.Kind >= VitalNames.Length)
continue;
vitals[VitalNames[vital.Kind]] = new { @base = (int)vital.Base };
}
var skills = new Dictionary<string, object>();
foreach (PluginSkillInfo skill in character.Skills.OrderBy(static s => s.SkillId))
{
if (string.IsNullOrEmpty(skill.Name))
continue;
skills[skill.Name.ToLowerInvariant().Replace(" ", "_", StringComparison.Ordinal)] = new
{
@base = (int)skill.Base,
training = TrainingName(skill.Training),
};
}
object? allegiance = null;
if (Allegiance() is { } a)
{
allegiance = new
{
name = a.Name,
monarch = a.Monarch is { } m ? new { name = m.Name, race = m.Race, rank = m.Rank, gender = m.Gender } : null,
patron = a.Patron is { } p ? new { name = p.Name, race = p.Race, rank = p.Rank, gender = p.Gender } : null,
rank = a.Rank,
followers = a.Followers,
};
}
int effectiveStrength = EffectiveStrength(character);
int burdenUnits = Int(PropEncumbranceVal);
int legalBurden = effectiveStrength * 150;
int burden = legalBurden > 0 ? (int)((float)burdenUnits / legalBurden * 100) : 0;
int encumbranceCapacity = (150 * effectiveStrength) + (Int(PropCarryingCapacityAugs) * 30 * effectiveStrength);
Dictionary<int, int>? properties = null;
if (props.Ints is { Count: > 0 } allInts)
{
properties = [];
foreach (var pair in allInts)
{
if (!PropertyBlacklist.Contains(pair.Key))
properties[unchecked((int)pair.Key)] = pair.Value;
}
if (properties.Count == 0)
properties = null;
}
IReadOnlyList<string>? titles = Titles();
long? lumEarned = Int64(Int64AvailableLuminance);
long? lumTotal = Int64(Int64MaximumLuminance);
return new
{
type = "character_stats",
timestamp = Wire.Timestamp(_context.Clock),
character_name = _context.CharacterName,
level = character.Level,
race = Str(StrHeritage),
gender = Str(StrSex),
birth = new DateTime(1970, 1, 1, 0, 0, 0)
.AddSeconds(Int(PropCreationTimestamp))
.ToString("M/d/yyyy h:mm:ss tt", CultureInfo.GetCultureInfo("en-US")),
total_xp = Int64(Int64TotalXp) ?? 0L,
unassigned_xp = Int64(Int64AvailableXp) ?? 0L,
skill_credits = Int(PropAvailableSkillCredits),
deaths = Int(PropNumDeaths),
burden,
burden_units = burdenUnits,
encumbrance_capacity = encumbranceCapacity,
luminance_earned = lumEarned is >= 0 ? lumEarned : null,
luminance_total = lumTotal is >= 0 ? lumTotal : null,
current_title = CurrentTitle() is int title && title >= 0 ? (int?)title : null,
attributes,
vitals,
skills,
allegiance,
properties,
titles = titles is { Count: > 0 } ? new List<string>(titles) : null,
};
}
private static int EffectiveStrength(ICharacterInfo character)
{
foreach (PluginAttributeInfo attribute in character.Attributes)
{
if (attribute.Kind == 0)
return (int)attribute.Current;
}
return 0;
}
/// <summary>The original's training word: the client library's enum name without its "eTrain" prefix.</summary>
private static string TrainingName(PluginSkillTraining training) => training switch
{
PluginSkillTraining.Untrained => "Untrained",
PluginSkillTraining.Trained => "Trained",
PluginSkillTraining.Specialized => "Specialized",
_ => "Unusable",
};
private static readonly PluginItemProperties Empty = new(
new Dictionary<uint, int>(), new Dictionary<uint, long>(), new Dictionary<uint, bool>(),
new Dictionary<uint, double>(), new Dictionary<uint, string>(), new Dictionary<uint, uint>(),
new Dictionary<uint, uint>());
}