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