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

@@ -134,6 +134,18 @@ namespace Server.Custom
case "election": Election(Arg(parts, 1)); break;
case "activate": Activate(Arg(parts, 1)); break;
case "password": Password(Arg(parts, 1), Arg(parts, 2)); break;
// Phase 11b. Plays the interfering GM a config lease's compare-and-set exists to
// catch, and reads a key back the way the game reads it. Both halves are here
// rather than only in `[leaseprobe` because a headless rig has no client to type
// a command at, and ServUO's own console takes a fixed verb set.
case "configset": ConfigSet(Arg(parts, 1), Arg(parts, 2)); break;
case "configread": ConfigRead(Arg(parts, 1)); break;
// Kill credit inside a participation area. Lives in BridgeParticipationProbe
// because it moves mobiles and spawns a creature; reachable from here because a
// headless rig has no client to type `[partprobe` at. The two files ship together.
case "partprobe":
BridgeParticipationProbe.Run(null, Arg(parts, 1), Int(Arg(parts, 2)), Int(Arg(parts, 3)));
break;
case "save": Say("saving"); Misc.AutoSave.Save(); break;
// A clean shutdown, which is the only kind that EMITS. `Stop-Process` drops the
// socket and the shard says nothing, so a killed shard is indistinguishable from
@@ -149,6 +161,64 @@ namespace Server.Custom
return i < parts.Length ? parts[i] : null;
}
private static int Int(string raw)
{
int n;
return Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out n) ? n : 0;
}
/// <summary>
/// Writes a live config key, so a lease's `drifted` verdict can be produced at all.
///
/// **`Config.Set` has exactly ONE caller in the whole of ServUO 57.4**
/// (`Server/ScriptCompiler.cs`, for `Compiler.Dynamic`). No in-game command, gump or
/// console verb writes a config key, so on a stock shard a GM cannot drift a
/// configuration lease even deliberately -- and the one safety property a lease has
/// that nothing else does would go untested. Written through the same typed setter a
/// float lease uses, so what it produces is indistinguishable to the compare-and-set
/// from a real interfering write.
///
/// Deliberately no `Config.Save()`, matching BridgeLeases: nothing about a rig should
/// leave a modified .cfg behind for the next boot to inherit.
/// </summary>
private static void ConfigSet(string key, string raw)
{
if (key == null || raw == null)
{
Say("configset <key> <value>");
return;
}
double n;
if (Double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out n))
Config.Set(key, n);
else
Config.Set(key, raw);
Say("configset " + key + " = " + raw + " (in memory only)");
}
/// <summary>
/// Reads a key back through `Config.Get`, at a moment long after every type
/// initialiser has run.
///
/// This is the check that tells a key which TOOK from one that only appeared to: a
/// lease on one of ServUO's ~150 cached call sites applies cleanly and does nothing,
/// which is the worst failure this feature has.
/// </summary>
private static void ConfigRead(string key)
{
if (key == null)
{
Say("configread <key>");
return;
}
Say("configread " + key + " = " + Config.Get(key, Double.NaN).ToString("R", CultureInfo.InvariantCulture)
+ " (double), \"" + Config.Get(key, "<unset>") + "\" (string)");
}
// ---- houses ----
/// <summary>