diff --git a/README.md b/README.md index d712a5c..ad36b7a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree |------:|-------| | 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** | | 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** | -| 2 — event streams | not started | +| 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** | | 3 — sweeps | not started | | 4 — request/response | not started | | 5 — `[link` account linking | not started | @@ -65,6 +65,7 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S | `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. | | `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. | | `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. | +| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. | `Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on. diff --git a/docs/PLAN.md b/docs/PLAN.md index 37edf26..3cb8d60 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -295,6 +295,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val | §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. | | §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. | | §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). | +| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. | | §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. | --- @@ -303,7 +304,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.` 1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11. -2. **Cheap event streams.** `Login`, `Logout`, `AccountGoldChange`, `ValidVendorPurchase`, `ValidVendorSell`, `PlayerDeath`, `PlayerMurdered`, `SkillGain`, `QuestComplete`. +2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12. 3. **Sweeps.** Vitals (30 s), decay-on-transition (60 s, with silent `ServerStarted` baseline), economy supply (5 min). All config-tunable; `[bridge reload` re-arms the timers. 4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side. 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. @@ -347,6 +348,35 @@ Two defects were found this way and fixed: --- +## 12. Phase 2 acceptance + +The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar: + +``` +{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345} +{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604} +{"kind":"karma.change",...,"old":7903,"new":7853} +{"kind":"world.save.before"} +{"kind":"world.save.after","items":206312,"mobiles":42826} +``` + +`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts. + +### The finding: `SkillGain` fires for NPCs, hard + +The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events. + +This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar. + +### Safety facts baked into the handlers + +- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process. +- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these. +- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer. +- The property is `FastWalkEventArgs.NetState`, not `.State`. + +--- + ## 10. Operational notes - **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail. diff --git a/overlay/Scripts/Custom/Bridge/BridgeEvents.cs b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs new file mode 100644 index 0000000..9c373ce --- /dev/null +++ b/overlay/Scripts/Custom/Bridge/BridgeEvents.cs @@ -0,0 +1,432 @@ +using System; +using System.Text; + +using Server.Accounting; +using Server.Commands; +using Server.Mobiles; + +namespace Server.Custom.Bridge +{ + /// + /// EventSink subscriptions. Every handler runs on the Core thread, synchronously, inside + /// the code path that raised it. Three rules, all load-bearing: + /// + /// 1. Never block. Emit() enqueues and returns; that is the only I/O allowed here. + /// 2. Never throw. A bridge exception escaping into a game code path is a shard bug, + /// so every handler body is wrapped. + /// 3. Never mutate the args. Several of these are veto hooks — AccountLogin has + /// Accepted/RejectReason, FastWalk has Blocked — and we are an observer, not a + /// participant. + /// + /// Copy primitives out synchronously. Some args objects are pooled and freed immediately + /// after the event returns. + /// + public static class BridgeEvents + { + public static void Initialize() + { + if (!BridgeConfig.Enabled) + return; + + // Session + EventSink.Login += OnLogin; + EventSink.Logout += OnLogout; + EventSink.AccountLogin += OnAccountLogin; + + // Economy + EventSink.AccountGoldChange += OnGoldChange; + EventSink.ValidVendorPurchase += OnVendorPurchase; + EventSink.ValidVendorSell += OnVendorSell; + EventSink.PlacePlayerVendor += OnVendorPlaced; + + // Progression + EventSink.SkillGain += OnSkillGain; + EventSink.FameChange += OnFameChange; + EventSink.KarmaChange += OnKarmaChange; + EventSink.QuestComplete += OnQuestComplete; + + // Death + EventSink.PlayerDeath += OnPlayerDeath; + EventSink.PlayerMurdered += OnPlayerMurdered; + EventSink.OnKilledBy += OnKilledBy; + + // Cheat detection and staff audit + EventSink.FastWalk += OnFastWalk; + EventSink.OnPropertyChanged += OnStaffPropertySet; + EventSink.Command += OnStaffCommand; + + // Save boundaries + EventSink.BeforeWorldSave += OnBeforeWorldSave; + EventSink.AfterWorldSave += OnAfterWorldSave; + + Console.WriteLine("[Bridge] event streams attached"); + } + + // ---- helpers ---- + + /// Writes a nested actor object: serial, name, and account when there is one. + private static StringBuilder Mob(this StringBuilder sb, string field, Mobile m) + { + sb.Append(",\"").Append(field).Append("\":"); + + if (m == null) + { + sb.Append("null"); + return sb; + } + + sb.Append("{\"serial\":\"0x").Append(m.Serial.Value.ToString("X")).Append('"'); + + sb.Append(",\"name\":"); + BridgeJson.Escape(sb, m.Name ?? ""); + + var acct = m.Account as Account; + + if (acct != null) + { + sb.Append(",\"acct\":"); + BridgeJson.Escape(sb, acct.Username); + } + + sb.Append(",\"player\":").Append(m.Player ? "true" : "false"); + sb.Append('}'); + + return sb; + } + + private static long ToGold(double currency) + { + return (long)(currency * Account.CurrencyThreshold); + } + + private static void Guard(string kind, Action body) + { + try + { + body(); + } + catch (Exception ex) + { + // Swallow: we are inside a game code path and must not disturb it. + Console.WriteLine("[Bridge] handler '{0}' threw: {1}", kind, ex.Message); + } + } + + // ---- session ---- + + private static void OnLogin(LoginEventArgs e) + { + Guard("mob.login", () => + { + var m = e.Mobile; + + if (m == null) + return; + + BridgeLink.Emit(BridgeJson.Begin("mob.login") + .Mob("who", m) + .Str("map", m.Map == null ? null : m.Map.Name) + .Num("x", m.X).Num("y", m.Y).Num("z", m.Z) + .End()); + }); + } + + private static void OnLogout(LogoutEventArgs e) + { + Guard("mob.logout", () => + { + var m = e.Mobile; + + if (m == null) + return; + + BridgeLink.Emit(BridgeJson.Begin("mob.logout").Mob("who", m).End()); + }); + } + + /// + /// Veto hook: AccountLoginEventArgs carries Accepted and RejectReason, and a plaintext + /// Password. We read the username only. The password must never leave the process. + /// Fires before the auth decision, so this is an attempt, not a result. + /// + private static void OnAccountLogin(AccountLoginEventArgs e) + { + Guard("account.login.attempt", () => + { + string address = null; + + if (e.State != null && e.State.Address != null) + address = e.State.Address.ToString(); + + BridgeLink.Emit(BridgeJson.Begin("account.login.attempt") + .Str("acct", e.Username) + .Str("ip", address) + .End()); + }); + } + + // ---- economy ---- + + private static void OnGoldChange(AccountGoldChangeEventArgs e) + { + Guard("gold.change", () => + { + var acct = e.Account as Account; + + if (acct == null) + return; + + long oldGold = ToGold(e.OldAmount); + long newGold = ToGold(e.NewAmount); + + BridgeLink.Emit(BridgeJson.Begin("gold.change") + .Str("acct", acct.Username) + .Num("old", oldGold) + .Num("new", newGold) + .Num("delta", newGold - oldGold) + .End()); + }); + } + + /// + /// ValidVendorPurchase is a validation-stage hook, not a committed sale. Treat as + /// "attempted". Total is AmountPerUnit times the stack size, not AmountPerUnit. + /// + private static void OnVendorPurchase(ValidVendorPurchaseEventArgs e) + { + Guard("vendor.buy", () => EmitVendorTrade("vendor.buy", e.Mobile, e.Vendor, e.Bought, e.AmountPerUnit)); + } + + private static void OnVendorSell(ValidVendorSellEventArgs e) + { + Guard("vendor.sell", () => EmitVendorTrade("vendor.sell", e.Mobile, e.Vendor, e.Sold, e.AmountPerUnit)); + } + + private static void EmitVendorTrade(string kind, Mobile who, Mobile vendor, IEntity entity, int perUnit) + { + int amount = 1; + var item = entity as Item; + + if (item != null) + amount = Math.Max(1, item.Amount); + + var sb = BridgeJson.Begin(kind) + .Mob("who", who) + .Mob("vendor", vendor) + .Str("item", entity == null ? null : entity.GetType().Name) + .Num("amount", amount) + .Num("perUnit", perUnit) + .Num("total", (long)perUnit * amount) + .Bool("committed", false); // validation stage; reconcile against gold.change + + if (entity != null) + sb.Ser("itemSerial", entity.Serial); + + BridgeLink.Emit(sb.End()); + } + + private static void OnVendorPlaced(PlacePlayerVendorEventArgs e) + { + Guard("vendor.placed", () => + BridgeLink.Emit(BridgeJson.Begin("vendor.placed") + .Mob("owner", e.Mobile) + .Mob("vendor", e.Vendor) + .End())); + } + + // ---- progression ---- + + /// + /// Player-only. SkillGain fires for creatures too, and they train constantly: on this + /// shard a single boot produced 115 gains in four seconds, every one of them an NPC + /// grinding Meditation. Unfiltered this is a firehose of noise. + /// + private static void OnSkillGain(SkillGainEventArgs e) + { + Guard("skill.gain", () => + { + if (e.Skill == null || e.From == null || !e.From.Player) + return; + + BridgeLink.Emit(BridgeJson.Begin("skill.gain") + .Mob("who", e.From) + .Str("skill", e.Skill.SkillName.ToString()) + .Num("gained", e.Gained) + .Num("base", e.Skill.Base) + .Num("cap", e.Skill.Cap) + .End()); + }); + } + + private static void OnFameChange(FameChangeEventArgs e) + { + Guard("fame.change", () => + { + if (e.Mobile == null || !e.Mobile.Player) + return; + + BridgeLink.Emit(BridgeJson.Begin("fame.change") + .Mob("who", e.Mobile) + .Num("old", e.OldValue) + .Num("new", e.NewValue) + .End()); + }); + } + + private static void OnKarmaChange(KarmaChangeEventArgs e) + { + Guard("karma.change", () => + { + if (e.Mobile == null || !e.Mobile.Player) + return; + + BridgeLink.Emit(BridgeJson.Begin("karma.change") + .Mob("who", e.Mobile) + .Num("old", e.OldValue) + .Num("new", e.NewValue) + .End()); + }); + } + + private static void OnQuestComplete(QuestCompleteEventArgs e) + { + Guard("quest.complete", () => + BridgeLink.Emit(BridgeJson.Begin("quest.complete") + .Mob("who", e.Mobile) + .Str("quest", e.QuestType == null ? null : e.QuestType.Name) + .End())); + } + + // ---- death ---- + + private static void OnPlayerDeath(PlayerDeathEventArgs e) + { + Guard("player.death", () => + BridgeLink.Emit(BridgeJson.Begin("player.death") + .Mob("who", e.Mobile) + .Mob("killer", e.Killer) + .End())); + } + + private static void OnPlayerMurdered(PlayerMurderedEventArgs e) + { + Guard("player.murdered", () => + BridgeLink.Emit(BridgeJson.Begin("player.murdered") + .Mob("victim", e.Victim) + .Mob("murderer", e.Murderer) + .End())); + } + + /// + /// Fires for creatures too. Only a kill involving a player is interesting, and filtering + /// here rather than in the sidecar keeps the mob-grinding firehose off the socket. + /// + private static void OnKilledBy(OnKilledByEventArgs e) + { + Guard("mob.killed", () => + { + var killed = e.Killed; + var killer = e.KilledBy; + + bool involvesPlayer = (killed != null && killed.Player) || (killer != null && killer.Player); + + if (!involvesPlayer) + return; + + BridgeLink.Emit(BridgeJson.Begin("mob.killed") + .Mob("killed", killed) + .Mob("killer", killer) + .End()); + }); + } + + // ---- cheat detection and staff audit ---- + + /// + /// Veto hook: FastWalkEventArgs.Blocked gates the move. Read only. The args carry only + /// a NetState, and NetState.Mobile can be null mid-handshake. + /// + private static void OnFastWalk(FastWalkEventArgs e) + { + Guard("cheat.fastwalk", () => + { + var state = e.NetState; + + if (state == null) + return; + + var sb = BridgeJson.Begin("cheat.fastwalk") + .Mob("who", state.Mobile); + + if (state.Address != null) + sb.Str("ip", state.Address.ToString()); + + BridgeLink.Emit(sb.End()); + }); + } + + /// + /// Raised only from Scripts/Commands/Properties.cs, i.e. staff `[set`. This is a + /// GM-abuse audit trail, not a stat-change stream. One of its three raise sites passes + /// a null Mobile, so the staffer is not always known. + /// + private static void OnStaffPropertySet(OnPropertyChangedEventArgs e) + { + Guard("audit.set", () => + { + if (e.Property == null) + return; + + var sb = BridgeJson.Begin("audit.set") + .Mob("staff", e.Mobile) + .Str("prop", e.Property.Name) + .Str("target", e.Instance == null ? null : e.Instance.GetType().Name) + .Str("old", e.OldValue == null ? null : e.OldValue.ToString()) + .Str("new", e.NewValue == null ? null : e.NewValue.ToString()); + + var ent = e.Instance as IEntity; + + if (ent != null) + sb.Ser("targetSerial", ent.Serial); + + BridgeLink.Emit(sb.End()); + }); + } + + private static void OnStaffCommand(CommandEventArgs e) + { + Guard("audit.command", () => + { + if (e.Mobile == null || e.Mobile.AccessLevel <= AccessLevel.Player) + return; // player commands are noise; staff commands are the audit trail + + BridgeLink.Emit(BridgeJson.Begin("audit.command") + .Mob("staff", e.Mobile) + .Str("command", e.Command) + .Str("args", e.ArgString) + .End()); + }); + } + + // ---- save boundaries ---- + + private static void OnBeforeWorldSave(BeforeWorldSaveEventArgs e) + { + Guard("world.save.before", () => + BridgeLink.Emit(BridgeJson.Begin("world.save.before").End())); + } + + /// + /// A natural checkpoint: the sidecar can treat this as a consistency boundary. Note that + /// timers and inbound commands do not run during the save itself. + /// + private static void OnAfterWorldSave(AfterWorldSaveEventArgs e) + { + Guard("world.save.after", () => + BridgeLink.Emit(BridgeJson.Begin("world.save.after") + .Num("items", World.Items.Count) + .Num("mobiles", World.Mobiles.Count) + .End())); + } + } +} diff --git a/tools/scaffolding/BridgeEventProbe.cs b/tools/scaffolding/BridgeEventProbe.cs new file mode 100644 index 0000000..cf38859 --- /dev/null +++ b/tools/scaffolding/BridgeEventProbe.cs @@ -0,0 +1,68 @@ +using System; + +using Server.Accounting; +using Server.Mobiles; + +namespace Server.Custom +{ + /// + /// Fires a handful of the bridge's event streams by doing real things to the world, so the + /// emit path and JSON shape can be verified without a game client attached. + /// + /// These are genuine triggers, not synthetic EventSink.Invoke calls: DepositGold raises + /// AccountGoldChange from Account.cs:1635, the Fame/Karma setters raise theirs from + /// Mobile.cs:7121,7141, and World.Save raises the save boundaries from World.cs:1151,1202. + /// Calling Invoke directly would prove only that the handler compiles. + /// + /// Test scaffolding. Never deployed. Mutates the world (gold, fame, karma) and saves. + /// Run only against a seeded throwaway world with a backup. + /// + public static class BridgeEventProbe + { + public static void Initialize() + { + if (Config.Get("Bridge.EventProbeOnStart", false)) + EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run); + } + + private static void Run() + { + try + { + var acct = Accounting.Accounts.GetAccount("seed_000") as Account; + + if (acct == null) + { + Console.WriteLine("[EventProbe] no seed_000 account; seed the world first"); + return; + } + + var pm = acct[0] as PlayerMobile; + + if (pm == null) + { + Console.WriteLine("[EventProbe] seed_000 has no character in slot 0"); + return; + } + + Console.WriteLine("[EventProbe] firing gold.change ..."); + acct.DepositGold(12345); + + Console.WriteLine("[EventProbe] firing fame.change ..."); + pm.Fame = pm.Fame + 100; + + Console.WriteLine("[EventProbe] firing karma.change ..."); + pm.Karma = pm.Karma - 50; + + Console.WriteLine("[EventProbe] firing world.save.before / world.save.after ..."); + World.Save(); + + Console.WriteLine("[EventProbe] done"); + } + catch (Exception ex) + { + Console.WriteLine("[EventProbe] FAILED: " + ex); + } + } + } +} diff --git a/tools/scaffolding/README.md b/tools/scaffolding/README.md index 08ae85d..bd24b0a 100644 --- a/tools/scaffolding/README.md +++ b/tools/scaffolding/README.md @@ -8,6 +8,11 @@ These two scripts produced the measured budget in `docs/PLAN.md` §1. They are k |------|--------------------------|------| | `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. | | `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. | +| `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. | + +## Deploy overwrites Bridge.cfg + +`deploy.ps1` copies `overlay/Config/Bridge.cfg`, which deliberately omits the scaffolding flags. So **every deploy strips `SeedOnStart` / `EventProbeOnStart` / etc.** Re-append the flag you need after deploying, or the probe silently does nothing on the next boot. (This bit once during Phase 2 testing.) ## Using them