Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
wtclaude f6a86ff8c2 feat(bridge): what an event borrows, and the two one-shots (Phase 12b)
The shard half of protocol 7 part b. Two lease planes whose value lives on
something already in the world, and two verbs that cannot be taken back.

A LEASE HERE MUST BE PERSISTED, AND THE CONFIG PLANE'S MUST NOT

11b's fail-safe is stated plainly in its own header: a lease that never reaches
disk means a shard restart is a FREE restore. That argument depends entirely on
the leased value being memory-only too, and here it is not. A spawner is an Item
and is in the world save; a seasonal entry is written to
`Saves/Misc/SeasonalEvents.bin` by ServUO's own `EventSink.WorldSave`. So a
restart does not put either back -- it puts the CHANGE back and throws away the
deadline timer that was going to undo it, leaving the world at the leased value
with nothing here remembering it is borrowed.

So the Bridge gains its THIRD save file, `Saves/Bridge/Leases.bin`, written by
the same `EventSink.WorldSave` that writes what it describes, with deadlines
re-armed at load. A deadline that passed while the shard was down fires AT ONCE:
the promise was "back at baseline by then", and extending it would silently turn
a two-hour lease into however long the outage was. Config holds are still not
written down -- the same argument, applied to planes where its premise is false.

A TARGET IS A SERIAL OR A UniqueId, AND BOTH ARE NEEDED

A serial is what `[props` shows a GM. An `XmlSpawner.UniqueId` is what the
shard's own `Spawns/*.xml` carry -- and it is not a convenience: a dropdown built
from serials is IMPOSSIBLE, because serials are assigned when the world is built
and nothing off-shard knows them. A lease addressable only by serial could have
no authoring list at all.

`Spawner` and `XmlSpawner` share all four property names, which is a fact about
this tree rather than a convenience: the spawn files load as XmlSpawners while
`[add spawner` makes the native one. And it is `MaxCount`, not the `Amount`
EVENTS_PLAN.md named -- there is no such property. `MinDelay`/`MaxDelay` are
TimeSpans, so the wire carries seconds.

The allowlist is checked against the object's OWN type, which is the sentence the
whole plane rests on: a serial is a number a caller chooses, so that check is all
that stands between `Spawner.MaxCount` and any item on the shard. Reflection is
bounded three ways -- the pair must be in the catalog, the property must carry
`CommandProperty` (so this can never reach further than `[set` could), and its
CLR type must be one this file renders.

THE SELF-CHECK, AND THE ONE FAILURE NO PROBE CAN CATCH

§N10 in full: a config key is probed live (write, read back, restore) because
there is exactly one of it. A property CANNOT be -- thousands of instances and no
canonical one, so probing would mean writing to somebody's spawner at boot. What
is verified instead is everything verifiable without touching the world.

And `TreasuresOfTokuno` is excluded by name, because `IsActive()` reads its own
`DropEra` rather than `Status`: the write succeeds, the value reads back, a
compare-and-set restore passes, and the capability does nothing at all. That is
N10's "capability that lies" in its purest form and the only way to find it is to
read the source. §G also called this toggle "small and safe" -- it is safe, but
`OnStatusChange()` generates or removes world content for six of the eight.

THE ONE-SHOTS

Who receives a grant is answered HERE. The website has the list too, but a module
cannot read core's tables, so the alternative was a new core surface handing
participants to a module. Not needed: 11b's participation ledger already holds
them, keyed by the same serials. A run with no ledger is a 404; a run whose
ledger is open and empty is a 200 with `granted: 0`, because an event nobody
attended still happened. An undeliverable grant is DELETED rather than dropped --
`AddItem` failing on a full backpack would otherwise leave it at (0,0).

A save stops the world, so it is rate-limited rather than capped, counting
ServUO's own autosave as the last one. Refused, never queued: a queued save would
land at a moment nobody chose.

VERIFIED

Compiles clean (0 warnings, 0 errors). Then a full walk on the real local ServUO
57.4 world against the release sidecar:

- all six catalog keys survive the boot self-check; `current` is correctly absent
  on the targeted rows and filled when a target is named;
- a spawner reads the same by UniqueId and by serial;
- TWO RUNS ON TWO SPAWNERS BOTH SUCCEED while a second run on the SAME spawner is
  refused -- the whole reason for the targeted ref;
- a GM edit behind the plane's back yields `lease.drifted` and the world is left
  at 55, not reverted; a clean release restores to baseline;
- ToT refused with its own reason, a bad status refused, Fellowship toggled;
- grant: no ledger 404, empty ledger 200 `granted: 0`, unknown item 400, over the
  stack bound 400; save 200 then 429 inside the interval;
- BOTH HOLDS AND BOTH LEASED VALUES SURVIVE save + clean shutdown + restart, the
  deadlines re-arm, and a release across the restart still compare-and-sets;
- with only a CONFIG lease held, `Leases.bin` is 8 bytes and names nothing;
- refusals: targeted-with-no-target, untargeted-with-a-target, out of range, over
  30 days, a target that is not there, and a `ChainChest` refused as a spawner;
- 90 seconds becomes `00:01:30` and the baseline reads back as 18000;
- a deleted target reads `unreadable` and releases `targetGone: true`.

The test world was never saved after the deliberate deletion, so it is intact.

Refs: docs/link/v7.md §11-§14, docs/website/EVENTS_PLAN.md Phase 12b

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-07 08:07:12 -05:00

969 lines
37 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// Protocol 6, part b. The run-scoped participation ledger: who took part in an event, and
/// how much.
///
/// EVENTS.md §G rates participation attribution as the largest remaining piece of new UO
/// work, and says why nothing composed out of the existing streams can stand in for it:
/// `region.enter` plus `mob.killed` is loosely composable and **not trustworthy enough to
/// publish results on**. Nothing scopes a kill or an arrival to a run, nothing separates a
/// passer-by from an attendee, and nothing survives a relog. Results and a leaderboard on
/// top of that would be a table of confident numbers that were not true.
///
/// So participation is measured here, where the world is, and reported as one opaque number
/// per member. **The plugin computes the score; core stores a decimal it never interprets.**
/// That split is what keeps the event engine game-agnostic: "one minute present plus five a
/// kill" is a sentence about Ultima Online, and the sentence has to live on the Ultima
/// Online side of the seam.
///
/// ── Keyed by character serial ──────────────────────────────────────────────────────────
///
/// Which matches `module-uo`'s existing Teams `memberKey` (`teamProvider.model.js`), so one
/// module speaks one member vocabulary and a participant can be joined to a roster without a
/// translation table. A player who attends on two characters is two members, and that is the
/// same answer Teams already gives.
///
/// ── Persisted in the world save, which is a first ──────────────────────────────────────
///
/// Nothing in this bridge has ever persisted anything. A ledger has to, because a run spans
/// hours and a restart mid-event is an ordinary Tuesday: an in-memory tally would silently
/// regress every attendee's score to whatever they earned after the restart. The only ways
/// to paper over that from the other side are a high-water rule in core — which must stay
/// game-agnostic and cannot have one — or a per-run offset in the module, which is the same
/// bug with more moving parts.
///
/// `Server.Persistence` plus `EventSink.WorldSave` writes a companion file beside the world
/// save rather than a persistence ITEM. No world object, no serial, nothing for a GM to find
/// and delete by accident, and a wipe of custom items leaves the ledger intact.
///
/// **The save/load hooks are attached unconditionally**, before the enabled gate is
/// consulted. An operator who switches the plane off for an afternoon must not come back to
/// a truncated file where a run's tally used to be.
///
/// ── The first handler that defers ──────────────────────────────────────────────────────
///
/// `participation.snapshot` resolves every member serial to a mobile and an account, so a
/// well-attended run is hundreds of world lookups in one inbound call — exactly the kind of
/// work the Core thread must not be handed in one piece. Above
/// `Bridge.ParticipationSnapshotChunk` members it walks in chunks across ticks.
///
/// That makes it the first handler in the bridge to complete AFTER its inbound call returns,
/// and therefore the first that can genuinely answer `bridge.busy` — protocol 6 built the
/// door in 11a with `BridgeIdempotency.Hold`/`Complete` and had nothing to walk through it.
/// </summary>
public static class BridgeParticipation
{
private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Participation.bin");
private const int SaveVersion = 1;
/// <summary>One character's part in one run.</summary>
private sealed class Member
{
public int Serial;
/// <summary>
/// Last seen name, kept only so the console and the snapshot can say something
/// useful about a character that has since been deleted. The website resolves its
/// own names from the serial and never reads this.
/// </summary>
public string Name;
/// <summary>
/// Accrued presence in SECONDS, not in sample counts.
///
/// A sample count would have to be multiplied by the sweep interval to mean
/// anything, and the interval is a config key an operator may change halfway
/// through a five-hour run — which would silently rewrite the first half of the
/// tally. Accruing the interval as it is actually used makes history immutable.
/// </summary>
public long Seconds;
public int Kills;
public long FirstMs;
public long LastMs;
}
/// <summary>One run's declared area and its members.</summary>
private sealed class Run
{
public string RunId;
public string MapName;
public int MapIndex;
public int X;
public int Y;
public int Radius;
public long OpenedMs;
public long UntilMs;
public long ClosedMs;
public bool Closed;
/// <summary>
/// Frozen at open, for the same reason presence is accrued in seconds: a weight the
/// operator retunes mid-run must not retroactively re-score the kills that already
/// happened under the old one.
/// </summary>
public double KillWeight;
/// <summary>Members the cap turned away. Reported, because a truncated tally that says so is usable and one that does not is a lie.</summary>
public long Refused;
public Dictionary<int, Member> Members = new Dictionary<int, Member>();
}
private static readonly Dictionary<string, Run> _runs = new Dictionary<string, Run>(StringComparer.Ordinal);
private static Timer _timer;
private static long _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused;
/// <summary>
/// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad`
/// fires, so this cannot be deferred to Initialize.
/// </summary>
[CallPriority(900)]
public static void Configure()
{
EventSink.WorldSave += OnWorldSave;
EventSink.WorldLoad += OnWorldLoad;
}
public static void Initialize()
{
if (!BridgeConfig.Enabled)
return;
BridgeBoot.RegisterHandler("participation.open", OnOpen);
BridgeBoot.RegisterHandler("participation.snapshot", OnSnapshot);
BridgeBoot.RegisterHandler("participation.close", OnClose);
EventSink.CreatureDeath += OnCreatureDeath;
EventSink.ServerStarted += OnServerStarted;
}
private static void OnServerStarted()
{
Rearm();
}
/// <summary>Stops and recreates the sweep timer from current config. Called by `[bridge reload`.</summary>
public static void Rearm()
{
Stop();
_timer = Timer.DelayCall(
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
TimeSpan.FromSeconds(BridgeConfig.ParticipationSweepSeconds),
Sweep);
}
public static void Stop()
{
if (_timer != null)
{
_timer.Stop();
_timer = null;
}
}
public static string Status()
{
int members = 0;
foreach (var run in _runs.Values)
members += run.Members.Count;
return String.Format(
"participation(runs={0} members={1} sweeps={2} opened={3} closed={4} snapshots={5} kills={6} deferred={7} refused={8})",
_runs.Count, members, _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused);
}
/// <summary>
/// Every character serial this run has recorded, or null when the run is unknown here.
///
/// Added by Phase 12b for the item grant, which needs a list of people and would
/// otherwise have had to reach through core for one — the website's
/// `event_run_participants` holds the same serials, but a module cannot read core's
/// tables and adding a core surface to hand them over would have been a second copy of
/// a list this shard has been keeping all along.
///
/// **Null and empty are different answers.** Null is "no ledger is open for that run",
/// which is a refusal; empty is "the ledger is open and nobody came", which is a real
/// outcome a grant has to be able to report rather than retry.
///
/// A closed run still answers: closing stops the counting, and a reward handed out
/// after the event has ended is the ordinary case rather than an edge one.
/// </summary>
public static List<int> MemberSerials(string runId)
{
Run run;
if (runId == null || !_runs.TryGetValue(runId, out run))
return null;
var serials = new List<int>(run.Members.Count);
foreach (var member in run.Members.Values)
serials.Add(member.Serial);
return serials;
}
// ---- participation.open ----
/// <summary>
/// Declares a run's area and starts counting.
///
/// The area is a map, a point and a radius (org lead, 2026-09-04). Not a region name:
/// protocol 6's own live walk established that the most specific region containing an
/// event is routinely anonymous, so a region-named area would be undeclarable for
/// exactly the venues events use. Not a rectangle either — an author picks the spot the
/// event happens at, not two opposite corners of it.
/// </summary>
private static void OnOpen(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "open"))
return;
var runId = BridgeJson.GetString(o, "runId");
if (String.IsNullOrEmpty(runId))
{
Err(reqId, "open", "a run id is required");
return;
}
var mapName = BridgeJson.GetString(o, "map");
var map = MapByName(mapName);
if (map == null)
{
Err(reqId, "open", "unknown map '" + (mapName ?? "") + "'");
return;
}
var radius = BridgeJson.GetInt(o, "radius", 0);
if (radius < 1 || radius > BridgeConfig.ParticipationMaxRadius)
{
Err(reqId, "open",
String.Format(CultureInfo.InvariantCulture,
"radius must be 1 to {0} tiles, and {1} was asked for",
BridgeConfig.ParticipationMaxRadius, radius));
return;
}
var x = BridgeJson.GetInt(o, "x", -1);
var y = BridgeJson.GetInt(o, "y", -1);
if (x < 0 || y < 0)
{
Err(reqId, "open", "an area needs an x and a y");
return;
}
Run existing;
if (_runs.TryGetValue(runId, out existing))
{
// Re-opening the same area is the ordinary consequence of a step being re-authored
// or a run being resumed, and answering it as an error would fail a run for doing
// nothing. Re-opening a DIFFERENT area is an authoring mistake, and silently
// moving the venue mid-run would make the tally describe two places at once.
if (existing.MapIndex != map.MapIndex || existing.X != x || existing.Y != y ||
existing.Radius != radius)
{
Err(reqId, "open", "run " + runId + " is already counting a different area");
return;
}
existing.Closed = false;
Ok(reqId, "open", existing);
return;
}
if (_runs.Count >= BridgeConfig.ParticipationMaxRuns)
{
Err(reqId, "open",
String.Format(CultureInfo.InvariantCulture,
"this shard counts at most {0} runs at once", BridgeConfig.ParticipationMaxRuns));
return;
}
var holdMs = BridgeJson.GetLong(o, "holdMs", 0L);
var now = BridgeJson.NowMs();
var run = new Run
{
RunId = runId,
MapName = map.Name,
MapIndex = map.MapIndex,
X = x,
Y = y,
Radius = radius,
OpenedMs = now,
UntilMs = holdMs > 0L ? now + holdMs : 0L,
KillWeight = BridgeConfig.ParticipationKillWeight,
};
_runs[runId] = run;
_opened++;
Console.WriteLine("[Bridge] participation: run {0} counting {1} tiles around {2} ({3}, {4})",
runId, radius, map.Name, x, y);
Ok(reqId, "open", run);
}
// ---- participation.close ----
/// <summary>
/// Stops counting. The tally stays readable through the grace window, because the run
/// that closes an event and the step that collects its results are two different steps
/// and either can be retried.
/// </summary>
private static void OnClose(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "close"))
return;
var runId = BridgeJson.GetString(o, "runId");
Run run;
if (runId == null || !_runs.TryGetValue(runId, out run))
{
// Not an error. A close of a run this shard has already forgotten — a restart, a
// second teardown attempt — has the same meaning as one it honoured: nothing is
// being counted for that run any more.
var gone = BridgeJson.Begin("participation.ok");
if (reqId != null) gone.Str("reqId", reqId);
gone.Str("action", "close").Str("runId", runId).Bool("closed", true).Bool("known", false);
BridgeLink.Emit(gone.End());
return;
}
if (!run.Closed)
{
// One last sweep before the books shut, so the people standing there when the event
// ended are credited for the interval they were standing there in.
SweepRun(run, BridgeConfig.ParticipationSweepSeconds);
run.Closed = true;
run.ClosedMs = BridgeJson.NowMs();
_closed++;
Console.WriteLine("[Bridge] participation: run {0} closed with {1} member(s)",
run.RunId, run.Members.Count);
}
Ok(reqId, "close", run);
}
// ---- participation.snapshot ----
/// <summary>One snapshot in progress. See the class header for why this exists at all.</summary>
private sealed class Job
{
public string ReqId;
public string IdempotencyKey;
public Run Run;
public List<Member> Members;
public int Index;
public StringBuilder Sb;
/// <summary>
/// Whether this job took the key out of the inbound call's hands.
///
/// Recorded rather than re-derived from the chunk size, because the chunk size is a
/// config key an operator may change between the Hold and the Complete — and a
/// Complete that did not happen leaves every retry answered `bridge.busy` until the
/// store evicts the key an hour later.
/// </summary>
public bool Held;
}
private static void OnSnapshot(Dictionary<string, object> o)
{
var reqId = BridgeJson.GetString(o, "reqId");
if (!Ready(reqId, "snapshot"))
return;
var runId = BridgeJson.GetString(o, "runId");
Run run;
if (runId == null || !_runs.TryGetValue(runId, out run))
{
Err(reqId, "snapshot", "this shard is not counting run '" + (runId ?? "") + "'");
return;
}
// **Copied, not iterated in place.** A sweep or a kill landing between two chunks would
// otherwise mutate the dictionary the walk is enumerating, and a snapshot is a
// point-in-time answer in any case: the run it describes is the run as it was when the
// question was asked.
var members = new List<Member>(run.Members.Values);
var job = new Job
{
ReqId = reqId,
IdempotencyKey = BridgeJson.GetString(o, "idempotencyKey"),
Run = run,
Members = members,
Index = 0,
Sb = OpenSnapshot(reqId, run, members.Count),
};
_snapshots++;
if (members.Count <= BridgeConfig.ParticipationSnapshotChunk)
{
// Small enough to answer in the inbound call. Deliberately NOT deferred anyway: the
// idempotency store captures a reply emitted inside the handler for free, and
// holding a key we did not need to hold would put an ordinary command through the
// in-flight path for no reason.
Step(job);
return;
}
// Deferring. The key must be HELD before this call returns, or a repeat arriving while
// the walk is still running would be executed a second time rather than answered
// `bridge.busy` — which is the entire failure protocol 6 exists to prevent, and it is
// reachable for the first time right here.
if (job.IdempotencyKey != null)
{
BridgeIdempotency.Hold(job.IdempotencyKey);
job.Held = true;
}
_deferred++;
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
}
/// <summary>One chunk of a snapshot. Re-arms itself until the walk is done.</summary>
private static void Step(Job job)
{
try
{
var end = Math.Min(job.Index + BridgeConfig.ParticipationSnapshotChunk, job.Members.Count);
for (; job.Index < end; job.Index++)
WriteMember(job.Sb, job.Run, job.Members[job.Index], job.Index > 0);
if (job.Index < job.Members.Count)
{
Timer.DelayCall(TimeSpan.Zero, () => Step(job));
return;
}
job.Sb.Append(']');
var line = job.Sb.End();
BridgeLink.Emit(line);
// Only a HELD key needs completing. An inline snapshot was captured by the
// idempotency store on its way through Emit, and completing it twice would replace
// a correlated reply with one this method has no correlation information for.
if (job.Held)
BridgeIdempotency.Complete(job.IdempotencyKey, line);
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] participation snapshot threw: {0}", ex.Message);
// A held key whose walk died must still be closed out, or every retry of this step
// gets `bridge.busy` until the store's TTL evicts it an hour later.
if (job.Held)
{
var sb = BridgeJson.Begin("participation.error");
if (job.ReqId != null) sb.Str("reqId", job.ReqId);
sb.Str("action", "snapshot").Str("reason", "the snapshot failed: " + ex.Message);
var line = sb.End();
BridgeLink.Emit(line);
BridgeIdempotency.Complete(job.IdempotencyKey, line);
}
}
}
private static StringBuilder OpenSnapshot(string reqId, Run run, int count)
{
var sb = BridgeJson.Begin("participation.snapshot.ok");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Str("runId", run.RunId)
.Str("map", run.MapName)
.Num("x", run.X)
.Num("y", run.Y)
.Num("radius", run.Radius)
.Bool("closed", run.Closed)
.Num("openedMs", run.OpenedMs)
.Num("killWeight", run.KillWeight)
.Num("members", count)
.Num("refused", run.Refused);
sb.Append(",\"participants\":[");
return sb;
}
/// <summary>
/// One member, with the score this shard computed and the two components it came from.
///
/// The components ride along because core stores the score opaquely and could never
/// explain it: a results table that can say "forty minutes and three kills" beside a
/// number is a table an operator can argue with, and one that shows only the number is
/// one they can only believe or not.
/// </summary>
private static void WriteMember(StringBuilder sb, Run run, Member member, bool comma)
{
if (comma)
sb.Append(',');
var minutes = member.Seconds / 60.0;
var score = minutes + run.KillWeight * member.Kills;
sb.Append("{\"serial\":\"0x").Append(((uint)member.Serial).ToString("X")).Append('"');
// Resolved now rather than at sweep time, and the mobile is looked up whether or not
// its owner is online: a character that took part and logged out is still in the world,
// so its account — and the linked website user with it — is still readable.
var mobile = World.FindMobile((Serial)member.Serial);
sb.Append(",\"name\":");
BridgeJson.Text(sb, mobile != null && !String.IsNullOrEmpty(mobile.Name) ? mobile.Name : member.Name);
var acct = mobile == null ? null : mobile.Account as Accounting.Account;
if (acct != null)
{
sb.Append(",\"acct\":");
BridgeJson.Text(sb, acct.Username);
var webId = BridgeAccountLink.WebIdFor(acct);
if (webId != null)
{
sb.Append(",\"webId\":");
BridgeJson.Text(sb, webId);
}
}
sb.Append(",\"seconds\":").Append(member.Seconds);
sb.Append(",\"minutes\":").Append(minutes.ToString("F2", CultureInfo.InvariantCulture));
sb.Append(",\"kills\":").Append(member.Kills);
sb.Append(",\"score\":").Append(score.ToString("F4", CultureInfo.InvariantCulture));
sb.Append(",\"firstMs\":").Append(member.FirstMs);
sb.Append(",\"lastMs\":").Append(member.LastMs);
sb.Append('}');
}
// ---- counting ----
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
Sweep();
}
private static void Sweep()
{
try
{
_sweeps++;
if (_runs.Count == 0)
return;
var seconds = BridgeConfig.ParticipationSweepSeconds;
var now = BridgeJson.NowMs();
List<string> expired = null;
foreach (var run in _runs.Values)
{
if (run.Closed)
continue;
// The run's own deadline, honoured here for the reason a lease's is honoured on
// the shard: a website that stopped talking must not leave this shard counting
// an event that ended days ago.
if (run.UntilMs > 0L && now >= run.UntilMs)
{
SweepRun(run, seconds);
run.Closed = true;
run.ClosedMs = now;
_closed++;
Console.WriteLine("[Bridge] participation: run {0} passed its deadline and stopped counting",
run.RunId);
continue;
}
SweepRun(run, seconds);
}
var cutoff = now - (long)BridgeConfig.ParticipationGraceSec * 1000L;
foreach (var run in _runs.Values)
{
if (!run.Closed || run.ClosedMs > cutoff)
continue;
if (expired == null)
expired = new List<string>();
expired.Add(run.RunId);
}
if (expired == null)
return;
for (int i = 0; i < expired.Count; i++)
{
Console.WriteLine("[Bridge] participation: forgetting run {0}, closed longer than the grace window",
expired[i]);
_runs.Remove(expired[i]);
}
}
catch (Exception ex)
{
Console.WriteLine("[Bridge] participation sweep threw: {0}", ex.Message);
}
}
/// <summary>Credits every online player standing in one run's area with one interval.</summary>
private static void SweepRun(Run run, int seconds)
{
var map = Map.Maps[run.MapIndex];
if (map == null)
return;
var now = BridgeJson.NowMs();
foreach (var m in World.Mobiles.Values)
{
var pm = m as PlayerMobile;
if (pm == null || pm.NetState == null || pm.Deleted)
continue;
if (!Inside(run, pm))
continue;
var member = Touch(run, pm, now);
if (member == null)
continue;
member.Seconds += seconds;
}
}
/// <summary>
/// Kill credit, and it goes to every damager standing in the area rather than to the
/// killer alone.
///
/// A last hit is a poor description of who fought something: the player who held it for
/// four minutes and died to it took part more than the one who happened to land the blow
/// that finished it. `Mobile.DamageEntries` is already populated and is readable here
/// because a `CreatureDeath` handler runs before the creature is disposed of — the same
/// fact protocol 6's damage table rests on.
///
/// The presence check is applied to the DAMAGER, not only to the corpse. Someone
/// shooting into the venue from outside it is not attending the event, and someone who
/// fought there and has since walked away is no longer accruing anything either.
/// </summary>
private static void OnCreatureDeath(CreatureDeathEventArgs e)
{
try
{
if (_runs.Count == 0 || e == null || e.Creature == null)
return;
var creature = e.Creature;
if (creature.Player)
return; // a player death is not a kill anybody is credited for
var now = BridgeJson.NowMs();
foreach (var run in _runs.Values)
{
if (run.Closed || !Inside(run, creature))
continue;
var entries = creature.DamageEntries;
if (entries == null)
continue;
// Summed into a set first: ServUO folds repeat damage into an existing entry,
// but an entry that expired and was re-created leaves two, and crediting per
// entry would pay a long fight twice. Expiry governs looting rights, not
// whether somebody was there.
var credited = new HashSet<Mobile>();
for (int i = 0; i < entries.Count; i++)
{
var de = entries[i];
if (de == null || de.Damager == null || de.Damager.Deleted || !de.Damager.Player)
continue;
if (!credited.Add(de.Damager))
continue;
if (!Inside(run, de.Damager))
continue;
var member = Touch(run, de.Damager, now);
if (member == null)
continue;
member.Kills++;
_kills++;
}
}
}
catch (Exception ex)
{
// A death handler must never be the thing that breaks a death.
Console.WriteLine("[Bridge] participation kill credit threw: {0}", ex.Message);
}
}
private static bool Inside(Run run, Mobile m)
{
if (m == null || m.Map == null || m.Map.MapIndex != run.MapIndex)
return false;
// A circle, and squared so the check costs no square root. `Radius` is in tiles and the
// z axis is deliberately ignored: a venue is a place on the map, and a player one floor
// up in a tower over the square is at the event.
var dx = m.X - run.X;
var dy = m.Y - run.Y;
return (dx * dx) + (dy * dy) <= run.Radius * run.Radius;
}
/// <summary>
/// Finds or creates a member row, or answers null when the cap turned it away.
///
/// The cap counts a refusal rather than swallowing it, and the count rides on every
/// snapshot: a truncated tally that says it is truncated is usable, and one that does
/// not is a leaderboard with people missing from it for no stated reason.
/// </summary>
private static Member Touch(Run run, Mobile m, long now)
{
var serial = m.Serial.Value;
Member member;
if (run.Members.TryGetValue((int)serial, out member))
{
member.LastMs = now;
member.Name = m.Name ?? member.Name;
return member;
}
if (run.Members.Count >= BridgeConfig.ParticipationMaxMembers)
{
run.Refused++;
_refused++;
return null;
}
member = new Member
{
Serial = (int)serial,
Name = m.Name ?? "",
FirstMs = now,
LastMs = now,
};
run.Members[member.Serial] = member;
return member;
}
// ---- persistence ----
private static void OnWorldSave(WorldSaveEventArgs e)
{
Persistence.Serialize(
SavePath,
writer =>
{
writer.Write(SaveVersion);
writer.Write(_runs.Count);
foreach (var run in _runs.Values)
{
writer.Write(run.RunId ?? "");
writer.Write(run.MapName ?? "");
writer.Write(run.MapIndex);
writer.Write(run.X);
writer.Write(run.Y);
writer.Write(run.Radius);
writer.Write(run.OpenedMs);
writer.Write(run.UntilMs);
writer.Write(run.ClosedMs);
writer.Write(run.Closed);
writer.Write(run.KillWeight);
writer.Write(run.Refused);
writer.Write(run.Members.Count);
foreach (var member in run.Members.Values)
{
writer.Write(member.Serial);
writer.Write(member.Name ?? "");
writer.Write(member.Seconds);
writer.Write(member.Kills);
writer.Write(member.FirstMs);
writer.Write(member.LastMs);
}
}
});
}
private static void OnWorldLoad()
{
Persistence.Deserialize(
SavePath,
reader =>
{
var version = reader.ReadInt();
if (version < 1)
return;
var runs = reader.ReadInt();
for (int i = 0; i < runs; i++)
{
var run = new Run
{
RunId = reader.ReadString(),
MapName = reader.ReadString(),
MapIndex = reader.ReadInt(),
X = reader.ReadInt(),
Y = reader.ReadInt(),
Radius = reader.ReadInt(),
OpenedMs = reader.ReadLong(),
UntilMs = reader.ReadLong(),
ClosedMs = reader.ReadLong(),
Closed = reader.ReadBool(),
KillWeight = reader.ReadDouble(),
Refused = reader.ReadLong(),
};
var members = reader.ReadInt();
for (int j = 0; j < members; j++)
{
var member = new Member
{
Serial = reader.ReadInt(),
Name = reader.ReadString(),
Seconds = reader.ReadLong(),
Kills = reader.ReadInt(),
FirstMs = reader.ReadLong(),
LastMs = reader.ReadLong(),
};
run.Members[member.Serial] = member;
}
if (!String.IsNullOrEmpty(run.RunId))
_runs[run.RunId] = run;
}
if (_runs.Count > 0)
Console.WriteLine("[Bridge] participation: {0} run(s) restored from the world save", _runs.Count);
});
}
// ---- helpers ----
private static Map MapByName(string name)
{
if (String.IsNullOrEmpty(name))
return null;
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 bool Ready(string reqId, string action)
{
if (!BridgeConfig.EventsEnabled)
{
Err(reqId, action, "the event plane is disabled on this shard (Bridge.EventsEnabled)");
return false;
}
return true;
}
private static void Ok(string reqId, string action, Run run)
{
var sb = BridgeJson.Begin("participation.ok");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Str("action", action)
.Str("runId", run.RunId)
.Str("map", run.MapName)
.Num("x", run.X)
.Num("y", run.Y)
.Num("radius", run.Radius)
.Bool("closed", run.Closed)
.Bool("known", true)
.Num("members", run.Members.Count)
.Num("refused", run.Refused)
.Num("untilMs", run.UntilMs);
BridgeLink.Emit(sb.End());
}
private static void Err(string reqId, string action, string reason)
{
var sb = BridgeJson.Begin("participation.error");
if (reqId != null)
sb.Str("reqId", reqId);
sb.Str("action", action).Str("reason", reason);
BridgeLink.Emit(sb.End());
}
}
}