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>
160 lines
6.6 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|