Phase 4: character-profile request/response

BridgeProfile builds the read-models the website consumes; BridgeRequests
registers the inbound handlers. The sidecar asks, the shard answers on the Core
thread (inbound lines are marshaled through Timer.DelayCall before a handler
runs), so all of these read live world state safely.

  - char.request: resolve by serial, or by account + slot, and reply with a full
    profile (stats, all trained skills, worn equipment with flattened AOS mods,
    resists). Works for offline characters since a logged-off mobile stays
    resident until Delete.
  - account.roster: light per-character summary, offline chars included.
  - vendor.snapshot: every player vendor owned by an account, with held gold and
    priced listings.

Each request may carry a reqId the reply echoes so the sidecar can correlate.
An unresolvable request gets a bridge.error reply rather than silence, so the
website can show a real failure instead of hanging.

Verified against the real world with a sending stub: all five requests answered,
both char lookup paths (account+slot and serial) returning the identical profile,
vendor.snapshot returning seed_000's two vendors and 80 listings, and the bad
account returning bridge.error. Two real-data findings noted in docs/PLAN.md §14:
a GM character can have skill base > cap (the website must not assume otherwise),
and the mod-flattening path still wants a genuinely kitted character to exercise
against real suffix gear.

Adds tools/stub_sidecar_request.ps1 (sends requests) and a hardened
tools/stub_sidecar.ps1 (survives reaping/rebind).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 11:34:14 -05:00
parent 7ea7570e5a
commit 7e48d60a8b
4 changed files with 555 additions and 16 deletions

View File

@@ -0,0 +1,245 @@
using System;
using System.Text;
using Server.Accounting;
using Server.Items;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// Builds the heavy read-models the website consumes: a full character profile, an account
/// roster, and a player's vendor holdings. All read live Mobile/Item state, so all must run
/// on the Core thread — which the inbound dispatch guarantees (BridgeLink marshals every
/// inbound line through Timer.DelayCall before a handler sees it).
///
/// A profile is the single most expensive read in the bridge (~0.07 ms + ~2.4 KB at the
/// seeded scale, more for a fully-kitted character), so it is built on demand only, never in
/// a sweep. See docs/PLAN.md §1.
/// </summary>
public static class BridgeProfile
{
private static readonly AosAttribute[] AllAttrs =
(AosAttribute[])Enum.GetValues(typeof(AosAttribute));
private static readonly AosWeaponAttribute[] AllWeaponAttrs =
(AosWeaponAttribute[])Enum.GetValues(typeof(AosWeaponAttribute));
private static readonly AosArmorAttribute[] AllArmorAttrs =
(AosArmorAttribute[])Enum.GetValues(typeof(AosArmorAttribute));
// ---- full profile ----
public static string BuildProfile(PlayerMobile m, string reqId)
{
var sb = BridgeJson.Begin("char.profile");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Ser("serial", m.Serial);
sb.Str("name", m.Name);
sb.Str("title", m.Title);
sb.Num("body", m.Body.BodyID);
sb.Num("hue", m.Hue);
sb.Bool("online", m.NetState != null);
var acct = m.Account as Account;
if (acct != null)
sb.Str("acct", acct.Username);
// stats
sb.Append(",\"stats\":{");
sb.Append("\"str\":").Append(m.Str).Append(",\"dex\":").Append(m.Dex).Append(",\"int\":").Append(m.Int);
sb.Append(",\"hits\":").Append(m.Hits).Append(",\"hitsMax\":").Append(m.HitsMax);
sb.Append(",\"mana\":").Append(m.Mana).Append(",\"manaMax\":").Append(m.ManaMax);
sb.Append(",\"stam\":").Append(m.Stam).Append(",\"stamMax\":").Append(m.StamMax);
sb.Append(",\"fame\":").Append(m.Fame).Append(",\"karma\":").Append(m.Karma);
sb.Append(",\"luck\":").Append(m.Luck);
sb.Append(",\"resist\":{\"phys\":").Append(m.PhysicalResistance);
sb.Append(",\"fire\":").Append(m.FireResistance);
sb.Append(",\"cold\":").Append(m.ColdResistance);
sb.Append(",\"pois\":").Append(m.PoisonResistance);
sb.Append(",\"energy\":").Append(m.EnergyResistance).Append("}}");
// skills: trained only (Base > 0), to avoid ~50 zeroes per character
sb.Append(",\"skills\":[");
bool first = true;
for (int i = 0; i < m.Skills.Length; i++)
{
var s = m.Skills[i];
if (s == null || s.Base <= 0.0)
continue;
if (!first) sb.Append(',');
first = false;
sb.Append("{\"n\":\"").Append(s.SkillName).Append('"');
sb.Append(",\"base\":").Append(s.Base.ToString("F1"));
sb.Append(",\"value\":").Append(s.Value.ToString("F1"));
sb.Append(",\"cap\":").Append(s.Cap.ToString("F1"));
sb.Append(",\"lock\":\"").Append(s.Lock).Append("\"}");
}
sb.Append(']');
// worn equipment only — not the backpack/bank (see docs/PLAN.md §IV.4)
sb.Append(",\"equipment\":[");
first = true;
foreach (var item in m.Items)
{
if (item == null || !IsGearLayer(item.Layer))
continue;
if (!first) sb.Append(',');
first = false;
WriteItem(sb, item);
}
sb.Append(']');
return sb.End();
}
private static bool IsGearLayer(Layer layer)
{
switch (layer)
{
case Layer.Backpack:
case Layer.Bank:
case Layer.Hair:
case Layer.FacialHair:
case Layer.Mount:
case Layer.Invalid:
return false;
default:
return true;
}
}
private static void WriteItem(StringBuilder sb, Item item)
{
sb.Append("{");
sb.Append("\"serial\":\"0x").Append(item.Serial.Value.ToString("X")).Append('"');
sb.Append(",\"layer\":\"").Append(item.Layer).Append('"');
sb.Append(",\"itemId\":").Append(item.ItemID);
sb.Append(",\"hue\":").Append(item.Hue);
sb.Append(",\"cliloc\":").Append(item.LabelNumber);
if (item.Name != null)
{
sb.Append(",\"name\":");
BridgeJson.Escape(sb, item.Name);
}
var weapon = item as BaseWeapon;
var armor = item as BaseArmor;
if (weapon != null)
{
sb.Append(",\"weapon\":{\"minDamage\":").Append(weapon.MinDamage);
sb.Append(",\"maxDamage\":").Append(weapon.MaxDamage).Append('}');
}
else if (armor != null)
{
sb.Append(",\"armor\":{\"baseRating\":").Append(armor.BaseArmorRating).Append('}');
}
// flattened union of non-zero mods across every attribute bag
sb.Append(",\"mods\":{");
bool first = true;
if (weapon != null)
{
WriteAttrs(sb, weapon.Attributes, ref first);
WriteWeaponAttrs(sb, weapon.WeaponAttributes, ref first);
}
else if (armor != null)
{
WriteAttrs(sb, armor.Attributes, ref first);
WriteArmorAttrs(sb, armor.ArmorAttributes, ref first);
}
sb.Append("}}");
}
private static void WriteAttrs(StringBuilder sb, AosAttributes a, ref bool first)
{
if (a == null) return;
for (int i = 0; i < AllAttrs.Length; i++)
{
int v = a[AllAttrs[i]];
if (v == 0) continue;
if (!first) sb.Append(','); first = false;
sb.Append('"').Append(AllAttrs[i]).Append("\":").Append(v);
}
}
private static void WriteWeaponAttrs(StringBuilder sb, AosWeaponAttributes a, ref bool first)
{
if (a == null) return;
for (int i = 0; i < AllWeaponAttrs.Length; i++)
{
int v = a[AllWeaponAttrs[i]];
if (v == 0) continue;
if (!first) sb.Append(','); first = false;
sb.Append('"').Append(AllWeaponAttrs[i]).Append("\":").Append(v);
}
}
private static void WriteArmorAttrs(StringBuilder sb, AosArmorAttributes a, ref bool first)
{
if (a == null) return;
for (int i = 0; i < AllArmorAttrs.Length; i++)
{
int v = a[AllArmorAttrs[i]];
if (v == 0) continue;
if (!first) sb.Append(','); first = false;
sb.Append('"').Append(AllArmorAttrs[i]).Append("\":").Append(v);
}
}
// ---- account roster ----
/// <summary>
/// Light per-character summary for an account. Offline characters are included: a
/// logged-off mobile stays resident (World.Mobiles) until Delete, so its roster entry is
/// always available.
/// </summary>
public static string BuildRoster(Account acct, string reqId)
{
var sb = BridgeJson.Begin("account.roster");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Str("acct", acct.Username);
sb.Append(",\"chars\":[");
bool first = true;
for (int i = 0; i < acct.Length; i++)
{
var m = acct[i];
if (m == null)
continue;
if (!first) sb.Append(',');
first = false;
sb.Append("{\"slot\":").Append(i);
sb.Append(",\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
sb.Append(",\"name\":");
BridgeJson.Escape(sb, m.Name ?? "");
sb.Append(",\"body\":").Append(m.Body.BodyID);
sb.Append(",\"online\":").Append(m.NetState != null ? "true" : "false");
sb.Append('}');
}
sb.Append(']');
return sb.End();
}
}
}