Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeProfile.cs
wtclaude ed8f568d94 feat(bridge): publish points/loyalty leaderboards as points.board
Protocol 3.0 §7 (docs/link/v3.md). ServUO carries ~25 separate point currencies
— Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city
loyalties, the Doom/Khaldun/Kotl treasure systems — every one a standing players
build over months, and none of them visible outside an in-game gump until now.

BridgePoints.cs
  - A diff sweep shaped like BridgeHousing: ServerStarted arms the timer, a
    sidecar connect clears the diff state so a fresh sidecar gets every board,
    and each pass emits only the systems whose top N or participant count moved.
    One ~600 B frame per system rather than one 12 KB frame, matching
    champ.update / guild.update. No points.remove — the system set is fixed at
    startup by PointsSystem.Configure, the same argument city.update makes.
  - Selection is a single bounded pass into a fixed N-element array kept sorted
    by insertion, NOT OrderByDescending().Take(N). PlayerTable is a plain List
    and ten of the ~25 systems have AutoAdd = true, so they hold a row for every
    character ever created: the naive version is ~25 full sorts on the Core
    thread, which BRIDGE_PLUGIN_PLAN.md §1 measured as the second thing in the
    bridge capable of blowing a frame budget.
  - Which systems publish defaults to the shard's OWN answer — ShowOnLoyaltyGump
    — rather than a list here that would drift; Bridge.cfg PointsSystems=
    overrides it, and an unrecognised name is logged rather than dropped.
  - Entries are written inline as {serial, name}, never via BridgeJson.Actor. A
    board is the widest-audience surface the bridge has, so acct/webId
    deliberately do not cross the wire; the site resolves serial → user from its
    own link mirror.

char.profile gains a points block, the titles precedent from PROTOCOL_2.md §10.3
  - Never uses PointsSystem.GetEntry/GetPoints: both MUTATE THE WORLD, since
    GetEntry(create: false) still calls AddEntry when the system has AutoAdd
    (PointsSystem.cs:207). Using them would have appended up to ten rows to the
    points save file every time anyone opened a character sheet. Hand-rolled
    read-only scan instead.
  - rank is off by default (PointsProfileRank). A points lookup stops at the
    character's own row; a rank must count every row that beats them, in every
    system, on every profile build.

Verified by running it, not by reading it: the whole Scripts tree (6,207 files)
compiles clean against real ServUO 57.4 assemblies, and a boot against the local
shard with a 43,011-mobile world emitted five live boards. That run caught a bug
no fake shard could — ServUO's uncapped idiom is MaxPoints = double.MaxValue,
and (long) on it is an UNCHECKED conversion yielding long.MinValue, so the first
sweep published "maxPoints": -9223372036854775808 for three of the five boards.
Cap()/Score() now normalise anything unrepresentable, and maxPoints: 0 is the
documented "uncapped" value — which on a real shard is the common case, not an
edge case. Re-verified after the fix: 0 for the uncapped systems, 15000 and
10000 for the two that genuinely cap.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:08 -05:00

435 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Text;
using Server.Accounting;
using Server.Engines.Points;
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 https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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 https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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(']');
WriteTitles(sb, m);
WritePoints(sb, m);
return sb.End();
}
/// <summary>
/// The titles a character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.3). `selected` is the index into
/// `reward` currently displayed (-1 if none). `fameKarma` and `skill` are the computed
/// display titles (may be absent). `reward` is the raw reward-title list — an entry may be
/// a cliloc number (as a string) or a literal string; resolve clilocs website-side.
/// </summary>
private static void WriteTitles(StringBuilder sb, PlayerMobile m)
{
sb.Append(",\"titles\":{\"selected\":").Append(m.SelectedTitle);
var fameKarma = m.FameKarmaTitle;
if (!String.IsNullOrEmpty(fameKarma))
{
sb.Append(",\"fameKarma\":");
BridgeJson.Escape(sb, fameKarma);
}
var skill = m.PaperdollSkillTitle;
if (!String.IsNullOrEmpty(skill))
{
sb.Append(",\"skill\":");
BridgeJson.Escape(sb, skill);
}
sb.Append(",\"reward\":[");
var rewards = m.RewardTitles;
if (rewards != null)
{
bool first = true;
for (int i = 0; i < rewards.Count; i++)
{
var r = rewards[i];
if (r == null)
continue;
if (!first) sb.Append(',');
first = false;
BridgeJson.Escape(sb, Convert.ToString(r, System.Globalization.CultureInfo.InvariantCulture));
}
}
sb.Append("]}");
}
/// <summary>
/// The point/loyalty standings this character holds (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7.3). Read-model
/// enrichment on an existing kind, exactly like <see cref="WriteTitles"/> — there is no
/// request kind for "one character's points", because the profile is already the place
/// the website asks for everything about one character.
///
/// Systems with no entry, or an entry at zero, are omitted: ten of the ~25 systems have
/// AutoAdd = true and therefore hold a zero-point row for every character that has ever
/// logged in, so emitting them all would be ~25 lines of noise on every sheet.
///
/// **Never call PointsSystem.GetEntry / GetPoints here.** Both look benign and both
/// MUTATE THE WORLD: `GetEntry(from, create: false)` still calls AddEntry when the system
/// has AutoAdd (PointsSystem.cs:207), which appends a row to PlayerTable and fires
/// OnPlayerAdded. A read model that used them would silently grow the points save file by
/// up to ten rows every time anyone viewed a character sheet. Hence the manual scan.
///
/// Cost: one early-exiting pass over each published system's PlayerTable. The AutoAdd
/// tables are census-sized, so this is the dominant term in the profile — roughly 10 × n
/// comparisons, against the ~0.069 ms/2.4 KB the rest of the profile measures at. That is
/// acceptable because profiles are built on demand at human rates and never in a sweep;
/// PointsProfileEnabled turns it off for a shard where it isn't.
///
/// **Deliberately no `rank`.** Rank cannot early-exit — it must count every row that
/// beats the player, in every system, every time — and the website can derive it from
/// the points.board frame for anyone who is actually on a board. See PointsProfileRank.
/// </summary>
private static void WritePoints(StringBuilder sb, PlayerMobile m)
{
if (!BridgeConfig.PointsProfileEnabled)
return;
sb.Append(",\"points\":[");
try
{
var systems = PointsSystem.Systems;
if (systems != null)
{
bool first = true;
for (int i = 0; i < systems.Count; i++)
{
var sys = systems[i];
if (sys == null || !sys.ShowOnLoyaltyGump)
continue;
var points = LookupPoints(sys, m);
if (points <= 0)
continue;
if (!first) sb.Append(',');
first = false;
var name = sys.Name;
sb.Append("{\"system\":\"").Append(sys.Loyalty).Append('"');
sb.Append(",\"nameString\":");
if (name == null || name.String == null)
sb.Append("null");
else
BridgeJson.Escape(sb, name.String);
sb.Append(",\"nameNumber\":").Append(name == null ? 0 : name.Number);
sb.Append(",\"points\":").Append(BridgePoints.Score(points));
sb.Append(",\"maxPoints\":").Append(BridgePoints.Cap(sys.MaxPoints));
// Off by default. The field is absent rather than null when disabled, so a
// consumer can tell "this shard does not compute rank" from "unranked".
if (BridgeConfig.PointsProfileRank)
sb.Append(",\"rank\":").Append(RankOf(sys, points));
sb.Append('}');
}
}
}
catch (Exception ex)
{
// A profile is worth more than its points block; never fail the sheet over one.
Console.WriteLine("[Bridge] profile points threw: {0}", ex.Message);
}
sb.Append(']');
}
/// <summary>
/// This character's score in one system, or 0 if it has no entry. A hand-rolled scan
/// rather than GetEntry/GetPoints for the mutation reason above; it stops at the match,
/// which the rank computation could not.
/// </summary>
private static double LookupPoints(PointsSystem sys, PlayerMobile m)
{
var table = sys.PlayerTable;
if (table == null)
return 0;
for (int i = 0; i < table.Count; i++)
{
var entry = table[i];
if (entry != null && entry.Player == m)
return entry.Points;
}
return 0;
}
/// <summary>
/// 1-based standing in one system: how many live characters hold strictly more points,
/// plus one. Ties share a rank, which is what a player expects to see.
///
/// Only reachable with PointsProfileRank=true, and off by default for the reason stated
/// in <see cref="WritePoints"/>: unlike the points lookup, this visits every row of the
/// table every time, so it turns a bounded early-exiting scan into a guaranteed full one
/// per published system per profile.
/// </summary>
private static int RankOf(PointsSystem sys, double points)
{
var table = sys.PlayerTable;
if (table == null)
return 1;
var better = 0;
for (int i = 0; i < table.Count; i++)
{
var entry = table[i];
if (entry == null || entry.Player == null || entry.Player.Deleted)
continue;
if (entry.Points > points)
better++;
}
return better + 1;
}
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();
}
}
}