Protocol 2.0 + 2.1 — account provisioning, world-state streams, Town Cryer news #7
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user