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:
937
overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
Normal file
937
overlay/Scripts/Custom/Bridge/BridgeParticipation.cs
Normal file
@@ -0,0 +1,937 @@
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user