diff --git a/patches/BridgeVendorSale.cs b/patches/BridgeVendorSale.cs
new file mode 100644
index 0000000..459102c
--- /dev/null
+++ b/patches/BridgeVendorSale.cs
@@ -0,0 +1,83 @@
+using System;
+
+using Server;
+using Server.Accounting;
+using Server.Custom.Bridge;
+
+namespace Server.Custom.Bridge
+{
+ ///
+ /// Subscribes to the PlayerVendorSale event added by the Phase 7 core patches. This file is
+ /// part of that coupled unit and is NOT in overlay/, because it references
+ /// PlayerVendorSaleEventArgs, which does not exist until the EventSink patch is applied —
+ /// shipping it in overlay/ would break the build on any install without the patch.
+ ///
+ /// Deploy: apply patches/playervendor-sale-*.patch, then copy this file to
+ /// Scripts/Custom/Bridge/BridgeVendorSale.cs.
+ ///
+ /// The event fires at the committed sale (PlayerVendorBuyGump.OnResponse), on the Core
+ /// thread, with buyer, vendor owner, item, price, and commission all in scope — richer than
+ /// the NPC ValidVendor* events (which lack owner and commission) and, unlike them, on a
+ /// committed sale rather than a validation stage. It is the backbone of the cheat-detection
+ /// feed: same-account buyer≈owner is gold laundering, off-market prices and burst patterns
+ /// are visible to the sidecar.
+ ///
+ public static class BridgeVendorSale
+ {
+ public static void Initialize()
+ {
+ if (!BridgeConfig.Enabled)
+ return;
+
+ EventSink.PlayerVendorSale += OnPlayerVendorSale;
+ Console.WriteLine("[Bridge] player-vendor sale stream attached");
+ }
+
+ private static void OnPlayerVendorSale(PlayerVendorSaleEventArgs e)
+ {
+ try
+ {
+ var sb = BridgeJson.Begin("vendor.sale")
+ .Bool("committed", true);
+
+ // Buyer
+ if (e.Buyer != null)
+ {
+ sb.Ser("buyerSerial", e.Buyer.Serial);
+ var ba = e.Buyer.Account as Account;
+ if (ba != null)
+ sb.Str("buyerAcct", ba.Username);
+ }
+
+ // Vendor owner — the player who actually profits.
+ if (e.Owner != null)
+ {
+ sb.Ser("ownerSerial", e.Owner.Serial);
+ var oa = e.Owner.Account as Account;
+ if (oa != null)
+ sb.Str("ownerAcct", oa.Username);
+ }
+
+ if (e.Vendor != null)
+ sb.Ser("vendorSerial", e.Vendor.Serial);
+
+ if (e.Item != null)
+ {
+ sb.Ser("itemSerial", e.Item.Serial);
+ sb.Str("itemType", e.Item.GetType().Name);
+ sb.Num("itemId", e.Item.ItemID);
+ sb.Num("amount", e.Item.Amount);
+ }
+
+ sb.Num("price", e.Price);
+ sb.Num("commission", e.Commission);
+
+ BridgeLink.Emit(sb.End());
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[Bridge] vendor.sale handler threw: {0}", ex.Message);
+ }
+ }
+ }
+}
diff --git a/patches/README.md b/patches/README.md
index 32cf8e4..c70f191 100644
--- a/patches/README.md
+++ b/patches/README.md
@@ -9,20 +9,28 @@ git apply --check patches/.patch # dry run
git apply patches/.patch
```
-## Current
+## Phase 7 — player-vendor sale (a coupled unit)
-| Patch | Phase | File | Why |
-|-------|:-----:|------|-----|
-| _(none yet)_ | | | |
+Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, the only place where buyer, vendor **owner**, price, and commission are all in scope — exactly what cheat detection needs. See `docs/PLAN.md` §6.
-## Planned
+This is the one non-drop-in piece. Apply all three together:
-| Patch | Phase | File | Why |
-|-------|:-----:|------|-----|
-| `playervendor-sale-event` | 7 | `Server/EventSink.cs` | Declare `PlayerVendorSale`, `InvokePlayerVendorSale`, `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }`. |
-| `playervendor-sale-event` | 7 | `Scripts/Gumps/PlayerVendorGumps.cs` | One `InvokePlayerVendorSale` call after the `HoldGold +=` at line 96, where the sale commits. |
+| Item | Target | What |
+|------|--------|------|
+| `playervendor-sale-eventsink.patch` | `Server/EventSink.cs` | Adds the `PlayerVendorSale` delegate, `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }`, the event field, and `InvokePlayerVendorSale`. |
+| `playervendor-sale-gump.patch` | `Scripts/Gumps/PlayerVendorGumps.cs` | One `InvokePlayerVendorSale(...)` call right after the committed `HoldGold +=`. |
+| `BridgeVendorSale.cs` | copy to `Scripts/Custom/Bridge/` | The subscriber that emits `vendor.sale`. **Not** in `overlay/` because it references `PlayerVendorSaleEventArgs`, which does not exist until the EventSink patch is applied — shipping it in overlay would break the build on any unpatched install. |
-Player-vendor purchases raise **no** EventSink. `ValidVendorPurchase` / `ValidVendorSell` cover NPC vendors only. The commit point is `PlayerVendorBuyGump.OnResponse`, and it is the only place where buyer, vendor **owner**, price, and commission are all in scope — which is exactly what cheat detection needs. See `docs/PLAN.md` §6.
+```bash
+cd
+git apply --check patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch # dry run
+git apply patches/playervendor-sale-eventsink.patch patches/playervendor-sale-gump.patch
+cp patches/BridgeVendorSale.cs Scripts/Custom/Bridge/BridgeVendorSale.cs
+```
+
+Both patches are `git`-format and verified with `git apply --check` against stock ServUO 57.4. Modifying `EventSink.cs` means the **core** rebuilds, so `ScriptCompiler`'s dynamic script build is not enough — rebuild the solution (`dotnet build ServUO.sln`) or the server binary.
+
+Not applicable to a non-git shard? `git apply` works in a plain directory too. If `patch` is used instead, note the core files are CRLF; use `patch --binary`.
## Note on `Scripts.csproj`
diff --git a/patches/playervendor-sale-eventsink.patch b/patches/playervendor-sale-eventsink.patch
new file mode 100644
index 0000000..d8714c7
--- /dev/null
+++ b/patches/playervendor-sale-eventsink.patch
@@ -0,0 +1,66 @@
+diff --git a/Server/EventSink.cs b/Server/EventSink.cs
+index d30788f..1da2667 100644
+--- a/Server/EventSink.cs
++++ b/Server/EventSink.cs
+@@ -171,6 +171,8 @@ namespace Server
+
+ public delegate void ValidVendorSellEventHandler(ValidVendorSellEventArgs e);
+
++ public delegate void PlayerVendorSaleEventHandler(PlayerVendorSaleEventArgs e);
++
+ public delegate void CorpseLootEventHandler(CorpseLootEventArgs e);
+
+ public delegate void RepairItemEventHandler(RepairItemEventArgs e);
+@@ -1521,6 +1523,29 @@ namespace Server
+ }
+ }
+
++ // Player-vendor purchases raise no other EventSink. This fires at the committed sale in
++ // PlayerVendorBuyGump.OnResponse, where buyer, vendor owner, item, price, and commission
++ // are all in scope -- the data the bridge's cheat-detection feed needs.
++ public class PlayerVendorSaleEventArgs : EventArgs
++ {
++ public Mobile Buyer { get; set; }
++ public Mobile Vendor { get; set; }
++ public Mobile Owner { get; set; }
++ public Item Item { get; set; }
++ public int Price { get; set; }
++ public int Commission { get; set; }
++
++ public PlayerVendorSaleEventArgs(Mobile buyer, Mobile vendor, Mobile owner, Item item, int price, int commission)
++ {
++ Buyer = buyer;
++ Vendor = vendor;
++ Owner = owner;
++ Item = item;
++ Price = price;
++ Commission = commission;
++ }
++ }
++
+ public class CorpseLootEventArgs : EventArgs
+ {
+ public Mobile Mobile { get; set; }
+@@ -1771,6 +1796,7 @@ namespace Server
+ public static event TameCreatureEventHandler TameCreature;
+ public static event ValidVendorPurchaseEventHandler ValidVendorPurchase;
+ public static event ValidVendorSellEventHandler ValidVendorSell;
++ public static event PlayerVendorSaleEventHandler PlayerVendorSale;
+ public static event CorpseLootEventHandler CorpseLoot;
+ public static event RepairItemEventHandler RepairItem;
+ public static event AlterItemEventHandler AlterItem;
+@@ -2416,6 +2442,14 @@ namespace Server
+ }
+ }
+
++ public static void InvokePlayerVendorSale(PlayerVendorSaleEventArgs e)
++ {
++ if (PlayerVendorSale != null)
++ {
++ PlayerVendorSale(e);
++ }
++ }
++
+ public static void InvokeCorpseLoot(CorpseLootEventArgs e)
+ {
+ if (CorpseLoot != null)
diff --git a/patches/playervendor-sale-gump.patch b/patches/playervendor-sale-gump.patch
new file mode 100644
index 0000000..1eedccd
--- /dev/null
+++ b/patches/playervendor-sale-gump.patch
@@ -0,0 +1,15 @@
+diff --git a/Scripts/Gumps/PlayerVendorGumps.cs b/Scripts/Gumps/PlayerVendorGumps.cs
+index 049aae6..f1b30d2 100644
+--- a/Scripts/Gumps/PlayerVendorGumps.cs
++++ b/Scripts/Gumps/PlayerVendorGumps.cs
+@@ -95,6 +95,10 @@ namespace Server.Gumps
+
+ m_Vendor.HoldGold += m_VI.Price - commission;
+
++ // uo-link: the only committed-sale hook for player vendors (no EventSink exists).
++ EventSink.InvokePlayerVendorSale(
++ new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));
++
+ from.SendLocalizedMessage(503201); // You take the item.
+ }
+ }
diff --git a/tools/scaffolding/BridgeVendorSaleProbe.cs b/tools/scaffolding/BridgeVendorSaleProbe.cs
new file mode 100644
index 0000000..dcbd18a
--- /dev/null
+++ b/tools/scaffolding/BridgeVendorSaleProbe.cs
@@ -0,0 +1,104 @@
+using System;
+
+using Server.Accounting;
+using Server.Items;
+using Server.Mobiles;
+
+namespace Server.Custom
+{
+ ///
+ /// Fires PlayerVendorSale with real seeded-vendor data to prove the full chain: the
+ /// EventSink event added by the Phase 7 patch, its args, the subscriber, and the vendor.sale
+ /// JSON. It does NOT exercise the gump call site (that needs a live buyer with a NetState) —
+ /// the hook line is placed at the committed-sale point in PlayerVendorBuyGump.OnResponse and
+ /// is verified by a real in-game purchase.
+ ///
+ /// Test scaffolding. Never deployed. Read-only (invokes an event; changes no world state).
+ ///
+ public static class BridgeVendorSaleProbe
+ {
+ public static void Initialize()
+ {
+ if (Config.Get("Bridge.VendorSaleProbeOnStart", false))
+ EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
+ }
+
+ private static void Run()
+ {
+ try
+ {
+ var vendors = PlayerVendor.PlayerVendors;
+
+ if (vendors == null || vendors.Count == 0)
+ {
+ Console.WriteLine("[VendorSaleProbe] no player vendors; seed the world first");
+ return;
+ }
+
+ // A real seeded vendor, its owner, and one of its actual priced items.
+ PlayerVendor vendor = null;
+ Item item = null;
+ int price = 0;
+
+ foreach (var v in vendors)
+ {
+ if (v == null || v.Deleted || v.Owner == null || v.Backpack == null)
+ continue;
+
+ foreach (var it in v.Backpack.Items)
+ {
+ var vi = v.GetVendorItem(it);
+ if (vi != null)
+ {
+ vendor = v; item = it; price = vi.Price;
+ break;
+ }
+ }
+
+ if (vendor != null)
+ break;
+ }
+
+ if (vendor == null)
+ {
+ Console.WriteLine("[VendorSaleProbe] no vendor with a priced item found");
+ return;
+ }
+
+ // A buyer on a DIFFERENT account than the owner, so buyerAcct != ownerAcct.
+ Mobile buyer = null;
+ foreach (Account a in Accounting.Accounts.GetAccounts())
+ {
+ if (!a.Username.StartsWith("seed_", StringComparison.Ordinal))
+ continue;
+
+ var pm = a[0] as PlayerMobile;
+ if (pm != null && pm != vendor.Owner &&
+ !(vendor.Owner != null && vendor.Owner.Account == a))
+ {
+ buyer = pm;
+ break;
+ }
+ }
+
+ int commission = 0;
+ if (vendor.IsCommission)
+ commission = (int)(price * (vendor.CommissionPerc / 100));
+
+ Console.WriteLine("[VendorSaleProbe] firing PlayerVendorSale: buyer={0} owner={1} item={2} price={3} commission={4}",
+ buyer == null ? "?" : buyer.Name,
+ vendor.Owner == null ? "?" : vendor.Owner.Name,
+ item.GetType().Name, price, commission);
+
+ EventSink.InvokePlayerVendorSale(
+ new PlayerVendorSaleEventArgs(buyer, vendor, vendor.Owner, item, price, commission));
+
+ Console.WriteLine("[VendorSaleProbe] done; watch for vendor.sale");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine("[VendorSaleProbe] FAILED: " + ex);
+ }
+ }
+ }
+}
diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md
index 6269d54..f548e30 100644
--- a/tools/scaffolding/README.md
+++ b/tools/scaffolding/README.md
@@ -12,6 +12,7 @@ These two scripts produced the measured budget in `docs/PLAN.md` §1. They are k
| `BridgeSweepProbe.cs` | `Scripts/Custom/BridgeSweepProbe.cs` | Bumps one seeded house's decay stage after baseline so the decay sweep's transition detection can be observed without waiting a real IDOC stage. Flag: `SweepProbeOnStart`. Pair with short `*SweepSeconds` overrides. |
| `BridgeLinkProbe.cs` | `Scripts/Custom/BridgeLinkProbe.cs` | Triggers `[link` for seed_001 without a client, then saves so the `WebsiteUserId` tag reaches `accounts.xml`. Flag: `LinkProbeOnStart`. Pair with a sidecar that reads the code and sends `link.confirm`. |
| `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`. |
## Deploy overwrites Bridge.cfg