diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 5d5f012..ed397e2 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -296,6 +296,25 @@ Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, "who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...} ``` +#### Houses (Protocol 2.0) + +The house registry — one row per house, complementing the `house.decay` *transition* feed (§ above). Polled (`HousingSweepSeconds`, default 300s) and diffed like the other boards. + +| kind | fields | notes | +|------|--------|-------| +| `house.update` | `serial`, `name`, `owner` (actor or null), `coOwners`, `friends`, `region`, `map`, `x`,`y`,`z`, `decay`, `price`, `builtOn`, `lastRefreshed` | A house's owner/region/decay/co-owners changed, or first sight this connection. `decay` is the level name (e.g. `LikeNew`). `price` is the placement value — **stock ServUO has no "for sale" flag**, so this is not a listing. | +| `house.remove` | `serial` | The house was demolished or no longer exists. Drop the row. | + +```json +{"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil","decay":"LikeNew", + "price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain", + "owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "coOwners":2,"friends":5,"builtOn":"2026-01-02T00:00:00Z","lastRefreshed":"2026-07-10T00:00:00Z", + "t":1752489280000} +``` + +Render from `GET /houses` (§6) on connect, then keep live with these events. + --- ## 5. REST — read queries @@ -602,6 +621,18 @@ GET /online 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`. +### House registry (Protocol 2.0) + +``` +GET /houses +→ { "houses": [ {"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil", + "decay":"LikeNew","price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain", + "owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "coOwners":2,"friends":5,"builtOn":"...","lastRefreshed":"...","t":...}, ... ] } +``` + +Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the `house.*` stream (§4). Ordered by name. Survives a sidecar restart. + --- ## 7. Status codes diff --git a/docs/PROTOCOL_2.md b/docs/PROTOCOL_2.md index 9a5b993..c03bb93 100644 --- a/docs/PROTOCOL_2.md +++ b/docs/PROTOCOL_2.md @@ -382,7 +382,7 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must 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.**~~ **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.) +3. ~~**Housing registry.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeHousing.cs`: a house sweep over `BaseHouse.AllHouses` → `house.update`/`house.remove` (owner, region, location, decay, co-owners, friends, price), complementing the existing `house.decay` transition feed. `HousingSweepSeconds` (300s), wired into `[bridge`. Sidecar `houses` board + `GET /houses`. (Stock ServUO has no "for sale" flag, so this is owner→houses; `price` is the placement value, not a listing.) *Live run pending.* 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/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 88d3992..4af7e27 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -164,6 +164,7 @@ namespace Server.Custom.Bridge BridgeSocial.Rearm(); BridgeGovernance.Rearm(); BridgePresence.Rearm(); + BridgeHousing.Rearm(); e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); break; @@ -179,12 +180,14 @@ namespace Server.Custom.Bridge BridgeSocial.SweepOnce(); BridgeGovernance.SweepOnce(); BridgePresence.SweepOnce(); + BridgeHousing.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()); + e.Mobile.SendMessage("Bridge: {0}", BridgeHousing.Status()); break; default: @@ -198,6 +201,7 @@ namespace Server.Custom.Bridge 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}", BridgeHousing.Status()); e.Mobile.SendMessage("Bridge: {0}", BridgePages.Status()); break; } diff --git a/overlay/Scripts/Custom/Bridge/BridgeHousing.cs b/overlay/Scripts/Custom/Bridge/BridgeHousing.cs new file mode 100644 index 0000000..bca9d1c --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeHousing.cs @@ -0,0 +1,165 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Server.Multis; + +namespace Server.Custom.Bridge +{ + /// + /// The housing registry (docs/PROTOCOL_2.md §11 #9). BridgeSweeps already emits house.decay + /// *transitions*; this is the complementary *board*: one row per house with owner, location, + /// region, co-owners, value, and current decay level, so the website can render an owner→houses + /// map. Like the other Part B boards it is a diff sweep over BaseHouse.AllHouses — emit + /// house.update only when a house's signature changes, and house.remove when a house is gone. + /// + /// Note: stock ServUO has no "for sale" flag on a house (houses are traded, not listed), so the + /// registry is owner→houses; `price` is the house's placement value, not a sale listing. + /// + public static class BridgeHousing + { + private static Timer _timer; + + // house serial -> last-emitted signature. + private static readonly Dictionary _last = new Dictionary(); + + private static long _sweeps, _emitted, _removed; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + BridgeLink.Connected_Core += OnConnected; + Rearm(); + } + + private static void OnConnected() + { + _last.Clear(); + } + + /// Stops and recreates the timer from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _timer = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.HousingSweepSeconds), + HouseSweep); + } + + public static void Stop() + { + if (_timer != null) { _timer.Stop(); _timer = null; } + } + + public static string Status() + { + return String.Format("housing(sweeps={0} emitted={1} removed={2} tracked={3})", + _sweeps, _emitted, _removed, _last.Count); + } + + /// Runs one sweep now. Wired into `[bridge sweepnow`. + public static void SweepOnce() + { + HouseSweep(); + } + + private static void HouseSweep() + { + try + { + _sweeps++; + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + var seen = new HashSet(); + + foreach (var house in BaseHouse.AllHouses) + { + if (house == null || house.Deleted) + continue; + + seen.Add(house.Serial); + + var level = house.DecayLevel; // computed getter — read once + var sig = Signature(house, level); + + string prior; + if (_last.TryGetValue(house.Serial, out prior) && prior == sig) + continue; // unchanged since last emit + + _last[house.Serial] = sig; + BridgeLink.Emit(WriteHouse(house, level)); + _emitted++; + } + + var gone = _last.Keys.Where(k => !seen.Contains(k)).ToList(); + foreach (var serial in gone) + { + _last.Remove(serial); + BridgeLink.Emit(BridgeJson.Begin("house.remove").Ser("serial", serial).End()); + _removed++; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] housing sweep threw: {0}", ex.Message); + } + } + + private static string Signature(BaseHouse house, DecayLevel level) + { + var ownerSerial = house.Owner == null ? 0 : house.Owner.Serial.Value; + var region = house.Region; + var regionName = region == null ? "" : (region.Name ?? ""); + var sign = house.Sign; + var name = sign == null ? "" : (sign.GetName() ?? ""); + var coOwners = house.CoOwners == null ? 0 : house.CoOwners.Count; + + return String.Concat( + ownerSerial.ToString(), "|", + level.ToString(), "|", + regionName, "|", + name, "|", + coOwners.ToString(), "|", + house.Price.ToString()); + } + + private static string WriteHouse(BaseHouse house, DecayLevel level) + { + var sb = BridgeJson.Begin("house.update") + .Ser("serial", house.Serial) + .Str("decay", level.ToString()) + .Num("price", house.Price) + .Str("map", house.Map == null ? null : house.Map.Name) + .Num("x", house.X).Num("y", house.Y).Num("z", house.Z); + + var sign = house.Sign; + if (sign != null) + sb.Str("name", sign.GetName()); + + var region = house.Region; + if (region != null) + sb.Str("region", region.Name); + + sb.Actor("owner", house.Owner); + + sb.Num("coOwners", house.CoOwners == null ? 0 : house.CoOwners.Count); + sb.Num("friends", house.Friends == null ? 0 : house.Friends.Count); + + sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o")); + sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o")); + + return sb.End(); + } + } +} diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index ea9a228..d60b849 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -164,6 +164,25 @@ async fn main() -> anyhow::Result<()> { } } } + // House registry (Protocol 2.0): house.update folds in each house's latest state + // (one row per serial); house.remove drops a demolished/traded house. + "house.update" => { + if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) { + if let Err(e) = event_store + .upsert_house(serial, ev.value.get("name").and_then(|n| n.as_str()), &text, t) + .await + { + tracing::warn!(error = %e, "failed to upsert house registry"); + } + } + } + "house.remove" => { + if let Some(serial) = ev.value.get("serial").and_then(|s| s.as_str()) { + if let Err(e) = event_store.delete_house(serial).await { + tracing::warn!(error = %e, "failed to remove house registry row"); + } + } + } _ => {} } } diff --git a/sidecar/src/store.rs b/sidecar/src/store.rs index 789884e..3d476b8 100644 --- a/sidecar/src/store.rs +++ b/sidecar/src/store.rs @@ -246,6 +246,40 @@ impl Store { .await?; Ok(parse_json_column(rows)) } + + // ---- house registry (Protocol 2.0) ---- + + /// Upserts one house's latest state, keyed by serial. Fed from `house.update`. + pub async fn upsert_house(&self, serial: &str, name: Option<&str>, json: &str, t: i64) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO houses (serial, name, json, updated_t) VALUES (?, ?, ?, ?) + ON CONFLICT(serial) DO UPDATE SET name = excluded.name, json = excluded.json, updated_t = excluded.updated_t", + ) + .bind(serial) + .bind(name) + .bind(json) + .bind(t) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Drops one house from the registry. Fed from `house.remove` (demolished / traded away). + pub async fn delete_house(&self, serial: &str) -> anyhow::Result<()> { + sqlx::query("DELETE FROM houses WHERE serial = ?") + .bind(serial) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// The full house registry: every house's latest snapshot, ordered by name then serial. + pub async fn houses_all(&self) -> anyhow::Result> { + let rows = sqlx::query("SELECT json FROM houses ORDER BY name, serial") + .fetch_all(&self.pool) + .await?; + Ok(parse_json_column(rows)) + } } fn parse_json_column(rows: Vec) -> Vec { @@ -297,4 +331,11 @@ CREATE TABLE IF NOT EXISTS governors ( json TEXT NOT NULL, updated_t INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS houses ( + serial TEXT PRIMARY KEY, + name TEXT, + json TEXT NOT NULL, + updated_t INTEGER NOT NULL +); "#; diff --git a/sidecar/src/web.rs b/sidecar/src/web.rs index 9ce5435..5657973 100644 --- a/sidecar/src/web.rs +++ b/sidecar/src/web.rs @@ -78,6 +78,7 @@ pub async fn serve(addr: &str, state: AppState) -> anyhow::Result<()> { .route("/guilds", get(guilds)) .route("/governors", get(governors)) .route("/online", get(online)) + .route("/houses", get(houses)) .route_layer(middleware::from_fn_with_state(state.clone(), gate)); let app = Router::new() @@ -730,6 +731,18 @@ async fn governors(State(st): State) -> impl IntoResponse { } } +/// The house registry: every house's latest snapshot (owner/region/location/decay/value). Served +/// from the local board table, so it hydrates without the shard and survives an outage. +async fn houses(State(st): State) -> impl IntoResponse { + match st.store.houses_all().await { + Ok(houses) => (StatusCode::OK, Json(json!({"houses": houses}))), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ), + } +} + /// 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