From afc97c414e9589115803c2737083cd92ea284341 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 10:59:21 -0500 Subject: [PATCH] Phase 3: polled sweeps (vitals, house decay, economy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeSweeps runs three repeating Core-thread timers for the state that has no EventSink. Cost measured in Phase 1 is why they can run on the main thread: a full pass of all three is well under a millisecond at the seeded scale. - Vitals: online players only. Small and volatile; the sidecar diffs snapshots. Offline characters do not move, so they are served on demand as full profiles instead, not swept. - House decay: emits only on a level transition. A silent baseline on ServerStarted records every house's current stage, so a restart does not re-announce them. Payload carries from/to, coords, nested ban location, region, sign name, owner serial+account, and built/refreshed timestamps, all null-guarded. - Economy supply: periodic sum of every account's currency as a snapshot. The level; AccountGoldChange and the vendor events are the flow. All three re-arm on `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters. Sweeps skip emitting while the sidecar is disconnected, since their state is perishable and re-emitted next tick anyway (unlike events, which queue through an outage). Verified on the seeded world with 8s intervals: baseline recorded 29 houses silently, a probe bumped one Somewhat->Fairly, and the next sweep emitted exactly one house.decay and none for the other 28. Economy emitted a supply snapshot per interval. Vitals emitted nothing, correctly, since all seeded characters are offline. Evidence in docs/PLAN.md §13. Adds tools/scaffolding/BridgeSweepProbe.cs (never deployed) to force a decay transition on demand. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +- docs/PLAN.md | 28 +- overlay/Scripts/Custom/Bridge/BridgeBoot.cs | 12 +- overlay/Scripts/Custom/Bridge/BridgeSweeps.cs | 279 ++++++++++++++++++ tools/scaffolding/BridgeSweepProbe.cs | 70 +++++ tools/scaffolding/README.md | 1 + 6 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 overlay/Scripts/Custom/Bridge/BridgeSweeps.cs create mode 100644 tools/scaffolding/BridgeSweepProbe.cs diff --git a/README.md b/README.md index ad36b7a..c36fc6d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree | 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** | | 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** | | 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** | -| 3 — sweeps | not started | +| 3 — sweeps (`BridgeSweeps`) | **done, acceptance in `docs/PLAN.md` §13** | | 4 — request/response | not started | | 5 — `[link` account linking | not started | | 6 — town-crier inbound | not started | @@ -66,6 +66,7 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S | `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. | | `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. | | `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. | +| `BridgeSweeps.cs` | Polled streams (Phase 3): vitals, house decay on transition, economy supply. Core-thread timers. | `Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on. diff --git a/docs/PLAN.md b/docs/PLAN.md index 3cb8d60..76fa16d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -305,7 +305,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.` 1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11. 2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12. -3. **Sweeps.** Vitals (30 s), decay-on-transition (60 s, with silent `ServerStarted` baseline), economy supply (5 min). All config-tunable; `[bridge reload` re-arms the timers. +3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. 4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side. 5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. @@ -348,6 +348,32 @@ Two defects were found this way and fixed: --- +## 13. Phase 3 acceptance + +`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters. + +Verified on the seeded world with intervals cut to 8 s: + +- **Decay is transition-only.** Baseline recorded 29 houses **silently** on `ServerStarted`. A probe bumped one house `Somewhat → Fairly` with `SetDynamicDecay`; the next sweep emitted **exactly one** `house.decay`, none for the other 28: + + ```json + {"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly", + "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House", + "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0}, + "builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"} + ``` + +- **Economy supply** emitted a snapshot each interval: `{"kind":"economy.supply","accounts":51,"gold":…}`. +- **Vitals** correctly emitted nothing — the seeded characters are all offline (`NetState == null`). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client. + +Notes from the run: + +- **`region` is null** for the seeded houses — they sit outside any named region. The handler guards `Region`, `Sign`, and `Owner` for null; all three can be absent on abandoned or oddly-placed houses. +- The sweeps **skip emitting when the sidecar is disconnected** (`BridgeLink.Connected`), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless. +- **Config duplicate keys: last write wins** (`Config.cs` does `_Entries[key] = e`), which is why the scaffolding appends test overrides to the end of `Bridge.cfg`. + +--- + ## 12. Phase 2 acceptance The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar: diff --git a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs index 9fcab73..b6fda4e 100644 --- a/overlay/Scripts/Custom/Bridge/BridgeBoot.cs +++ b/overlay/Scripts/Custom/Bridge/BridgeBoot.cs @@ -148,7 +148,7 @@ namespace Server.Custom.Bridge BridgeLink.Emit(sb.End()); } - [Usage("bridge [status | reload | ping]")] + [Usage("bridge [status | reload | ping | sweepnow]")] [Description("Inspects and controls the sidecar link.")] private static void Bridge_OnCommand(CommandEventArgs e) { @@ -158,8 +158,9 @@ namespace Server.Custom.Bridge { case "reload": BridgeConfig.Load(); + BridgeSweeps.Rearm(); e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); - e.Mobile.SendMessage("Bridge: endpoint changes take effect on reconnect."); + e.Mobile.SendMessage("Bridge: sweeps re-armed; endpoint changes take effect on reconnect."); break; case "ping": @@ -167,12 +168,19 @@ namespace Server.Custom.Bridge e.Mobile.SendMessage("Bridge: ping queued."); break; + case "sweepnow": + BridgeSweeps.SweepOnce(); + e.Mobile.SendMessage("Bridge: ran one sweep of each stream."); + e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); + break; + default: e.Mobile.SendMessage("Bridge: {0}", BridgeConfig.Describe()); e.Mobile.SendMessage( "Bridge: connected={0} depth={1} sent={2} dropped={3} received={4} connects={5} writeErrors={6}", BridgeLink.Connected, BridgeLink.Depth, BridgeLink.Sent, BridgeLink.Dropped, BridgeLink.Received, BridgeLink.Connects, BridgeLink.WriteErrors); + e.Mobile.SendMessage("Bridge: {0}", BridgeSweeps.Status()); break; } } diff --git a/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs b/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs new file mode 100644 index 0000000..9630ee2 --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeSweeps.cs @@ -0,0 +1,279 @@ +using System; +using System.Collections.Generic; +using System.Text; + +using Server.Accounting; +using Server.Mobiles; +using Server.Multis; + +namespace Server.Custom.Bridge +{ + /// + /// The three polled streams, for state that has no EventSink: player vitals, house decay, + /// and money supply. All three run on the Core thread via repeating Timers, and the + /// measured cost (docs/PLAN.md §1) is why they can: at the seeded scale a full pass of all + /// three is well under a millisecond. + /// + /// Timers do not fire during a world save (Timer.cs:322), so a sweep that would have landed + /// mid-save simply happens a few seconds later. That is fine for all three. + /// + public static class BridgeSweeps + { + private static Timer _vitals, _decay, _economy; + + // Last-known decay level per house. In memory, rebuilt from a silent baseline on + // ServerStarted, so a restart does not re-announce every house's current stage. + private static readonly Dictionary _decayState = + new Dictionary(); + + private static bool _baselined; + + private static long _vitalsSweeps, _vitalsEmitted, _decaySweeps, _decayTransitions, _economySweeps; + + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + BaselineDecay(); + Rearm(); + } + + /// Stops and recreates the timers from current config. Called by `[bridge reload`. + public static void Rearm() + { + Stop(); + + _vitals = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.StatSweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.StatSweepSeconds), + VitalsSweep); + + _decay = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.DecaySweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.DecaySweepSeconds), + DecaySweep); + + _economy = Timer.DelayCall( + TimeSpan.FromSeconds(BridgeConfig.EconomySweepSeconds), + TimeSpan.FromSeconds(BridgeConfig.EconomySweepSeconds), + EconomySweep); + } + + public static void Stop() + { + if (_vitals != null) { _vitals.Stop(); _vitals = null; } + if (_decay != null) { _decay.Stop(); _decay = null; } + if (_economy != null) { _economy.Stop(); _economy = null; } + } + + public static string Status() + { + return String.Format( + "vitals(sweeps={0} emitted={1}) decay(sweeps={2} transitions={3} tracked={4}) economy(sweeps={5})", + _vitalsSweeps, _vitalsEmitted, _decaySweeps, _decayTransitions, _decayState.Count, _economySweeps); + } + + // ---- vitals ---- + + /// + /// Online players only. Vitals are small and volatile; the sidecar diffs successive + /// snapshots and forwards only changes. Offline characters do not move, so there is + /// nothing to sweep — their state is served on demand as a full profile instead. + /// + private static void VitalsSweep() + { + try + { + _vitalsSweeps++; + + if (!BridgeLink.Connected) + return; // nothing is listening; do not fill the queue with perishable snapshots + + foreach (var m in World.Mobiles.Values) + { + var pm = m as PlayerMobile; + + if (pm == null || pm.NetState == null || pm.Deleted) + continue; + + BridgeLink.Emit(WriteVitals(pm)); + _vitalsEmitted++; + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] vitals sweep threw: {0}", ex.Message); + } + } + + private static string WriteVitals(PlayerMobile m) + { + return BridgeJson.Begin("char.vitals") + .Ser("serial", m.Serial) + .Num("hits", m.Hits).Num("hitsMax", m.HitsMax) + .Num("mana", m.Mana).Num("manaMax", m.ManaMax) + .Num("stam", m.Stam).Num("stamMax", m.StamMax) + .Num("str", m.Str).Num("dex", m.Dex).Num("int", m.Int) + .Str("map", m.Map == null ? null : m.Map.Name) + .Num("x", m.X).Num("y", m.Y) + .End(); + } + + // ---- house decay ---- + + /// + /// Populates the last-known level for every house without emitting. Without this, the + /// first sweep after a restart would report every house as a fresh transition. + /// + private static void BaselineDecay() + { + try + { + _decayState.Clear(); + + foreach (var house in BaseHouse.AllHouses) + { + if (house == null || house.Deleted) + continue; + + _decayState[house.Serial] = house.DecayLevel; + } + + _baselined = true; + Console.WriteLine("[Bridge] decay baseline: {0} houses", _decayState.Count); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] decay baseline threw: {0}", ex.Message); + } + } + + private static void DecaySweep() + { + try + { + _decaySweeps++; + + if (!_baselined) + BaselineDecay(); + + foreach (var house in BaseHouse.AllHouses) + { + if (house == null || house.Deleted) + continue; + + var level = house.DecayLevel; // computed getter — read once + var serial = house.Serial; + + DecayLevel prior; + bool known = _decayState.TryGetValue(serial, out prior); + + if (known && prior == level) + continue; + + _decayState[serial] = level; + + if (!known) + continue; // a house that appeared since baseline; record, do not announce + + _decayTransitions++; + + if (BridgeLink.Connected) + BridgeLink.Emit(WriteDecay(house, prior, level)); + } + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] decay sweep threw: {0}", ex.Message); + } + } + + private static string WriteDecay(BaseHouse house, DecayLevel from, DecayLevel to) + { + var sb = BridgeJson.Begin("house.decay") + .Ser("serial", house.Serial) + .Str("from", from.ToString()) + .Str("to", to.ToString()) + .Str("map", house.Map == null ? null : house.Map.Name) + .Num("x", house.X).Num("y", house.Y).Num("z", house.Z); + + var region = house.Region; + if (region != null) + sb.Str("region", region.Name); + + var sign = house.Sign; + if (sign != null) + sb.Str("name", sign.GetName()); + + var owner = house.Owner; + if (owner != null) + { + sb.Ser("ownerSerial", owner.Serial); + var acct = owner.Account as Account; + if (acct != null) + sb.Str("ownerAcct", acct.Username); + } + + // Where a player would physically stand to see it. + var ban = house.BanLocation; + sb.Append(",\"ban\":{\"x\":").Append(ban.X) + .Append(",\"y\":").Append(ban.Y) + .Append(",\"z\":").Append(ban.Z).Append('}'); + + sb.Str("builtOn", house.BuiltOn.ToUniversalTime().ToString("o")); + sb.Str("lastRefreshed", house.LastRefreshed.ToUniversalTime().ToString("o")); + + return sb.End(); + } + + // ---- economy supply ---- + + /// + /// Money supply = the sum of every account's currency, as a periodic snapshot. This is + /// the level; AccountGoldChange and the vendor events are the flow. The sidecar keeps + /// both. + /// + private static void EconomySweep() + { + try + { + _economySweeps++; + + if (!BridgeLink.Connected) + return; + + double totalCurrency = 0; + int accounts = 0; + + foreach (Account a in Accounting.Accounts.GetAccounts()) + { + totalCurrency += a.TotalCurrency; + accounts++; + } + + BridgeLink.Emit(BridgeJson.Begin("economy.supply") + .Num("accounts", accounts) + .Num("gold", (long)(totalCurrency * Account.CurrencyThreshold)) + .End()); + } + catch (Exception ex) + { + Console.WriteLine("[Bridge] economy sweep threw: {0}", ex.Message); + } + } + + /// Runs each sweep once, now. For `[bridge sweepnow`. + public static void SweepOnce() + { + VitalsSweep(); + DecaySweep(); + EconomySweep(); + } + } +} diff --git a/tools/scaffolding/BridgeSweepProbe.cs b/tools/scaffolding/BridgeSweepProbe.cs new file mode 100644 index 0000000..b892e1c --- /dev/null +++ b/tools/scaffolding/BridgeSweepProbe.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; + +using Server.Multis; + +namespace Server.Custom +{ + /// + /// Forces a house-decay transition so the decay sweep's transition detection can be + /// observed without waiting out a real 12–24 h IDOC stage. + /// + /// After the bridge takes its silent baseline on ServerStarted, this bumps one condemned + /// seeded house one stage further with SetDynamicDecay. The next decay sweep should see the + /// level change and emit exactly one house.decay. + /// + /// Test scaffolding. Never deployed. Only meaningful against the seeded world. + /// + public static class BridgeSweepProbe + { + public static void Initialize() + { + if (Config.Get("Bridge.SweepProbeOnStart", false)) + EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(6.0), Run); + } + + private static void Run() + { + try + { + BaseHouse target = null; + DecayLevel current = DecayLevel.Ageless; + + // Find a house already decaying (not Ageless/LikeNew), so a bump is a real move. + foreach (var h in BaseHouse.AllHouses) + { + if (h == null || h.Deleted) + continue; + + var lvl = h.DecayLevel; + + if (lvl == DecayLevel.Greatly || lvl == DecayLevel.Fairly || lvl == DecayLevel.Somewhat) + { + target = h; + current = lvl; + break; + } + } + + if (target == null) + { + Console.WriteLine("[SweepProbe] no decaying house found to bump"); + return; + } + + var next = current + 1; // e.g. Somewhat -> Fairly -> Greatly -> IDOC + + Console.WriteLine("[SweepProbe] bumping house 0x{0:X} from {1} to {2}", + target.Serial.Value, current, next); + + target.SetDynamicDecay(next); + + Console.WriteLine("[SweepProbe] done; the next decay sweep should emit house.decay"); + } + catch (Exception ex) + { + Console.WriteLine("[SweepProbe] FAILED: " + ex); + } + } + } +} diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md index bd24b0a..14a83e8 100644 --- a/tools/scaffolding/README.md +++ b/tools/scaffolding/README.md @@ -9,6 +9,7 @@ These two scripts produced the measured budget in `docs/PLAN.md` §1. They are k | `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. | | `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. | | `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. | +| `BridgeSweepProbe.cs` | `Scripts/Custom/BridgeSweepProbe.cs` | Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: `SweepProbeOnStart`. Pair with short `*SweepSeconds` overrides. | ## Deploy overwrites Bridge.cfg