fix(bridge): make the market sweep notice a vendor running out of gold

`BridgeMarket.Signature()` diffs shop name, owner, map, coordinates and the
item/price list -- the things a LISTING is made of. Protocol 5 added a `fees`
block to the frame and the change detector never learned about it.

So a vendor quietly running down its gold altered nothing the sweep compared,
emitted no frame, and `uo.vendor.expiring` -- the notification whose entire
subject is a vendor running out of gold -- could fire only by coincidence: when
somebody happened to reprice an item on a shop that was already broke. Proved on
the engagement Phase 11b live rig by setting a vendor's held gold to zero and
watching no frame follow.

The signature carries the DERIVED values, `exempt` and `periodsRemaining`, not
the raw ones. An integer division moves only when the shard's own answer to "is
this vendor in danger" moves; `HoldGold` changes on every sale and `NextPayTime`
on every tick, and keying on either would re-emit a fat listing frame for a shop
whose listings had not changed.

Emit CADENCE, not frame shape: no field added, PROTOCOL_VERSION untouched, and
`overlay.toml` unchanged. The general form is worth carrying forward -- a
sweep-based kind has a change detector, and a field added to the frame but not to
the detector ships correct and arrives never.

Also adds `tools/scaffolding/BridgeRigDriver.cs`: the shard driven from outside
the game over a polled command file. A walk asserts 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, so `[p5probe`
cannot be typed at a headless shard at all. Never deployed; `deploy.ps1` copies
only `overlay/`. The README gains the two ServUO facts the walk cost a rebuild
each to learn: a condemned house cannot be refreshed, and only a clean shutdown
emits.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 07:12:46 -05:00
parent a144c12c46
commit 1dd490b483
3 changed files with 552 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ 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
@@ -139,3 +140,33 @@ reports `Ageless`, so a forced `SetDynamicDecay` is wiped on the very next read,
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.