feat(protocol2): presence stream — online population + region transitions (Part B ph.2)
Overlay BridgePresence (new): - presence.online sweep over online PlayerMobiles: total plus per-facet and per-region counts, emitted only when the population changes. - region.enter real-time from EventSink.OnEnterRegion (player-filtered), the cheap location signal PLAN.md prefers over Movement. - PresenceSweepSeconds (30s); wired into [bridge reload|sweepnow|status. Sidecar: - GET /online serves the latest presence.online snapshot from the event store (survives restart); population time series via /history?kind=presence.online. Docs: INTEGRATION.md presence events + /online endpoint; PROTOCOL_2 ph.2 built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
203
overlay/Scripts/Custom/Bridge/BridgePresence.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Mobiles;
|
||||
|
||||
namespace Server.Custom.Bridge
|
||||
{
|
||||
/// <summary>
|
||||
/// The presence stream (docs/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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Stops and recreates the timer from current config. Called by `[bridge reload`.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Runs one sweep now. Wired into `[bridge sweepnow`.</summary>
|
||||
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<string, int>(StringComparer.Ordinal);
|
||||
var byRegion = new SortedDictionary<string, int>(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<string, int> map, string key)
|
||||
{
|
||||
int n;
|
||||
map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
|
||||
}
|
||||
|
||||
private static string Signature(int total, SortedDictionary<string, int> byFacet, SortedDictionary<string, int> 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<string, int> byFacet, SortedDictionary<string, int> byRegion)
|
||||
{
|
||||
var sb = BridgeJson.Begin("presence.online").Num("count", total);
|
||||
|
||||
WriteCounts(sb, "byFacet", byFacet);
|
||||
WriteCounts(sb, "byRegion", byRegion);
|
||||
|
||||
return sb.End();
|
||||
}
|
||||
|
||||
/// <summary>Writes a nested object of {name: count} pairs.</summary>
|
||||
private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary<string, int> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user