using System; using System.Collections.Generic; using Server.Mobiles; namespace Server.Custom.Bridge { /// /// The presence stream (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/PROTOCOL_2.md §11 #1/#2): who is online and where. Two parts: /// /// presence.online - a periodic population snapshot (total, per-facet, per-region), emitted /// on a sweep but only when it changes, so the site has a live "N online" /// plus a change history without a firehose of identical frames. /// region.enter - a real-time location transition from EventSink.OnEnterRegion, the cheap /// per-player movement signal PLAN.md §5.6 recommends over Movement. /// /// The snapshot is derived each sweep from the online PlayerMobiles (NetState != null), the /// same population the vitals sweep already walks; counting them by map and region is a handful /// of field reads. region.enter is filtered to players. /// public static class BridgePresence { private static Timer _timer; // Signature of the last-emitted snapshot, so an unchanged population emits nothing. private static string _lastSig; private static long _sweeps, _emitted, _regionEnters; public static void Initialize() { if (!BridgeConfig.Enabled) return; EventSink.OnEnterRegion += OnEnterRegion; EventSink.ServerStarted += OnServerStarted; } private static void OnServerStarted() { // Force the next sweep to emit after a (re)connect, so a sidecar that restarted gets the // current population within one sweep. BridgeLink.Connected_Core += OnConnected; Rearm(); } private static void OnConnected() { _lastSig = null; } /// Stops and recreates the timer from current config. Called by `[bridge reload`. public static void Rearm() { Stop(); _timer = Timer.DelayCall( TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds), TimeSpan.FromSeconds(BridgeConfig.PresenceSweepSeconds), PresenceSweep); } public static void Stop() { if (_timer != null) { _timer.Stop(); _timer = null; } } public static string Status() { return String.Format("presence(sweeps={0} emitted={1} regionEnters={2})", _sweeps, _emitted, _regionEnters); } /// Runs one sweep now. Wired into `[bridge sweepnow`. public static void SweepOnce() { PresenceSweep(); } private static void PresenceSweep() { try { _sweeps++; if (!BridgeLink.Connected) return; // nothing is listening; do not fill the queue with perishable snapshots int total = 0; var byFacet = new SortedDictionary(StringComparer.Ordinal); var byRegion = new SortedDictionary(StringComparer.Ordinal); foreach (var m in World.Mobiles.Values) { var pm = m as PlayerMobile; if (pm == null || pm.NetState == null || pm.Deleted) continue; total++; var facet = pm.Map == null ? "Internal" : pm.Map.Name; Bump(byFacet, facet); var region = pm.Region; var regionName = (region == null || String.IsNullOrEmpty(region.Name)) ? "Wilderness" : region.Name; Bump(byRegion, regionName); } var sig = Signature(total, byFacet, byRegion); if (sig == _lastSig) return; // population unchanged since last emit _lastSig = sig; BridgeLink.Emit(WriteOnline(total, byFacet, byRegion)); _emitted++; } catch (Exception ex) { Console.WriteLine("[Bridge] presence sweep threw: {0}", ex.Message); } } private static void Bump(IDictionary map, string key) { int n; map[key] = map.TryGetValue(key, out n) ? n + 1 : 1; } private static string Signature(int total, SortedDictionary byFacet, SortedDictionary byRegion) { var sb = new System.Text.StringBuilder(); sb.Append(total); foreach (var kv in byFacet) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value); sb.Append('#'); foreach (var kv in byRegion) sb.Append('|').Append(kv.Key).Append(':').Append(kv.Value); return sb.ToString(); } private static string WriteOnline(int total, SortedDictionary byFacet, SortedDictionary byRegion) { var sb = BridgeJson.Begin("presence.online").Num("count", total); WriteCounts(sb, "byFacet", byFacet); WriteCounts(sb, "byRegion", byRegion); return sb.End(); } /// Writes a nested object of {name: count} pairs. private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary counts) { sb.Append(",\"").Append(field).Append("\":{"); bool first = true; foreach (var kv in counts) { if (!first) sb.Append(','); first = false; BridgeJson.Escape(sb, kv.Key); sb.Append(':').Append(kv.Value); } sb.Append('}'); } // ---- real-time region transitions ---- private static void OnEnterRegion(OnEnterRegionEventArgs e) { try { if (e == null || e.From == null || !e.From.Player) return; var from = e.OldRegion; var to = e.NewRegion; // Only meaningful when the named region actually changed. var fromName = from == null ? null : from.Name; var toName = to == null ? null : to.Name; if (String.Equals(fromName, toName, StringComparison.Ordinal)) return; var sb = BridgeJson.Begin("region.enter") .Str("from", fromName) .Str("to", toName) .Str("map", e.From.Map == null ? null : e.From.Map.Name); sb.Actor("who", e.From); BridgeLink.Emit(sb.End()); _regionEnters++; } catch (Exception ex) { Console.WriteLine("[Bridge] region enter handler threw: {0}", ex.Message); } } } }