From a837edd5eedea377a59f5d873198de784877f2f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:05:47 -0500 Subject: [PATCH] feat(protocol2): house registry board (Part B ph.3) Overlay BridgeHousing (new): a diff sweep over BaseHouse.AllHouses -> house.update / house.remove (owner, region, location, decay level, co-owners, friends, placement price), complementing the existing house.decay transition feed. HousingSweepSeconds (300s); wired into [bridge reload|sweepnow|status. Stock ServUO has no "for sale" flag, so this is an owner->houses registry; price is the placement value, not a listing. Sidecar: houses board table with upsert/delete/all; main routes house.update/ remove into it; GET /houses served from the store. Docs: INTEGRATION.md house.* events + /houses endpoint; PROTOCOL_2 ph.3 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 --- overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 4 + .../Scripts/Custom/Bridge/BridgeHousing.cs | 165 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeHousing.cs 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(); + } + } +}