Three emitter changes and the overlay's protocol declaration, in one PR because
"The bridge is a contract": overlay.toml must be bumped in the same change as the
emitters or the next bundle silently fails to compose.
BridgeSweeps — house.decay gains ownerName and a nested `schedule`
{dynamicDecay, nextStage, decayPeriodSec, estimatedCollapse}.
estimatedCollapse is emitted only where ServUO can actually know it. Dynamic decay
(Core.ML) draws each stage's duration at RANDOM when the stage is entered, so
NextDecayStage is exact for the next transition and nothing beyond it is known —
collapse becomes exact only at IDOC, where the next transition IS the collapse.
Static decay is a pure function of LastRefreshed and DecayPeriod, so it is exact at
every stage. Emitting it anywhere else would publish a guess as a fact, and on the
website's side that becomes a dated promise in someone's mail.
BridgeMarket — vendor.listing gains ownerAcct and a nested `fees` block.
ownerAcct is the one that matters structurally: the frame has carried ownerName
since v3, but a character name joins to nothing — only the game account is the
website's link key. The fees block resolves PlayerVendor.PayTimer's dismissal rule
(pay > totalGold => Destroy) on the shard, because both halves of that comparison
differ between ServUO's two vendor systems and re-deriving them downstream would be
a second implementation of a rule that lives in core.
No daysRemaining: under the old vendor system a pay period is a UO day
(Clock.MinutesPerUODay, about two real hours), so the obvious name would be wrong
by a factor of twelve on exactly the shards least likely to notice. periodsRemaining
plus the interval, and dismissalAt as an instant. A commission vendor has no pay
timer at all and reports exempt with no schedule — "never dismissed" is not the same
as "dismissed in 400 days".
BridgeEvents — a new account.login.result kind.
EventSink.AccountLogin is a veto hook that fires BEFORE the auth decision, and
AccountLoginEventArgs constructs with Accepted = true, so the existing
account.login.attempt fires on successful logins too and cannot carry a verdict. A
security rule built on it would have mailed "someone tried to get into your account"
every time the player logged in.
The verdict is read one Core slice later via DelayCall(Zero). That needs no core
patch AND does not depend on handler subscription order, which ServUO does not define
and a shard's own scripts can change. reason is omitted on an accept, because
ALRReason's zero value is Invalid and would read as a failure reason. The address is
resolved inside the handler, since AccountLogin_ReplyRej disposes the NetState before
the deferred read runs. The password is never read, logged or emitted.
tools/scaffolding/BridgeProtocol5Probe.cs drives all three on a live shard, and the
README records the two traps it took to get there — both of which produce SILENCE
rather than an error, so each looks exactly like a broken emitter:
* An in-process login probe can never produce accepted:true. AccountHandler calls
acct.HasAccess(e.State) BEFORE it checks the password, and a null NetState fails
that. Only a real socket proves the accepted half — and it is the better test
anyway, since it also produces the real ip.
* Forcing a decay stage on a house that cannot decay emits nothing at all. Only
Condemned and ManualRefresh houses decay; an AutoRefresh one — and the owner's
NEWEST house is always AutoRefresh — has a DecayLevel getter that calls
ResetDynamicDecay() and reports Ageless, wiping the forced stage before the sweep
reads it.
Verified on the local rig against the release sidecar: a house walked
Fairly -> Greatly -> IDOC carried estimatedCollapse on the IDOC frame and only there;
every vendor's periodsRemaining matched funds/chargePerPeriod, including one at 0
whose dismissalAt equals its next tick; a real socket login gave
accepted:false reason:BadPass and then accepted:true. Compiles clean against ServUO
57.4 reference assemblies.
Docs: RunicGateway/docs link/v5.md.
Co-Authored-By: Claude <noreply@anthropic.com>
485 lines
18 KiB
C#
485 lines
18 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());
|
|
|
|
EmitLoginResult(e, address);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Protocol 5. The RESULT of the login above, which the attempt itself cannot carry.
|
|
///
|
|
/// Why a second kind rather than two more fields: PacketHandlers.AccountLogin invokes this
|
|
/// sink and only THEN branches on e.Accepted, and the decision is made by the handlers
|
|
/// themselves -- Server.Misc.AccountHandler is the one that validates the password and
|
|
/// sets Accepted/RejectReason. Inside our own handler the verdict therefore does not exist
|
|
/// yet: Accepted is still its constructor default of `true` for a password that is about
|
|
/// to be rejected. Anything built on the attempt alone fires on every SUCCESSFUL login
|
|
/// too, which is the wrong way round for a security notice -- it would tell a player
|
|
/// "someone tried to get into your account" every time they logged in themselves.
|
|
///
|
|
/// Reading it one Core slice later, via DelayCall(Zero), is what makes the verdict final
|
|
/// without a core patch and without depending on handler subscription ORDER, which
|
|
/// ServUO does not define and which a shard's own scripts can change.
|
|
///
|
|
/// On holding the args object: it carries the plaintext Password, so it is deliberately
|
|
/// alive for one extra slice and no longer, and exactly two properties are read off it.
|
|
/// The password is never read, never logged and never emitted -- the same rule the
|
|
/// attempt emitter above states.
|
|
/// </summary>
|
|
private static void EmitLoginResult(AccountLoginEventArgs e, string address)
|
|
{
|
|
// The NetState is disposed by AccountLogin_ReplyRej before this runs, which is why the
|
|
// address is passed in already resolved rather than re-read from e.State.
|
|
Timer.DelayCall(TimeSpan.Zero, () =>
|
|
Guard("account.login.result", () =>
|
|
{
|
|
var sb = BridgeJson.Begin("account.login.result")
|
|
.Str("acct", e.Username)
|
|
.Str("ip", address)
|
|
.Bool("accepted", e.Accepted);
|
|
|
|
// ALRReason is only meaningful on a rejection; on an accept it is still the
|
|
// enum's zero value (Invalid), which would read as a failure reason if emitted.
|
|
if (!e.Accepted)
|
|
sb.Str("reason", e.RejectReason.ToString());
|
|
|
|
BridgeLink.Emit(sb.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()));
|
|
}
|
|
}
|
|
}
|