Files
wtclaude b818f6cf37 feat(bridge): protocol 5 — decay schedule, vendor fee state, and a login result
Three emitter changes and the overlay's protocol declaration, in one PR because
"The bridge is a contract": overlay.toml must be bumped in the same change as the
emitters or the next bundle silently fails to compose.

BridgeSweeps — house.decay gains ownerName and a nested `schedule`
{dynamicDecay, nextStage, decayPeriodSec, estimatedCollapse}.

estimatedCollapse is emitted only where ServUO can actually know it. Dynamic decay
(Core.ML) draws each stage's duration at RANDOM when the stage is entered, so
NextDecayStage is exact for the next transition and nothing beyond it is known —
collapse becomes exact only at IDOC, where the next transition IS the collapse.
Static decay is a pure function of LastRefreshed and DecayPeriod, so it is exact at
every stage. Emitting it anywhere else would publish a guess as a fact, and on the
website's side that becomes a dated promise in someone's mail.

BridgeMarket — vendor.listing gains ownerAcct and a nested `fees` block.

ownerAcct is the one that matters structurally: the frame has carried ownerName
since v3, but a character name joins to nothing — only the game account is the
website's link key. The fees block resolves PlayerVendor.PayTimer's dismissal rule
(pay > totalGold => Destroy) on the shard, because both halves of that comparison
differ between ServUO's two vendor systems and re-deriving them downstream would be
a second implementation of a rule that lives in core.

No daysRemaining: under the old vendor system a pay period is a UO day
(Clock.MinutesPerUODay, about two real hours), so the obvious name would be wrong
by a factor of twelve on exactly the shards least likely to notice. periodsRemaining
plus the interval, and dismissalAt as an instant. A commission vendor has no pay
timer at all and reports exempt with no schedule — "never dismissed" is not the same
as "dismissed in 400 days".

BridgeEvents — a new account.login.result kind.

EventSink.AccountLogin is a veto hook that fires BEFORE the auth decision, and
AccountLoginEventArgs constructs with Accepted = true, so the existing
account.login.attempt fires on successful logins too and cannot carry a verdict. A
security rule built on it would have mailed "someone tried to get into your account"
every time the player logged in.

The verdict is read one Core slice later via DelayCall(Zero). That needs no core
patch AND does not depend on handler subscription order, which ServUO does not define
and a shard's own scripts can change. reason is omitted on an accept, because
ALRReason's zero value is Invalid and would read as a failure reason. The address is
resolved inside the handler, since AccountLogin_ReplyRej disposes the NetState before
the deferred read runs. The password is never read, logged or emitted.

tools/scaffolding/BridgeProtocol5Probe.cs drives all three on a live shard, and the
README records the two traps it took to get there — both of which produce SILENCE
rather than an error, so each looks exactly like a broken emitter:

  * An in-process login probe can never produce accepted:true. AccountHandler calls
    acct.HasAccess(e.State) BEFORE it checks the password, and a null NetState fails
    that. Only a real socket proves the accepted half — and it is the better test
    anyway, since it also produces the real ip.
  * Forcing a decay stage on a house that cannot decay emits nothing at all. Only
    Condemned and ManualRefresh houses decay; an AutoRefresh one — and the owner's
    NEWEST house is always AutoRefresh — has a DecayLevel getter that calls
    ResetDynamicDecay() and reports Ageless, wiping the forced stage before the sweep
    reads it.

Verified on the local rig against the release sidecar: a house walked
Fairly -> Greatly -> IDOC carried estimatedCollapse on the IDOC frame and only there;
every vendor's periodsRemaining matched funds/chargePerPeriod, including one at 0
whose dismissalAt equals its next tick; a real socket login gave
accepted:false reason:BadPass and then accepted:true. Compiles clean against ServUO
57.4 reference assemblies.

Docs: RunicGateway/docs link/v5.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 19:19:34 -05:00

344 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Text;
using Server.Accounting;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom.Bridge
{
/// <summary>
/// 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.
/// </summary>
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<Serial, DecayLevel> _decayState =
new Dictionary<Serial, DecayLevel>();
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();
}
/// <summary>Stops and recreates the timers from current config. Called by `[bridge reload`.</summary>
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 ----
/// <summary>
/// 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.
/// </summary>
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 ----
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
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 ----
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>Runs each sweep once, now. For `[bridge sweepnow`.</summary>
public static void SweepOnce()
{
VitalsSweep();
DecaySweep();
EconomySweep();
}
}
}