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:
2026-07-17 08:02:05 -05:00
parent 2ed0b0bd00
commit d47170581d
7 changed files with 275 additions and 2 deletions

View File

@@ -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<AppState>) -> 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<AppState>) -> 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<AppState>) -> impl IntoResponse {