diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 3960840..5d5f012 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -280,6 +280,22 @@ In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set Render the current board from `GET /governors` (§6) on connect, then keep it live with these events. +#### Presence (Protocol 2.0) + +Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, default 30s) and emitted **only when it changes**; region transitions arrive in real time. + +| kind | fields | notes | +|------|--------|-------| +| `presence.online` | `count`, `byFacet` `{map: n}`, `byRegion` `{region: n}` | The current online population. Emitted when the count or any breakdown changes. `GET /online` gives the latest; `GET /history?kind=presence.online` the time series. | +| `region.enter` | `from` (or null), `to` (or null), `map`, `who` (actor object) | A player crossed into a new named region. `from`/`to` are region names (`Wilderness` is unnamed). Cheap "who's where" feed. | + +```json +{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30}, + "byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000} +{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca", + "who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...} +``` + --- ## 5. REST — read queries @@ -576,6 +592,16 @@ GET /governors Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city. +### Online population (Protocol 2.0) + +``` +GET /online +→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30}, + "byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000} +``` + +The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`. + --- ## 7. Status codes diff --git a/docs/PROTOCOL_2.md b/docs/PROTOCOL_2.md index 5455b73..9a5b993 100644 --- a/docs/PROTOCOL_2.md +++ b/docs/PROTOCOL_2.md @@ -381,8 +381,8 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must ## 13. Part B phasing 1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* -2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store. -3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.) +2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.* +3. **Housing registry.** Extend the decay sweep to a full owner→houses list → `GET /houses`. (Selected from the §11 menu. Note: stock ServUO has no "for sale" flag on houses, so the registry is owner→houses; for-sale is dropped.) 4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. 5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. 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); + } + } + } +} diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index a8f8c73..9ce5435 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -77,6 +77,7 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { // and survive an outage with the last-known snapshot (docs/PROTOCOL_2.md §12.2). .route("/guilds", get(guilds)) .route("/governors", get(governors)) + .route("/online", get(online)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -729,6 +730,26 @@ async fn governors(State(st): State) -> impl IntoResponse { } } +/// The current online population: total plus per-facet and per-region counts. This is the most +/// recent `presence.online` snapshot from the event store (so it survives a sidecar restart); the +/// live `presence.online` stream keeps it current, and `GET /history?kind=presence.online` gives the +/// population time series. Returns `count: 0` if the shard has not reported one yet. +async fn online(State(st): State) -> impl IntoResponse { + match st.store.recent(Some("presence.online"), 1).await { + Ok(mut events) => match events.pop() { + Some(latest) => (StatusCode::OK, Json(latest)), + None => ( + StatusCode::OK, + Json(json!({"kind": "presence.online", "count": 0, "byFacet": {}, "byRegion": {}})), + ), + }, + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + // ---- websocket ---- async fn ws_upgrade(ws: WebSocketUpgrade, State(state): State) -> impl IntoResponse {