using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using Server.Mobiles; namespace Server.Custom.Bridge { /// /// 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. /// public static class BridgeParticipation { private static readonly string SavePath = Path.Combine("Saves", "Bridge", "Participation.bin"); private const int SaveVersion = 1; /// One character's part in one run. private sealed class Member { public int Serial; /// /// 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. /// public string Name; /// /// 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. /// public long Seconds; public int Kills; public long FirstMs; public long LastMs; } /// One run's declared area and its members. 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; /// /// 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. /// public double KillWeight; /// Members the cap turned away. Reported, because a truncated tally that says so is usable and one that does not is a lie. public long Refused; public Dictionary Members = new Dictionary(); } private static readonly Dictionary _runs = new Dictionary(StringComparer.Ordinal); private static Timer _timer; private static long _sweeps, _opened, _closed, _snapshots, _kills, _deferred, _refused; /// /// Attaches persistence. Runs before `World.Load()`, which is when `EventSink.WorldLoad` /// fires, so this cannot be deferred to Initialize. /// [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(); } /// Stops and recreates the sweep timer from current config. Called by `[bridge reload`. 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); } /// /// 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. /// public static List MemberSerials(string runId) { Run run; if (runId == null || !_runs.TryGetValue(runId, out run)) return null; var serials = new List(run.Members.Count); foreach (var member in run.Members.Values) serials.Add(member.Serial); return serials; } // ---- participation.open ---- /// /// 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. /// private static void OnOpen(Dictionary 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 ---- /// /// 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. /// private static void OnClose(Dictionary 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 ---- /// One snapshot in progress. See the class header for why this exists at all. private sealed class Job { public string ReqId; public string IdempotencyKey; public Run Run; public List Members; public int Index; public StringBuilder Sb; /// /// 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. /// public bool Held; } private static void OnSnapshot(Dictionary 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(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)); } /// One chunk of a snapshot. Re-arms itself until the walk is done. 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; } /// /// 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. /// 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 ---- /// Runs one sweep now. Wired into `[bridge sweepnow`. 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 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(); 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); } } /// Credits every online player standing in one run's area with one interval. 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; } } /// /// 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. /// 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(); 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; } /// /// 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. /// 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()); } } }