diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index 50f60f5..df1edb6 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -48,6 +48,41 @@ PresenceSweepSeconds=30
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
HousingSweepSeconds=300
+# Points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7). ServUO carries ~25 point
+# currencies (Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city loyalties,
+# the Doom/Khaldun/Kotl treasure systems, …). Each is diffed on this interval and emitted as
+# one points.board frame per system when its top N moves.
+#
+# Slow on purpose: these are month-scale standings, and ten of the systems keep a row for
+# every character ever created, so the pass is the widest read in the bridge. It is still
+# cheap — a single bounded pass, never a sort — but there is nothing to gain by hurrying it.
+PointsSweepSeconds=300
+
+# Master switch for the boards. Off leaves char.profile points alone (see below).
+PointsLeaderboardEnabled=true
+
+# How many players per board. Clamped to 1..100 — the frame is emitted PER SYSTEM, so a big
+# N is multiplied by ~25.
+PointsTopN=10
+
+# Which systems to publish, as a comma-separated list of PointsType names, e.g.
+# PointsSystems=QueensLoyalty,CleanUpBritannia,VoidPool
+# Blank (the default) publishes whatever the shard itself shows on the in-game loyalty gump
+# (ShowOnLoyaltyGump), so a subsystem you add later gets a board without an edit here.
+# An unrecognized name is logged and ignored, never silently dropped.
+PointsSystems=
+
+# Include a per-character "points" block in char.profile (the website character sheet). This
+# is a lookup across every published system's table, so it is the dominant cost of building a
+# profile; turn it off on a very large shard that does not want the sheet paying for it.
+PointsProfileEnabled=true
+
+# Also compute each system's rank in that block. OFF by default and worth leaving off: a
+# points lookup stops at the character's own row, but a rank must count every row that beats
+# them, in every system, on every profile build. The website already derives rank from the
+# board for anyone in the top N.
+PointsProfileRank=false
+
# Shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5). One world.ruleset frame — expansion, which
# systems are on, skill/stat caps, account and house limits, champion scroll rules —
# emitted on every sidecar connect (and on [bridge reload), so the website's rules page
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index 5708b6b..256cda9 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -165,6 +165,7 @@ namespace Server.Custom.Bridge
BridgeGovernance.Rearm();
BridgePresence.Rearm();
BridgeHousing.Rearm();
+ BridgePoints.Rearm();
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
// .cfg wants the change on the site now, not after a shard restart.
BridgeRuleset.Emit();
@@ -184,6 +185,7 @@ namespace Server.Custom.Bridge
BridgeGovernance.SweepOnce();
BridgePresence.SweepOnce();
BridgeHousing.SweepOnce();
+ BridgePoints.SweepOnce();
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
@@ -191,6 +193,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
break;
default:
@@ -205,6 +208,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
break;
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index 9433ebe..c9a6343 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -35,6 +35,14 @@ namespace Server.Custom.Bridge
public static int CitySweepSeconds { get; private set; }
public static int PresenceSweepSeconds { get; private set; }
public static int HousingSweepSeconds { get; private set; }
+ public static int PointsSweepSeconds { get; private set; }
+
+ // ---- points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7) ----
+ public static bool PointsLeaderboardEnabled { get; private set; }
+ public static int PointsTopN { get; private set; }
+ public static string PointsSystems { get; private set; }
+ public static bool PointsProfileEnabled { get; private set; }
+ public static bool PointsProfileRank { get; private set; }
// ---- shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5) ----
public static bool RulesetEnabled { get; private set; }
@@ -112,6 +120,35 @@ namespace Server.Custom.Bridge
if (HousingSweepSeconds < 1)
HousingSweepSeconds = 1;
+ // Points/loyalty boards. The sweep touches every point entry on the shard, and ten of
+ // the ~25 systems keep a row per character ever created, so the default interval is
+ // deliberately slow — these are month-scale standings, not live state.
+ PointsSweepSeconds = Config.Get("Bridge.PointsSweepSeconds", 300);
+ if (PointsSweepSeconds < 1)
+ PointsSweepSeconds = 1;
+
+ PointsLeaderboardEnabled = Config.Get("Bridge.PointsLeaderboardEnabled", true);
+
+ // Board size. Bounded below at 1 because the selection indexes the Nth slot directly,
+ // and above at 100 because the frame is emitted per system — a large N multiplied by
+ // ~25 systems is how a "board" turns into a bandwidth problem.
+ PointsTopN = Config.Get("Bridge.PointsTopN", 10);
+ if (PointsTopN < 1)
+ PointsTopN = 1;
+ if (PointsTopN > 100)
+ PointsTopN = 100;
+
+ // Blank (the default) means "publish whatever the shard itself shows on the loyalty
+ // gump", so a shard that adds a subsystem gets its board without an edit here.
+ PointsSystems = Config.Get("Bridge.PointsSystems", "");
+
+ PointsProfileEnabled = Config.Get("Bridge.PointsProfileEnabled", true);
+
+ // Off by default, and the default is the point: a rank cannot early-exit the way a
+ // points lookup can — it must count every row that beats the player, in every system,
+ // on every profile build. See BridgeProfile.WritePoints.
+ PointsProfileRank = Config.Get("Bridge.PointsProfileRank", false);
+
// The ruleset frame is not a sweep — it is emitted once per sidecar connect (and on
// `[bridge reload`), so it has no interval. PublicConnectAddress is the ONE connection
// detail the bridge will publish, and only because an operator typed it here for that
diff --git a/overlay/Scripts/Custom/Bridge/BridgePoints.cs b/overlay/Scripts/Custom/Bridge/BridgePoints.cs
new file mode 100644
index 0000000..2a85c9d
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgePoints.cs
@@ -0,0 +1,404 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+using Server.Engines.Points;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// Points / loyalty leaderboards (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §7). ServUO carries ~25 separate point
+ /// currencies (Queen's Loyalty, Void Pool, Casino, Clean Up Britannia, the nine city
+ /// loyalties, Blackthorn, the Doom/Khaldun/Kotl treasure systems, …), every one of them a
+ /// standing a player accumulates over months — and none of them has ever been visible
+ /// anywhere but an in-game gump. This is the diff sweep that publishes them as boards.
+ ///
+ /// Shaped like : ServerStarted arms a 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 actually moved. One frame per system (~600 B) rather than one 12 KB
+ /// frame, matching champ.update / guild.update.
+ ///
+ /// **There is no `points.remove`.** The set of systems is fixed at Configure() time by
+ /// PointsSystem.Configure — a system cannot disappear at runtime — which is the same
+ /// argument city.update already makes for cities.
+ ///
+ /// ---- The perf trap, and why the selection looks like this ----
+ ///
+ /// `PlayerTable` is a plain List<PointsEntry>, and QueensLoyalty has AutoAdd = true, so it
+ /// holds an entry for every PlayerMobile that has ever logged in — zero-point rows included.
+ /// The obvious `.OrderByDescending(e => e.Points).Take(N)` is a full sort PER SYSTEM: at
+ /// 20,000 historical characters that is ~25 sorts and ~7.5 M comparisons on the Core thread,
+ /// tens of milliseconds, which BRIDGE_PLUGIN_PLAN.md §1 measured as the second thing in the
+ /// whole bridge capable of blowing a frame budget (bulk profile generation being the first).
+ ///
+ /// So: a single pass per system into a fixed N-element array kept sorted by insertion.
+ /// O(n·N) with tiny constants, one allocation for the whole sweep, and the common case is a
+ /// single comparison against the running Nth place before the row is rejected. ~500 k cheap
+ /// iterations per pass at the default 300 s interval.
+ ///
+ public static class BridgePoints
+ {
+ private static Timer _timer;
+
+ // PointsType name -> last-emitted signature.
+ private static readonly Dictionary _last =
+ new Dictionary(StringComparer.Ordinal);
+
+ private static long _sweeps, _emitted;
+
+ // Reused across systems and across sweeps: the selection is single-threaded (Core thread)
+ // and fully overwritten each time, so there is nothing to allocate per pass.
+ private static PointsEntry[] _top = new PointsEntry[0];
+
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ EventSink.ServerStarted += OnServerStarted;
+ }
+
+ private static void OnServerStarted()
+ {
+ BridgeLink.Connected_Core += OnConnected;
+ Rearm();
+ }
+
+ private static void OnConnected()
+ {
+ // A new sidecar knows nothing; drop the diff state so the next pass re-emits every board.
+ _last.Clear();
+ }
+
+ /// Stops and recreates the timer from current config. Called by `[bridge reload`.
+ public static void Rearm()
+ {
+ Stop();
+
+ _timer = Timer.DelayCall(
+ TimeSpan.FromSeconds(BridgeConfig.PointsSweepSeconds),
+ TimeSpan.FromSeconds(BridgeConfig.PointsSweepSeconds),
+ PointsSweep);
+ }
+
+ public static void Stop()
+ {
+ if (_timer != null) { _timer.Stop(); _timer = null; }
+ }
+
+ public static string Status()
+ {
+ return String.Format("points(enabled={0} sweeps={1} emitted={2} tracked={3} topN={4})",
+ BridgeConfig.PointsLeaderboardEnabled, _sweeps, _emitted, _last.Count,
+ BridgeConfig.PointsTopN);
+ }
+
+ /// Runs one sweep now. Wired into `[bridge sweepnow`.
+ public static void SweepOnce()
+ {
+ PointsSweep();
+ }
+
+ private static void PointsSweep()
+ {
+ try
+ {
+ if (!BridgeConfig.PointsLeaderboardEnabled)
+ return;
+
+ _sweeps++;
+
+ if (!BridgeLink.Connected)
+ return; // nothing is listening; do not fill the queue with perishable snapshots
+
+ // Systems is a mutable static populated by ~25 separate subsystem constructors in
+ // PointsSystem.Configure(). It is null before that runs and could in principle hold
+ // a null element, so neither is assumed.
+ var systems = PointsSystem.Systems;
+
+ if (systems == null)
+ return;
+
+ var selected = SelectedSystems();
+
+ var n = BridgeConfig.PointsTopN;
+ if (_top.Length != n)
+ _top = new PointsEntry[n];
+
+ for (int i = 0; i < systems.Count; i++)
+ {
+ var sys = systems[i];
+
+ if (sys == null)
+ continue;
+
+ // One bad system must not cost the rest of the sweep: Name/MaxPoints are
+ // abstract members implemented by 25 unrelated subsystems, any of which could
+ // throw on a shard running modified scripts.
+ try
+ {
+ SweepSystem(sys, selected);
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] points sweep threw for {0}: {1}",
+ sys.Loyalty, ex.Message);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] points sweep threw: {0}", ex.Message);
+ }
+ }
+
+ private static void SweepSystem(PointsSystem sys, HashSet selected)
+ {
+ var key = sys.Loyalty.ToString();
+
+ if (!IsPublished(sys, key, selected))
+ return;
+
+ int ranked;
+ var count = SelectTop(sys, out ranked);
+
+ var sig = Signature(count, ranked);
+
+ string prior;
+ if (_last.TryGetValue(key, out prior) && prior == sig)
+ return; // top N and participant count both unchanged since last emit
+
+ _last[key] = sig;
+ BridgeLink.Emit(WriteBoard(sys, key, count, ranked));
+ _emitted++;
+ }
+
+ ///
+ /// Which systems are published. The default is the shard's OWN answer to "is this
+ /// player-facing?" — ShowOnLoyaltyGump, the flag that decides whether a system appears
+ /// on the in-game loyalty gump — rather than a list invented here that would drift from
+ /// the server every time a subsystem is added. `Bridge.cfg PointsSystems=` overrides it
+ /// with an explicit comma-separated list of PointsType names.
+ ///
+ private static bool IsPublished(PointsSystem sys, string key, HashSet selected)
+ {
+ if (selected != null)
+ return selected.Contains(key);
+
+ return sys.ShowOnLoyaltyGump;
+ }
+
+ // Parsed form of BridgeConfig.PointsSystems, rebuilt when the raw string changes so
+ // `[bridge reload` picks up an edit without a restart. null == "no override, use
+ // ShowOnLoyaltyGump".
+ private static string _selectedRaw;
+ private static HashSet _selected;
+
+ private static HashSet SelectedSystems()
+ {
+ var raw = BridgeConfig.PointsSystems ?? "";
+
+ if (raw == _selectedRaw)
+ return _selected;
+
+ _selectedRaw = raw;
+ _selected = null;
+
+ if (raw.Trim().Length == 0)
+ return null;
+
+ var set = new HashSet(StringComparer.Ordinal);
+
+ foreach (var part in raw.Split(','))
+ {
+ var name = part.Trim();
+
+ if (name.Length == 0)
+ continue;
+
+ // Resolve through the enum so a typo is reported loudly rather than silently
+ // publishing one board fewer than the operator asked for.
+ PointsType parsed;
+ if (Enum.TryParse(name, true, out parsed) && Enum.IsDefined(typeof(PointsType), parsed))
+ set.Add(parsed.ToString());
+ else
+ Console.WriteLine("[Bridge] unknown PointsSystems entry '{0}', ignoring", name);
+ }
+
+ _selected = set;
+ return _selected;
+ }
+
+ ///
+ /// Single pass over one system's PlayerTable, keeping the best _top.Length entries
+ /// in descending order. Returns how many slots were filled;
+ /// receives the number of players actually holding points.
+ ///
+ /// Ties do not displace (the shift test is strict, and the reject test is inclusive), so
+ /// an unchanged table produces an unchanged board — which is what makes the diff
+ /// signature meaningful rather than a source of spurious re-emits.
+ ///
+ private static int SelectTop(PointsSystem sys, out int ranked)
+ {
+ ranked = 0;
+
+ var table = sys.PlayerTable;
+ var top = _top;
+
+ if (table == null || top.Length == 0)
+ return 0;
+
+ var count = 0;
+
+ for (int i = 0; i < table.Count; i++)
+ {
+ var entry = table[i];
+
+ if (entry == null)
+ continue;
+
+ var player = entry.Player;
+
+ // A deleted character keeps its row until the next save/load cycle, and AutoAdd
+ // systems are mostly zero-point rows. Neither belongs on a leaderboard.
+ if (player == null || player.Deleted || entry.Points <= 0)
+ continue;
+
+ ranked++;
+
+ var points = entry.Points;
+
+ // The common case for a big table: worse than the running Nth place, one compare.
+ if (count == top.Length && points <= top[count - 1].Points)
+ continue;
+
+ var pos = count < top.Length ? count : top.Length - 1;
+
+ while (pos > 0 && top[pos - 1].Points < points)
+ {
+ top[pos] = top[pos - 1];
+ pos--;
+ }
+
+ top[pos] = entry;
+
+ if (count < top.Length)
+ count++;
+ }
+
+ return count;
+ }
+
+ ///
+ /// A system's point ceiling as a whole number, or **0 meaning "uncapped"**.
+ ///
+ /// `MaxPoints` is a double, and ServUO's idiom for "no cap" is `double.MaxValue`
+ /// (DespiseCrystals, ShameCrystals and VoidPool all do this). A plain `(long)` cast of
+ /// that is an UNCHECKED conversion — it does not throw, it produces `long.MinValue` —
+ /// which is exactly what the first sweep against a real shard published:
+ /// `"maxPoints": -9223372036854775808`. Anything not representable as a positive long
+ /// therefore becomes 0, which the website already renders as "no maximum".
+ ///
+ internal static long Cap(double value)
+ {
+ // NaN first: every comparison against NaN is false, so it would otherwise fall through
+ // to the same unchecked cast.
+ if (Double.IsNaN(value) || value <= 0 || value >= 9.2233720368547758E18)
+ return 0;
+
+ return (long)value;
+ }
+
+ ///
+ /// A score as a whole number. Same unchecked-cast hazard as , but the
+ /// saturating direction is the opposite: an implausibly large score is still a large
+ /// score, so it clamps to long.MaxValue rather than collapsing to 0.
+ ///
+ internal static long Score(double value)
+ {
+ if (Double.IsNaN(value) || value <= 0)
+ return 0;
+
+ if (value >= 9.2233720368547758E18)
+ return Int64.MaxValue;
+
+ return (long)value;
+ }
+
+ ///
+ /// The diff key: every published serial and its whole-point score, plus the participant
+ /// count. Points are compared exactly as they are emitted, so a fractional award that
+ /// does not move the displayed number does not cost a frame either.
+ ///
+ private static string Signature(int count, int ranked)
+ {
+ var sb = new StringBuilder(64);
+
+ sb.Append(ranked).Append('|');
+
+ for (int i = 0; i < count; i++)
+ {
+ var entry = _top[i];
+ sb.Append(entry.Player.Serial.Value.ToString("X"))
+ .Append(':')
+ .Append(Score(entry.Points))
+ .Append(';');
+ }
+
+ return sb.ToString();
+ }
+
+ ///
+ /// One board frame.
+ ///
+ /// `nameString` AND `nameNumber` are both emitted because Name is a TextDefinition, which
+ /// may carry either a literal or a cliloc id — the same contract titles.reward already
+ /// documents at BridgeProfile.cs:107-110. Resolving clilocs is the website's job.
+ ///
+ /// **Entries are written inline as {serial, name} — never through BridgeJson.Actor.**
+ /// That is deliberate even though the website can now reveal fields by audience rung:
+ /// Actor would add `acct` and `webId`, and neither is needed here, because the site
+ /// resolves serial → user from its own shard_account_links mirror for staff views. A
+ /// board is the widest-audience surface the bridge has; the account name of every ranked
+ /// player has no business crossing the wire to reach it.
+ ///
+ private static string WriteBoard(PointsSystem sys, string key, int count, int ranked)
+ {
+ var name = sys.Name;
+
+ var sb = BridgeJson.Begin("points.board")
+ .Str("system", key)
+ .Str("nameString", name == null ? null : name.String)
+ .Num("nameNumber", name == null ? 0 : name.Number)
+ .Num("maxPoints", Cap(sys.MaxPoints))
+ .Bool("showOnGump", sys.ShowOnLoyaltyGump)
+ // Players actually HOLDING points, not PlayerTable.Count: an AutoAdd system has a
+ // zero-point row for every character that ever logged in, so the raw count would
+ // report the shard's whole character census as this system's participants.
+ .Num("players", ranked);
+
+ sb.Append(",\"top\":[");
+
+ for (int i = 0; i < count; i++)
+ {
+ var entry = _top[i];
+
+ if (i > 0)
+ sb.Append(',');
+
+ sb.Append("{\"rank\":").Append(i + 1);
+ sb.Append(",\"serial\":\"0x").Append(entry.Player.Serial.Value.ToString("X")).Append('"');
+ sb.Append(",\"name\":");
+ BridgeJson.Escape(sb, entry.Player.Name ?? "");
+ // Whole points: every one of these systems awards and displays integers in game,
+ // and a board that renders 29500.00000000001 would be a bug report.
+ sb.Append(",\"points\":").Append(Score(entry.Points));
+ sb.Append('}');
+ }
+
+ sb.Append(']');
+
+ return sb.End();
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs
index dd3a30b..b69d7c3 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeProfile.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeProfile.cs
@@ -2,6 +2,7 @@ using System;
using System.Text;
using Server.Accounting;
+using Server.Engines.Points;
using Server.Items;
using Server.Mobiles;
@@ -99,6 +100,7 @@ namespace Server.Custom.Bridge
sb.Append(']');
WriteTitles(sb, m);
+ WritePoints(sb, m);
return sb.End();
}
@@ -147,6 +149,147 @@ namespace Server.Custom.Bridge
sb.Append("]}");
}
+ ///
+ /// 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 — 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.
+ ///
+ 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(']');
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// 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 : 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.
+ ///
+ 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)