BridgeAccountLink ties a game account to a website account. [link mints a one-time, 5-minute code from an unambiguous alphabet (no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits link.request. The website relays the code back through the sidecar as link.confirm; the shard validates, writes the WebsiteUserId account tag, and replies link.ok. A bad or expired code gets link.error. The tag persists to accounts.xml in ServUO's standard <tags> format, read by LoadTags at boot, so a link survives restarts with no new persistence layer. mob.login now carries webId when the account is linked, so the sidecar can attribute a session to a site user without a lookup. Safeguards: one-time codes; only the newest code per account is valid; per-account 30s rate limit against code spam; a 1-minute purge bounds the code table; the websiteUserId is trusted only because the socket is loopback-only. The tag reaches memory on confirm but disk only on the next save — a hard crash between loses it, and the player just re-runs [link. Verified end to end with a smart stub that reads the emitted code and confirms it: link.request -> link.confirm -> link.ok, a bad code -> link.error, and the tag observed in accounts.xml after a save. Evidence in docs/PLAN.md §15. The [link command body is exposed as RequestLink(Mobile) so it can be driven in tests without a client. Adds tools/stub_sidecar_link.ps1 and tools/scaffolding/BridgeLinkProbe.cs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
441 lines
15 KiB
C#
441 lines
15 KiB
C#
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;
|
|
|
|
// 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());
|
|
});
|
|
}
|
|
|
|
/// <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()));
|
|
}
|
|
}
|
|
}
|