diff --git a/overlay/Config/Bridge.cfg b/overlay/Config/Bridge.cfg
index 6cb896e..bf4fc04 100644
--- a/overlay/Config/Bridge.cfg
+++ b/overlay/Config/Bridge.cfg
@@ -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
diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
index 6629494..88d3992 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs
@@ -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;
}
diff --git a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
index 68ee2c3..e8aebcc 100644
--- a/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
+++ b/overlay/Scripts/Custom/Bridge/BridgeConfig.cs
@@ -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);
diff --git a/overlay/Scripts/Custom/Bridge/BridgePresence.cs b/overlay/Scripts/Custom/Bridge/BridgePresence.cs
new file mode 100644
index 0000000..d0a50f9
--- /dev/null
+++ b/overlay/Scripts/Custom/Bridge/BridgePresence.cs
@@ -0,0 +1,203 @@
+using System;
+using System.Collections.Generic;
+
+using Server.Mobiles;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// Stops and recreates the timer from current config. Called by `[bridge reload`.
+ 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);
+ }
+
+ /// Runs one sweep now. Wired into `[bridge sweepnow`.
+ 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(StringComparer.Ordinal);
+ var byRegion = new SortedDictionary(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 map, string key)
+ {
+ int n;
+ map[key] = map.TryGetValue(key, out n) ? n + 1 : 1;
+ }
+
+ private static string Signature(int total, SortedDictionary byFacet, SortedDictionary 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 byFacet, SortedDictionary byRegion)
+ {
+ var sb = BridgeJson.Begin("presence.online").Num("count", total);
+
+ WriteCounts(sb, "byFacet", byFacet);
+ WriteCounts(sb, "byRegion", byRegion);
+
+ return sb.End();
+ }
+
+ /// Writes a nested object of {name: count} pairs.
+ private static void WriteCounts(System.Text.StringBuilder sb, string field, SortedDictionary 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);
+ }
+ }
+ }
+}