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:
@@ -39,6 +39,15 @@ GuildSweepSeconds=60
|
||||
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
|
||||
CitySweepSeconds=300
|
||||
|
||||
# Presence poll. Online population (total, per-facet, per-region) is snapshotted on this
|
||||
# interval and emitted as presence.online only when it changes. Region transitions come
|
||||
# through separately in real time as region.enter (EventSink.OnEnterRegion).
|
||||
PresenceSweepSeconds=30
|
||||
|
||||
# Housing registry poll. Every house is diffed on this interval to emit house.update /
|
||||
# house.remove (owner, region, location, decay). Houses change slowly; a few minutes is fine.
|
||||
HousingSweepSeconds=300
|
||||
|
||||
# Shown to a player when they run [link. The website page where they enter the code.
|
||||
LinkUrl=https://yoursite/link
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ namespace Server.Custom.Bridge
|
||||
BridgeChamps.Rearm();
|
||||
BridgeSocial.Rearm();
|
||||
BridgeGovernance.Rearm();
|
||||
BridgePresence.Rearm();
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||
break;
|
||||
@@ -177,11 +178,13 @@ namespace Server.Custom.Bridge
|
||||
BridgeChamps.SweepOnce();
|
||||
BridgeSocial.SweepOnce();
|
||||
BridgeGovernance.SweepOnce();
|
||||
BridgePresence.SweepOnce();
|
||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -194,6 +197,7 @@ namespace Server.Custom.Bridge
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ namespace Server.Custom.Bridge
|
||||
public static int ChampSweepSeconds { get; private set; }
|
||||
public static int GuildSweepSeconds { get; private set; }
|
||||
public static int CitySweepSeconds { get; private set; }
|
||||
public static int PresenceSweepSeconds { get; private set; }
|
||||
public static int HousingSweepSeconds { get; private set; }
|
||||
|
||||
public static string LinkUrl { get; private set; }
|
||||
|
||||
@@ -91,6 +93,14 @@ namespace Server.Custom.Bridge
|
||||
if (CitySweepSeconds < 1)
|
||||
CitySweepSeconds = 1;
|
||||
|
||||
PresenceSweepSeconds = Config.Get("Bridge.PresenceSweepSeconds", 30);
|
||||
if (PresenceSweepSeconds < 1)
|
||||
PresenceSweepSeconds = 1;
|
||||
|
||||
HousingSweepSeconds = Config.Get("Bridge.HousingSweepSeconds", 300);
|
||||
if (HousingSweepSeconds < 1)
|
||||
HousingSweepSeconds = 1;
|
||||
|
||||
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||
|
||||
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
||||
|
||||
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