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
204 lines
7.1 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|