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