Files
servuo-plugins/tools/scaffolding/BridgeParticipationProbe.cs
wtclaude 63a7dc4374 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>
2026-09-04 19:31:20 -05:00

160 lines
6.6 KiB
C#

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);
}
}
}