From 81d355349216ceefea841f5178ec61a526d81acd Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 15:46:07 -0500 Subject: [PATCH] Phase 7: PlayerVendorSale core event + subscriber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one non-drop-in piece. Player-vendor purchases raise no EventSink, so the sale is invisible to subscription. Two git-format core patches add a PlayerVendorSale event and raise it at the committed sale in PlayerVendorBuyGump.OnResponse (right after HoldGold +=), where buyer, vendor owner, item, price, and commission are all in scope. The subscriber BridgeVendorSale emits vendor.sale. All three are a coupled unit. The subscriber references PlayerVendorSaleEventArgs, which does not exist until the EventSink patch is applied, so it lives in patches/ not overlay/ -- shipping it in overlay would break the build on any unpatched install. patches/README.md documents applying the unit; both patches verified with git apply --check against stock ServUO 57.4. This is the first phase that rebuilds the core (ServUO.exe), not just Scripts.dll. vendor.sale carries buyer and vendor-owner accounts, both present and distinct, which is the pair that flags gold-laundering when they match -- richer than the ownerless NPC ValidVendor* events, and on a committed sale rather than a validation stage. Verified with a probe firing the event on real seeded-vendor data: vendor.sale emitted with buyerAcct=seed_001, ownerAcct=seed_000, Longsword, price 69819. The probe proves the event, args, subscriber, and payload; the literal gump call site firing on a real purchase needs a live buyer with a NetState and is confirmed by an in-game buy. Evidence in docs/PLAN.md §17. This completes every phase on the ServUO side. Phases 0-6 are drop-in (overlay/); 7 is patches/. Cheat signals are folded into existing streams (fastwalk, audit, vendor.sale), not a separate phase. Remaining work is the Rust sidecar. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +- docs/PLAN.md | 23 +++++ patches/BridgeVendorSale.cs | 83 ++++++++++++++++ patches/README.md | 28 ++++-- patches/playervendor-sale-eventsink.patch | 66 +++++++++++++ patches/playervendor-sale-gump.patch | 15 +++ tools/scaffolding/BridgeVendorSaleProbe.cs | 104 +++++++++++++++++++++ tools/scaffolding/README.md | 1 + 8 files changed, 315 insertions(+), 13 deletions(-) create mode 100644 patches/BridgeVendorSale.cs create mode 100644 patches/playervendor-sale-eventsink.patch create mode 100644 patches/playervendor-sale-gump.patch create mode 100644 tools/scaffolding/BridgeVendorSaleProbe.cs diff --git a/README.md b/README.md index 9fee76d..1f34aad 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,11 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree | 4 — request/response (`BridgeRequests`) | **done, acceptance in `docs/PLAN.md` §14** | | 5 — `[link` account linking (`BridgeAccountLink`) | **done, acceptance in `docs/PLAN.md` §15** | | 6 — town-crier inbound (`BridgeTownCrier`) | **done, acceptance in `docs/PLAN.md` §16** | -| 6 — town-crier inbound | not started | -| 7 — `PlayerVendorSale` core event | not started | -| 8 — cheat signals | not started | +| 7 — `PlayerVendorSale` core event (`patches/` + `BridgeVendorSale`) | **done, acceptance in `docs/PLAN.md` §17** | + +Every phase on the ServUO side is complete. Phases 0–6 are drop-in (`overlay/`); Phase 7 is the one core change, shipped as `patches/`. Remaining work is the Rust sidecar. + +Cheat-detection signals are not a separate phase — they are folded into the streams above: `cheat.fastwalk`, `audit.set`, `audit.command`, and `vendor.sale` (buyer + owner for laundering detection). ## Phase 0 — what it fixes diff --git a/docs/PLAN.md b/docs/PLAN.md index e886427..cefc30d 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -309,6 +309,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. 5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15. 6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16. +7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17. 5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. @@ -350,6 +351,28 @@ Two defects were found this way and fixed: --- +## 17. Phase 7 acceptance + +The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`. + +Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so. + +Verified end to end with a probe that fired the event using **real seeded-vendor data**: + +```json +{"kind":"vendor.sale","committed":true, + "buyerSerial":"0x1F8","buyerAcct":"seed_001", + "ownerSerial":"0x1F5","ownerAcct":"seed_000", + "vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword", + "itemId":3937,"amount":1,"price":69819,"commission":0} +``` + +Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events. + +**Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`). + +--- + ## 16. Phase 6 acceptance `BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary. 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