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>
435 lines
17 KiB
C#
435 lines
17 KiB
C#
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();
|
||
}
|
||
}
|
||
}
|