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();
}
}
}