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:
2026-07-28 21:04:08 -05:00
parent a7a383e6d9
commit ed8f568d94
5 changed files with 623 additions and 0 deletions

View File

@@ -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)