using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
///
/// Protocol 6, part b. The run-scoped participation ledger: who took part in an event, and
/// how much.
///
/// EVENTS.md §G rates participation attribution as the largest remaining piece of new UO
/// work, and says why nothing composed out of the existing streams can stand in for it:
/// `region.enter` plus `mob.killed` is loosely composable and **not trustworthy enough to
/// publish results on**. Nothing scopes a kill or an arrival to a run, nothing separates a
/// passer-by from an attendee, and nothing survives a relog. Results and a leaderboard on
/// top of that would be a table of confident numbers that were not true.
///
/// So participation is measured here, where the world is, and reported as one opaque number
/// per member. **The plugin computes the score; core stores a decimal it never interprets.**
/// That split is what keeps the event engine game-agnostic: "one minute present plus five a
/// kill" is a sentence about Ultima Online, and the sentence has to live on the Ultima
/// Online side of the seam.
///
/// ── Keyed by character serial ──────────────────────────────────────────────────────────
///
/// Which matches `module-uo`'s existing Teams `memberKey` (`teamProvider.model.js`), so one
/// module speaks one member vocabulary and a participant can be joined to a roster without a
/// translation table. A player who attends on two characters is two members, and that is the
/// same answer Teams already gives.
///
/// ── Persisted in the world save, which is a first ──────────────────────────────────────
///
/// Nothing in this bridge has ever persisted anything. A ledger has to, because a run spans
/// hours and a restart mid-event is an ordinary Tuesday: an in-memory tally would silently
/// regress every attendee's score to whatever they earned after the restart. The only ways
/// to paper over that from the other side are a high-water rule in core — which must stay
/// game-agnostic and cannot have one — or a per-run offset in the module, which is the same
/// bug with more moving parts.
///
/// `Server.Persistence` plus `EventSink.WorldSave` writes a companion file beside the world
/// save rather than a persistence ITEM. No world object, no serial, nothing for a GM to find
/// and delete by accident, and a wipe of custom items leaves the ledger intact.
///
/// **The save/load hooks are attached unconditionally**, before the enabled gate is
/// consulted. An operator who switches the plane off for an afternoon must not come back to
/// a truncated file where a run's tally used to be.
///
/// ── The first handler that defers ──────────────────────────────────────────────────────
///
/// `participation.snapshot` resolves every member serial to a mobile and an account, so a
/// well-attended run is hundreds of world lookups in one inbound call — exactly the kind of
/// work the Core thread must not be handed in one piece. Above
/// `Bridge.ParticipationSnapshotChunk` members it walks in chunks across ticks.
///
/// That makes it the first handler in the bridge to complete AFTER its inbound call returns,
/// and therefore the first that can genuinely answer `bridge.busy` — protocol 6 built the
/// door in 11a with `BridgeIdempotency.Hold`/`Complete` and had nothing to walk through it.
///
public static class BridgeParticipation
{
private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Participation.bin");
private const int SaveVersion = 1;
/// One character's part in one run.
private sealed class Member
{
public int Serial;
///
/// Last seen name, kept only so the console and the snapshot can say something
/// useful about a character that has since been deleted. The website resolves its
/// own names from the serial and never reads this.
///
public string Name;
///
/// Accrued presence in SECONDS, not in sample counts.
///
/// A sample count would have to be multiplied by the sweep interval to mean
/// anything, and the interval is a config key an operator may change halfway
/// through a five-hour run — which would silently rewrite the first half of the
/// tally. Accruing the interval as it is actually used makes history immutable.
///
public long Seconds;
public int Kills;
public long FirstMs;
public long LastMs;
}
/// One run's declared area and its members.
private sealed class Run
{
public string RunId;
public string MapName;
public int MapIndex;
public int X;
public int Y;
public int Radius;
public long OpenedMs;
public long UntilMs;
public long ClosedMs;
public bool Closed;
///
/// Frozen at open, for the same reason presence is accrued in seconds: a weight the
/// operator retunes mid-run must not retroactively re-score the kills that already
/// happened under the old one.
///
public double KillWeight;
/// Members the cap turned away. Reported, because a truncated tally that says so is usable and one that does not is a lie.
public long Refused;
public Dictionary Members = new Dictionary();
}
private static readonly Dictionary _runs = new Dictionary(StringComparer.Ordinal);
private static Timer _timer;
private static long _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused;
///
/// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad`
/// fires, so this cannot be deferred to Initialize.
///
[CallPriority(900)]
public static void Configure()
{
EventSink.WorldSave += OnWorldSave;
EventSink.WorldLoad += OnWorldLoad;
}
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("participation.open", OnOpen);
BridgeBoot.RegisterHandler("participation.snapshot", OnSnapshot);
BridgeBoot.RegisterHandler("participation.close", OnClose);
EventSink.CreatureDeath += OnCreatureDeath;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
Rearm();
}
/// Stops and recreates the sweep timer from current config. Called by `[bridge reload`.
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
Sweep);
}
public static void Stop()
{
if (_timer != null)
{
_timer.Stop();
_timer = null;
}
}
public static string Status()
{
int members = 0;
foreach (var run in _runs.Values)
members += run.Members.Count;
return String.Format(
"participation(runs={0} members={1} sweeps={2} opened={3} closed={4} snapshots={5} kills={6} deferred={7} refused={8})",
_runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused);
}
// ---- participation.open ----
///
/// Declares a run's area and starts counting.
///
/// The area is a map, a point and a radius (org lead, 2026-09-04). Not a region name:
/// protocol 6's own live walk established that the most specific region containing an
/// event is routinely anonymous, so a region-named area would be undeclarable for
/// exactly the venues events use. Not a rectangle either — an author picks the spot the
/// event happens at, not two opposite corners of it.
///
private static void OnOpen(Dictionary o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "open"))
return;
var runId = BridgeJson.GetString(o, "runId");
if (String.IsNullOrEmpty(runId))
{
Err(reqId, "open", "a run id is required");
return;
}
var mapName = BridgeJson.GetString(o, "map");
var map = MapByName(mapName);
if (map == null)
{
Err(reqId, "open", "unknown map '" + (mapName ?? "") + "'");
return;
}
var radius = BridgeJson.GetInt(o, "radius", 0);
if (radius < 1 || radius > BridgeConfig.ParticipationMaxRadius)
{
Err(reqId, "open",
String.Format(CultureInfo.InvariantCulture,
"radius must be 1 to {0} tiles, and {1} was asked for",
BridgeConfig.ParticipationMaxRadius, radius));
return;
}
var x = BridgeJson.GetInt(o, "x", -1);
var y = BridgeJson.GetInt(o, "y", -1);
if (x < 0 || y < 0)
{
Err(reqId, "open", "an area needs an x and a y");
return;
}
Run existing;
if (_runs.TryGetValue(runId, out existing))
{
// Re-opening the same area is the ordinary consequence of a step being re-authored
// or a run being resumed, and answering it as an error would fail a run for doing
// nothing. Re-opening a DIFFERENT area is an authoring mistake, and silently
// moving the venue mid-run would make the tally describe two places at once.
if (existing.MapIndex != map.MapIndex || existing.X != x || existing.Y != y ||
existing.Radius != radius)
{
Err(reqId, "open", "run " + runId + " is already counting a different area");
return;
}
existing.Closed = false;
Ok(reqId, "open", existing);
return;
}
if (_runs.Count >= BridgeConfig.ParticipationMaxRuns)
{
Err(reqId, "open",
String.Format(CultureInfo.InvariantCulture,
"this shard counts at most {0} runs at once", BridgeConfig.ParticipationMaxRuns));
return;
}
var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
var now = BridgeJson.NowMs();
var run = new Run
{
RunId = runId,
MapName = map.Name,
MapIndex = map.MapIndex,
X = x,
Y = y,
Radius = radius,
OpenedMs = now,
UntilMs = holdMs > 0L ? now + holdMs : 0L,
KillWeight = BridgeConfig.ParticipationKillWeight,
};
_runs[runId] = run;
_opened++;
Console.WriteLine("[Bridge] participation: run {0} counting {1} tiles around {2} ({3}, {4})",
runId, radius, map.Name, x, y);
Ok(reqId, "open", run);
}
// ---- participation.close ----
///
/// Stops counting. The tally stays readable through the grace window, because the run
/// that closes an event and the step that collects its results are two different steps
/// and either can be retried.
///
private static void OnClose(Dictionary o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "close"))
return;
var runId = BridgeJson.GetString(o, "runId");
Run run;
if (runId == null || !_runs.TryGetValue(runId, out run))
{
// Not an error. A close of a run this shard has already forgotten — a restart, a
// second teardown attempt — has the same meaning as one it honoured: nothing is
// being counted for that run any more.
var gone = BridgeJson.Begin("participation.ok");
if (reqId != null) gone.Str("reqId", reqId);
gone.Str("action", "close").Str("runId", runId).Bool("closed", true).Bool("known", false);
BridgeLink.Emit(gone.End());
return;
}
if (!run.Closed)
{
// One last sweep before the books shut, so the people standing there when the event
// ended are credited for the interval they were standing there in.
SweepRun(run, BridgeConfig.ParticipationSweepSeconds);
run.Closed = true;
run.ClosedMs = BridgeJson.NowMs();
_closed++;
Console.WriteLine("[Bridge] participation: run {0} closed with {1} member(s)",
run.RunId, run.Members.Count);
}
Ok(reqId, "close", run);
}
// ---- participation.snapshot ----
/// One snapshot in progress. See the class header for why this exists at all.
private sealed class Job
{
public string ReqId;
public string IdempotencyKey;
public Run Run;
public List Members;
public int Index;
public StringBuilder Sb;
///
/// Whether this job took the key out of the inbound call's hands.
///
/// Recorded rather than re-derived from the chunk size, because the chunk size is a
/// config key an operator may change between the Hold and the Complete — and a
/// Complete that did not happen leaves every retry answered `bridge.busy` until the
/// store evicts the key an hour later.
///
public bool Held;
}
private static void OnSnapshot(Dictionary o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "snapshot"))
return;
var runId = BridgeJson.GetString(o, "runId");
Run run;
if (runId == null || !_runs.TryGetValue(runId, out run))
{
Err(reqId, "snapshot", "this shard is not counting run '" + (runId ?? "") + "'");
return;
}
// **Copied, not iterated in place.** A sweep or a kill landing between two chunks would
// otherwise mutate the dictionary the walk is enumerating, and a snapshot is a
// point-in-time answer in any case: the run it describes is the run as it was when the
// question was asked.
var members = new List(run.Members.Values);
var job = new Job
{
ReqId = reqId,
IdempotencyKey = BridgeJson.GetString(o, "idempotencyKey"),
Run = run,
Members = members,
Index = 0,
Sb = OpenSnapshot(reqId, run, members.Count),
};
_snapshots++;
if (members.Count <= BridgeConfig.ParticipationSnapshotChunk)
{
// Small enough to answer in the inbound call. Deliberately NOT deferred anyway: the
// idempotency store captures a reply emitted inside the handler for free, and
// holding a key we did not need to hold would put an ordinary command through the
// in-flight path for no reason.
Step(job);
return;
}
// Deferring. The key must be HELD before this call returns, or a repeat arriving while
// the walk is still running would be executed a second time rather than answered
// `bridge.busy` — which is the entire failure protocol 6 exists to prevent, and it is
// reachable for the first time right here.
if (job.IdempotencyKey != null)
{
BridgeIdempotency.Hold(job.IdempotencyKey);
job.Held = true;
}
_deferred++;
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
}
/// One chunk of a snapshot. Re-arms itself until the walk is done.
private static void Step(Job job)
{
try
{
var end = Math.Min(job.Index + BridgeConfig.ParticipationSnapshotChunk, job.Members.Count);
for (; job.Index < end; job.Index++)
WriteMember(job.Sb, job.Run, job.Members[job.Index], job.Index > 0);
if (job.Index < job.Members.Count)
{
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
return;
}
job.Sb.Append(']');
var line = job.Sb.End();
BridgeLink.Emit(line);
// Only a HELD key needs completing. An inline snapshot was captured by the
// idempotency store on its way through Emit, and completing it twice would replace
// a correlated reply with one this method has no correlation information for.
if (job.Held)
BridgeIdempotency.Complete(job.IdempotencyKey, line);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] participation snapshot threw: {0}", ex.Message);
// A held key whose walk died must still be closed out, or every retry of this step
// gets `bridge.busy` until the store's TTL evicts it an hour later.
if (job.Held)
{
var sb = BridgeJson.Begin("participation.error");
if (job.ReqId != null) sb.Str("reqId", job.ReqId);
sb.Str("action", "snapshot").Str("reason", "the snapshot failed: " + ex.Message);
var line = sb.End();
BridgeLink.Emit(line);
BridgeIdempotency.Complete(job.IdempotencyKey, line);
}
}
}
private static StringBuilder OpenSnapshot(string reqId, Run run, int count)
{
var sb = BridgeJson.Begin("participation.snapshot.ok");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Str("runId", run.RunId)
.Str("map", run.MapName)
.Num("x", run.X)
.Num("y", run.Y)
.Num("radius", run.Radius)
.Bool("closed", run.Closed)
.Num("openedMs", run.OpenedMs)
.Num("killWeight", run.KillWeight)
.Num("members", count)
.Num("refused", run.Refused);
sb.Append(",\"participants\":[");
return sb;
}
///
/// One member, with the score this shard computed and the two components it came from.
///
/// The components ride along because core stores the score opaquely and could never
/// explain it: a results table that can say "forty minutes and three kills" beside a
/// number is a table an operator can argue with, and one that shows only the number is
/// one they can only believe or not.
///
private static void WriteMember(StringBuilder sb, Run run, Member member, bool comma)
{
if (comma)
sb.Append(',');
var minutes = member.Seconds / 60.0;
var score = minutes + run.KillWeight * member.Kills;
sb.Append("{\"serial\":\"0x").Append(((uint)member.Serial).ToString("X")).Append('"');
// Resolved now rather than at sweep time, and the mobile is looked up whether or not
// its owner is online: a character that took part and logged out is still in the world,
// so its account — and the linked website user with it — is still readable.
var mobile = World.FindMobile((Serial)member.Serial);
sb.Append(",\"name\":");
BridgeJson.Text(sb, mobile != null && !String.IsNullOrEmpty(mobile.Name) ? mobile.Name : member.Name);
var acct = mobile == null ? null : mobile.Account as Accounting.Account;
if (acct != null)
{
sb.Append(",\"acct\":");
BridgeJson.Text(sb, acct.Username);
var webId = BridgeAccountLink.WebIdFor(acct);
if (webId != null)
{
sb.Append(",\"webId\":");
BridgeJson.Text(sb, webId);
}
}
sb.Append(",\"seconds\":").Append(member.Seconds);
sb.Append(",\"minutes\":").Append(minutes.ToString("F2", CultureInfo.InvariantCulture));
sb.Append(",\"kills\":").Append(member.Kills);
sb.Append(",\"score\":").Append(score.ToString("F4", CultureInfo.InvariantCulture));
sb.Append(",\"firstMs\":").Append(member.FirstMs);
sb.Append(",\"lastMs\":").Append(member.LastMs);
sb.Append('}');
}
// ---- counting ----
/// Runs one sweep now. Wired into `[bridge sweepnow`.
public static void SweepOnce()
{
Sweep();
}
private static void Sweep()
{
try
{
_sweeps++;
if (_runs.Count == 0)
return;
var seconds = BridgeConfig.ParticipationSweepSeconds;
var now = BridgeJson.NowMs();
List expired = null;
foreach (var run in _runs.Values)
{
if (run.Closed)
continue;
// The run's own deadline, honoured here for the reason a lease's is honoured on
// the shard: a website that stopped talking must not leave this shard counting
// an event that ended days ago.
if (run.UntilMs > 0L && now >= run.UntilMs)
{
SweepRun(run, seconds);
run.Closed = true;
run.ClosedMs = now;
_closed++;
Console.WriteLine("[Bridge] participation: run {0} passed its deadline and stopped counting",
run.RunId);
continue;
}
SweepRun(run, seconds);
}
var cutoff = now - (long)BridgeConfig.ParticipationGraceSec * 1000L;
foreach (var run in _runs.Values)
{
if (!run.Closed || run.ClosedMs > cutoff)
continue;
if (expired == null)
expired = new List();
expired.Add(run.RunId);
}
if (expired == null)
return;
for (int i = 0; i < expired.Count; i++)
{
Console.WriteLine("[Bridge] participation: forgetting run {0}, closed longer than the grace window",
expired[i]);
_runs.Remove(expired[i]);
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] participation sweep threw: {0}", ex.Message);
}
}
/// Credits every online player standing in one run's area with one interval.
private static void SweepRun(Run run, int seconds)
{
var map = Map.Maps[run.MapIndex];
if (map == null)
return;
var now = BridgeJson.NowMs();
foreach (var m in World.Mobiles.Values)
{
var pm = m as PlayerMobile;
if (pm == null || pm.NetState == null || pm.Deleted)
continue;
if (!Inside(run, pm))
continue;
var member = Touch(run, pm, now);
if (member == null)
continue;
member.Seconds += seconds;
}
}
///
/// Kill credit, and it goes to every damager standing in the area rather than to the
/// killer alone.
///
/// A last hit is a poor description of who fought something: the player who held it for
/// four minutes and died to it took part more than the one who happened to land the blow
/// that finished it. `Mobile.DamageEntries` is already populated and is readable here
/// because a `CreatureDeath` handler runs before the creature is disposed of — the same
/// fact protocol 6's damage table rests on.
///
/// The presence check is applied to the DAMAGER, not only to the corpse. Someone
/// shooting into the venue from outside it is not attending the event, and someone who
/// fought there and has since walked away is no longer accruing anything either.
///
private static void OnCreatureDeath(CreatureDeathEventArgs e)
{
try
{
if (_runs.Count == 0 || e == null || e.Creature == null)
return;
var creature = e.Creature;
if (creature.Player)
return; // a player death is not a kill anybody is credited for
var now = BridgeJson.NowMs();
foreach (var run in _runs.Values)
{
if (run.Closed || !Inside(run, creature))
continue;
var entries = creature.DamageEntries;
if (entries == null)
continue;
// Summed into a set first: ServUO folds repeat damage into an existing entry,
// but an entry that expired and was re-created leaves two, and crediting per
// entry would pay a long fight twice. Expiry governs looting rights, not
// whether somebody was there.
var credited = new HashSet();
for (int i = 0; i < entries.Count; i++)
{
var de = entries[i];
if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player)
continue;
if (!credited.Add(de.Damager))
continue;
if (!Inside(run, de.Damager))
continue;
var member = Touch(run, de.Damager, now);
if (member == null)
continue;
member.Kills++;
_kills++;
}
}
}
catch (Exception ex)
{
// A death handler must never be the thing that breaks a death.
Console.WriteLine("[Bridge] participation kill credit threw: {0}", ex.Message);
}
}
private static bool Inside(Run run, Mobile m)
{
if (m == null || m.Map == null || m.Map.MapIndex != run.MapIndex)
return false;
// A circle, and squared so the check costs no square root. `Radius` is in tiles and the
// z axis is deliberately ignored: a venue is a place on the map, and a player one floor
// up in a tower over the square is at the event.
var dx = m.X - run.X;
var dy = m.Y - run.Y;
return (dx * dx) + (dy * dy) <= run.Radius * run.Radius;
}
///
/// Finds or creates a member row, or answers null when the cap turned it away.
///
/// The cap counts a refusal rather than swallowing it, and the count rides on every
/// snapshot: a truncated tally that says it is truncated is usable, and one that does
/// not is a leaderboard with people missing from it for no stated reason.
///
private static Member Touch(Run run, Mobile m, long now)
{
var serial = m.Serial.Value;
Member member;
if (run.Members.TryGetValue((int)serial, out member))
{
member.LastMs = now;
member.Name = m.Name ?? member.Name;
return member;
}
if (run.Members.Count >= BridgeConfig.ParticipationMaxMembers)
{
run.Refused++;
_refused++;
return null;
}
member = new Member
{
Serial = (int)serial,
Name = m.Name ?? "",
FirstMs = now,
LastMs = now,
};
run.Members[member.Serial] = member;
return member;
}
// ---- persistence ----
private static void OnWorldSave(WorldSaveEventArgs e)
{
Persistence.Serialize(
SavePath,
writer =>
{
writer.Write(SaveVersion);
writer.Write(_runs.Count);
foreach (var run in _runs.Values)
{
writer.Write(run.RunId ?? "");
writer.Write(run.MapName ?? "");
writer.Write(run.MapIndex);
writer.Write(run.X);
writer.Write(run.Y);
writer.Write(run.Radius);
writer.Write(run.OpenedMs);
writer.Write(run.UntilMs);
writer.Write(run.ClosedMs);
writer.Write(run.Closed);
writer.Write(run.KillWeight);
writer.Write(run.Refused);
writer.Write(run.Members.Count);
foreach (var member in run.Members.Values)
{
writer.Write(member.Serial);
writer.Write(member.Name ?? "");
writer.Write(member.Seconds);
writer.Write(member.Kills);
writer.Write(member.FirstMs);
writer.Write(member.LastMs);
}
}
});
}
private static void OnWorldLoad()
{
Persistence.Deserialize(
SavePath,
reader =>
{
var version = reader.ReadInt();
if (version < 1)
return;
var runs = reader.ReadInt();
for (int i = 0; i < runs; i++)
{
var run = new Run
{
RunId = reader.ReadString(),
MapName = reader.ReadString(),
MapIndex = reader.ReadInt(),
X = reader.ReadInt(),
Y = reader.ReadInt(),
Radius = reader.ReadInt(),
OpenedMs = reader.ReadLong(),
UntilMs = reader.ReadLong(),
ClosedMs = reader.ReadLong(),
Closed = reader.ReadBool(),
KillWeight = reader.ReadDouble(),
Refused = reader.ReadLong(),
};
var members = reader.ReadInt();
for (int j = 0; j < members; j++)
{
var member = new Member
{
Serial = reader.ReadInt(),
Name = reader.ReadString(),
Seconds = reader.ReadLong(),
Kills = reader.ReadInt(),
FirstMs = reader.ReadLong(),
LastMs = reader.ReadLong(),
};
run.Members[member.Serial] = member;
}
if (!String.IsNullOrEmpty(run.RunId))
_runs[run.RunId] = run;
}
if (_runs.Count > 0)
Console.WriteLine("[Bridge] participation: {0} run(s) restored from the world save", _runs.Count);
});
}
// ---- helpers ----
private static Map MapByName(string name)
{
if (String.IsNullOrEmpty(name))
return null;
for (int i = 0; i < Map.Maps.Length; i++)
{
var map = Map.Maps[i];
if (map != null && String.Equals(map.Name, name, StringComparison.OrdinalIgnoreCase))
return map;
}
return null;
}
private static bool Ready(string reqId, string action)
{
if (!BridgeConfig.EventsEnabled)
{
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
return false;
}
return true;
}
private static void Ok(string reqId, string action, Run run)
{
var sb = BridgeJson.Begin("participation.ok");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Str("action", action)
.Str("runId", run.RunId)
.Str("map", run.MapName)
.Num("x", run.X)
.Num("y", run.Y)
.Num("radius", run.Radius)
.Bool("closed", run.Closed)
.Bool("known", true)
.Num("members", run.Members.Count)
.Num("refused", run.Refused)
.Num("untilMs", run.UntilMs);
BridgeLink.Emit(sb.End());
}
private static void Err(string reqId, string action, string reason)
{
var sb = BridgeJson.Begin("participation.error");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Str("action", action).Str("reason", reason);
BridgeLink.Emit(sb.End());
}
}
}