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(); } } }