Phase 2: cheap event streams

BridgeEvents subscribes the streams selected for tracking, economy, and cheat
detection: Login/Logout/AccountLogin, AccountGoldChange, ValidVendorPurchase/
Sell, PlacePlayerVendor, SkillGain, FameChange, KarmaChange, QuestComplete,
PlayerDeath, PlayerMurdered, OnKilledBy, FastWalk, OnPropertyChanged, Command,
and Before/AfterWorldSave.

Every handler runs on the Core thread inside the path that raised it, so each is
wrapped to never throw, does only Emit (which enqueues and returns), and never
mutates the args. Three of these are veto hooks and are read strictly:
AccountLogin (Accepted/RejectReason, and a plaintext Password we never emit),
FastWalk (Blocked), and the login decision path generally.

Testing on the live shard found that SkillGain fires for NPCs, hard: the first
boot emitted 115 skill.gain events in four seconds, all spawned creatures
grinding Meditation, zero players. That is the general rule here — most "player"
events also fire for NPCs — so SkillGain, FameChange, KarmaChange, and OnKilledBy
all filter to players on the Core thread before the socket. Gold, fame, karma,
and the save boundaries were fired through their real code paths and observed at
the stub sidecar; gold.change round-trips the platinum->gold conversion and
persists across restarts. Evidence in docs/PLAN.md §12.

Adds tools/scaffolding/BridgeEventProbe.cs (never deployed) which triggers those
events through real world mutations rather than synthetic Invoke calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 10:46:41 -05:00
parent 9c02ba45dc
commit d5035cb6c6
5 changed files with 538 additions and 2 deletions

View File

@@ -0,0 +1,432 @@
using System;
using System.Text;
using Server.Accounting;
using Server.Commands;
using Server.Mobiles;
namespace Server.Custom.Bridge
{
/// <summary>
/// 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.
/// </summary>
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 ----
/// <summary>Writes a nested actor object: serial, name, and account when there is one.</summary>
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());
});
}
/// <summary>
/// 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.
/// </summary>
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());
});
}
/// <summary>
/// ValidVendorPurchase is a validation-stage hook, not a committed sale. Treat as
/// "attempted". Total is AmountPerUnit times the stack size, not AmountPerUnit.
/// </summary>
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 ----
/// <summary>
/// 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.
/// </summary>
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()));
}
/// <summary>
/// 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.
/// </summary>
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 ----
/// <summary>
/// Veto hook: FastWalkEventArgs.Blocked gates the move. Read only. The args carry only
/// a NetState, and NetState.Mobile can be null mid-handshake.
/// </summary>
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());
});
}
/// <summary>
/// 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.
/// </summary>
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()));
}
/// <summary>
/// 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.
/// </summary>
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()));
}
}
}