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

@@ -36,7 +36,7 @@ Anything under `overlay/` is authoritative. Do not edit files in the server tree
|------:|-------| |------:|-------|
| 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** | | 0 — build fix (`Scripts.csproj`) | **done, verified end-to-end** |
| 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** | | 1 — transport (`BridgeLink`) | **done, acceptance in `docs/PLAN.md` §11** |
| 2 — event streams | not started | | 2 — event streams (`BridgeEvents`) | **done, acceptance in `docs/PLAN.md` §12** |
| 3 — sweeps | not started | | 3 — sweeps | not started |
| 4 — request/response | not started | | 4 — request/response | not started |
| 5 — `[link` account linking | not started | | 5 — `[link` account linking | not started |
@@ -65,6 +65,7 @@ Runtime script compilation therefore had no effect, silently. `overlay/Scripts/S
| `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. | | `BridgeJson.cs` | Outbound JSON by hand (Core thread, so no reflection serializer). Inbound via `JavaScriptSerializer`. |
| `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. | | `BridgeLink.cs` | The socket. Link thread owns it; a bounded drop-oldest queue fronts it; a reader thread marshals inbound lines to the Core thread. |
| `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. | | `BridgeBoot.cs` | Lifecycle, inbound dispatch, `[bridge status\|reload\|ping]`. |
| `BridgeEvents.cs` | EventSink subscriptions (Phase 2). Read-only, player-filtered, never emits secrets. |
`Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on. `Emit()` is called from the Core thread. It enqueues and returns — it never touches the socket, never blocks, never allocates a syscall. **A wedged or absent sidecar cannot stall the shard**, and that is the property everything else depends on.

View File

@@ -295,6 +295,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
| §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. | | §IV.4 | Resolve clilocs against `Data/Cliloc.enu`. | No such file. Cliloc data is in the client install via `DataPath`. Resolve website-side. |
| §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. | | §0 | "117 mobiles / 2469 items per the last crash report." | The world holds **203,386 items and 42,591 mobiles** before seeding. |
| §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). | | §II.2 | Stat sweep is the thing to budget for. | Measured free (0.0015 ms/char). The real cost is bulk **profiles** (69 ms/1000). |
| §2 | `SkillGain` is a "medium" player-activity signal. | Fires for NPCs — 115 events in 4 s on a quiet shard, all mob training. Player-filter it or it is a firehose. |
| §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. | | §II.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. |
--- ---
@@ -303,7 +304,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val
0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.` 0. ~~**Fix the build** (§3).~~ **Done.** Verified: a plain boot now logs `Core: Compiling scripts... / Build succeeded.`
1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11. 1. ~~**Transport.**~~ **Done.** `BridgeLink`: `TcpClient`, link thread + bounded drop-oldest queue, reader thread → `Timer.DelayCall`, reconnect with backoff capped at 5 s. Emits `server.hello` / `server.shutdown` / `server.crashed`, answers `ping` with `pong`. `[bridge status|reload|ping]`. Acceptance evidence in §11.
2. **Cheap event streams.** `Login`, `Logout`, `AccountGoldChange`, `ValidVendorPurchase`, `ValidVendorSell`, `PlayerDeath`, `PlayerMurdered`, `SkillGain`, `QuestComplete`. 2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12.
3. **Sweeps.** Vitals (30 s), decay-on-transition (60 s, with silent `ServerStarted` baseline), economy supply (5 min). All config-tunable; `[bridge reload` re-arms the timers. 3. **Sweeps.** Vitals (30 s), decay-on-transition (60 s, with silent `ServerStarted` baseline), economy supply (5 min). All config-tunable; `[bridge reload` re-arms the timers.
4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side. 4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side.
5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed. 5. **`[link` account linking.** `CommandSystem.Register("link", AccessLevel.Player, …)`, one-time short-TTL codes in a main-thread dict, `Account.SetTag("WebsiteUserId", id)` — persists to `accounts.xml` for free. Loopback-only is the trust boundary; add a shared secret if the sidecar is ever exposed.
@@ -347,6 +348,35 @@ Two defects were found this way and fixed:
--- ---
## 12. Phase 2 acceptance
The selected streams (`Login`, `Logout`, `AccountLogin`, `AccountGoldChange`, `ValidVendorPurchase`/`Sell`, `PlacePlayerVendor`, `SkillGain`, `FameChange`, `KarmaChange`, `QuestComplete`, `PlayerDeath`, `PlayerMurdered`, `OnKilledBy`, `FastWalk`, `OnPropertyChanged`, `Command`, `Before`/`AfterWorldSave`) are in `BridgeEvents.cs`. Gold, fame, karma, and the save boundaries were fired through their real code paths (`DepositGold`, the `Fame`/`Karma` setters, `World.Save()`) and observed at the stub sidecar:
```
{"kind":"gold.change","acct":"seed_000","old":3836893,"new":3849238,"delta":12345}
{"kind":"fame.change","who":{"serial":"0x1F5","name":"Seed000A","acct":"seed_000","player":true},"old":4504,"new":4604}
{"kind":"karma.change",...,"old":7903,"new":7853}
{"kind":"world.save.before"}
{"kind":"world.save.after","items":206312,"mobiles":42826}
```
`gold.change` reads `old:3836893`, exactly the previous boot's `new` (the probe adds 12,345 each run), which confirms both the platinum→gold conversion and persistence across restarts.
### The finding: `SkillGain` fires for NPCs, hard
The first run emitted **115 `skill.gain` events in four seconds — every one an NPC** grinding Meditation, zero players. Spawned creatures train constantly. The catalog rated this "Med"; unfiltered it is a firehose of noise on the socket. `OnSkillGain` now drops anything where `!From.Player`. After the filter the same boot produced zero stray skill events.
This is the general rule for this codebase, and the reason each handler filters at the top: **most "player" events also fire for NPCs.** `FameChange`, `KarmaChange`, and `OnKilledBy` are all filtered to players/player-involving for the same reason. Filter on the Core thread, before the socket, not in the sidecar.
### Safety facts baked into the handlers
- **`AccountLoginEventArgs` carries a plaintext `Password`** and is a veto hook (`Accepted`, `RejectReason`). We read the username and IP only; the password never leaves the process.
- **`FastWalkEventArgs.Blocked`** and **`AccountLogin.Accepted`** gate game logic. Handlers are read-only; they never set these.
- **`OnPropertyChanged` passes a null `Mobile`** from one of its three raise sites, so `audit.set` tolerates an unknown staffer.
- The property is `FastWalkEventArgs.NetState`, not `.State`.
---
## 10. Operational notes ## 10. Operational notes
- **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail. - **Commands and timers do not run during a world save.** `TimerMain` early-continues while `World.Saving || World.Loading` (`Server/Timer.cs:322`), and the main loop is inside `World.Save` anyway. A `link.confirm` arriving mid-save is delayed seconds. The website should show "confirming…", not fail.

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()));
}
}
}

View 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);
}
}
}
}

View File

@@ -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