Merge pull request 'feat(bridge): publish points/loyalty leaderboards as points.board' (#4) from feat/points-board into edge
Reviewed-on: #4 Reviewed-by: Colby Whitlock <whitlocktech@gmail.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.
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
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 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("]}");
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
switch (layer)
|
||||
|
||||
Reference in New Issue
Block a user