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())); } } }