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;
// Carry the linked website id on the login anchor so the sidecar can attribute
// this session (and everything after it) to a site user without a lookup.
var webId = BridgeAccountLink.WebIdFor(m.Account as Account);
var sb = 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);
if (webId != null)
sb.Str("webId", webId);
BridgeLink.Emit(sb.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()));
}
}
}