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).
|
# Idle (emits nothing) unless the City Loyalty system is enabled (CityLoyalty.Enabled).
|
||||||
CitySweepSeconds=300
|
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.
|
# Shown to a player when they run [link. The website page where they enter the code.
|
||||||
LinkUrl=https://yoursite/link
|
LinkUrl=https://yoursite/link
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeChamps.Rearm();
|
BridgeChamps.Rearm();
|
||||||
BridgeSocial.Rearm();
|
BridgeSocial.Rearm();
|
||||||
BridgeGovernance.Rearm();
|
BridgeGovernance.Rearm();
|
||||||
|
BridgePresence.Rearm();
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe());
|
||||||
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect.");
|
||||||
break;
|
break;
|
||||||
@@ -177,11 +178,13 @@ namespace Server.Custom.Bridge
|
|||||||
BridgeChamps.SweepOnce();
|
BridgeChamps.SweepOnce();
|
||||||
BridgeSocial.SweepOnce();
|
BridgeSocial.SweepOnce();
|
||||||
BridgeGovernance.SweepOnce();
|
BridgeGovernance.SweepOnce();
|
||||||
|
BridgePresence.SweepOnce();
|
||||||
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
e.Mobile.SendMessage("Bridge: ran one sweep of each stream.");
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -194,6 +197,7 @@ namespace Server.Custom.Bridge
|
|||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeChamps.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeSocial.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgeGovernance.Status());
|
||||||
|
e.Mobile.SendMessage("Bridge: {0}", BridgePresence.Status());
|
||||||
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ namespace Server.Custom.Bridge
|
|||||||
public static int ChampSweepSeconds { get; private set; }
|
public static int ChampSweepSeconds { get; private set; }
|
||||||
public static int GuildSweepSeconds { get; private set; }
|
public static int GuildSweepSeconds { get; private set; }
|
||||||
public static int CitySweepSeconds { 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; }
|
public static string LinkUrl { get; private set; }
|
||||||
|
|
||||||
@@ -91,6 +93,14 @@ namespace Server.Custom.Bridge
|
|||||||
if (CitySweepSeconds < 1)
|
if (CitySweepSeconds < 1)
|
||||||
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");
|
LinkUrl = Config.Get("Bridge.LinkUrl", "https://yoursite/link");
|
||||||
|
|
||||||
TownCrierMaxLines = Config.Get("Bridge.TownCrierMaxLines", 6);
|
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