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