using System;
using System.Collections.Generic;
using Server.Accounting;
using Server.Commands;
using Server.Mobiles;
using Server.Multis;
using Server.Network;
namespace Server.Custom
{
///
/// 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`.
///
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 ----
///
/// 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.
///
private static void WalkHouseToIdoc(Mobile to)
{
BaseHouse target = null;
var byType = new Dictionary();
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());
}
/// The decay sweep interval, read the same way the bridge reads it.
private static int BridgeConfigSeconds()
{
return Config.Get("Bridge.DecaySweepSeconds", 60);
}
// ---- (b) vendor.listing fees ----
///
/// 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.
///
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 ? "" : 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 ----
///
/// 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.
///
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));
}
}
}