feat(bridge): lease deadlines and the participation ledger (Phase 11b)

Protocol 6 amended in place. Two mechanisms behind one new default-off gate,
`Bridge.EventsEnabled` -- deliberately not `AdminWriteEnabled`, because enabling
the admin plane is consenting to staff moderation from a screen a human is
looking at, and this is consenting to the world being changed and watched on a
schedule, unattended.

BridgeLeases: a live config value held for a bounded time, with the deadline
honoured on the shard whether or not the website is heard from again, and a
compare-and-set restore that reports `drifted` rather than overwriting a GM's
deliberate change. Memory-only -- nothing calls Config.Save() -- so a restart is
a free restore.

BridgeParticipation: presence in a declared area plus kill credit inside it,
keyed by character serial, persisted in the world save. The Bridge's first
persisted state, because a run spans hours and an in-memory tally would regress
every attendee's score after one restart. Its snapshot is also the first handler
that DEFERS, which makes `bridge.busy` reachable for the first time.

And it immediately found a defect in 11a: BridgeIdempotency.Busy built its frame
with Begin("bridge.busy") and then appended a diagnostic `.Str("kind", ...)`, so
the object carried two `kind` fields and every JSON parser takes the last. The
sidecar answered 200 instead of 425. Renamed `busyKind`.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-04 19:31:20 -05:00
parent d2a12c46e2
commit 63a7dc4374
10 changed files with 2181 additions and 7 deletions

View File

@@ -307,6 +307,19 @@ namespace Server.Custom.Bridge
return sb.ToString();
}
/// <summary>
/// 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 <see cref="Escape"/> would
/// throw on the null a nullable field is entitled to be.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
public static long GetLong(Dictionary<string, object> 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;
}
}
/// <summary>
/// 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.
/// </summary>
public static double GetDouble(Dictionary<string, object> 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;
}
}
}
}