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

Merged
whitlocktech merged 1 commits from fix/vendor-fee-signature into edge 2026-09-01 12:32:15 +00:00
Member

Found by engagement Phase 11b's live acceptance walk. Pairs with website#179, Module-uo#24 and docs#.

The defect

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 alters nothing the sweep compares, emits no frame, and uo.vendor.expiring — the notification whose entire subject is a vendor running out of gold — can fire only by coincidence: when somebody happens to reprice an item on a shop that is already broke. Which is the one shop nobody is restocking.

Proved on the rig by setting a vendor's held gold to zero and watching nothing follow:

06:15:30  funds 129745  periods 14      ← last frame, before the change
(gold set to 0)
(nothing)

…and after the fix, the same knob, with no price or stock change anywhere:

06:53:56  funds 900000  periods 102
06:54:58  funds 0       periods 0       ← the frame that was never coming

The fix, and why the derived values

The signature carries exempt and periodsRemaining, not holdGold / bankAccount / nextPayAt.

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. HoldGold changes on every sale and NextPayTime on every pay tick, and keying on either would re-emit a fat listing frame for a shop whose listings had not changed.

AppendFeeSignature sits directly beside AppendFees and mirrors its branches (commission → exempt; charge <= 0 → free), with a comment saying so, because the failure mode of the two drifting apart is exactly this bug again.

Emit cadence, not frame shape. No field added, PROTOCOL_VERSION untouched, overlay.toml unchanged, sidecar unaffected. The general form is worth carrying to the next enrichment and is now recorded in docs/link/v5.md: 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: tools/scaffolding/BridgeRigDriver.cs

The walk needed the shard driven from outside the game, one verb per line in a file the driver polls and truncates. Every other probe here runs a fixed script at boot or from an in-game client, and both are the wrong shape: 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.

decaylist · decay · vendorlist · vendorfunds · citylist · governor · election · activate · password · save · shutdown. Flag RigDriverEnabled, absent from overlay/Config/Bridge.cfg like every other scaffolding flag, so a server whose config lacks the key never runs it. Never deployed — deploy.ps1 copies only overlay/.

The README gains the two ServUO facts the walk cost a rebuild each to learn:

  • A decaying house cannot be refreshed; only its owner coming back rescues it. RefreshDecay() returns false outright for DecayType.Condemned, and on a seeded world every house that can decay is Condemned — the seeder backdates accounts past Account.InactiveDuration precisely to make them decay. The rescue then reports Ageless for the owner's newest house and LikeNew for an older one, and a consumer watching only for LikeNew misses the common case.
  • 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. Hence the driver's shutdown verb (Core.Kill).

Verification

  • dotnet build Scripts.csproj -c Release clean (ServUO compiles the plugin at boot; there is no CI build).
  • deploy.ps1 -Verify clean before, add=0 change=2 after.
  • The frames above are from the real sidecar's /history on a live shard.
  • Downstream: uo.vendor.expiring then fired once to the linked owner on both channels, and the second crossing inside the day was cooled — the rung this unblocks.

AI-assisted: written with Claude Code.

🤖 Generated with Claude Code

Found by engagement Phase 11b's live acceptance walk. Pairs with **website#179**, **Module-uo#24** and **docs#**. ## The defect `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 alters nothing the sweep compares, emits no frame, and `uo.vendor.expiring` — the notification whose entire subject is *a vendor running out of gold* — can fire only by coincidence: when somebody happens to reprice an item on a shop that is already broke. Which is the one shop nobody is restocking. Proved on the rig by setting a vendor's held gold to zero and watching nothing follow: ``` 06:15:30 funds 129745 periods 14 ← last frame, before the change (gold set to 0) (nothing) ``` …and after the fix, the same knob, with no price or stock change anywhere: ``` 06:53:56 funds 900000 periods 102 06:54:58 funds 0 periods 0 ← the frame that was never coming ``` ## The fix, and why the derived values The signature carries `exempt` and `periodsRemaining`, **not** `holdGold` / `bankAccount` / `nextPayAt`. `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. `HoldGold` changes on every sale and `NextPayTime` on every pay tick, and keying on either would re-emit a **fat listing frame** for a shop whose listings had not changed. `AppendFeeSignature` sits directly beside `AppendFees` and mirrors its branches (commission → exempt; `charge <= 0` → free), with a comment saying so, because the failure mode of the two drifting apart is exactly this bug again. **Emit cadence, not frame shape.** No field added, `PROTOCOL_VERSION` untouched, `overlay.toml` unchanged, sidecar unaffected. The general form is worth carrying to the next enrichment and is now recorded in `docs/link/v5.md`: *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: `tools/scaffolding/BridgeRigDriver.cs` The walk needed the shard driven from **outside** the game, one verb per line in a file the driver polls and truncates. Every other probe here runs a fixed script at boot or from an in-game client, and both are the wrong shape: 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. `decaylist` · `decay` · `vendorlist` · `vendorfunds` · `citylist` · `governor` · `election` · `activate` · `password` · `save` · `shutdown`. Flag `RigDriverEnabled`, absent from `overlay/Config/Bridge.cfg` like every other scaffolding flag, so a server whose config lacks the key never runs it. Never deployed — `deploy.ps1` copies only `overlay/`. The README gains the two ServUO facts the walk cost a rebuild each to learn: - **A decaying house cannot be refreshed; only its owner coming back rescues it.** `RefreshDecay()` returns false outright for `DecayType.Condemned`, and on a seeded world every house that *can* decay is Condemned — the seeder backdates accounts past `Account.InactiveDuration` precisely to make them decay. The rescue then reports **`Ageless`** for the owner's newest house and `LikeNew` for an older one, and a consumer watching only for `LikeNew` misses the common case. - **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. Hence the driver's `shutdown` verb (`Core.Kill`). ## Verification - `dotnet build Scripts.csproj -c Release` clean (ServUO compiles the plugin at boot; there is no CI build). - `deploy.ps1 -Verify` clean before, `add=0 change=2` after. - The frames above are from the real sidecar's `/history` on a live shard. - Downstream: `uo.vendor.expiring` then fired once to the linked owner on both channels, and the second crossing inside the day was cooled — the rung this unblocks. --- AI-assisted: written with Claude Code. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
wtclaude added 1 commit 2026-09-01 12:15:33 +00:00
`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>
whitlocktech merged commit 28b878c850 into edge 2026-09-01 12:32:15 +00:00
whitlocktech deleted branch fix/vendor-fee-signature 2026-09-01 12:32:15 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: RunicGateway/servuo-plugins#19
No description provided.