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>
This commit is contained in:
275
tools/scaffolding/BridgeProtocol5Probe.cs
Normal file
275
tools/scaffolding/BridgeProtocol5Probe.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using Server.Accounting;
|
||||
using Server.Commands;
|
||||
using Server.Mobiles;
|
||||
using Server.Multis;
|
||||
using Server.Network;
|
||||
|
||||
namespace Server.Custom
|
||||
{
|
||||
/// <summary>
|
||||
/// Exercises all three Protocol 5 enrichments on a live shard, without a game client.
|
||||
///
|
||||
/// Each of the three needs something a unit test cannot produce, and each needs it for a
|
||||
/// different reason:
|
||||
///
|
||||
/// * house.decay's `schedule` is only interesting ACROSS a transition, and the interesting
|
||||
/// pair is Greatly -> IDOC: the first must carry no estimatedCollapse (under dynamic
|
||||
/// decay the remaining stages have not been drawn yet) and the second must carry one.
|
||||
/// A fixture can assert the mapping; only a real BaseHouse walking a real
|
||||
/// SetDynamicDecay proves the emitter reads ServUO the way the comment claims.
|
||||
/// * vendor.listing's `fees` are computed from PlayerVendor state that differs between
|
||||
/// ServUO's two vendor systems. This reports what the shard actually holds so the
|
||||
/// emitted frame can be checked against it rather than against an assumption.
|
||||
/// * account.login.result is the one that could not be built at all before v5, because
|
||||
/// EventSink.AccountLogin fires BEFORE the verdict exists. Invoking the real sink with a
|
||||
/// real password (right and wrong) runs the shard's own AccountHandler, which is what
|
||||
/// sets Accepted/RejectReason -- so this proves the deferred read sees the FINAL verdict
|
||||
/// and not the constructor's default of true.
|
||||
///
|
||||
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
|
||||
/// In game / at the console: `[p5probe`.
|
||||
/// </summary>
|
||||
public static class BridgeProtocol5Probe
|
||||
{
|
||||
public static void Initialize()
|
||||
{
|
||||
CommandSystem.Register("p5probe", AccessLevel.Administrator, Probe_OnCommand);
|
||||
|
||||
if (Config.Get("Bridge.Protocol5ProbeOnStart", false))
|
||||
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(8.0), () => Run(null));
|
||||
}
|
||||
|
||||
[Usage("p5probe")]
|
||||
[Description("Drives the three Protocol 5 enrichments so their frames can be observed.")]
|
||||
private static void Probe_OnCommand(CommandEventArgs e)
|
||||
{
|
||||
Run(e.Mobile);
|
||||
}
|
||||
|
||||
private static void Report(Mobile to, string line)
|
||||
{
|
||||
Console.WriteLine("[P5Probe] " + line);
|
||||
|
||||
if (to != null)
|
||||
to.SendMessage(line);
|
||||
}
|
||||
|
||||
private static void Run(Mobile to)
|
||||
{
|
||||
try
|
||||
{
|
||||
ReportVendorFees(to);
|
||||
DriveLogins(to);
|
||||
WalkHouseToIdoc(to);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Report(to, "threw: " + ex);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- (a) house.decay schedule ----
|
||||
|
||||
/// <summary>
|
||||
/// Walks one house Greatly, then (after a pause long enough for a decay sweep to run)
|
||||
/// IDOC. Two frames, and the PAIR is the assertion: no estimatedCollapse on the first,
|
||||
/// one on the second.
|
||||
/// </summary>
|
||||
private static void WalkHouseToIdoc(Mobile to)
|
||||
{
|
||||
BaseHouse target = null;
|
||||
var byType = new Dictionary<string, int>();
|
||||
|
||||
foreach (var h in BaseHouse.AllHouses)
|
||||
{
|
||||
if (h == null || h.Deleted || h.Owner == null)
|
||||
continue;
|
||||
|
||||
var type = h.DecayType.ToString();
|
||||
byType[type] = (byType.ContainsKey(type) ? byType[type] : 0) + 1;
|
||||
|
||||
// CanDecay is the filter that matters, and getting it wrong is silent. A house
|
||||
// whose DecayType is AutoRefresh or Ageless -- and the owner's NEWEST house is
|
||||
// always AutoRefresh -- has a DecayLevel getter that calls ResetDynamicDecay() and
|
||||
// reports Ageless, so a forced SetDynamicDecay is wiped on the very next read. The
|
||||
// sweep then sees no change and emits nothing at all, which looks exactly like a
|
||||
// broken emitter.
|
||||
if (!h.CanDecay)
|
||||
continue;
|
||||
|
||||
// The current level does NOT disqualify a house. On this rig every decaying house
|
||||
// is already at IDOC (a seeded world has only a couple of Condemned houses and they
|
||||
// have long since bottomed out), so the walk starts by putting one BACK to Fairly.
|
||||
// BridgeDemoDress.PrimeIdoc does the same thing for the same reason.
|
||||
target = h;
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var kv in byType)
|
||||
Report(to, "houses by DecayType: " + kv.Key + "=" + kv.Value);
|
||||
|
||||
if (target == null)
|
||||
{
|
||||
Report(to, "no walkable house found (none with CanDecay below IDOC)");
|
||||
return;
|
||||
}
|
||||
|
||||
Report(to, string.Format(
|
||||
"walking house 0x{0:X} owner={1} decayType={2} from {3}; dynamicDecay={4}",
|
||||
target.Serial.Value,
|
||||
target.Owner == null ? "?" : target.Owner.Name,
|
||||
target.DecayType,
|
||||
target.DecayLevel,
|
||||
DynamicDecay.Enabled));
|
||||
|
||||
// Each step needs its own sweep to land, or the sweep sees one net change and emits a
|
||||
// single frame -- which would collapse the whole point, since the assertion is the
|
||||
// DIFFERENCE between the Greatly frame and the IDOC one.
|
||||
var step = TimeSpan.FromSeconds(Math.Max(4, BridgeConfigSeconds()) * 2 + 4);
|
||||
|
||||
Step(to, target, DecayLevel.Fairly, TimeSpan.Zero, "reset (no estimatedCollapse expected)");
|
||||
Step(to, target, DecayLevel.Greatly, step, "expect schedule WITHOUT estimatedCollapse");
|
||||
Step(to, target, DecayLevel.IDOC, TimeSpan.FromTicks(step.Ticks * 2), "expect schedule WITH estimatedCollapse");
|
||||
}
|
||||
|
||||
private static void Step(Mobile to, BaseHouse house, DecayLevel level, TimeSpan after, string note)
|
||||
{
|
||||
Action go = () =>
|
||||
{
|
||||
if (house.Deleted)
|
||||
return;
|
||||
|
||||
Report(to, string.Format("house 0x{0:X} -> {1} ({2})", house.Serial.Value, level, note));
|
||||
house.SetDynamicDecay(level);
|
||||
};
|
||||
|
||||
if (after <= TimeSpan.Zero)
|
||||
go();
|
||||
else
|
||||
Timer.DelayCall(after, () => go());
|
||||
}
|
||||
|
||||
/// <summary>The decay sweep interval, read the same way the bridge reads it.</summary>
|
||||
private static int BridgeConfigSeconds()
|
||||
{
|
||||
return Config.Get("Bridge.DecaySweepSeconds", 60);
|
||||
}
|
||||
|
||||
// ---- (b) vendor.listing fees ----
|
||||
|
||||
/// <summary>
|
||||
/// Prints the fee state of the first few player vendors straight off the PlayerVendor
|
||||
/// objects, so the emitted `fees` block can be compared against the shard's own numbers
|
||||
/// rather than against what the emitter believes them to be.
|
||||
/// </summary>
|
||||
private static void ReportVendorFees(Mobile to)
|
||||
{
|
||||
bool newSystem = BaseHouse.NewVendorSystem;
|
||||
int shown = 0;
|
||||
|
||||
Report(to, "NewVendorSystem=" + newSystem);
|
||||
|
||||
foreach (var m in World.Mobiles.Values)
|
||||
{
|
||||
var v = m as PlayerVendor;
|
||||
|
||||
if (v == null || v.Deleted)
|
||||
continue;
|
||||
|
||||
int charge = newSystem ? v.ChargePerRealWorldDay : v.ChargePerDay;
|
||||
int funds = newSystem ? v.HoldGold : v.BankAccount + v.HoldGold;
|
||||
var acct = v.Owner == null ? null : v.Owner.Account as Account;
|
||||
|
||||
Report(to, string.Format(
|
||||
"vendor 0x{0:X} owner={1} acct={2} commission={3} charge={4} funds={5} periods={6} nextPay={7:o}",
|
||||
v.Serial.Value,
|
||||
v.Owner == null ? "?" : v.Owner.Name,
|
||||
acct == null ? "<none>" : acct.Username,
|
||||
v.IsCommission,
|
||||
charge,
|
||||
funds,
|
||||
charge > 0 ? (funds / charge).ToString() : "n/a",
|
||||
v.NextPayTime.ToUniversalTime()));
|
||||
|
||||
if (++shown >= 3)
|
||||
break;
|
||||
}
|
||||
|
||||
if (shown == 0)
|
||||
Report(to, "no player vendors in the world");
|
||||
}
|
||||
|
||||
// ---- (c) account.login.result ----
|
||||
|
||||
/// <summary>
|
||||
/// Fires the real EventSink.AccountLogin twice against a real account: once with a
|
||||
/// deliberately wrong password and once with the right one.
|
||||
///
|
||||
/// The shard's own AccountHandler is what decides, and it decides AFTER our handler has
|
||||
/// returned. So a correct implementation emits `accepted:false reason:BadPass` for the
|
||||
/// first and `accepted:true` for the second. An implementation that read the verdict
|
||||
/// inside the handler would emit `accepted:true` for BOTH -- which is precisely the bug
|
||||
/// this kind exists to make impossible, and precisely what this probe would show.
|
||||
///
|
||||
/// The password is read from config, never compiled in. `Bridge.Protocol5ProbeAccount`
|
||||
/// and `Bridge.Protocol5ProbePassword`; with no password configured only the failing
|
||||
/// half runs, which is still the half that matters.
|
||||
/// </summary>
|
||||
private static void DriveLogins(Mobile to)
|
||||
{
|
||||
var username = Config.Get("Bridge.Protocol5ProbeAccount", (string)null);
|
||||
|
||||
if (String.IsNullOrEmpty(username))
|
||||
{
|
||||
Report(to, "no Bridge.Protocol5ProbeAccount configured; skipping the login probe");
|
||||
return;
|
||||
}
|
||||
|
||||
var password = Config.Get("Bridge.Protocol5ProbePassword", (string)null);
|
||||
|
||||
// Accounts store a hash, so the rig cannot READ a password to log in with -- it has to
|
||||
// set one. Same posture as BridgeDemoDress, which does this for the same reason: the
|
||||
// value comes from config and is never compiled in or logged.
|
||||
if (!String.IsNullOrEmpty(password))
|
||||
{
|
||||
var acct = Accounts.GetAccount(username) as Account;
|
||||
|
||||
if (acct == null)
|
||||
{
|
||||
Report(to, "account '" + username + "' does not exist; skipping the login probe");
|
||||
return;
|
||||
}
|
||||
|
||||
acct.SetPassword(password);
|
||||
Report(to, "set a known password on '" + username + "' for the accepted half");
|
||||
}
|
||||
|
||||
Report(to, "login probe: '" + username + "' with a WRONG password (expect accepted:false)");
|
||||
Fire(username, "definitely-not-the-password-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
if (String.IsNullOrEmpty(password))
|
||||
{
|
||||
Report(to, "no Bridge.Protocol5ProbePassword configured; skipping the accepted half");
|
||||
return;
|
||||
}
|
||||
|
||||
// Spaced out so the two results are unambiguous in the sidecar's history.
|
||||
Timer.DelayCall(TimeSpan.FromSeconds(3.0), () =>
|
||||
{
|
||||
Report(to, "login probe: '" + username + "' with the RIGHT password (expect accepted:true)");
|
||||
Fire(username, password);
|
||||
});
|
||||
}
|
||||
|
||||
private static void Fire(string username, string password)
|
||||
{
|
||||
// A null NetState is deliberate and is itself part of the test: the real emitter reads
|
||||
// the address defensively because AccountLogin_ReplyRej disposes the state before the
|
||||
// deferred read runs, so it must already survive not having one.
|
||||
EventSink.InvokeAccountLogin(new AccountLoginEventArgs(null, username, password));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user