Files
servuo-plugins/overlay/Scripts/Custom/Bridge/BridgePresence.cs
Claude 0fd6b91f22 docs: move docs to RunicGateway/docs, repoint all references
Extracted docs/ (ADMIN_CONTROLS, INTEGRATION, PLAN, PROTOCOL_2, RESEARCH,
SHARD_PREREQS) into the central RunicGateway/docs repo under link/, with
full commit history preserved via git filter-repo.

The source cites these design docs by section throughout, so every in-repo
reference (C# + Rust comments, Bridge.cfg, and the READMEs) is repointed at
the new docs-repo URL. README references are rendered as markdown links; a
Documentation pointer section is added to the top-level README.

Docs repo: https://gitea.whitlocktech.com/RunicGateway/docs
2026-07-18 00:08:34 -05:00

204 lines
7.1 KiB
C#

using System;
using System.Collections.Generic;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// 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.
/// </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);
}
}
}
}