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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user