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:
159
tools/scaffolding/BridgeParticipationProbe.cs
Normal file
159
tools/scaffolding/BridgeParticipationProbe.cs
Normal file
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
using Server.Commands;
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <map> <x> <y>`. From a headless rig, through
|
||||
/// `BridgeRigDriver`'s `partprobe` verb — the two ship together for that reason.
|
||||
/// **Moves players and spawns and kills a creature. Rig only.**
|
||||
/// </summary>
|
||||
public static class BridgeParticipationProbe
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("partprobe", AccessLevel.Administrator, Probe_OnCommand);
|
||||
}
|
||||
|
||||
[Usage("partprobe <map> <x> <y>")]
|
||||
[Description("Moves two players to a point, spawns a creature there and kills it.")]
|
||||
private static void Probe_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
if (e.Length < 3)
|
||||
{
|
||||
Say(e.Mobile, "partprobe <map> <x> <y>");
|
||||
return;
|
||||
}
|
||||
|
||||
Run(e.Mobile, e.GetString(0), e.GetInt32(1), e.GetInt32(2));
|
||||
}
|
||||
|
||||
public static void Run(Mobile from, string mapName, int x, int y)
|
||||
{
|
||||
var map = MapByName(mapName);
|
||||
|
||||
if (map == null)
|
||||
{
|
||||
Say(from, "partprobe: unknown map " + mapName);
|
||||
return;
|
||||
}
|
||||
|
||||
var players = FindPlayers(2);
|
||||
|
||||
if (players.Count < 2)
|
||||
{
|
||||
Say(from, "partprobe: need two player mobiles in the world; found " + players.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
var z = map.GetAverageZ(x, y);
|
||||
|
||||
for (int i = 0; i < players.Count; i++)
|
||||
{
|
||||
// Spread them a tile apart so neither lands inside the other, and so the area test
|
||||
// is answering about two distinct points rather than one.
|
||||
players[i].MoveToWorld(new Point3D(x + i, y, z), map);
|
||||
Say(from, String.Format(CultureInfo.InvariantCulture,
|
||||
"partprobe: {0} moved to {1} ({2}, {3})", players[i].Name, map.Name, x + i, y));
|
||||
}
|
||||
|
||||
var victim = new Mongbat();
|
||||
victim.MoveToWorld(new Point3D(x, y + 1, z), map);
|
||||
|
||||
// Real damage through the real path, unequal so the fold is doing something: the
|
||||
// ledger credits one kill per damager regardless of how much they did, and a table
|
||||
// where both did the same amount could not show that.
|
||||
//
|
||||
// **Both amounts are small on purpose, and the first run of this probe is why.** A
|
||||
// Mongbat has around thirty hit points, and an opening blow of 40 killed it where it
|
||||
// stood -- so the SECOND damager never landed a hit, `DamageEntries` held one name,
|
||||
// and the ledger correctly credited one player. The frame looked like a plugin bug
|
||||
// crediting only the killer and was a rig artefact. A probe that means to produce two
|
||||
// damagers has to leave the creature alive to receive the second one.
|
||||
var hit = Math.Max(1, victim.HitsMax / 10);
|
||||
victim.Damage(hit * 2, players[0]);
|
||||
victim.Damage(hit, players[1]);
|
||||
|
||||
Say(from, String.Format(CultureInfo.InvariantCulture,
|
||||
"partprobe: {0} spawned at ({1}, {2}) and damaged by {3} and {4}",
|
||||
victim.Name, x, y + 1, players[0].Name, players[1].Name));
|
||||
|
||||
// Killed on the next tick rather than inline, so the damage above has actually been
|
||||
// registered against the creature before CreatureDeath reads the entries.
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(1.0), () =>
|
||||
{
|
||||
victim.Kill();
|
||||
Say(from, "partprobe: killed; the credit should now be on the ledger");
|
||||
});
|
||||
}
|
||||
|
||||
private static List<PlayerMobile> FindPlayers(int count)
|
||||
{
|
||||
var found = new List<PlayerMobile>();
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var pm = m as PlayerMobile;
|
||||
|
||||
if (pm == null || pm.Deleted || pm.AccessLevel > AccessLevel.Player)
|
||||
continue;
|
||||
|
||||
found.Add(pm);
|
||||
|
||||
if (found.Count >= count)
|
||||
break;
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private static Map MapByName(string name)
|
||||
{
|
||||
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 void Say(Mobile to, string text)
|
||||
{
|
||||
if (to != null)
|
||||
to.SendMessage(text);
|
||||
else
|
||||
Console.WriteLine("[PartProbe] " + text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -14,9 +14,10 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
|
||||
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
|
||||
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
|
||||
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
|
||||
| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `save`, `shutdown`. Flag: `RigDriverEnabled`. **Sets passwords and mutates the world.** |
|
||||
| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `configset`, `configread`, `partprobe`, `save`, `shutdown`. Flag: `RigDriverEnabled`. `configset` exists because **`Config.Get` is written by exactly ONE caller in the whole of ServUO 57.4** (`Server/ScriptCompiler.cs`): 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 a lease's compare-and-set restore would have no way to be proved. `configread` reads a key back through `Config.Get` long after every type initialiser has run, which is how a key that TOOK is told from one that only appeared to. **Sets passwords, writes live config and mutates the world.** |
|
||||
| `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** |
|
||||
| `BridgeProtocol6Probe.cs` | `Scripts/Custom/BridgeProtocol6Probe.cs` | Spawns a real champion boss through the shard's own `SpawnChampion()`, waits two champ sweeps so the boss is attributed to its altar, registers unequal damage from two seeded players and kills it -- so `champ.boss.killed` can be observed with a real damage table. **The wait is the assertion**: without it the kill still emits, but with no `serial`/`type`/`level`, which is the documented fallback rather than the case being tested. The altar is placed inside a NAMED region on purpose (see below). Flag: `Protocol6ProbeOnStart`. In game: `[p6probe`. **Spawns and kills a champion boss; rig only.** Protocol 6's other half, the idempotency key, needs no probe -- it is driven from outside with two identical POSTs to the sidecar. |
|
||||
| `BridgeParticipationProbe.cs` | `Scripts/Custom/BridgeParticipationProbe.cs` | Produces real kill credit inside a participation area with no game client: moves two player mobiles to the venue, spawns a creature there, damages it unequally from both and kills it. **Presence is the half it cannot drive** -- the sweep credits players with a live `NetState`, which is the correct test and not one a probe should loosen, so presence accrual needs a real login. In game: `[partprobe <map> <x> <y>`; from a headless rig, through `BridgeRigDriver`'s `partprobe` verb (the two ship together for that reason). **Moves players and spawns and kills a creature; rig only.** |
|
||||
|
||||
## Deploy overwrites Bridge.cfg
|
||||
|
||||
|
||||
Reference in New Issue
Block a user