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:
432
overlay/Scripts/Custom/Bridge/BridgeEvents.cs
Normal file
432
overlay/Scripts/Custom/Bridge/BridgeEvents.cs
Normal 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
tools/scaffolding/BridgeEventProbe.cs
Normal file
68
tools/scaffolding/BridgeEventProbe.cs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
using Server.Accounting;
|
||||||
|
using Server.Mobiles;
|
||||||
|
|
||||||
|
namespace Server.Custom
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fires a handful of the bridge's event streams by doing real things to the world, so the
|
||||||
|
/// emit path and JSON shape can be verified without a game client attached.
|
||||||
|
///
|
||||||
|
/// These are genuine triggers, not synthetic EventSink.Invoke calls: DepositGold raises
|
||||||
|
/// AccountGoldChange from Account.cs:1635, the Fame/Karma setters raise theirs from
|
||||||
|
/// Mobile.cs:7121,7141, and World.Save raises the save boundaries from World.cs:1151,1202.
|
||||||
|
/// Calling Invoke directly would prove only that the handler compiles.
|
||||||
|
///
|
||||||
|
/// Test scaffolding. Never deployed. Mutates the world (gold, fame, karma) and saves.
|
||||||
|
/// Run only against a seeded throwaway world with a backup.
|
||||||
|
/// </summary>
|
||||||
|
public static class BridgeEventProbe
|
||||||
|
{
|
||||||
|
public static void Initialize()
|
||||||
|
{
|
||||||
|
if (Config.Get("Bridge.EventProbeOnStart", false))
|
||||||
|
EventSink.ServerStarted += () => Timer.DelayCall(TimeSpan.FromSeconds(4.0), Run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Run()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var acct = Accounting.Accounts.GetAccount("seed_000") as Account;
|
||||||
|
|
||||||
|
if (acct == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[EventProbe] no seed_000 account; seed the world first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pm = acct[0] as PlayerMobile;
|
||||||
|
|
||||||
|
if (pm == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[EventProbe] seed_000 has no character in slot 0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine("[EventProbe] firing gold.change ...");
|
||||||
|
acct.DepositGold(12345);
|
||||||
|
|
||||||
|
Console.WriteLine("[EventProbe] firing fame.change ...");
|
||||||
|
pm.Fame = pm.Fame + 100;
|
||||||
|
|
||||||
|
Console.WriteLine("[EventProbe] firing karma.change ...");
|
||||||
|
pm.Karma = pm.Karma - 50;
|
||||||
|
|
||||||
|
Console.WriteLine("[EventProbe] firing world.save.before / world.save.after ...");
|
||||||
|
World.Save();
|
||||||
|
|
||||||
|
Console.WriteLine("[EventProbe] done");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[EventProbe] FAILED: " + ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ These two scripts produced the measured budget in `docs/PLAN.md` §1. They are k
|
|||||||
|------|--------------------------|------|
|
|------|--------------------------|------|
|
||||||
| `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. |
|
| `BridgeSeeder.cs` | `Scripts/Custom/BridgeSeeder.cs` | Populates a synthetic world: 50 accounts, 150 characters, 30 houses, 30 player vendors with 40 listings each. |
|
||||||
| `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. |
|
| `BridgeProbe.cs` | `Scripts/Custom/BridgeProbe.cs` | Times every read the plugin performs, on the Core thread. Read-only. |
|
||||||
|
| `BridgeEventProbe.cs` | `Scripts/Custom/BridgeEventProbe.cs` | Fires gold/fame/karma/save events through their real code paths so the emit path can be verified without a game client. **Mutates the world and saves.** Flag: `EventProbeOnStart`. |
|
||||||
|
|
||||||
|
## Deploy overwrites Bridge.cfg
|
||||||
|
|
||||||
|
`deploy.ps1` copies `overlay/Config/Bridge.cfg`, which deliberately omits the scaffolding flags. So **every deploy strips `SeedOnStart` / `EventProbeOnStart` / etc.** Re-append the flag you need after deploying, or the probe silently does nothing on the next boot. (This bit once during Phase 2 testing.)
|
||||||
|
|
||||||
## Using them
|
## Using them
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user