feat(bridge): publish points/loyalty leaderboards as points.board
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>
This commit is contained in:
@@ -48,6 +48,41 @@ PresenceSweepSeconds=30
|
|||||||
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
|
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
|
||||||
HousingSweepSeconds=300
|
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
|
# 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 —
|
# 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
|
# emitted on every sidecar connect (and on [bridge reload), so the website's rules page
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeGovernance.Rearm();
|
BridgeGovernance.Rearm();
|
||||||
BridgePresence.Rearm();
|
BridgePresence.Rearm();
|
||||||
BridgeHousing.Rearm();
|
BridgeHousing.Rearm();
|
||||||
|
BridgePoints.Rearm();
|
||||||
// Not a sweep, so it has nothing to re-arm — but an operator who just edited a
|
// 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.
|
// .cfg wants the change on the site now, not after a shard restart.
|
||||||
BridgeRuleset.Emit();
|
BridgeRuleset.Emit();
|
||||||
@@ -184,6 +185,7 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeGovernance.SweepOnce();
|
BridgeGovernance.SweepOnce();
|
||||||
BridgePresence.SweepOnce();
|
BridgePresence.SweepOnce();
|
||||||
BridgeHousing.SweepOnce();
|
BridgeHousing.SweepOnce();
|
||||||
|
BridgePoints.SweepOnce();
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.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}", BridgeGovernance.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -205,6 +208,7 @@ namespace Server.Custom.Bridge
|
|||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.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}", BridgePages.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ namespace Server.Custom.Bridge
|
|||||||
public static int CitySweepSeconds { get; private set; }
|
public static int CitySweepSeconds { get; private set; }
|
||||||
public static int PresenceSweepSeconds { get; private set; }
|
public static int PresenceSweepSeconds { get; private set; }
|
||||||
public static int HousingSweepSeconds { 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) ----
|
// ---- shard ruleset (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §5) ----
|
||||||
public static bool RulesetEnabled { get; private set; }
|
public static bool RulesetEnabled { get; private set; }
|
||||||
@@ -112,6 +120,35 @@ namespace Server.Custom.Bridge
|
|||||||
if (HousingSweepSeconds < 1)
|
if (HousingSweepSeconds < 1)
|
||||||
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
|
// 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
|
// `[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
|
// detail the bridge will publish, and only because an operator typed it here for that
|
||||||
|
|||||||
404
overlay/Scripts/Custom/Bridge/BridgePoints.cs
Normal file
404
overlay/Scripts/Custom/Bridge/BridgePoints.cs
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Server.Engines.Points;
|
||||||
|
|
||||||
|
namespace Server.Custom.Bridge
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="BridgeHousing"/>: 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgePoints
|
||||||
|
{
|
||||||
|
private static Timer _timer;
|
||||||
|
|
||||||
|
// PointsType name -> last-emitted signature.
|
||||||
|
private static readonly Dictionary<string, string> _last =
|
||||||
|
new Dictionary<string, string>(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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
|
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<string> 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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsPublished(PointsSystem sys, string key, HashSet<string> 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<string> _selected;
|
||||||
|
|
||||||
|
private static HashSet<string> 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<string>(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single pass over one system's PlayerTable, keeping the best <c>_top.Length</c> entries
|
||||||
|
/// in descending order. Returns how many slots were filled; <paramref name="ranked"/>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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".
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A score as a whole number. Same unchecked-cast hazard as <see cref="Cap"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static long Score(double value)
|
||||||
|
{
|
||||||
|
if (Double.IsNaN(value) || value <= 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (value >= 9.2233720368547758E18)
|
||||||
|
return Int64.MaxValue;
|
||||||
|
|
||||||
|
return (long)value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using Server.Accounting;
|
using Server.Accounting;
|
||||||
|
using Server.Engines.Points;
|
||||||
using Server.Items;
|
using Server.Items;
|
||||||
using Server.Mobiles;
|
using Server.Mobiles;
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ namespace Server.Custom.Bridge
|
|||||||
sb.Append(']');
|
sb.Append(']');
|
||||||
|
|
||||||
WriteTitles(sb, m);
|
WriteTitles(sb, m);
|
||||||
|
WritePoints(sb, m);
|
||||||
|
|
||||||
return sb.End();
|
return sb.End();
|
||||||
}
|
}
|
||||||
@@ -147,6 +149,147 @@ namespace Server.Custom.Bridge
|
|||||||
sb.Append("]}");
|
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)
|
private static bool IsGearLayer(Layer layer)
|
||||||
{
|
{
|
||||||
switch (layer)
|
switch (layer)
|
||||||
|
|||||||
Reference in New Issue
Block a user