Merge pull request 'feat(bridge): protocol 5 — cutover 2a of 7 (edgemain)' (#20) from edge into main
All checks were successful
Release overlay / release (push) Successful in 23s

Reviewed-on: #20
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-09-01 13:55:02 +00:00
7 changed files with 1049 additions and 2 deletions

View File

@@ -23,8 +23,8 @@
# manual duty: when the protocol changes, bump it here in the same PR that
# changes the emitters, exactly as link bumps PROTOCOL_VERSION.
#
# Current: 4 — see docs/link/v4.md (guild.roster, guild.leave).
protocol = 4
# Current: 5 — see docs/link/v5.md (house.decay scheduling, vendor.listing fees, account.login.result).
protocol = 5
# ── ServUO compatibility ─────────────────────────────────────────────────────
#

View File

@@ -170,9 +170,53 @@ namespace Server.Custom.Bridge
.Str("acct", e.Username)
.Str("ip", address)
.End());
EmitLoginResult(e, address);
});
}
/// <summary>
/// Protocol 5. The RESULT of the login above, which the attempt itself cannot carry.
///
/// Why a second kind rather than two more fields: PacketHandlers.AccountLogin invokes this
/// sink and only THEN branches on e.Accepted, and the decision is made by the handlers
/// themselves -- Server.Misc.AccountHandler is the one that validates the password and
/// sets Accepted/RejectReason. Inside our own handler the verdict therefore does not exist
/// yet: Accepted is still its constructor default of `true` for a password that is about
/// to be rejected. Anything built on the attempt alone fires on every SUCCESSFUL login
/// too, which is the wrong way round for a security notice -- it would tell a player
/// "someone tried to get into your account" every time they logged in themselves.
///
/// Reading it one Core slice later, via DelayCall(Zero), is what makes the verdict final
/// without a core patch and without depending on handler subscription ORDER, which
/// ServUO does not define and which a shard's own scripts can change.
///
/// On holding the args object: it carries the plaintext Password, so it is deliberately
/// alive for one extra slice and no longer, and exactly two properties are read off it.
/// The password is never read, never logged and never emitted -- the same rule the
/// attempt emitter above states.
/// </summary>
private static void EmitLoginResult(AccountLoginEventArgs e, string address)
{
// The NetState is disposed by AccountLogin_ReplyRej before this runs, which is why the
// address is passed in already resolved rather than re-read from e.State.
Timer.DelayCall(TimeSpan.Zero, () =>
Guard("account.login.result", () =>
{
var sb = BridgeJson.Begin("account.login.result")
.Str("acct", e.Username)
.Str("ip", address)
.Bool("accepted", e.Accepted);
// ALRReason is only meaningful on a rejection; on an accept it is still the
// enum's zero value (Invalid), which would read as a failure reason if emitted.
if (!e.Accepted)
sb.Str("reason", e.RejectReason.ToString());
BridgeLink.Emit(sb.End());
}));
}
// ---- economy ----
private static void OnGoldChange(AccountGoldChangeEventArgs e)

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Text;
using Server.Accounting;
using Server.Items;
using Server.Mobiles;
using Server.Multis;
@@ -455,6 +456,20 @@ namespace Server.Custom.Bridge
sb.Append(vendor.Map == null ? "" : vendor.Map.Name).Append('|');
sb.Append(vendor.X).Append(',').Append(vendor.Y).Append('|');
// The FEE STATE, and it belongs here for a reason found on a live rig: a vendor
// quietly running out of gold changes none of the fields above, so without this the
// sweep sees no change, emits nothing, and `uo.vendor.expiring` -- the warning whose
// entire subject is a vendor running out of gold -- can only fire by coincidence,
// when somebody happens to reprice an item on a shop that is already broke.
//
// The DERIVED values, not the raw ones. `periodsRemaining` is an integer division, so
// it moves only when the shard's own answer to "is this vendor in danger" moves --
// near-zero extra frame volume -- while `HoldGold` changes on every sale and
// `NextPayTime` on every tick, which would re-emit a fat listing frame for a shop
// whose listings did not change. Protocol-neutral: the fields already ship in
// `AppendFees`, and this changes only WHEN a frame is sent.
AppendFeeSignature(sb, vendor);
var limit = Math.Min(_items.Count, BridgeConfig.MarketMaxListings);
sb.Append(_items.Count).Append('|');
@@ -506,8 +521,16 @@ namespace Server.Custom.Bridge
{
sb.Ser("ownerSerial", owner.Serial);
sb.Str("ownerName", owner.Name);
// Protocol 5. Without this the listing names an owner the website cannot resolve to
// a person: ownerName is a character name, and only the account is the link key.
var acct = owner.Account as Account;
if (acct != null)
sb.Str("ownerAcct", acct.Username);
}
AppendFees(sb, vendor);
sb.Append(",\"location\":{\"map\":");
Text(sb, vendor.Map == null ? null : vendor.Map.Name);
sb.Append(",\"x\":").Append(vendor.X);
@@ -587,5 +610,96 @@ namespace Server.Custom.Bridge
return sb.End();
}
/// <summary>
/// Protocol 5. The vendor's fee state, which is what makes "your vendor is about to be
/// dismissed" a thing the website can say BEFORE it happens instead of after.
///
/// The dismissal rule is PlayerVendor.PayTimer.OnTick: at every tick the charge is
/// compared with the funds, and `if (pay > totalGold) Destroy()`. Both halves of that
/// comparison differ between ServUO's two vendor systems, so both are resolved here
/// rather than left for the sidecar or the website to guess at:
///
/// | charge | funds | interval
/// NewVendorSystem | ChargePerRealWorldDay | HoldGold | 1 real day
/// old system | ChargePerDay | BankAccount + HoldGold | 1 UO day
///
/// Two consequences worth stating, because both are easy to get wrong downstream:
///
/// * A field called `daysRemaining` would be WRONG on an old-system shard, where a pay
/// period is a UO day (Clock.MinutesPerUODay, roughly two real hours) rather than a
/// real one. So this emits `periodsRemaining` plus the interval that gives it meaning,
/// and resolves the arithmetic into `dismissalAt` -- an instant, which needs no units.
/// * A commission vendor (IsCommission) has no PayTimer at all and is never dismissed
/// for fees. It reports exempt:true and no schedule, rather than a misleading
/// "infinite days".
///
/// `dismissalAt` assumes no further sales or deposits, exactly as a bank balance
/// projection does. Unlike a dynamic-decay house, though, there is no randomness in it:
/// given the current funds it is the exact tick the vendor is destroyed on.
/// </summary>
/// <summary>
/// The fee state as the change-detector sees it: exempt, and how many pay ticks the
/// vendor survives. Kept beside `AppendFees` so the two cannot drift -- a fee field
/// that becomes decision-relevant has to be added in both places, and this comment is
/// where the next person is told so.
/// </summary>
private static void AppendFeeSignature(StringBuilder sb, PlayerVendor vendor)
{
if (vendor == null || vendor.IsCommission)
{
sb.Append("exempt|");
return;
}
int charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
int funds = BaseHouse.NewVendorSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
// Mirrors AppendFees: a free vendor never runs out, and reports no periods at all.
sb.Append(charge > 0 ? (funds / charge).ToString() : "free").Append('|');
}
private static void AppendFees(StringBuilder sb, PlayerVendor vendor)
{
sb.Append(",\"fees\":{");
if (vendor.IsCommission)
{
sb.Append("\"exempt\":true}");
return;
}
bool newSystem = BaseHouse.NewVendorSystem;
int charge = newSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
int funds = newSystem ? vendor.HoldGold : vendor.BankAccount + vendor.HoldGold;
sb.Append("\"exempt\":false");
sb.Append(",\"newVendorSystem\":").Append(newSystem ? "true" : "false");
sb.Append(",\"chargePerPeriod\":").Append(charge);
sb.Append(",\"funds\":").Append(funds);
sb.Append(",\"holdGold\":").Append(vendor.HoldGold);
sb.Append(",\"bankAccount\":").Append(vendor.BankAccount);
var interval = newSystem ? TimeSpan.FromDays(1.0) : TimeSpan.FromMinutes(Clock.MinutesPerUODay);
sb.Append(",\"payIntervalSec\":").Append((long)interval.TotalSeconds);
var nextPay = vendor.NextPayTime.ToUniversalTime();
sb.Append(",\"nextPayAt\":");
Text(sb, nextPay.ToString("o"));
// A free vendor (no priced stock under the old system can reach charge 0) never runs out.
if (charge > 0)
{
// Ticks it survives before the one that finds pay > totalGold.
long periods = funds / charge;
sb.Append(",\"periodsRemaining\":").Append(periods);
sb.Append(",\"dismissalAt\":");
Text(sb, nextPay.AddSeconds(periods * interval.TotalSeconds).ToString("o"));
}
sb.Append('}');
}
}
}

View File

@@ -215,11 +215,14 @@ namespace Server.Custom.Bridge
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)
@@ -232,6 +235,67 @@ namespace Server.Custom.Bridge
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>

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

View File

@@ -0,0 +1,486 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Server.Accounting;
using Server.Commands;
using Server.Engines.CityLoyalty;
using Server.Mobiles;
using Server.Multis;
namespace Server.Custom
{
/// <summary>
/// Drives the shard from OUTSIDE the game, one verb per line in a file the driver polls.
///
/// Every other probe here runs a fixed script at boot or from `[command`, and both are the
/// wrong shape for an acceptance walk: a walk asserts what happened BETWEEN two steps
/// ("one mail, then nothing for a day"), so the steps have to be separated by the observer
/// rather than by a hard-coded delay -- and ServUO's console reads a fixed verb set
/// (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard
/// at all. A file is the one channel a headless shard already has.
///
/// Write one or more lines to `Config/rigcmd.txt`; the driver runs them on the Core thread
/// within a second, prints `[RigDriver]` lines, and TRUNCATES the file so the next write is
/// the next command. Output is console-only: nothing here emits, and everything observed
/// travels the real bridge.
///
/// Verbs:
/// decaylist houses that CAN decay, with owner account and stage
/// decay &lt;serial|any&gt; &lt;stage&gt; force a decay stage (LikeNew|Slightly|Somewhat|
/// Fairly|Greatly|IDOC|Collapsed)
/// vendorlist player vendors, with owner account and next pay time
/// vendorfunds &lt;serial&gt; &lt;gold&gt; set a vendor's held gold (drives periodsRemaining)
/// citylist cities, governors and election phases
/// governor &lt;city&gt; &lt;mobile|none&gt; seat a governor (a mobile serial, or a player's name)
/// election &lt;city&gt; force a new election into its nomination window
/// activate &lt;account&gt; clear an account's inactivity, so its houses stop
/// being Condemned and CAN be refreshed
/// password &lt;account&gt; &lt;pw&gt; set a game account's password (for a login probe)
/// save a world save
/// shutdown a CLEAN shutdown, so the bridge emits server.shutdown
///
/// Test scaffolding. Never deployed; `deploy.ps1` copies only `overlay/`.
/// </summary>
public static class BridgeRigDriver
{
private static string _path;
private static DateTime _lastWrite = DateTime.MinValue;
public static void Initialize()
{
if (!Config.Get("Bridge.RigDriverEnabled", false))
return;
_path = Path.Combine(Core.BaseDirectory, "Config", "rigcmd.txt");
CommandSystem.Register("rigdriver", AccessLevel.Administrator, e => Poll());
Console.WriteLine("[RigDriver] watching {0}", _path);
Timer.DelayCall(TimeSpan.FromSeconds(2.0), TimeSpan.FromSeconds(1.0), Poll);
}
// ---- the poll ----
private static void Poll()
{
try
{
if (!File.Exists(_path))
return;
// Written-and-not-finished is a real case: the observer writes with a shell
// redirect while this timer fires. An empty file is nothing to do, and the
// timestamp guard keeps a slow write from being run twice.
var stamp = File.GetLastWriteTimeUtc(_path);
if (stamp <= _lastWrite)
return;
var lines = File.ReadAllLines(_path);
if (lines.Length == 0)
return;
_lastWrite = stamp;
File.WriteAllText(_path, String.Empty);
foreach (var line in lines)
{
var trimmed = (line ?? String.Empty).Trim();
if (trimmed.Length == 0 || trimmed.StartsWith("#"))
continue;
try
{
Run(trimmed);
}
catch (Exception ex)
{
Say("\"" + trimmed + "\" threw: " + ex.Message);
}
}
Say("done");
}
catch (IOException)
{
// The writer still holds it. Next tick.
}
catch (Exception ex)
{
Say("poll threw: " + ex.Message);
}
}
private static void Say(string line)
{
Console.WriteLine("[RigDriver] " + line);
}
private static void Run(string line)
{
var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
var verb = parts[0].ToLowerInvariant();
switch (verb)
{
case "decaylist": DecayList(); break;
case "decay": Decay(Arg(parts, 1), Arg(parts, 2)); break;
case "vendorlist": VendorList(); break;
case "vendorfunds": VendorFunds(Arg(parts, 1), Arg(parts, 2)); break;
case "citylist": CityList(); break;
case "governor": Governor(Arg(parts, 1), Arg(parts, 2)); break;
case "election": Election(Arg(parts, 1)); break;
case "activate": Activate(Arg(parts, 1)); break;
case "password": Password(Arg(parts, 1), Arg(parts, 2)); break;
case "save": Say("saving"); Misc.AutoSave.Save(); break;
// A clean shutdown, which is the only kind that EMITS. `Stop-Process` drops the
// socket and the shard says nothing, so a killed shard is indistinguishable from
// a wedged one -- and `uo.server.down` never fires. Core.Kill runs
// EventSink.Shutdown, which is what BridgeBoot listens on.
case "shutdown": Say("shutting down"); Timer.DelayCall(TimeSpan.Zero, () => Core.Kill(false)); break;
default: Say("unknown verb \"" + verb + "\""); break;
}
}
private static string Arg(string[] parts, int i)
{
return i < parts.Length ? parts[i] : null;
}
// ---- houses ----
/// <summary>
/// `CanDecay` is the filter, and getting it wrong is silent: an AutoRefresh house --
/// and the owner's newest house is always AutoRefresh -- has a DecayLevel getter that
/// calls ResetDynamicDecay(), so a forced stage is wiped before the sweep reads it and
/// NOTHING is emitted. That looks exactly like a broken emitter.
/// </summary>
private static IEnumerable<BaseHouse> Decayable()
{
return BaseHouse.AllHouses
.Where(h => h != null && !h.Deleted && h.Owner != null && h.CanDecay);
}
private static void DecayList()
{
foreach (var h in Decayable())
{
var acct = h.Owner.Account == null ? "-" : h.Owner.Account.Username;
Say(String.Format(
"house 0x{0:X} owner={1} acct={2} name=\"{3}\" region={4} type={5} level={6}",
h.Serial.Value, h.Owner.Name, acct, HouseName(h), RegionName(h),
h.DecayType, h.DecayLevel));
}
Say("decayable=" + Decayable().Count());
}
private static string HouseName(BaseHouse h)
{
return h.Sign != null && h.Sign.Name != null ? h.Sign.Name : String.Empty;
}
private static string RegionName(BaseHouse h)
{
var r = Region.Find(h.Location, h.Map);
return r == null ? "-" : r.Name ?? "-";
}
private static void Decay(string which, string stage)
{
DecayLevel level;
if (!TryParseStage(stage, out level))
{
Say("unknown stage \"" + stage + "\"");
return;
}
BaseHouse house = null;
if (String.IsNullOrEmpty(which) || which == "any")
house = Decayable().FirstOrDefault();
else
{
var serial = ParseSerial(which);
house = Decayable().FirstOrDefault(h => h.Serial.Value == serial);
}
if (house == null)
{
Say("no decayable house matched \"" + which + "\"");
return;
}
var from = house.DecayLevel;
// A refresh is what a player does at the sign, and it is NOT SetDynamicDecay: the
// level is derived from LastRefreshed, so a "LikeNew" that only rewrote the dynamic
// stage would be undone by the next read.
if (level == DecayLevel.LikeNew)
house.RefreshDecay();
else
house.SetDynamicDecay(level);
Say(String.Format(
"house 0x{0:X} {1} -> {2} (now {3})",
house.Serial.Value, from, level, house.DecayLevel));
}
private static bool TryParseStage(string s, out DecayLevel level)
{
level = DecayLevel.Ageless;
if (String.IsNullOrEmpty(s))
return false;
foreach (DecayLevel candidate in Enum.GetValues(typeof(DecayLevel)))
{
if (String.Equals(candidate.ToString(), s, StringComparison.OrdinalIgnoreCase))
{
level = candidate;
return true;
}
}
return false;
}
private static int ParseSerial(string s)
{
var text = s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? s.Substring(2) : s;
int parsed;
if (Int32.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out parsed))
return parsed;
return Int32.TryParse(s, out parsed) ? parsed : 0;
}
// ---- vendors ----
private static IEnumerable<PlayerVendor> Vendors()
{
return World.Mobiles.Values.OfType<PlayerVendor>().Where(v => !v.Deleted);
}
private static void VendorList()
{
Say("NewVendorSystem=" + BaseHouse.NewVendorSystem);
foreach (var v in Vendors())
{
var owner = v.Owner;
var acct = owner == null || owner.Account == null ? "-" : owner.Account.Username;
Say(String.Format(
"vendor 0x{0:X} shop=\"{1}\" owner={2} acct={3} hold={4} charge={5} nextPay={6}",
v.Serial.Value, v.ShopName, owner == null ? "-" : owner.Name, acct,
v.HoldGold, v.ChargePerDay, v.NextPayTime.ToUniversalTime().ToString("o")));
}
Say("vendors=" + Vendors().Count());
}
/// <summary>
/// Set a vendor's held gold, which is the only knob that walks it toward dismissal
/// without waiting a pay period -- `NextPayTime` has a private setter, and a period is
/// a real day on the new vendor system and a UO day (~2 real hours) on the old one.
/// The emitter computes `periodsRemaining` as funds / chargePerPeriod, so this moves
/// exactly the field the threshold tracker watches.
/// </summary>
private static void VendorFunds(string which, string gold)
{
var serial = ParseSerial(which ?? String.Empty);
var vendor = Vendors().FirstOrDefault(v => v.Serial.Value == serial);
if (vendor == null)
{
Say("no vendor matched \"" + which + "\"");
return;
}
int funds;
if (!Int32.TryParse(gold, out funds))
{
Say("bad gold \"" + gold + "\"");
return;
}
// Both, because the old vendor system spends BankAccount + HoldGold and the new one
// spends HoldGold alone -- setting one would leave the other paying the charge.
vendor.HoldGold = funds;
vendor.BankAccount = 0;
var charge = BaseHouse.NewVendorSystem ? vendor.ChargePerRealWorldDay : vendor.ChargePerDay;
Say(String.Format(
"vendor 0x{0:X} hold={1} bank=0 charge={2} periodsRemaining={3}",
vendor.Serial.Value, vendor.HoldGold, charge, charge > 0 ? funds / charge : -1));
}
// ---- accounts ----
/// <summary>
/// Mark an account as having just logged in.
///
/// This is the ONLY way to walk a decaying house back out of danger on a seeded
/// world, and the reason is ServUO's, not the rig's: every house that CAN decay here
/// is `DecayType.Condemned` (the seeder backdates accounts past
/// `Account.InactiveDuration` precisely to make them decay), and
/// `BaseHouse.RefreshDecay()` returns false immediately for a Condemned house. A
/// condemned house is not refreshable by anyone; it is rescued by its OWNER LOGGING
/// IN, which is what this reproduces.
///
/// What the shard then reports depends on how many houses the owner has:
/// `AutoRefresh` (their newest) stops decaying and reads **Ageless**, while an older
/// `ManualRefresh` one is back on the clock and reads **LikeNew**. Both are "out of
/// danger", and a mapper that reads only one of them misses most rescues.
/// </summary>
private static void Activate(string username)
{
var acct = Accounts.GetAccount(username) as Account;
if (acct == null)
{
Say("no account \"" + username + "\"");
return;
}
acct.LastLogin = DateTime.UtcNow;
Say(String.Format("account {0} lastLogin=now inactive={1}", acct.Username, acct.Inactive));
foreach (var h in BaseHouse.AllHouses)
{
if (h == null || h.Deleted || h.Owner == null || h.Owner.Account != acct)
continue;
Say(String.Format(
" house 0x{0:X} type={1} level={2}", h.Serial.Value, h.DecayType, h.DecayLevel));
}
}
/// <summary>
/// Set a game account's password, so a login can be driven over a real socket.
///
/// The socket is not optional for the ACCEPTED half: ServUO's own AccountHandler calls
/// `acct.HasAccess(e.State)` before it ever checks the password, and a null NetState
/// fails that -- so an in-process probe reports "access denied" for a correct password
/// and can never produce `accepted:true`.
/// </summary>
private static void Password(string username, string pw)
{
var acct = Accounts.GetAccount(username) as Account;
if (acct == null)
{
Say("no account \"" + username + "\"");
return;
}
if (String.IsNullOrEmpty(pw))
{
Say("refusing to set an empty password");
return;
}
acct.SetPassword(pw);
Say("account " + acct.Username + " password set");
}
// ---- cities ----
private static void CityList()
{
Say("CityLoyaltySystem.Enabled=" + CityLoyaltySystem.Enabled);
foreach (var city in CityLoyaltySystem.Cities)
{
if (city == null)
continue;
var e = city.Election;
Say(String.Format(
"city={0} governor={1} elect={2} election={3} candidates={4} autoPick={5}",
city.City,
city.Governor == null ? "-" : city.Governor.Name + "/0x" + city.Governor.Serial.Value.ToString("X"),
city.GovernorElect == null ? "-" : city.GovernorElect.Name,
e == null ? "-" : (e.CanNominate() ? "nominate" : e.CanVote() ? "vote" : e.Ongoing ? "pending" : "none"),
e == null || e.Candidates == null ? 0 : e.Candidates.Count,
e == null ? "-" : e.AutoPickGovernor.ToUniversalTime().ToString("o")));
}
}
private static CityLoyaltySystem FindCity(string name)
{
return CityLoyaltySystem.Cities.FirstOrDefault(
c => c != null && String.Equals(c.City.ToString(), name, StringComparison.OrdinalIgnoreCase));
}
private static void Governor(string cityName, string who)
{
var city = FindCity(cityName);
if (city == null)
{
Say("no city \"" + cityName + "\"");
return;
}
if (String.Equals(who, "none", StringComparison.OrdinalIgnoreCase))
{
city.Governor = null;
Say("city=" + city.City + " governor cleared");
return;
}
var mob = FindMobile(who);
if (mob == null)
{
Say("no player matched \"" + who + "\"");
return;
}
city.Governor = mob;
var acct = mob.Account == null ? "-" : mob.Account.Username;
Say(String.Format(
"city={0} governor={1} 0x{2:X} acct={3}",
city.City, mob.Name, mob.Serial.Value, acct));
}
private static Mobile FindMobile(string who)
{
var serial = ParseSerial(who);
if (serial != 0)
{
var bySerial = World.FindMobile(serial);
if (bySerial != null)
return bySerial;
}
return World.Mobiles.Values.OfType<PlayerMobile>()
.FirstOrDefault(m => !m.Deleted && String.Equals(m.Name, who, StringComparison.OrdinalIgnoreCase));
}
private static void Election(string cityName)
{
var city = FindCity(cityName);
if (city == null)
{
Say("no city \"" + cityName + "\"");
return;
}
if (city.Election == null)
{
Say("city=" + city.City + " has no election object");
return;
}
city.Election.StartNewElection();
Say(String.Format(
"city={0} election restarted; autoPick={1} nominate={2}",
city.City,
city.Election.AutoPickGovernor.ToUniversalTime().ToString("o"),
city.Election.CanNominate()));
}
}
}

View File

@@ -14,6 +14,8 @@ These two scripts produced the measured budget in [PLAN.md](https://gitea.whitlo
| `BridgeCrierProbe.cs` | `Scripts/Custom/BridgeCrierProbe.cs` | Logs the global town-crier entry list every 3s so `towncrier.add` / `remove` can be seen landing in game state. Flag: `CrierProbeOnStart`. |
| `BridgeVendorSaleProbe.cs` | `Scripts/Custom/BridgeVendorSaleProbe.cs` | Fires `PlayerVendorSale` (Phase 7) with real seeded-vendor data so `vendor.sale` can be verified without a live buy. Requires the Phase 7 patches applied. Flag: `VendorSaleProbeOnStart`. |
| `BridgeDemoDress.cs` | `Scripts/Custom/BridgeDemoDress.cs` | Renames a seeded world so it is presentable in a screenshot: shop signs, vendor and character names, house signs. Also stages a few condemned houses back into IDOC, and sets a known password on `seed_000` so a character can be logged in. Flags: `DemoDressOnStart`, `DemoDressPassword`. In game: `[demodress`. |
| `BridgeRigDriver.cs` | `Scripts/Custom/BridgeRigDriver.cs` | Drives the shard from OUTSIDE the game, one verb per line in `Config/rigcmd.txt`, which the driver polls and truncates. Written for the engagement Phase 11b acceptance walk, where each step's assertion is what happened BETWEEN two steps, so the steps have to be separated by the observer rather than by a hard-coded delay -- and ServUO's console takes a fixed verb set (`Scripts/Misc/ConsoleCommands.cs`), so `[p5probe` cannot be typed at a headless shard at all. Verbs: `decaylist`, `decay`, `vendorlist`, `vendorfunds`, `citylist`, `governor`, `election`, `activate`, `password`, `save`, `shutdown`. Flag: `RigDriverEnabled`. **Sets passwords and mutates the world.** |
| `BridgeProtocol5Probe.cs` | `Scripts/Custom/BridgeProtocol5Probe.cs` | Drives all three Protocol 5 enrichments so their frames can be observed: walks one house Fairly -> Greatly -> IDOC (the PAIR is the assertion -- `estimatedCollapse` must appear only on the IDOC frame), reports each player vendor's fee state straight off the `PlayerVendor` so the emitted `fees` block can be checked against the shard's own numbers, and fires `EventSink.AccountLogin`. Flags: `Protocol5ProbeOnStart`, `Protocol5ProbeAccount`, `Protocol5ProbePassword`. In game: `[p5probe`. **Sets a password on the named account.** |
## Deploy overwrites Bridge.cfg
@@ -106,3 +108,65 @@ Probe, best-of-20 on the Core thread:
```
Seeded characters carry 8 items with ~6 mods each and ~12 trained skills. A real endgame character has more of both, so profile cost and payload are a **floor** — budget 24× for a fully-kitted character.
## The login half needs a socket, not the sink
`BridgeProtocol5Probe` fires `EventSink.InvokeAccountLogin` directly, which proves the REJECTED
half of `account.login.result` and nothing more. ServUO's own `AccountHandler` calls
`acct.HasAccess(e.State)` *before* it ever checks the password, and a null `NetState` fails that --
so an in-process probe logs `Access denied` for a correct password too, and never produces an
`accepted:true`.
To prove the accepted half, speak the wire. A real socket also gives the frame a real `ip`, which
is one of the fields being tested:
```python
# 4-byte seed, then 0x80 = [0x80][30b username][30b password][1b]
s = socket.create_connection(('127.0.0.1', 2593))
s.sendall(b'\x7f\x00\x00\x01')
s.sendall(b'\x80' + pad(user) + pad(password) + b'\x5d')
```
The shard logs `Invalid password for '<acct>'` or `Valid credentials for '<acct>'`, and the sidecar's
`/history?kind=account.login.result` should show `accepted:false reason:BadPass` and `accepted:true`
respectively. **Both saying `accepted:true` is the bug the kind exists to prevent** -- it means the
verdict was read inside the handler, before it existed.
## Walking a house into IDOC needs a house that can decay
Only a `Condemned` or `ManualRefresh` house decays. An `AutoRefresh` one -- 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 sees no
change, and **nothing is emitted at all**. That looks exactly like a broken emitter. Filter on
`house.CanDecay`, and expect a seeded world to have only one or two houses that qualify -- both
probably already at IDOC, so the walk has to put one back down first.
## A decaying house cannot be refreshed — only its owner coming back rescues it
`BaseHouse.RefreshDecay()` returns `false` immediately when `DecayType == Condemned`, and on a
seeded world **every house that can decay is Condemned** — the seeder backdates 18 accounts past
`Account.InactiveDuration` precisely to make them decay. So `SetDynamicDecay(DecayLevel.LikeNew)`
is wiped by the next read and `RefreshDecay()` does nothing: the sweep sees no change and emits
nothing, which looks exactly like a broken emitter for the second time on the same page.
The rescue is the OWNER LOGGING IN (`BridgeRigDriver`'s `activate <account>` reproduces it by
setting `LastLogin`). What the shard then reports depends on how many houses that owner has:
| the house | `DecayType` after the login | `DecayLevel` reads |
|---|---|---|
| their newest | `AutoRefresh` | **`Ageless`** — off the decay clock entirely |
| any older one | `ManualRefresh` | **`LikeNew`** — back on the clock, at the top |
Both are "out of danger", and the newest-house case is the common one. A consumer that watches only
for `LikeNew` misses most rescues — which is what the engagement mapper did until this walk.
## The console takes a fixed verb set, so `[commands` cannot be typed at a headless shard
`Scripts/Misc/ConsoleCommands.cs` handles `save`, `shutdown`, `restart`, `online`, `kick` and a
handful more; it does **not** dispatch arbitrary `[commands`. Every other probe here therefore runs
either at boot or from an in-game client, and neither works for a walk driven from a script. That is
what `BridgeRigDriver` and its `rigcmd.txt` are for.
Also: only a CLEAN shutdown emits. `Stop-Process` drops the socket and the shard says nothing, so a
killed shard is indistinguishable from a wedged one and `server.shutdown` never reaches the sidecar —
use the driver's `shutdown` verb (`Core.Kill`) when the shutdown itself is what is being tested.