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