feat(bridge)!: protocol 4 — guild rosters and per-member leaves (Teams cutover 1/6) #14
@@ -23,8 +23,8 @@
|
|||||||
# manual duty: when the protocol changes, bump it here in the same PR that
|
# manual duty: when the protocol changes, bump it here in the same PR that
|
||||||
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
|
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
|
||||||
#
|
#
|
||||||
# Current: 3 — see docs/link/v3.md (world.ruleset, points.board, vendor.listing).
|
# Current: 4 — see docs/link/v4.md (guild.roster, guild.leave).
|
||||||
protocol = 3
|
protocol = 4
|
||||||
|
|
||||||
# ── ServUO compatibility ─────────────────────────────────────────────────────
|
# ── ServUO compatibility ─────────────────────────────────────────────────────
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ PageSweepSeconds=5
|
|||||||
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
|
# interval (emit guild.update / guild.remove). Guild membership moves slowly; 60s is ample.
|
||||||
GuildSweepSeconds=60
|
GuildSweepSeconds=60
|
||||||
|
|
||||||
|
# Members per guild.roster frame (Protocol 4). A roster is the only fat frame the bridge emits
|
||||||
|
# (~69 bytes per member) and the sidecar reads a line with no length bound, so this caps it; a
|
||||||
|
# guild over the cap is split across continuation frames carrying seq/more. 500 members is ~35 KB,
|
||||||
|
# past any realistic guild, so the split path is an edge case rather than the norm.
|
||||||
|
GuildRosterMembersPerLine=500
|
||||||
|
|
||||||
|
# Guilds that may emit a roster in one sweep. Every guild looks changed right after a sidecar
|
||||||
|
# reconnect, and building hundreds of fat frames in a single Core-thread pass is exactly the stall
|
||||||
|
# the bridge exists to avoid. The sweep re-arms itself every 2s while a baseline is draining, so
|
||||||
|
# lowering this slows the catch-up without making the site wait a full sweep interval per batch.
|
||||||
|
GuildRosterGuildsPerTick=25
|
||||||
|
|
||||||
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
|
# Town-governor poll. Each city's Governor / election is diffed on this interval to emit
|
||||||
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
|
# city.update on change. Governors turn over on the order of weeks, so a slow sweep is fine.
|
||||||
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
|
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ namespace Server.Custom.Bridge
|
|||||||
public static int PointsSweepSeconds { get; private set; }
|
public static int PointsSweepSeconds { get; private set; }
|
||||||
public static int MarketSweepSeconds { get; private set; }
|
public static int MarketSweepSeconds { get; private set; }
|
||||||
|
|
||||||
|
// ---- guild rosters (Protocol 4) ----
|
||||||
|
public static int GuildRosterMembersPerLine { get; private set; }
|
||||||
|
public static int GuildRosterGuildsPerTick { get; private set; }
|
||||||
|
|
||||||
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
|
// ---- player-vendor market index (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/v3.md §8) ----
|
||||||
public static bool MarketEnabled { get; private set; }
|
public static bool MarketEnabled { get; private set; }
|
||||||
public static int MarketSweepBatch { get; private set; }
|
public static int MarketSweepBatch { get; private set; }
|
||||||
@@ -114,6 +118,24 @@ namespace Server.Custom.Bridge
|
|||||||
if (GuildSweepSeconds < 1)
|
if (GuildSweepSeconds < 1)
|
||||||
GuildSweepSeconds = 1;
|
GuildSweepSeconds = 1;
|
||||||
|
|
||||||
|
// A roster line is the only fat frame this plugin emits — measured at roughly 69 bytes
|
||||||
|
// per member — and the sidecar reads a line with no length bound. The cap turns an
|
||||||
|
// unbounded frame into a bounded one; a guild above it is split across continuation
|
||||||
|
// lines. 500 members is ~35 KB, comfortably past any real guild, so the split path is
|
||||||
|
// an edge case rather than the norm.
|
||||||
|
GuildRosterMembersPerLine = Config.Get("Bridge.GuildRosterMembersPerLine", 500);
|
||||||
|
if (GuildRosterMembersPerLine < 16)
|
||||||
|
GuildRosterMembersPerLine = 16;
|
||||||
|
|
||||||
|
// How many guilds may emit a roster in a single sweep. Every guild re-emits after a
|
||||||
|
// reconnect (the diff caches are cleared), and building a few hundred fat JSON frames in
|
||||||
|
// one Core-thread pass is exactly the stall this bridge exists to avoid. The sweep
|
||||||
|
// re-arms itself promptly while a baseline is still draining, so this throttles the work
|
||||||
|
// without making the site wait a full sweep interval per batch.
|
||||||
|
GuildRosterGuildsPerTick = Config.Get("Bridge.GuildRosterGuildsPerTick", 25);
|
||||||
|
if (GuildRosterGuildsPerTick < 1)
|
||||||
|
GuildRosterGuildsPerTick = 1;
|
||||||
|
|
||||||
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
|
CitySweepSeconds = Config.Get("Bridge.CitySweepSeconds", 300);
|
||||||
if (CitySweepSeconds < 1)
|
if (CitySweepSeconds < 1)
|
||||||
CitySweepSeconds = 1;
|
CitySweepSeconds = 1;
|
||||||
|
|||||||
@@ -85,11 +85,66 @@ namespace Server.Custom.Bridge
|
|||||||
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
|
public static StringBuilder Actor(this StringBuilder sb, string name, Mobile m)
|
||||||
{
|
{
|
||||||
sb.Append(",\"").Append(name).Append("\":");
|
sb.Append(",\"").Append(name).Append("\":");
|
||||||
|
WriteActor(sb, m);
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a named array of actor objects — a guild roster (Protocol 4) being the first
|
||||||
|
/// caller. Every other outbound helper here emits a leading `,"name":`, so an array
|
||||||
|
/// element needs the bare object; that is why <see cref="WriteActor"/> exists separately
|
||||||
|
/// rather than <see cref="Actor"/> being reused.
|
||||||
|
///
|
||||||
|
/// `count` bounds how many are written, because a roster frame must stay a bounded line
|
||||||
|
/// (Bridge.GuildRosterMembersPerLine). A null entry in the sequence is skipped rather
|
||||||
|
/// than written as null, so the array is always a list of real members and a caller can
|
||||||
|
/// trust its length.
|
||||||
|
/// </summary>
|
||||||
|
public static StringBuilder Actors(
|
||||||
|
this StringBuilder sb, string name, IList<Mobile> mobiles, int start, int count)
|
||||||
|
{
|
||||||
|
sb.Append(",\"").Append(name).Append("\":[");
|
||||||
|
|
||||||
|
if (mobiles != null)
|
||||||
|
{
|
||||||
|
var end = Math.Min(start + count, mobiles.Count);
|
||||||
|
bool first = true;
|
||||||
|
|
||||||
|
for (int i = start; i < end; i++)
|
||||||
|
{
|
||||||
|
var m = mobiles[i];
|
||||||
|
|
||||||
|
if (m == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!first)
|
||||||
|
sb.Append(',');
|
||||||
|
|
||||||
|
WriteActor(sb, m);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.Append(']');
|
||||||
|
return sb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One bare actor object, with no leading field name: serial, name, account (when there
|
||||||
|
/// is one), the linked webId (when the account is linked), and the player flag. A `null`
|
||||||
|
/// mobile writes null.
|
||||||
|
///
|
||||||
|
/// `acct` and `webId` are the site-identity fields, and they are emitted here
|
||||||
|
/// unconditionally by design — the sidecar is a forwarder, and deciding who may see them
|
||||||
|
/// is the website's job (it projects per the shard visibility rungs). Note that `acct` is
|
||||||
|
/// genuinely optional: a PlayerMobile can have no Account at all.
|
||||||
|
/// </summary>
|
||||||
|
private static void WriteActor(StringBuilder sb, Mobile m)
|
||||||
|
{
|
||||||
if (m == null)
|
if (m == null)
|
||||||
{
|
{
|
||||||
sb.Append("null");
|
sb.Append("null");
|
||||||
return sb;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"');
|
||||||
@@ -113,7 +168,6 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
|
sb.Append(",\"player\":").Append(m.Player ? "true" : "false");
|
||||||
sb.Append('}');
|
sb.Append('}');
|
||||||
return sb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
/// <summary>Closes the object. The trailing newline is the frame delimiter.</summary>
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ namespace Server.Custom.Bridge
|
|||||||
/// A guild that vanishes (or disbands — Disbanded == leader gone) leaves via `guild.remove`.
|
/// 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-
|
/// 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. A membership change also moves the board
|
/// so joined" feed does not wait for the next sweep.
|
||||||
/// signature (member count + serial sum), so a *leave* surfaces as the member count dropping in
|
///
|
||||||
/// the next `guild.update`; per-member leave events would need a core tap and are a later
|
/// Protocol 4 adds the membership half that §10.1 deferred. The sweep holds each guild's
|
||||||
/// refinement (§10.1).
|
/// 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
|
/// "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
|
/// wire event — otherwise a sidecar reconnect, which clears the diff cache and re-emits every
|
||||||
@@ -32,7 +36,16 @@ namespace Server.Custom.Bridge
|
|||||||
// was cleared on reconnect), so its next sweep counts as a change.
|
// was cleared on reconnect), so its next sweep counts as a change.
|
||||||
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
private static readonly Dictionary<int, string> _last = new Dictionary<int, string>();
|
||||||
|
|
||||||
private static long _sweeps, _emitted, _removed, _joins;
|
// 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()
|
public static void Initialize()
|
||||||
{
|
{
|
||||||
@@ -52,6 +65,7 @@ namespace Server.Custom.Bridge
|
|||||||
private static void OnConnected()
|
private static void OnConnected()
|
||||||
{
|
{
|
||||||
_last.Clear();
|
_last.Clear();
|
||||||
|
_members.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||||
@@ -72,8 +86,9 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
public static string Status()
|
public static string Status()
|
||||||
{
|
{
|
||||||
return String.Format("guilds(sweeps={0} emitted={1} removed={2} joins={3} tracked={4})",
|
return String.Format(
|
||||||
_sweeps, _emitted, _removed, _joins, _last.Count);
|
"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>
|
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||||
@@ -93,6 +108,12 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
var seen = new HashSet<int>();
|
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)
|
foreach (var bg in BaseGuild.List.Values)
|
||||||
{
|
{
|
||||||
var g = bg as Guild;
|
var g = bg as Guild;
|
||||||
@@ -104,15 +125,64 @@ namespace Server.Custom.Bridge
|
|||||||
|
|
||||||
seen.Add(g.Id);
|
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);
|
var sig = Signature(g);
|
||||||
|
|
||||||
string prior;
|
string prior;
|
||||||
if (_last.TryGetValue(g.Id, out prior) && prior == sig)
|
var sigChanged = !_last.TryGetValue(g.Id, out prior) || prior != sig;
|
||||||
|
|
||||||
|
if (!sigChanged && !membersChanged)
|
||||||
continue; // unchanged since last emit
|
continue; // unchanged since last emit
|
||||||
|
|
||||||
_last[g.Id] = sig;
|
if (sigChanged)
|
||||||
BridgeLink.Emit(WriteGuild(g));
|
{
|
||||||
_emitted++;
|
_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.
|
// Anything tracked last sweep but not seen now has disbanded or been removed.
|
||||||
@@ -120,9 +190,19 @@ namespace Server.Custom.Bridge
|
|||||||
foreach (var id in gone)
|
foreach (var id in gone)
|
||||||
{
|
{
|
||||||
_last.Remove(id);
|
_last.Remove(id);
|
||||||
|
_members.Remove(id);
|
||||||
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
|
BridgeLink.Emit(BridgeJson.Begin("guild.remove").Num("id", id).End());
|
||||||
_removed++;
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -130,14 +210,15 @@ namespace Server.Custom.Bridge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The volatile fields that define a meaningful change: name, abbreviation, leader, member
|
/// <summary>
|
||||||
// count, the member set (order-independent serial sum), and alliance.
|
/// The guild's live member serials. Held per guild between sweeps so a membership change
|
||||||
private static string Signature(Guild g)
|
/// yields both the fact that it changed and *who* left (Protocol 4).
|
||||||
|
/// </summary>
|
||||||
|
private static HashSet<int> MemberSerials(Guild g)
|
||||||
{
|
{
|
||||||
long memberSum = 0;
|
var set = new HashSet<int>();
|
||||||
int count = 0;
|
|
||||||
|
|
||||||
var members = g.Members;
|
var members = g.Members;
|
||||||
|
|
||||||
if (members != null)
|
if (members != null)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < members.Count; i++)
|
for (int i = 0; i < members.Count; i++)
|
||||||
@@ -145,8 +226,28 @@ namespace Server.Custom.Bridge
|
|||||||
var m = members[i];
|
var m = members[i];
|
||||||
if (m == null)
|
if (m == null)
|
||||||
continue;
|
continue;
|
||||||
count++;
|
set.Add(m.Serial.Value);
|
||||||
unchecked { memberSum += (uint)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++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +258,6 @@ namespace Server.Custom.Bridge
|
|||||||
g.Abbreviation ?? "", "|",
|
g.Abbreviation ?? "", "|",
|
||||||
leaderSerial.ToString(), "|",
|
leaderSerial.ToString(), "|",
|
||||||
count.ToString(), "|",
|
count.ToString(), "|",
|
||||||
memberSum.ToString(), "|",
|
|
||||||
g.Alliance == null ? "" : (g.AllianceName ?? ""));
|
g.Alliance == null ? "" : (g.AllianceName ?? ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +291,51 @@ namespace Server.Custom.Bridge
|
|||||||
return sb.End();
|
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);
|
||||||
|
|
||||||
|
sb.Actors("members", members, start, perLine);
|
||||||
|
|
||||||
|
BridgeLink.Emit(sb.End());
|
||||||
|
_rosters++;
|
||||||
|
|
||||||
|
start += perLine;
|
||||||
|
seq++;
|
||||||
|
}
|
||||||
|
while (start < total);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- real-time join ----
|
// ---- real-time join ----
|
||||||
|
|
||||||
private static void OnJoinGuild(JoinGuildEventArgs e)
|
private static void OnJoinGuild(JoinGuildEventArgs e)
|
||||||
|
|||||||
Reference in New Issue
Block a user