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 (https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/link/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); sb.Str("ownerName", owner.Name); var acct = owner.Account as Account; if (acct != null) sb.Str("ownerAcct", acct.Username); } AppendDecaySchedule(sb, house, to); // 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(); } /// /// Protocol 5. The three scheduling fields, and the reason they are not all always present. /// /// ServUO has two decay implementations and they differ in how KNOWABLE the future is: /// /// * Dynamic decay (DynamicDecay.Enabled, i.e. Core.ML) draws each stage's duration at /// RANDOM when the stage is entered (BaseHouse.SetDynamicDecay -> /// DynamicDecay.GetRandomDuration). So NextDecayStage is exact for the NEXT transition /// and nothing beyond it is known at all. Collapse becomes exact only once the house is /// already at IDOC, because then the next transition IS the collapse. /// * Static decay (GetOldDecayLevel) is a pure function of LastRefreshed and DecayPeriod, /// so collapse is exact at EVERY stage -- there is no randomness to wait out. /// /// Emitting estimatedCollapse from a dynamic-decay house at, say, Fairly would therefore be /// publishing a guess as a fact, which on the website's side becomes a dated promise in a /// player's mail. It is omitted rather than approximated: the website's `required: false` /// declaration already permits its absence, and an absent field is honest where a wrong /// date is not. /// private static void AppendDecaySchedule(StringBuilder sb, BaseHouse house, DecayLevel to) { // ONE nested object rather than four sibling keys, for the same reason vendor.listing // nests `location`: the website's visibility projection matches literal JSON keys, so a // nested group is one admin rule that can hide the whole schedule, where four flat keys // would be four rules that drift apart. sb.Append(",\"schedule\":{"); // The stage clock. Only dynamic decay keeps one; static decay leaves it at MinValue. bool dynamic = DynamicDecay.Enabled; var next = house.NextDecayStage; sb.Append("\"dynamicDecay\":").Append(dynamic ? "true" : "false"); if (dynamic && next > DateTime.MinValue) sb.Str("nextStage", next.ToUniversalTime().ToString("o")); // Total seconds from a full refresh to collapse. Constant per house type, but it is what // lets a reader turn lastRefreshed into a percentage without knowing ServUO's tables. var period = house.DecayPeriod; if (period > TimeSpan.Zero) sb.Num("decayPeriodSec", (long)period.TotalSeconds); DateTime collapse; bool knowable = true; if (!dynamic) collapse = house.LastRefreshed.ToUniversalTime() + period; else if (to == DecayLevel.IDOC && next > DateTime.MinValue) collapse = next.ToUniversalTime(); else { collapse = DateTime.MinValue; knowable = false; } if (knowable) sb.Str("estimatedCollapse", collapse.ToString("o")); sb.Append('}'); } // ---- 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(); } } }