Protocol 4 is still on `edge` and unreleased, so this amends it in place rather
than bumping: `PROTOCOL_VERSION` and `overlay.toml` both stay at 4. A bump is
only owed once a protocol has reached `main`.
Phase 1 shipped the roster member as the standard actor object, which carries no
guild rank. The consequence surfaced in Teams phase 2: the website could only
learn leadership from the board's single `leader` field, so `getTeamLeaders()`
could return exactly one member — while a UO guild routinely has several at rank
4, and TEAMS.md §2.5 treats multiple leaders as the normal case.
Roster members now carry `rank` (0-4, 4 being Leader per RankDefinition.Ranks)
plus `rankCliloc`, or `rankName` when a custom rank definition uses a literal
string instead of a cliloc. Only the raw rank goes on the wire: ServUO names the
five standard ranks with cliloc ids and ships no text for them, so this shard
cannot produce "Warlord" without a client-file table it does not have. The
website module has one, and resolving a game term is its job in any case.
`withGuildRank` is a parameter on `Actors()` rather than a change to the shared
actor writer. Rank is a property of a mobile's membership of THIS guild, not of
the mobile, and every other actor this bridge writes is a bystander, a killer or
a governor, where guild rank is meaningless. `WriteActor` is split into a
fields-only writer so both forms share one definition of an actor.
## The trap this found
**`PlayerMobile.GuildRank` returns `RankDefinition.Leader` for anyone at
GameMaster or above, whatever their actual rank.** It is a gameplay convenience
so staff can operate a guild stone, and it is emphatically not a claim about who
leads the guild -- but it is what the only public accessor returns, and the true
value sits in a private field. Emitting it verbatim would have published every
staff member in a guild as a guild leader on a public website.
Staff are therefore written with no rank fields at all. A staff account that
genuinely leads its guild shows as an unranked member, which is a visible gap
rather than a false claim -- the right way round, given the name on that roster
reaches a public page.
## Verification
This repo has no CI build, so compiling is not evidence. Run against the local
ServUO tree with a throwaway probe that synthesised a guild from real
PlayerMobiles across the rank ladder, with one account promoted to GameMaster.
The emitted frame:
tester rank 4 cliloc 1062959 (Leader)
Seed000A rank 4 cliloc 1062959 (Leader) <- two at once, the point of this
Seed000B rank 3 cliloc 1062960 (Warlord)
Seed000C rank 2 cliloc 1062961 (Emissary)
Seed001A rank 1 cliloc 1062962 (Member)
Seed001B no rank fields <- GameMaster, stored rank 0,
getter reported rank 4
The probe printed stored vs reported rank per member, so the getter's substitution
is recorded rather than inferred: `Seed001B storedRank=0 reportedRank=4
access=GameMaster`. The line parsed as valid JSON.
`dotnet build Scripts.csproj` clean, 0 warnings. Probe deleted, tree rebuilt, and
`deploy.ps1 -Verify` reports 0 changes against the overlay. The shard was killed
without a world save, so the synthetic guild did not persist (Guilds.bin still 0
bytes).
**The sidecar needs no change.** It treats roster members as opaque values and
never reads a field inside one -- `accumulate_roster` moves them and
`upsert_guild_roster` stores them, both by value. That is the forwarder design
paying off.
Refs docs/link/v4.md §2.3
Co-Authored-By: Claude <noreply@anthropic.com>
368 lines
15 KiB
C#
368 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
|
|
using Server.Guilds;
|
|
|
|
namespace Server.Custom.Bridge
|
|
{
|
|
/// <summary>
|
|
/// The guild stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §10.1). Guilds have almost no useful EventSink:
|
|
/// EventSink.CreateGuild is only the load-time deserialization factory (Server/World.cs), and
|
|
/// leave/disband/leader/alliance changes raise nothing. Only EventSink.JoinGuild is real. So,
|
|
/// exactly like <see cref="BridgeChamps"/>, the roster is polled: enumerate BaseGuild.List each
|
|
/// tick, fold each guild to a small signature, and emit `guild.update` only when it changes.
|
|
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
|
|
///
|
|
/// On top of the board we emit a real-time `guild.join` from EventSink.JoinGuild, so a "so-and-
|
|
/// so joined" feed does not wait for the next sweep.
|
|
///
|
|
/// Protocol 4 adds the membership half that §10.1 deferred. The sweep holds each guild's
|
|
/// member serial **set** rather than a sum of it, so a change is detected by set comparison
|
|
/// (no hash collisions, unlike the old sum where two offsetting changes could cancel) and the
|
|
/// departures are recoverable by difference — which is what makes a per-member `guild.leave`
|
|
/// possible without a core tap. A changed set also re-emits `guild.roster`, the full member
|
|
/// list, so the board self-corrects and nothing downstream has to replay deltas to stay right.
|
|
///
|
|
/// "Created" is derived sidecar-side from a first-seen id (as champs derive it), rather than a
|
|
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
|
|
/// guild, would look like every guild being created at once.
|
|
/// </summary>
|
|
public static class BridgeSocial
|
|
{
|
|
private static Timer _timer;
|
|
|
|
// guild id -> last-emitted signature. An id absent here has never been emitted (or the cache
|
|
// was cleared on reconnect), so its next sweep counts as a change.
|
|
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
|
|
|
// guild id -> last-emitted member serial set (Protocol 4). Held rather than summed so a
|
|
// departure can be recovered as a set difference; see the class remarks.
|
|
private static readonly Dictionary<int, HashSet<int>> _members =
|
|
new Dictionary<int, HashSet<int>>();
|
|
|
|
private static long _sweeps, _emitted, _removed, _joins, _rosters, _leaves;
|
|
|
|
// Set while a post-reconnect baseline is still draining, so the sweep re-arms promptly
|
|
// instead of leaving the site a sweep interval behind. See GuildSweep.
|
|
private static bool _draining;
|
|
|
|
public static void Initialize()
|
|
{
|
|
if (!BridgeConfig.Enabled)
|
|
return;
|
|
|
|
EventSink.JoinGuild += OnJoinGuild;
|
|
EventSink.ServerStarted += OnServerStarted;
|
|
}
|
|
|
|
private static void OnServerStarted()
|
|
{
|
|
BridgeLink.Connected_Core += OnConnected;
|
|
Rearm();
|
|
}
|
|
|
|
private static void OnConnected()
|
|
{
|
|
_last.Clear();
|
|
_members.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.GuildSweepSeconds),
|
|
TimeSpan.FromSeconds(BridgeConfig.GuildSweepSeconds),
|
|
GuildSweep);
|
|
}
|
|
|
|
public static void Stop()
|
|
{
|
|
if (_timer != null) { _timer.Stop(); _timer = null; }
|
|
}
|
|
|
|
public static string Status()
|
|
{
|
|
return String.Format(
|
|
"guilds(sweeps={0} emitted={1} removed={2} joins={3} rosters={4} leaves={5} tracked={6} draining={7})",
|
|
_sweeps, _emitted, _removed, _joins, _rosters, _leaves, _last.Count, _draining);
|
|
}
|
|
|
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
|
public static void SweepOnce()
|
|
{
|
|
GuildSweep();
|
|
}
|
|
|
|
private static void GuildSweep()
|
|
{
|
|
try
|
|
{
|
|
_sweeps++;
|
|
|
|
if (!BridgeLink.Connected)
|
|
return; // nothing is listening; do not fill the queue with perishable snapshots
|
|
|
|
var seen = new HashSet<int>();
|
|
|
|
// Guilds whose roster this sweep is still allowed to emit. Every guild looks changed
|
|
// right after a reconnect, and a roster is this plugin's only fat frame, so the
|
|
// baseline is spread over several passes rather than built in one Core-thread tick.
|
|
var rosterBudget = BridgeConfig.GuildRosterGuildsPerTick;
|
|
var deferred = false;
|
|
|
|
foreach (var bg in BaseGuild.List.Values)
|
|
{
|
|
var g = bg as Guild;
|
|
|
|
// Skip disbanded guilds (leader gone): they linger in the list until cleaned up,
|
|
// and treating them as absent lets the "gone" pass below emit guild.remove.
|
|
if (g == null || g.Disbanded)
|
|
continue;
|
|
|
|
seen.Add(g.Id);
|
|
|
|
var current = MemberSerials(g);
|
|
|
|
HashSet<int> priorMembers;
|
|
var known = _members.TryGetValue(g.Id, out priorMembers);
|
|
var membersChanged = !known || !priorMembers.SetEquals(current);
|
|
|
|
var sig = Signature(g);
|
|
|
|
string prior;
|
|
var sigChanged = !_last.TryGetValue(g.Id, out prior) || prior != sig;
|
|
|
|
if (!sigChanged && !membersChanged)
|
|
continue; // unchanged since last emit
|
|
|
|
if (sigChanged)
|
|
{
|
|
_last[g.Id] = sig;
|
|
BridgeLink.Emit(WriteGuild(g));
|
|
_emitted++;
|
|
}
|
|
|
|
if (!membersChanged)
|
|
continue;
|
|
|
|
// Over budget: leave _members untouched so this guild is still "changed" next
|
|
// pass and gets its roster then. The guild.update above has already gone, so the
|
|
// board's counts are current either way.
|
|
if (rosterBudget <= 0)
|
|
{
|
|
deferred = true;
|
|
continue;
|
|
}
|
|
|
|
rosterBudget--;
|
|
|
|
// Departures, per member, before the roster that supersedes them: a consumer
|
|
// building a "so-and-so left" feed needs the individual events, while a consumer
|
|
// holding the membership table only needs the roster. On the very first sweep for
|
|
// a guild there is no prior set, so nothing is reported as having left — an
|
|
// unknown roster becoming known is not 155 people leaving.
|
|
if (known)
|
|
{
|
|
foreach (var serial in priorMembers)
|
|
{
|
|
if (current.Contains(serial))
|
|
continue;
|
|
|
|
BridgeLink.Emit(BridgeJson.Begin("guild.leave")
|
|
.Num("id", g.Id)
|
|
.Str("name", g.Name)
|
|
.Ser("who", (Serial)serial)
|
|
.End());
|
|
_leaves++;
|
|
}
|
|
}
|
|
|
|
EmitRoster(g);
|
|
_members[g.Id] = current;
|
|
}
|
|
|
|
// Anything tracked last sweep but not seen now has disbanded or been removed.
|
|
var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList();
|
|
foreach (var id in gone)
|
|
{
|
|
_last.Remove(id);
|
|
_members.Remove(id);
|
|
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
|
|
_removed++;
|
|
}
|
|
|
|
// Re-arm promptly while a baseline is still draining. Without this the remaining
|
|
// guilds would each wait a full GuildSweepSeconds, so a 200-guild shard would take
|
|
// hours to publish its rosters after a reconnect instead of seconds. The sweep is
|
|
// idempotent, so an extra pass that finds nothing changed costs a few field reads.
|
|
_draining = deferred;
|
|
|
|
if (deferred)
|
|
Timer.DelayCall(TimeSpan.FromSeconds(2.0), GuildSweep);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine("[Bridge] guild sweep threw: {0}", ex.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The guild's live member serials. Held per guild between sweeps so a membership change
|
|
/// yields both the fact that it changed and *who* left (Protocol 4).
|
|
/// </summary>
|
|
private static HashSet<int> MemberSerials(Guild g)
|
|
{
|
|
var set = new HashSet<int>();
|
|
var members = g.Members;
|
|
|
|
if (members != null)
|
|
{
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
var m = members[i];
|
|
if (m == null)
|
|
continue;
|
|
set.Add(m.Serial.Value);
|
|
}
|
|
}
|
|
|
|
return set;
|
|
}
|
|
|
|
// The volatile fields that define a meaningful change to the *board row*: name, abbreviation,
|
|
// leader, member count and alliance. Membership is no longer folded in here as a serial sum —
|
|
// the sweep compares the real member set instead, which cannot collide the way a sum can when
|
|
// one member joins and another leaves between two passes.
|
|
private static string Signature(Guild g)
|
|
{
|
|
int count = 0;
|
|
|
|
var members = g.Members;
|
|
if (members != null)
|
|
{
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
if (members[i] != null)
|
|
count++;
|
|
}
|
|
}
|
|
|
|
var leaderSerial = g.Leader == null ? 0 : g.Leader.Serial.Value;
|
|
|
|
return String.Concat(
|
|
g.Name ?? "", "|",
|
|
g.Abbreviation ?? "", "|",
|
|
leaderSerial.ToString(), "|",
|
|
count.ToString(), "|",
|
|
g.Alliance == null ? "" : (g.AllianceName ?? ""));
|
|
}
|
|
|
|
private static string WriteGuild(Guild g)
|
|
{
|
|
int online = 0, count = 0;
|
|
var members = g.Members;
|
|
if (members != null)
|
|
{
|
|
for (int i = 0; i < members.Count; i++)
|
|
{
|
|
var m = members[i];
|
|
if (m == null)
|
|
continue;
|
|
count++;
|
|
if (m.NetState != null)
|
|
online++;
|
|
}
|
|
}
|
|
|
|
var sb = BridgeJson.Begin("guild.update")
|
|
.Num("id", g.Id)
|
|
.Str("name", g.Name)
|
|
.Str("abbr", g.Abbreviation)
|
|
.Num("members", count)
|
|
.Num("online", online)
|
|
.Str("alliance", g.Alliance == null ? null : g.AllianceName);
|
|
|
|
sb.Actor("leader", g.Leader);
|
|
|
|
return sb.End();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits the guild's full member list as one or more `guild.roster` frames (Protocol 4).
|
|
///
|
|
/// A roster is the only fat frame this plugin produces — roughly 69 bytes per member — and
|
|
/// the sidecar reads a line with no length bound, so the member count per line is capped
|
|
/// (Bridge.GuildRosterMembersPerLine). A guild over the cap is split, and each frame
|
|
/// carries `seq` plus `more` so a consumer can tell a complete roster from a partial one:
|
|
/// `seq` 0 begins a roster and replaces whatever was held, and `more` false ends it. A
|
|
/// guild inside the cap — every realistic one — emits exactly one frame with `seq` 0 and
|
|
/// `more` false, which is the same shape as if chunking did not exist.
|
|
/// </summary>
|
|
private static void EmitRoster(Guild g)
|
|
{
|
|
var members = g.Members;
|
|
var total = members == null ? 0 : members.Count;
|
|
var perLine = BridgeConfig.GuildRosterMembersPerLine;
|
|
|
|
var seq = 0;
|
|
var start = 0;
|
|
|
|
// do/while, not while: a guild with no members must still emit one empty roster frame,
|
|
// or a consumer could never learn that a roster it holds has emptied.
|
|
do
|
|
{
|
|
var more = start + perLine < total;
|
|
|
|
var sb = BridgeJson.Begin("guild.roster")
|
|
.Num("id", g.Id)
|
|
.Str("name", g.Name)
|
|
.Str("abbr", g.Abbreviation)
|
|
.Num("total", total)
|
|
.Num("seq", seq)
|
|
.Bool("more", more);
|
|
|
|
// `withGuildRank` — the roster is the one place a member's rank in THIS guild is
|
|
// meaningful, and the only frame that carries it. Leadership is rank 4
|
|
// (RankDefinition.Ranks), and a guild can have several members at it, which is why
|
|
// the board's single `leader` field was never enough to answer "who leads this".
|
|
sb.Actors("members", members, start, perLine, withGuildRank: true);
|
|
|
|
BridgeLink.Emit(sb.End());
|
|
_rosters++;
|
|
|
|
start += perLine;
|
|
seq++;
|
|
}
|
|
while (start < total);
|
|
}
|
|
|
|
// ---- real-time join ----
|
|
|
|
private static void OnJoinGuild(JoinGuildEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
if (e == null || e.Mobile == null)
|
|
return;
|
|
|
|
var g = e.Guild as Guild;
|
|
|
|
var sb = BridgeJson.Begin("guild.join");
|
|
if (g != null)
|
|
sb.Num("id", g.Id).Str("name", g.Name).Str("abbr", g.Abbreviation);
|
|
sb.Actor("who", e.Mobile);
|
|
BridgeLink.Emit(sb.End());
|
|
_joins++;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine("[Bridge] guild join handler threw: {0}", ex.Message);
|
|
}
|
|
}
|
|
}
|
|
}
|