diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index c79076f..db5de9f 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -199,6 +199,46 @@ RequireIpForCreate=true
AccountNameMaxLength=16
AccountPasswordMaxLength=30
+# ── The event plane (docs/link/v6.md 8) ──────────────────────────────────────
+#
+# Leases and the participation ledger: the website holding a live config value for a bounded
+# time, and this shard counting who took part in a run. Both are driven on a SCHEDULE, by an
+# event the website starts unattended.
+#
+# This is deliberately NOT AdminWriteEnabled. Turning the admin plane on is consenting to
+# staff moderation driven from a screen a human is looking at; turning this on is consenting
+# to the website changing and watching your world at four in the morning. One switch could
+# not honestly express both.
+#
+# A lease always carries its own deadline and this shard restores the baseline when it
+# passes, whether or not the website is ever heard from again -- and a lease is never written
+# to disk, so a restart puts every leased value back too.
+EventsEnabled=false
+
+# The longest this shard will hold a lease, whatever the website asks for. Thirty days.
+# A longer request is REFUSED rather than shortened: a silently-clamped lease would leave the
+# two halves disagreeing about when the world comes back.
+LeaseMaxDurationSec=2592000
+
+# How long a finished lease stays listed after its deadline restored it, so a teardown that
+# arrives late still gets a definite verdict instead of finding nothing.
+LeaseGraceSec=86400
+
+# How often the participation sweep credits everyone standing in a run's area, and what one
+# kill inside it is worth against one minute of being there.
+ParticipationSweepSeconds=30
+ParticipationKillWeight=5.0
+
+# Bounds. Runs counted at once, members per run, and the widest area an event may declare.
+ParticipationMaxRuns=8
+ParticipationMaxMembers=2000
+ParticipationMaxRadius=300
+
+# How long a closed run's tally stays readable before this shard forgets it, and how many
+# members one snapshot resolves before yielding the Core thread.
+ParticipationGraceSec=86400
+ParticipationSnapshotChunk=100
+
# The test scaffolding in tools/scaffolding/ reads its own flags from this file
# (SeedOnStart, CensusOnStart, ProbeOnStart). They are absent here on purpose:
# Config.Get returns the default of false when a key is missing, so a deployed
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index a45ac05..9a3a66b 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -202,6 +202,8 @@ namespace Server.Custom.Bridge
BridgeHousing.Rearm();
BridgePoints.Rearm();
BridgeMarket.Rearm();
+ BridgeParticipation.Rearm();
+ BridgeLeases.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();
@@ -223,6 +225,7 @@ namespace Server.Custom.Bridge
BridgeHousing.SweepOnce();
BridgePoints.SweepOnce();
BridgeMarket.SweepOnce();
+ BridgeParticipation.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());
@@ -232,6 +235,7 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgePoints.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeMarket.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
break;
default:
@@ -251,6 +255,8 @@ namespace Server.Custom.Bridge
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeRuleset.Status());
e.Mobile.SendMessage("Bridge: {0}", BridgeIdempotency.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeLeases.Status());
+ e.Mobile.SendMessage("Bridge: {0}", BridgeParticipation.Status());
break;
}
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index ee067fe..41f6466 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -78,6 +78,26 @@ namespace Server.Custom.Bridge
public static int AdminReasonMaxLength { get; private set; }
public static int AdminBanMaxDurationSec { get; private set; }
+ // ---- the event plane (docs/link/v6.md §8, EVENTS_PLAN.md Phase 11b) ----
+ //
+ // **Its own gate, deliberately not AdminWriteEnabled** (org lead, 2026-09-04). Enabling the
+ // admin plane is an operator consenting to staff moderation driven from the website - a
+ // human pressing kick or ban on a screen. A lease and a participation ledger are the
+ // website changing and watching the world on a SCHEDULE, unattended, at four in the
+ // morning. Those are different consents, and one switch cannot express both.
+ public static bool EventsEnabled { get; private set; }
+
+ public static int LeaseMaxDurationSec { get; private set; }
+ public static int LeaseGraceSec { get; private set; }
+
+ public static int ParticipationSweepSeconds { get; private set; }
+ public static double ParticipationKillWeight { get; private set; }
+ public static int ParticipationMaxRuns { get; private set; }
+ public static int ParticipationMaxMembers { get; private set; }
+ public static int ParticipationMaxRadius { get; private set; }
+ public static int ParticipationGraceSec { get; private set; }
+ public static int ParticipationSnapshotChunk { get; private set; }
+
// ---- account provisioning (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md Part A) ----
public static SignupMode Signup { get; private set; }
public static bool AccountCreateEnabled { get; private set; }
@@ -235,6 +255,61 @@ namespace Server.Custom.Bridge
AdminReasonMaxLength = Config.Get("Bridge.AdminReasonMaxLength", 400);
AdminBanMaxDurationSec = Config.Get("Bridge.AdminBanMaxDurationSec", 31536000);
+ // The event plane. Off until an operator says otherwise - see the field block above for
+ // why this is not AdminWriteEnabled.
+ EventsEnabled = Config.Get("Bridge.EventsEnabled", false);
+
+ // Thirty days, matching core's own MAX_LEASE_MS. This is the shard's INDEPENDENT
+ // ceiling rather than a mirror of it: the website bounds what it will ask for, and a
+ // shard that trusted the asking would have no bound of its own at the one moment it
+ // matters, which is when the website is wrong.
+ LeaseMaxDurationSec = Config.Get("Bridge.LeaseMaxDurationSec", 2592000);
+ if (LeaseMaxDurationSec < 1)
+ LeaseMaxDurationSec = 1;
+
+ // How long a finished lease stays listed after its deadline restored it, so teardown
+ // still gets a definite verdict rather than finding nothing and having to guess.
+ LeaseGraceSec = Config.Get("Bridge.LeaseGraceSec", 86400);
+ if (LeaseGraceSec < 0)
+ LeaseGraceSec = 0;
+
+ ParticipationSweepSeconds = Config.Get("Bridge.ParticipationSweepSeconds", 30);
+ if (ParticipationSweepSeconds < 1)
+ ParticipationSweepSeconds = 1;
+
+ // What one kill inside the area is worth against one minute of standing in it. Both
+ // halves live on the shard because the score IS the shard's number: core stores an
+ // opaque decimal it never interprets, so a weight core could edit would be a weight
+ // nobody could explain from either side.
+ ParticipationKillWeight = Config.Get("Bridge.ParticipationKillWeight", 5.0);
+ if (ParticipationKillWeight < 0.0)
+ ParticipationKillWeight = 0.0;
+
+ ParticipationMaxRuns = Config.Get("Bridge.ParticipationMaxRuns", 8);
+ if (ParticipationMaxRuns < 1)
+ ParticipationMaxRuns = 1;
+
+ ParticipationMaxMembers = Config.Get("Bridge.ParticipationMaxMembers", 2000);
+ if (ParticipationMaxMembers < 1)
+ ParticipationMaxMembers = 1;
+
+ // A radius, not a rectangle, and bounded: an area big enough to cover a facet makes
+ // "took part" meaningless and the sweep expensive in the same stroke.
+ ParticipationMaxRadius = Config.Get("Bridge.ParticipationMaxRadius", 300);
+ if (ParticipationMaxRadius < 1)
+ ParticipationMaxRadius = 1;
+
+ ParticipationGraceSec = Config.Get("Bridge.ParticipationGraceSec", 86400);
+ if (ParticipationGraceSec < 0)
+ ParticipationGraceSec = 0;
+
+ // How many members one snapshot resolves before yielding the Core thread. See
+ // BridgeParticipation: this is what makes the handler DEFER, which is what makes
+ // `bridge.busy` reachable at all.
+ ParticipationSnapshotChunk = Config.Get("Bridge.ParticipationSnapshotChunk", 100);
+ if (ParticipationSnapshotChunk < 1)
+ ParticipationSnapshotChunk = 1;
+
// Account provisioning. An absent SignupMode defaults to Hybrid; a *present but
// unrecognized* value falls back to Game (the safest — no website creation), so a
// typo can never accidentally open provisioning.
@@ -313,9 +388,10 @@ namespace Server.Custom.Bridge
public static string Describe()
{
return String.Format(
- "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11})",
+ "enabled={0} endpoint={1}:{2} queueCap={3} sweeps(stat={4}s decay={5}s econ={6}s champ={7}s) adminWrite={8}(floor={9}) signup={10}(create={11}) events={12}",
Enabled, Host, Port, QueueCap, StatSweepSeconds, DecaySweepSeconds, EconomySweepSeconds,
- ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled);
+ ChampSweepSeconds, AdminWriteEnabled, AdminAccessFloor, Signup, AccountCreateEnabled,
+ EventsEnabled);
}
}
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs b/overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
index 47f52ee..f0dc9ea 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeIdempotency.cs
@@ -239,9 +239,10 @@ namespace Server.Custom.Bridge
/// (so a repeat is answered `bridge.busy` rather than executed) and takes on the duty of
/// calling with the reply it eventually emits.
///
- /// Nothing in protocol 6 defers yet. The door exists because the leases and world verbs
- /// that follow do, and because a deferred handler that had no way to hold its key would
- /// quietly be the one place the guarantee did not hold.
+ /// 11a built this door and had nothing to walk through it. `participation.snapshot` is
+ /// the first: above a threshold it walks its members in chunks across Core ticks, so it
+ /// completes long after its inbound call returned, and a repeat arriving in between is
+ /// the first `bridge.busy` this shard can actually produce.
///
public static void Hold(string key)
{
@@ -250,6 +251,15 @@ namespace Server.Custom.Bridge
if (entry == null)
return;
+ // The caller must be holding the key it was dispatched under. A mismatch would leave
+ // the OPEN key marked done by Finish while the named one stayed in flight forever, so
+ // it is refused rather than honoured: capture stays open and the ordinary path runs.
+ if (key == null || !_byKey.ContainsKey(key))
+ {
+ Console.WriteLine("[Bridge] idempotency: Hold called with an unknown key '{0}'; ignoring", key);
+ return;
+ }
+
// Close capture without marking done: the key stays in flight until Complete.
_open = null;
_openCorr = null;
@@ -373,8 +383,18 @@ namespace Server.Custom.Bridge
if (corrField != null)
sb.Str(corrField, corr);
+ // **`busyKind`, not `kind`, and the name is the whole bug.** `Begin` has already
+ // written this frame's own `kind` as `bridge.busy`, so a second `kind` field made the
+ // object carry two -- and every JSON parser worth the name takes the LAST. The sidecar
+ // matches `bridge.busy` to decide on a 425, read `participation.snapshot` instead, and
+ // answered an ordinary 200 with a body saying nothing had happened.
+ //
+ // It shipped in 11a and could not be seen there: with only synchronous handlers a
+ // repeat can never arrive mid-flight, so this arm was unreachable on a live shard and
+ // the unit test that covers the sidecar's mapping was, correctly, feeding it a frame
+ // built by hand. The first deferring handler produced it on its first collision.
sb.Str("idempotencyKey", key)
- .Str("kind", prior.Kind)
+ .Str("busyKind", prior.Kind)
.Str("reason", "a command with this idempotency key is still in flight");
BridgeLink.Emit(sb.End());
diff --git a/overlay/Scripts/Custom/Bridge/BridgeJson.cs b/overlay/Scripts/Custom/Bridge/BridgeJson.cs
index a99440e..ccf10d4 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeJson.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeJson.cs
@@ -307,6 +307,19 @@ namespace Server.Custom.Bridge
return sb.ToString();
}
+ ///
+ /// Writes a bare JSON string value, or `null`, with no leading comma and no field name.
+ /// For the hand-built arrays the event plane emits, where would
+ /// throw on the null a nullable field is entitled to be.
+ ///
+ public static void Text(StringBuilder sb, string value)
+ {
+ if (value == null)
+ sb.Append("null");
+ else
+ Escape(sb, value);
+ }
+
public static void Escape(StringBuilder sb, string value)
{
sb.Append('"');
@@ -487,5 +500,50 @@ namespace Server.Custom.Bridge
return fallback;
}
}
+
+ ///
+ /// Epoch milliseconds and lease durations do not fit an int, and JavaScriptSerializer
+ /// hands a large JSON number back as a long or a decimal depending on its magnitude, so
+ /// the conversion is done rather than the cast attempted.
+ ///
+ public static long GetLong(Dictionary o, string key, long fallback)
+ {
+ object v;
+
+ if (o == null || !o.TryGetValue(key, out v) || v == null)
+ return fallback;
+
+ try
+ {
+ return Convert.ToInt64(v, CultureInfo.InvariantCulture);
+ }
+ catch
+ {
+ return fallback;
+ }
+ }
+
+ ///
+ /// A lease VALUE arrives as text on the wire whatever its declared type (see
+ /// BridgeLeases), so this exists for the numbers that are genuinely numbers - a radius,
+ /// a weight. InvariantCulture throughout: a shard running under a comma-decimal locale
+ /// must read the same bytes the same way as one that is not.
+ ///
+ public static double GetDouble(Dictionary o, string key, double fallback)
+ {
+ object v;
+
+ if (o == null || !o.TryGetValue(key, out v) || v == null)
+ return fallback;
+
+ try
+ {
+ return Convert.ToDouble(v, CultureInfo.InvariantCulture);
+ }
+ catch
+ {
+ return fallback;
+ }
+ }
}
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeLeases.cs b/overlay/Scripts/Custom/Bridge/BridgeLeases.cs
new file mode 100644
index 0000000..6ea1045
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeLeases.cs
@@ -0,0 +1,807 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// Protocol 6, part b. The lease plane: a live configuration value the website may hold for
+ /// a bounded time, and which this shard puts back on its own when the time is up.
+ ///
+ /// EVENTS.md calls the lease the primitive underneath the whole event system, and the two
+ /// mechanisms it names are the whole of this file:
+ ///
+ /// 1. **Restore is compare-and-set, never a blind write.** Before writing the baseline
+ /// back, the current value must still equal what the event applied. If it does not,
+ /// somebody moved it deliberately: report `drifted`, leave the world alone, and let an
+ /// operator decide. Blindly restoring would silently revert a staff member's change,
+ /// which is the one failure that would make operators distrust the feature.
+ ///
+ /// 2. **The expiry lives here, not only in core.** The deadline comes down the wire and
+ /// this shard honours it whether or not the website is ever heard from again. Core
+ /// drives the normal restore; this is the backstop. That inversion is what makes an
+ /// unattended, scheduled world change defensible: the failure mode is a world back at
+ /// baseline early, never a world stuck changed indefinitely.
+ ///
+ /// ── What a lease is made of, and why it is memory-only ─────────────────────────────────
+ ///
+ /// `Server.Config` is a runtime key-value store. `Config.Set` mutates the in-memory entry
+ /// table; `Config.Load()` is guarded by `_Initialized` and so runs exactly once at boot,
+ /// which is what makes a Set survive every later Get. **Nothing here ever calls
+ /// `Config.Save()`**, and that is a decision rather than an omission (org lead, 2026-09-04):
+ /// a lease that never reaches disk means a shard restart is a *free* restore. It is the
+ /// strongest fail-safe available and it costs nothing, and it is also why `lease.list`
+ /// reports an empty hand after a restart, which is exactly what lets the website's
+ /// reconcile notice that the lease is gone.
+ ///
+ /// A pleasant consequence of `Config.Entry.Set`: restoring the baseline restores the entry's
+ /// ORIGINAL default marker too, because the entry compares against the value it was loaded
+ /// with. Restoring a key that was `@`-defaulted in a cfg file leaves it `@`-defaulted.
+ ///
+ /// ── The catalog is short on purpose, and shorter than EVENTS.md expected ───────────────
+ ///
+ /// §D describes the 258 `Config.Get` call sites as splitting into two patterns — cached at
+ /// type initialisation (a lease does nothing) and read live (a lease takes effect at once).
+ /// Measured on ServUO 57.4 the split is not near even: of the 158 non-Bridge call sites in
+ /// `Scripts/`, roughly **eight** are live reads. A lease on any of the others applies
+ /// cleanly and changes nothing, which is the worst failure this feature has.
+ ///
+ /// So the catalog below is an allowlist of keys verified by reading the call site, never
+ /// "any config key", and Phase 11b ships exactly one. Phase 12 adds the rest along with the
+ /// boot-time self-check that drops a key from the advertised catalog if it does not take.
+ ///
+ /// ── Drift cannot happen by accident on a stock shard ───────────────────────────────────
+ ///
+ /// `Config.Set` has exactly ONE caller in the whole of ServUO 57.4
+ /// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). There is no in-game command, gump
+ /// or console path that writes a config key, so on a stock shard a GM cannot drift a config
+ /// lease even deliberately. The compare-and-set below is still correct and still required —
+ /// Phase 12's object-property leases are trivially driftable, and a shard with custom
+ /// scripts may well write config at runtime — but proving the `drifted` path needs the
+ /// scaffolding command in `tools/scaffolding/`, and this paragraph is why.
+ ///
+ public static class BridgeLeases
+ {
+ private enum LeaseType
+ {
+ Float,
+ Int,
+ Bool,
+ Text
+ }
+
+ /// One allowlisted key: what it is, what it holds, and what it is worth by default.
+ private sealed class Catalog
+ {
+ public string Key;
+ public string Label;
+ public LeaseType Type;
+ public double Min;
+ public double Max;
+
+ ///
+ /// The value the shard's own call site passes as its default, as text.
+ ///
+ /// It is carried rather than inferred because `Config.Get` cannot tell "absent" from
+ /// "absent, and here is what the caller would have used" — it just returns whatever
+ /// default it is handed. Reading a key with the WRONG default would make the
+ /// baseline a fiction, and restoring that fiction would leave the shard running on
+ /// a number no source file ever chose.
+ ///
+ public string Default;
+ }
+
+ ///
+ /// Phase 11b's one proven key (org lead, 2026-09-04).
+ ///
+ /// `Scripts/Misc/CharacterCreation.cs` reads it live, inside the per-character creation
+ /// path, and divides by ten to get the per-skill cap. So it takes effect on the next
+ /// character created and is observable without a restart, which is what "proven" has to
+ /// mean here — the failure this catalog exists to prevent is a key that applies cleanly
+ /// and does nothing at all.
+ ///
+ private static readonly Catalog[] Keys =
+ {
+ new Catalog
+ {
+ Key = "PlayerCaps.SkillCap",
+ Label = "Starting skill cap",
+ Type = LeaseType.Float,
+ Min = 1000.0,
+ Max = 1500.0,
+ Default = "1000",
+ },
+ };
+
+ /// A lease this shard is holding, or has finished holding and not yet been asked about.
+ private sealed class Held
+ {
+ public string Key;
+ public string Baseline; // canonical text, as read before the lease applied
+ public string Applied; // canonical text, as written
+ public long UntilMs;
+ public string RunId;
+ public Timer Deadline;
+
+ // Set once the deadline has fired. The entry stays listed through the grace window so
+ // that teardown gets a definite verdict rather than finding nothing and having to guess
+ // whether the value came back or was never held.
+ public bool Expired;
+ public bool Restored;
+ public bool Drifted;
+ public string Current; // what was there instead, when drifted
+ public long ExpiredAtMs;
+ }
+
+ private static readonly Dictionary _held =
+ new Dictionary(StringComparer.Ordinal);
+
+ private static Timer _prune;
+
+ private static long _applied, _released, _drifted, _expired, _refused;
+
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ BridgeBoot.RegisterHandler("lease.apply", OnApply);
+ BridgeBoot.RegisterHandler("lease.release", OnRelease);
+ BridgeBoot.RegisterHandler("lease.list", OnList);
+
+ EventSink.ServerStarted += OnServerStarted;
+ }
+
+ private static void OnServerStarted()
+ {
+ Rearm();
+ }
+
+ /// Stops and recreates the prune timer from current config. Called by `[bridge reload`.
+ public static void Rearm()
+ {
+ if (_prune != null)
+ {
+ _prune.Stop();
+ _prune = null;
+ }
+
+ _prune = Timer.DelayCall(TimeSpan.FromMinutes(1.0), TimeSpan.FromMinutes(1.0), Prune);
+ }
+
+ public static string Status()
+ {
+ return String.Format(
+ "leases(held={0} applied={1} released={2} drifted={3} expired={4} refused={5})",
+ _held.Count, _applied, _released, _drifted, _expired, _refused);
+ }
+
+ // ---- lease.apply ----
+
+ ///
+ /// Takes a lease. `holdMs` is authoritative and `untilMs` is carried for display only.
+ ///
+ /// That split is deliberate. An absolute deadline computed on the website and honoured
+ /// on the shard is a deadline measured against two clocks; a shard whose clock is ten
+ /// minutes fast would restore a ten-minute lease the instant it took it. A duration is
+ /// immune, and the absolute time is still worth carrying so that `lease.list` and the
+ /// run console can say when the hold ends in terms the operator's own clock agrees with.
+ ///
+ private static void OnApply(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+
+ if (!Ready(reqId, "apply"))
+ return;
+
+ var entry = Lookup(BridgeJson.GetString(o, "key"));
+
+ if (entry == null)
+ {
+ Err(reqId, "apply", "no lease is offered for key '" + BridgeJson.GetString(o, "key") + "'");
+ return;
+ }
+
+ string canonical;
+ string why;
+
+ if (!Coerce(entry, BridgeJson.GetString(o, "value"), out canonical, out why))
+ {
+ Err(reqId, "apply", why);
+ return;
+ }
+
+ var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
+ var maxMs = (long)BridgeConfig.LeaseMaxDurationSec * 1000L;
+
+ if (holdMs < 1L)
+ {
+ Err(reqId, "apply", "a lease needs a positive holdMs");
+ return;
+ }
+
+ // **Refused, never clamped.** A clamp would silently give the website a shorter lease
+ // than it believes it has, and the website is the half that schedules the restore; the
+ // two would then disagree about when the world comes back. The shard's ceiling exists
+ // precisely for the case where the website is wrong, and being loud about it is the
+ // whole value.
+ if (holdMs > maxMs)
+ {
+ Err(reqId, "apply",
+ String.Format(CultureInfo.InvariantCulture,
+ "this shard holds a lease for at most {0} seconds, and {1} were asked for",
+ BridgeConfig.LeaseMaxDurationSec, holdMs / 1000L));
+ return;
+ }
+
+ Held existing;
+
+ if (_held.TryGetValue(entry.Key, out existing) && !existing.Expired)
+ {
+ Err(reqId, "apply",
+ "'" + entry.Key + "' is already leased" +
+ (existing.RunId == null ? "" : " by run " + existing.RunId));
+ return;
+ }
+
+ // A key whose previous lease expired is re-leasable, and the baseline is read fresh
+ // rather than inherited: whatever is true now is what this lease undertakes to restore.
+ var baseline = Read(entry);
+
+ var held = new Held
+ {
+ Key = entry.Key,
+ Baseline = baseline,
+ Applied = canonical,
+ UntilMs = BridgeJson.GetLong(o, "untilMs", BridgeJson.NowMs() + holdMs),
+ RunId = BridgeJson.GetString(o, "runId"),
+ };
+
+ Write(entry, canonical);
+
+ held.Deadline = Timer.DelayCall(TimeSpan.FromMilliseconds(holdMs), () => OnDeadline(entry.Key));
+ _held[entry.Key] = held;
+ _applied++;
+
+ Console.WriteLine("[Bridge] lease {0}: {1} -> {2} for {3}s (run {4})",
+ entry.Key, baseline, canonical, holdMs / 1000L, held.RunId ?? "-");
+
+ BridgeLink.Emit(BridgeJson.Begin("lease.applied")
+ .Str("key", entry.Key)
+ .Str("label", entry.Label)
+ .Str("baseline", baseline)
+ .Str("applied", canonical)
+ .Num("untilMs", held.UntilMs)
+ .Str("runId", held.RunId)
+ .End());
+
+ var sb = BridgeJson.Begin("lease.ok");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", "apply")
+ .Str("key", entry.Key)
+ .Str("baseline", baseline)
+ .Str("applied", canonical)
+ .Num("untilMs", held.UntilMs);
+ BridgeLink.Emit(sb.End());
+ }
+
+ // ---- lease.release ----
+
+ ///
+ /// Gives a lease back, compare-and-set.
+ ///
+ /// `expected` is what the event applied and `baseline` is what to put back. Both come
+ /// from the website's ledger rather than from this shard's memory, so a release still
+ /// works across a sidecar reconnect — and so that a shard which has forgotten the lease
+ /// entirely (a restart) can answer honestly instead of refusing.
+ ///
+ private static void OnRelease(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+
+ if (!Ready(reqId, "release"))
+ return;
+
+ var entry = Lookup(BridgeJson.GetString(o, "key"));
+
+ if (entry == null)
+ {
+ Err(reqId, "release", "no lease is offered for key '" + BridgeJson.GetString(o, "key") + "'");
+ return;
+ }
+
+ Held held;
+ _held.TryGetValue(entry.Key, out held);
+
+ // The deadline already dealt with it, and it drifted. That verdict is the one thing
+ // teardown must not lose, so it is held here through the grace window and handed over
+ // now rather than being reported as an ordinary restore.
+ if (held != null && held.Expired && held.Drifted)
+ {
+ Drop(entry.Key);
+ Drifted(reqId, entry.Key, held.Current);
+ return;
+ }
+
+ // Either the deadline restored it, or this shard restarted and never had it. Both are
+ // "the value is back and nothing more is owed", which is a successful release: the
+ // fail-safe firing is not a failure.
+ if (held == null || held.Expired)
+ {
+ Drop(entry.Key);
+ _released++;
+
+ var already = BridgeJson.Begin("lease.ok");
+ if (reqId != null) already.Str("reqId", reqId);
+ already.Str("action", "release")
+ .Str("key", entry.Key)
+ .Bool("released", true)
+ .Bool("alreadyRestored", true)
+ .Str("current", Read(entry));
+ BridgeLink.Emit(already.End());
+ return;
+ }
+
+ var expected = BridgeJson.GetString(o, "expected");
+ var current = Read(entry);
+
+ if (expected != null && !Same(entry, current, expected))
+ {
+ // Somebody moved it. Stop honouring the deadline too: the value is no longer this
+ // lease's to restore, and a timer that fired later would revert the change that was
+ // just reported as somebody else's.
+ Drop(entry.Key);
+ Drifted(reqId, entry.Key, current);
+ return;
+ }
+
+ var baseline = BridgeJson.GetString(o, "baseline");
+
+ if (baseline == null)
+ baseline = held.Baseline;
+
+ string canonical;
+ string why;
+
+ if (!Coerce(entry, baseline, out canonical, out why))
+ {
+ // The website handed back a baseline this key cannot hold. Refusing is right: the
+ // alternative is writing a value nothing has ever verified into a live shard.
+ Err(reqId, "release", "the baseline offered is not valid for this key: " + why);
+ return;
+ }
+
+ Write(entry, canonical);
+ Drop(entry.Key);
+ _released++;
+
+ Console.WriteLine("[Bridge] lease {0}: restored to {1}", entry.Key, canonical);
+
+ var sb = BridgeJson.Begin("lease.ok");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", "release")
+ .Str("key", entry.Key)
+ .Bool("released", true)
+ .Bool("alreadyRestored", false)
+ .Str("current", canonical);
+ BridgeLink.Emit(sb.End());
+ }
+
+ // ---- lease.list ----
+
+ ///
+ /// Every key this shard offers, with what it is worth right now and what is holding it.
+ ///
+ /// It answers two different questions with one frame on purpose. The website's lease
+ /// `read()` needs the current value before it applies anything; its `inForce()` needs to
+ /// know whether the shard still has a record of the hold. Splitting them into two verbs
+ /// would mean two round trips to answer one question about one key.
+ ///
+ /// **`held` means "this shard still has a record of the lease", not "the value is still
+ /// overridden".** A lease whose deadline has fired is `held` with `expired: true` until
+ /// teardown collects its verdict, precisely so that reconcile does not report it gone
+ /// and have core write it off as orphaned when what actually happened was the backstop
+ /// working correctly.
+ ///
+ private static void OnList(Dictionary o)
+ {
+ var reqId = BridgeJson.GetString(o, "reqId");
+
+ if (!Ready(reqId, "list"))
+ return;
+
+ var sb = BridgeJson.Begin("lease.list.ok");
+ if (reqId != null) sb.Str("reqId", reqId);
+
+ sb.Append(",\"leases\":[");
+
+ for (int i = 0; i < Keys.Length; i++)
+ {
+ var entry = Keys[i];
+
+ if (i > 0)
+ sb.Append(',');
+
+ sb.Append("{\"key\":");
+ BridgeJson.Text(sb, entry.Key);
+ sb.Append(",\"label\":");
+ BridgeJson.Text(sb, entry.Label);
+ sb.Append(",\"type\":\"").Append(TypeName(entry.Type)).Append('"');
+ sb.Append(",\"default\":");
+ BridgeJson.Text(sb, entry.Default);
+ sb.Append(",\"current\":");
+ BridgeJson.Text(sb, Read(entry));
+
+ if (entry.Type == LeaseType.Float || entry.Type == LeaseType.Int)
+ {
+ sb.Append(",\"min\":").Append(entry.Min.ToString("R", CultureInfo.InvariantCulture));
+ sb.Append(",\"max\":").Append(entry.Max.ToString("R", CultureInfo.InvariantCulture));
+ }
+
+ Held held;
+
+ if (_held.TryGetValue(entry.Key, out held))
+ {
+ sb.Append(",\"held\":true");
+ sb.Append(",\"baseline\":");
+ BridgeJson.Text(sb, held.Baseline);
+ sb.Append(",\"applied\":");
+ BridgeJson.Text(sb, held.Applied);
+ sb.Append(",\"untilMs\":").Append(held.UntilMs);
+ sb.Append(",\"runId\":");
+ BridgeJson.Text(sb, held.RunId);
+ sb.Append(",\"expired\":").Append(held.Expired ? "true" : "false");
+
+ if (held.Expired)
+ {
+ sb.Append(",\"restored\":").Append(held.Restored ? "true" : "false");
+ sb.Append(",\"drifted\":").Append(held.Drifted ? "true" : "false");
+ }
+ }
+ else
+ {
+ sb.Append(",\"held\":false");
+ }
+
+ sb.Append('}');
+ }
+
+ sb.Append(']');
+ BridgeLink.Emit(sb.End());
+ }
+
+ // ---- the deadline ----
+
+ ///
+ /// The backstop. Runs on the Core thread whether or not the website still exists, which
+ /// is the entire point of the lease framing: the undo is the default and holding is the
+ /// exception, so nothing has to be alive for the world to come back.
+ ///
+ private static void OnDeadline(string key)
+ {
+ Held held;
+
+ if (!_held.TryGetValue(key, out held) || held.Expired)
+ return;
+
+ var entry = Lookup(key);
+
+ if (entry == null)
+ return;
+
+ held.Deadline = null;
+ held.Expired = true;
+ held.ExpiredAtMs = BridgeJson.NowMs();
+ _expired++;
+
+ var current = Read(entry);
+
+ if (!Same(entry, current, held.Applied))
+ {
+ held.Drifted = true;
+ held.Current = current;
+ _drifted++;
+
+ Console.WriteLine("[Bridge] lease {0}: deadline passed but the value is now {1}, not {2}; NOT restoring",
+ key, current, held.Applied);
+ }
+ else
+ {
+ Write(entry, held.Baseline);
+ held.Restored = true;
+
+ Console.WriteLine("[Bridge] lease {0}: deadline passed, restored to {1} without being asked",
+ key, held.Baseline);
+ }
+
+ BridgeLink.Emit(BridgeJson.Begin("lease.expired")
+ .Str("key", key)
+ .Str("runId", held.RunId)
+ .Str("baseline", held.Baseline)
+ .Str("applied", held.Applied)
+ .Bool("restored", held.Restored)
+ .Bool("drifted", held.Drifted)
+ .Str("current", held.Drifted ? held.Current : held.Baseline)
+ .End());
+ }
+
+ ///
+ /// Drops expired entries once the grace window has passed.
+ ///
+ /// The window exists so teardown can still collect a verdict; the prune exists because a
+ /// run that is never torn down must not leave a row here for the life of the process.
+ /// Dropping a DRIFTED entry is worth a line in the console: it is the one case where the
+ /// shard is quietly forgetting something an operator was meant to look at.
+ ///
+ private static void Prune()
+ {
+ if (_held.Count == 0)
+ return;
+
+ var cutoff = BridgeJson.NowMs() - (long)BridgeConfig.LeaseGraceSec * 1000L;
+ List drop = null;
+
+ foreach (var kv in _held)
+ {
+ if (!kv.Value.Expired || kv.Value.ExpiredAtMs > cutoff)
+ continue;
+
+ if (drop == null)
+ drop = new List();
+
+ drop.Add(kv.Key);
+ }
+
+ if (drop == null)
+ return;
+
+ for (int i = 0; i < drop.Count; i++)
+ {
+ Held held;
+
+ if (_held.TryGetValue(drop[i], out held) && held.Drifted)
+ {
+ Console.WriteLine(
+ "[Bridge] lease {0}: dropping a DRIFTED record nobody collected; the world is still at {1}",
+ drop[i], held.Current);
+ }
+
+ Drop(drop[i]);
+ }
+ }
+
+ // ---- the config plane ----
+
+ private static Catalog Lookup(string key)
+ {
+ if (key == null)
+ return null;
+
+ for (int i = 0; i < Keys.Length; i++)
+ {
+ if (String.Equals(Keys[i].Key, key, StringComparison.Ordinal))
+ return Keys[i];
+ }
+
+ return null;
+ }
+
+ ///
+ /// Reads a key through the same typed accessor the game does, and renders the answer as
+ /// canonical text.
+ ///
+ /// Text is the transport for every lease value in both directions, whatever the declared
+ /// type. JSON would otherwise decide for us: `1200` and `1200.0` are one number to a
+ /// parser and two strings to a diff, and a compare-and-set that compared formatted
+ /// numbers would report drift on a value nobody touched. Comparison is done by
+ /// , on parsed values, for exactly that reason.
+ ///
+ private static string Read(Catalog entry)
+ {
+ switch (entry.Type)
+ {
+ case LeaseType.Float:
+ return Config.Get(entry.Key, ParseDouble(entry.Default))
+ .ToString("R", CultureInfo.InvariantCulture);
+
+ case LeaseType.Int:
+ return Config.Get(entry.Key, (int)ParseDouble(entry.Default))
+ .ToString(CultureInfo.InvariantCulture);
+
+ case LeaseType.Bool:
+ return Config.Get(entry.Key, ParseBool(entry.Default)) ? "true" : "false";
+
+ default:
+ return Config.Get(entry.Key, entry.Default);
+ }
+ }
+
+ private static void Write(Catalog entry, string canonical)
+ {
+ switch (entry.Type)
+ {
+ case LeaseType.Float:
+ Config.Set(entry.Key, ParseDouble(canonical));
+ break;
+
+ case LeaseType.Int:
+ Config.Set(entry.Key, (int)ParseDouble(canonical));
+ break;
+
+ case LeaseType.Bool:
+ Config.Set(entry.Key, ParseBool(canonical));
+ break;
+
+ default:
+ Config.Set(entry.Key, canonical);
+ break;
+ }
+
+ // Deliberately no Config.Save(). See the class header: a lease that never reaches disk
+ // makes a shard restart a free restore.
+ }
+
+ /// Parses and range-checks a wire value, answering the canonical text for it.
+ private static bool Coerce(Catalog entry, string raw, out string canonical, out string why)
+ {
+ canonical = null;
+ why = null;
+
+ if (raw == null)
+ {
+ why = "no value was given";
+ return false;
+ }
+
+ if (entry.Type == LeaseType.Bool)
+ {
+ var t = raw.Trim();
+
+ if (String.Equals(t, "true", StringComparison.OrdinalIgnoreCase) || t == "1")
+ canonical = "true";
+ else if (String.Equals(t, "false", StringComparison.OrdinalIgnoreCase) || t == "0")
+ canonical = "false";
+ else
+ {
+ why = "'" + raw + "' is not a yes or no value";
+ return false;
+ }
+
+ return true;
+ }
+
+ if (entry.Type == LeaseType.Text)
+ {
+ canonical = raw;
+ return true;
+ }
+
+ double n;
+
+ if (!Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
+ {
+ why = "'" + raw + "' is not a number";
+ return false;
+ }
+
+ if (entry.Type == LeaseType.Int && n != Math.Floor(n))
+ {
+ why = "'" + raw + "' is not a whole number";
+ return false;
+ }
+
+ // The shard's own range, checked even though core checks the module's declaration
+ // first. The two are the same numbers today and that is not the point: this one is the
+ // one that is true when the website is wrong.
+ if (n < entry.Min || n > entry.Max)
+ {
+ why = String.Format(CultureInfo.InvariantCulture,
+ "{0} accepts {1} to {2}, and '{3}' is outside that",
+ entry.Label, entry.Min, entry.Max, raw);
+ return false;
+ }
+
+ canonical = entry.Type == LeaseType.Int
+ ? ((long)n).ToString(CultureInfo.InvariantCulture)
+ : n.ToString("R", CultureInfo.InvariantCulture);
+
+ return true;
+ }
+
+ ///
+ /// Compare-and-set's comparison, done on parsed values rather than on text.
+ ///
+ /// The two sides are formatted by two different runtimes — one of them a JavaScript
+ /// engine — and `1200` against `1200.0` is a difference only a string comparison can
+ /// see. Reporting that as drift would refuse to restore a value nobody had touched,
+ /// which is the failure mode of a safety check that is too eager: it leaves the world
+ /// changed and blames an innocent operator.
+ ///
+ private static bool Same(Catalog entry, string a, string b)
+ {
+ if (a == null || b == null)
+ return a == b;
+
+ if (entry.Type == LeaseType.Text)
+ return String.Equals(a, b, StringComparison.Ordinal);
+
+ if (entry.Type == LeaseType.Bool)
+ return ParseBool(a) == ParseBool(b);
+
+ double x, y;
+
+ if (!Double.TryParse(a, NumberStyles.Float, CultureInfo.InvariantCulture, out x) ||
+ !Double.TryParse(b, NumberStyles.Float, CultureInfo.InvariantCulture, out y))
+ return String.Equals(a, b, StringComparison.Ordinal);
+
+ return x == y;
+ }
+
+ private static double ParseDouble(string s)
+ {
+ double n;
+ return Double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out n) ? n : 0.0;
+ }
+
+ private static bool ParseBool(string s)
+ {
+ return String.Equals(s, "true", StringComparison.OrdinalIgnoreCase) || s == "1";
+ }
+
+ private static string TypeName(LeaseType t)
+ {
+ switch (t)
+ {
+ case LeaseType.Float: return "float";
+ case LeaseType.Int: return "int";
+ case LeaseType.Bool: return "bool";
+ default: return "string";
+ }
+ }
+
+ // ---- replies ----
+
+ 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 Err(string reqId, string action, string reason)
+ {
+ _refused++;
+
+ var sb = BridgeJson.Begin("lease.error");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("action", action).Str("reason", reason);
+ BridgeLink.Emit(sb.End());
+ }
+
+ private static void Drifted(string reqId, string key, string current)
+ {
+ _drifted++;
+
+ Console.WriteLine("[Bridge] lease {0}: DRIFTED, the world is at {1} and was left alone", key, current);
+
+ var sb = BridgeJson.Begin("lease.drifted");
+ if (reqId != null) sb.Str("reqId", reqId);
+ sb.Str("key", key).Str("current", current);
+ BridgeLink.Emit(sb.End());
+ }
+
+ private static void Drop(string key)
+ {
+ Held held;
+
+ if (_held.TryGetValue(key, out held) && held.Deadline != null)
+ held.Deadline.Stop();
+
+ _held.Remove(key);
+ }
+ }
+}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs b/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
new file mode 100644
index 0000000..785adc0
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
@@ -0,0 +1,937 @@
+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());
+ }
+ }
+}
diff --git a/tools/scaffolding/BridgeParticipationProbe.cs b/tools/scaffolding/BridgeParticipationProbe.cs
new file mode 100644
index 0000000..101d9b8
--- /dev/null
+++ b/tools/scaffolding/BridgeParticipationProbe.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+
+using Server.Commands;
+using Server.Mobiles;
+
+namespace Server.Custom
+{
+ ///
+ /// Produces real kill credit inside a participation area, without a game client.
+ ///
+ /// ── What this can drive, and what it cannot ───────────────────────────────────────────
+ ///
+ /// The participation ledger counts two things: presence, and kill credit. Only one of them
+ /// is reachable from a headless rig, and the split is worth stating rather than discovering.
+ ///
+ /// **Presence needs a connected client.** The sweep credits online players — `NetState !=
+ /// null` — which is the correct test and not one a probe should loosen: a character parked
+ /// in Britain and logged out for eight hours did not attend anything, and a ledger that said
+ /// otherwise would put people at the top of a leaderboard for being AFK. There is no way to
+ /// produce a NetState here short of writing a client, so presence accrual is exercised by a
+ /// real login and not by this file.
+ ///
+ /// **Kill credit needs none.** `EventSink.CreatureDeath` fires for a creature killed by any
+ /// means, `Mobile.DamageEntries` is populated by real damage, and the area test is a
+ /// coordinate comparison. So the whole of the credit path — the damager filter, the
+ /// per-damager fold, the area test applied to the DAMAGER rather than only the corpse, the
+ /// member cap — runs exactly as it would in a fight.
+ ///
+ /// What it does, in order: moves two real player mobiles to the venue, spawns a creature
+ /// there, damages it unequally from both, and kills it.
+ ///
+ /// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
+ /// In game: `[partprobe
+ public static class BridgeParticipationProbe
+ {
+ public static void Initialize()
+ {
+ CommandSystem.Register("partprobe", AccessLevel.Administrator, Probe_OnCommand);
+ }
+
+ [Usage("partprobe