From ed8f568d94157a4ce2b42045dca7548cd514c663 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 21:04:08 -0500 Subject: [PATCH] feat(bridge): publish points/loyalty leaderboards as points.board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- overlay/Config/Bridge.cfg | 35 ++ overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 4 + overlay/Scripts/Custom/Bridge/BridgeConfig.cs | 37 ++ overlay/Scripts/Custom/Bridge/BridgePoints.cs | 404 ++++++++++++++++++ .../Scripts/Custom/Bridge/BridgeProfile.cs | 143 +++++++ 5 files changed, 623 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgePoints.cs 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)