From c87ca88d8693907f5f92df20d5c882bbb981e586 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 04:34:18 -0500 Subject: [PATCH 01/23] Phase 0: fix runtime script compilation ScriptCompiler.Compile() runs `dotnet build Scripts/Scripts.csproj -c Release` with no Platform, so MSBuild defaults to AnyCPU. Scripts.csproj gated both OutputPath and DefineConstants on Configuration|Platform == Release|x64, so under the server's own build the DLL landed in Scripts/bin/Release/ (while the core loads Scripts.dll from the base directory) and TRACE;NEWTIMERS;ServUO went undefined (XmlSpawner compiled its non-ServUO branches). Compile() also never checks the build's exit code before Assembly.LoadFrom, so the failure was silent and the stale DLL reloaded. Runtime script compilation had had no effect since 2026-05-30. Condition both property groups on Configuration alone. Server.csproj is left alone: nothing under Server/ uses those symbols, and giving it OutputPath=..\ would make the boot-time build try to overwrite the running ServUO.exe. Verified end-to-end: a plain boot now logs "Core: Compiling scripts... / Build succeeded." and loads 206208 items, 42771 mobiles. Also adds the implementation plan, the measured performance budget, the test scaffolding used to produce it (seeder + probe, both default-off), and the record of shard repairs that had to precede any of this. Co-Authored-By: Claude Opus 4.8 --- link/PLAN.md | 322 +++++++++++++++++++++++++ link/RESEARCH.md | 544 ++++++++++++++++++++++++++++++++++++++++++ link/SHARD_PREREQS.md | 71 ++++++ 3 files changed, 937 insertions(+) create mode 100644 link/PLAN.md create mode 100644 link/RESEARCH.md create mode 100644 link/SHARD_PREREQS.md diff --git a/link/PLAN.md b/link/PLAN.md new file mode 100644 index 0000000..dc3ecb9 --- /dev/null +++ b/link/PLAN.md @@ -0,0 +1,322 @@ +# ServUO Bridge Plugin — Implementation Plan & Data Catalog + +**Status:** Design, grounded in **measurements taken on this shard**, not estimates. +**Date:** 2026-07-10 +**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**. +**Supersedes** the speculative parts of `BRIDGE_FINDINGS.md`. See [§8](#8-corrections-to-bridge_findingsmd) for where that document is wrong. + +Test scaffolding used to produce this plan lives in `Scripts/Custom/BridgeSeeder.cs` (world population) and `Scripts/Custom/BridgeProbe.cs` (timing). Both are gated behind `Config/Bridge.cfg` flags and default to off. **Neither is part of the bridge.** Delete before production. + +--- + +## 1. Measured budget + +Taken on the seeded world (50 accounts, 150 characters, 35 houses, 30 player vendors, 1200 vendor listings, 206,208 items, 42,771 mobiles). Best-of-20, on the **Core thread** — the probe printed `thread: Core Thread (id 1)`, which empirically confirms the threading model that `BRIDGE_FINDINGS.md` could only infer from a crash log. + +| Read | Cost | Payload | Per-unit | +|------|------|---------|----------| +| Full character profile | **0.069 ms/char** | 2,386 B JSON | — | +| Vitals sweep (150 chars) | 0.223 ms | ~180 B/char | 0.0015 ms/char | +| House decay sweep (35 houses) | 0.007 ms | — | 0.0002 ms/house | +| Economy supply sweep (51 accounts) | 0.001 ms | — | ~0.00002 ms/acct | +| Vendor snapshot (30 vendors, 1200 listings) | 0.343 ms | — | 0.0003 ms/listing | + +Linear extrapolation at the same gear complexity: + +| Scenario | Cost | Verdict | +|----------|------|---------| +| Vitals sweep @ 200 online | 0.30 ms | free | +| Vitals sweep @ 1000 online | 1.49 ms | free | +| Decay sweep @ 2000 houses | 0.38 ms | free | +| Economy @ 5000 accounts | 0.06 ms | free | +| **Profiles for 1000 chars** | **69.4 ms** | **stall — never in a sweep** | + +**The headline result inverts the original doc's anxiety.** `BRIDGE_FINDINGS.md` treated the periodic stat sweep as the thing to budget carefully. Measured, it is free: a thousand online players cost 1.5 ms per sweep, against a 30-second interval. What is *not* free is the full profile — 0.069 ms each is fine one at a time, but it is a hard stall in bulk. **Tier by volatility and serve profiles on demand.** That conclusion survives; the reasoning behind it changes. + +### Caveat on these numbers + +Seeded characters carry **8 equipped items with ~6 non-zero mods each and ~12 trained skills**. A real endgame character has more trained skills (up to 58) and often richer suffix mods. Profile cost and payload size are therefore **understated, plausibly by 2–4×**. Read `0.069 ms / 2.4 KB` as a floor: budget ~0.2 ms and ~6–8 KB per profile for a fully-kitted character. The sweep numbers are unaffected — vitals touch a fixed set of scalars. + +Everything else here is a single fixed shard, so these are one data point, not a curve. They tell you the shape (profiles are 50× a vitals read) and that nothing except bulk profiles is close to a frame budget. + +--- + +## 2. Architecture (confirmed, unchanged) + +``` +ServUO plugin (C#, net48) ──loopback TCP, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website + (Core-thread reads) ◄──inbound commands─────────────┘ (owns WS, auth, buffering, fan-out) +``` + +ServUO does **not** speak WebSocket. It writes `{...}\n` lines to `127.0.0.1`. All backpressure, reconnect, retry, schema validation, and website fan-out live in Rust. + +Non-negotiable rules, all of which the measurements support: + +- **Every world read happens on the Core thread.** Verified: probe reported `Core Thread (id 1)`. +- **The Core thread never touches the socket.** Producer formats a line, enqueues to a bounded `ConcurrentQueue`, returns. A dedicated writer thread drains it. +- **Inbound commands marshal back via `Timer.DelayCall(TimeSpan.Zero, ...)`**, which is lock-protected and cross-thread safe (`Server/Timer.cs:243-251`). The read thread touches no `World`/`Mobile`/`Item` API. +- **Bound the outbound queue** (drop-oldest + a dropped counter). A stalled sidecar must never OOM the shard. +- **Never block or throw inside an EventSink handler.** Several are veto hooks sitting in a transaction path. + +--- + +## 3. Prerequisite: fix the build, or the plugin will not load + +`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) runs `dotnet build Scripts/Scripts.csproj -c Release`, **prints the output, never checks the exit code**, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. Two consequences: + +1. A failing script build is **silently ignored** and the previous `Scripts.dll` reloads. (`BRIDGE_FINDINGS.md` §1 claims the opposite — that a compile error takes the shard down at boot. It does not. It is invisible, which is strictly worse for a bridge you would otherwise assume is running.) +2. The build passes no `Platform`, so it defaults to `AnyCPU`. `OutputPath` is only set under the `Release|x64` condition, so the DLL lands in `Scripts/bin/Release/` while the server loads `Scripts.dll` from the repo root. **Script edits currently never take effect.** + +**Fix before writing any bridge code.** Either add a default `x64` to `Scripts.csproj` and `Server.csproj`, or pass `-p:Platform=x64` in `ScriptCompiler.cs:38`. Without it, `AnyCPU` also leaves `TRACE;NEWTIMERS;ServUO` undefined for the scripts build while the core was compiled with them — a latent mismatch. + +--- + +## 4. Plugin layout + +All under `Scripts/Custom/Bridge/`. Keep each file small and wrap every handler body in `try/catch` — an exception escaping into a game code path is a shard bug. + +| File | Responsibility | +|------|----------------| +| `BridgeConfig.cs` | `Configure()`: read `Config/Bridge.cfg` into static fields. Runs **before** `World.Load`. | +| `BridgeLink.cs` | `TcpClient` to `127.0.0.1`. Writer thread draining a bounded queue; reader thread parsing lines → `Timer.DelayCall`. Reconnect on EOF. | +| `BridgeJson.cs` | Hand-rolled `StringBuilder` writers. No reflection serializer — the probe's numbers assume this. | +| `BridgeEvents.cs` | `Initialize()`: subscribe the EventSink streams in §5. | +| `BridgeSweeps.cs` | Vitals / decay / economy / vendor timers. Re-armable via `[bridge reload`. | +| `BridgeRequests.cs` | Inbound `char.request`, `account.roster`, `vendor.snapshot`. | +| `BridgeLink.Commands.cs` | `[link` registration, code table, `link.confirm` handling. | + +**Lifecycle** (`Server/Main.cs:544-562`, all Core thread): +`Configure()` → `World.Load()` → `Initialize()` → `EventSink.ServerStarted`. + +Read config in `Configure`. Subscribe events in `Initialize`. Open the socket and take the decay baseline on `ServerStarted`. Tear down on `EventSink.Shutdown` — but **`Shutdown` does not fire on a crash** (`Main.cs:198,313`), so the sidecar must treat socket EOF as normal and re-handshake. + +--- + +## 5. Data catalog — everything the shard can give you + +91 `public static event` declarations exist in `Server/EventSink.cs`. Below is every one worth shipping, grouped by stream, with the raise site verified. + +### 5.1 Session & identity + +| Signal | Hook | Freq | Notes | +|--------|------|:----:|-------| +| Player online | `EventSink.Login` | low | Best per-player anchor. Snapshot account, char, serial, map, loc. | +| Player offline | `EventSink.Logout` | low | Pair with Login. | +| Socket up/down | `Connected` / `Disconnected` | low | Lower level; fires at char-select too. | +| Auth attempts | `AccountLogin`, `GameLogin` | low | Failed-login / IP signals for the website. | +| Roster change | `CharacterCreated`, `DeleteRequest` | rare | Keep the sidecar's roster cache honest. | +| Client fingerprint | `ClientVersionReceived`, `ClientTypeReceived` | low | Classic vs Enhanced; version enforcement. | + +### 5.2 Character state + +| Signal | Hook | Freq | Notes | +|--------|------|:----:|-------| +| **Vitals** | 30 s sweep | periodic | **0.0015 ms/char.** hits/mana/stam, str/dex/int, loc, online flag. | +| **Full profile** | on demand + on `Login` | request | **0.069 ms/char, 2.4 KB.** All skills, worn gear, flattened mods, resists. | +| Skill progression | `SkillGain` | medium | High-signal. Ship it. | +| Skill/stat caps | `SkillCapChange`, `StatCapChange` | rare | Powerscroll application. | +| Reputation | `FameChange`, `KarmaChange` | low-med | Naturally diff-shaped. | +| Hunger | `HungerChanged` | low | Cosmetic; optional. | + +> ⚑ **There is still no per-change event for Str/Dex/Int/Hits/Mana/Stam.** They move through the delta queue (`Mobile.ProcessDeltaQueue`). Sweep and let the sidecar diff. At 0.0015 ms/char this is a non-issue — you could sweep every 5 seconds at 1000 players for 1.5 ms and still be free. +> +> ⚑ `EventSink.OnPropertyChanged` **is not** a stat-change hook. It is raised only from `Scripts/Commands/Properties.cs:282,444,472` — i.e. staff `[set` commands. See §5.7. + +### 5.3 Economy & commerce + +| Signal | Hook | Freq | Notes | +|--------|------|:----:|-------| +| Account gold delta | `EventSink.AccountGoldChange` | low-med | ✔ AccountGold is live on this shard. Args give `IAccount` + old/new `TotalCurrency` (a `double`). | +| **Money supply** | economy sweep | periodic | **0.001 ms / 51 accts.** Sum `Account.TotalCurrency` × `Account.CurrencyThreshold`. | +| NPC vendor — buy | `ValidVendorPurchase` | medium | `Scripts/VendorInfo/GenericBuy.cs:379`. **Total = `AmountPerUnit` × stack `Amount`.** | +| NPC vendor — sell | `ValidVendorSell` | medium | `Scripts/Mobiles/NPCs/BaseVendor.cs:2209`. | +| **Player vendor sale** | ⚑ **needs core edit** | medium | See §6. The one non-drop-in piece. | +| Vendor placed | `PlacePlayerVendor` | rare | `PlayerVendorDeed.cs:60,106`, `VendorRentalGumps.cs:418`. Tracks vendor population. | +| Vendor listings | vendor snapshot sweep / on demand | periodic | **0.0003 ms/listing.** Serial, itemId, price, `IsForSale`, `HoldGold`. | +| Item consumed | `OnConsume` | medium | Regs, potions — consumption side of the economy. | + +> ⚠️ `ValidVendorPurchase` / `ValidVendorSell` are **validation-stage veto hooks**, not "sale committed" callbacks. Treat as *sale attempted*; reconcile against `AccountGoldChange` if you need ledger accuracy. **Never block or throw in them.** + +Note: `CurrencyThreshold` is **1,000,000,000** on this shard. `TotalCurrency` is a `double` in *platinum* units. `DepositGold(n)` stores `n / CurrencyThreshold`. Total shard supply measured: **110,478,209 gold** across 51 accounts. Do not read `TotalCurrency` as gold. + +### 5.4 Housing / IDOC + +| Signal | Hook | Freq | Notes | +|--------|------|:----:|-------| +| Decay transition | decay sweep, emit on change | 30–60 s | **0.0002 ms/house.** No EventSink exists. | + +Hold a `Dictionary` and emit only on transition. On `ServerStarted`, take a **silent baseline pass** (populate without emitting), or every house re-announces its stage on every boot. Optionally emit one `idoc.snapshot` for houses already at IDOC/Collapsed, clearly flagged as a snapshot. + +**The decay model in `BRIDGE_FINDINGS.md` §III.3 is wrong for this shard.** Corrected: + +- `DynamicDecay.Enabled` returns `Core.ML` (`Scripts/Multis/DynamicDecay.cs:21`). Expansion is EJ, so **`Core.ML` is true**, so `BaseHouse.GetOldDecayLevel()` and its "IDOC = 95.0–99.9% of `DecayPeriod`" thresholds are **dead code**. The live model is the staged machine (`m_CurrentStage`, `NextDecayStage`, `SetDynamicDecay`). Real IDOC stage duration: **12–24 h random** (`DynamicDecay.cs:18`). +- **`BaseHouse.CanDecay` is true only for `DecayType.Condemned` or `DecayType.ManualRefresh`** (`BaseHouse.cs:136-157`). An active owner's *newest* house is `AutoRefresh` and **never decays**. So a house reaches IDOC only when the owner account is inactive (`LastLogin` older than `Account.InactiveDuration`, 180 days → `Condemned`) or the house is not the owner's newest. +- Any account with `AccessLevel >= GameMaster` — or **any character on it** — makes all its houses `Ageless`. + +Payload per transition: house serial, `from`→`to` level, `X/Y/Z`, `Map`, `BanLocation`, `Region.Name`, `Sign?.GetName()`, owner serial + account, co-owners, `BuiltOn`, `LastRefreshed`, `NextDecayStage`. Guard `Owner`/`Sign`/`Region` for null (abandoned or mid-demolition). Read `house.DecayLevel` **once per house per sweep** into a local — the getter is computed and mutates `m_CurrentStage`. + +### 5.5 Combat, death, PvP + +| Signal | Hook | Freq | Notes | +|--------|------|:----:|-------| +| Player death | `PlayerDeath` | low | | +| Murder | `PlayerMurdered` | low | High-signal for the website. | +| Killer attribution | `OnKilledBy` | medium | `Killed` + `KilledBy`. Better than `PlayerDeath` for PvP feeds. | +| Creature death | `CreatureDeath` | **high** | Every mob kill. Filter or aggregate. | +| Aggression | `AggressiveAction` | med-high | Per aggression state change, **not** per swing. | + +> ⚑ **No per-hit damage event.** Damage numbers require overriding `Mobile.Damage` / weapon `OnHit`, not an EventSink. + +### 5.6 Progression & activity + +`QuestComplete`, `CraftSuccess`, `ResourceHarvestSuccess`, `ResourceHarvestAttempt`, `TameCreature`, `JoinGuild`, `CreateGuild`, `VirtueLevelChange`, `BODOffered`, `BODUsed`, `RepairItem`, `AlterItem`, `Speech`, `OnEnterRegion`. + +`OnEnterRegion` (`Server/Region.cs:1160`) gives `from`, `oldRegion`, `newRegion` — a **cheap location stream**, and the right answer instead of `Movement`. Filter to `PlayerMobile`. + +> ⚠️ **`Movement` is the single most dangerous event to export.** Raised from `Mobile.InternalOnMove` for *every mobile that takes a step*, including all NPCs. It is synchronous and **cancellable** (`args.Blocked` gates the move), so your handler sits inside the movement decision path. Its args are **pooled and `Free()`d immediately** (`EventSink.cs:802-834`) — never retain the reference. Prefer `OnEnterRegion`. +> +> Same caution for `ItemCreated`/`ItemDeleted`/`MobileCreated`/`MobileDeleted` — they fire for every transient object. + +### 5.7 Cheat detection & staff audit + +This is where the catalog earns its keep, and it is thin in the original doc. + +| Signal | Hook | Why | +|--------|------|-----| +| **Speedhack** | `EventSink.FastWalk` | Core's own fast-walk detector. Straight to the fraud feed. | +| **Staff property edits** | `OnPropertyChanged` | Raised only from `[set` (`Properties.cs:282,444,472`). Gives `Mobile` (the staffer), target `Instance`, `PropertyInfo`, old and new value. An audit trail for GM abuse. | +| Staff commands | `EventSink.Command` | Every command invocation. | +| **Player-vendor sale** | new event (§6) | Buyer + owner + price + commission. Same-account buyer≈owner = gold laundering; off-market prices; burst patterns. | +| Gold flow | `AccountGoldChange` | Reconcile against sale stream. | + +### 5.8 Lifecycle + +`ServerStarted`, `Shutdown`, `Crashed`, `WorldLoad`, `WorldSave`, `BeforeWorldSave`, `AfterWorldSave`, `WorldBroadcast`. + +`AfterWorldSave` is a natural snapshot boundary. `Crashed` gives an `args.Close` vote. **`Shutdown` is skipped on a crash.** + +### 5.9 Known gaps (no clean hook) + +- **Item pickup / drop / lift.** No EventSink. Lives on virtuals: `Item.OnDragLift` / `OnDragDrop` / `OnDroppedInto`, `Mobile.OnDragDrop` / `OnDragLift`. Partial coverage via `OnItemObtained`, `ContainerDroppedTo`, `CorpseLoot`. **The biggest remaining gap.** +- **Per-hit combat damage.** Virtual overrides only. +- **Equip / unequip.** `CheckEquipItem` is a *veto* hook; `EquipMacro`/`UnequipMacro` are macro-only. +- **Stat/vital deltas.** Sweep. (Cheap — see §5.2.) + +--- + +## 6. The one core edit: `PlayerVendorSale` + +Player-vendor purchases do **not** raise `ValidVendorPurchase`. The sale commits in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), at the gold transfer: + +```csharp +// PlayerVendorGumps.cs:84-96 +leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack +if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank +int commission = 0; +commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100)); +m_Vendor.HoldGold += m_VI.Price - commission; // seller credited — committed +``` + +At that point everything cheat detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the player who profits), **item** (`m_VI.Item`), **price** (`m_VI.Price`), **commission**. This is *better* data than the NPC `Valid*` events, which lack owner and commission — and unlike them it fires on a **committed** sale. + +Three edits, then the bridge stays pure-subscription: + +1. `Server/EventSink.cs` — declare `PlayerVendorSaleEventHandler PlayerVendorSale`, `InvokePlayerVendorSale`, and `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape). +2. `Scripts/Gumps/PlayerVendorGumps.cs` — one line after the `HoldGold +=` at line 96. +3. Bridge subscribes in `Initialize` like any other event. + +~15 lines. The reflection-based alternative (diffing vendor inventories) cannot identify the **buyer**, which is exactly what cheat detection needs. + +--- + +## 7. Wire protocol + +Newline-delimited JSON, one object per line, `serial` as the primary key. + +### Outbound (shard → sidecar) + +```jsonc +{"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"} +{"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88, + "str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true} +{"t":1752…,"kind":"gold.change","acct":"PerryAdimn","old":12000,"new":11500,"delta":-500} +{"t":1752…,"kind":"vendor.sale","buyer":{"serial":"0x1A2B","acct":"PerryAdimn"}, + "owner":{"serial":"0x33C1","acct":"Feng"},"vendor":"0x0F21", + "item":{"serial":"0x4001A2","type":"Longsword","amount":1},"price":75000,"commission":3750} +{"t":1752…,"kind":"house.decay","serial":"0x40001234","from":"Greatly","to":"IDOC", + "map":"Felucca","x":1420,"y":1631,"z":0,"ban":{"x":1422,"y":1635,"z":0}, + "region":"Britain","name":"The Silver Anvil", + "owner":{"serial":"0x1A2B","acct":"PerryAdimn"},"coOwners":[], + "builtOn":"2026-01-02T…","lastRefreshed":"2026-06-30T…","nextStage":"2026-07-11T…"} +{"t":1752…,"kind":"cheat.fastwalk","serial":"0x1A2B","acct":"PerryAdimn"} +{"t":1752…,"kind":"audit.set","staff":"Feng","target":"0x4001A2","prop":"Price","old":50,"new":1} +{"t":1752…,"kind":"economy.supply","accounts":51,"gold":110478209} +``` + +`char.profile` follows the shape in `BRIDGE_FINDINGS.md` §IV.3 — it was correct — with `mods` a flattened union of non-zero entries across `AosAttributes`, `AosWeaponAttributes`, `AosArmorAttributes`, produced by iterating each enum through the bag's indexer (`Scripts/Misc/AOS.cs:924,1464,2238`). No hardcoded property names. + +### Inbound (sidecar → shard) + +```jsonc +{"kind":"char.request","account":"PerryAdimn","slot":0} +{"kind":"account.roster","account":"PerryAdimn"} +{"kind":"vendor.snapshot","owner":"PerryAdimn"} +{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"} +{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","Market tax is now 5%."],"durationSec":3600} +{"kind":"towncrier.remove","id":"n123"} +``` + +Every inbound handler marshals to the Core thread before touching world state. + +### Item names are clilocs + +`Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory. + +--- + +## 8. Corrections to `BRIDGE_FINDINGS.md` + +| § | Claim | Reality | +|---|-------|---------| +| §1 | "A compile error in your bridge file takes the whole shard down at boot." | **False.** `Compile()` ignores the build exit code; a failing build silently reloads the stale `Scripts.dll`. Worse: your plugin would appear absent, not broken. See §3. | +| §III.3 | IDOC = 95.0–99.9% of `DecayPeriod`, per `GetOldDecayLevel`. | **Dead code on EJ.** `DynamicDecay.Enabled == Core.ML == true`, so the staged machine governs. IDOC lasts 12–24 h. Also: `CanDecay` is true only for `Condemned`/`ManualRefresh`, so an active owner's newest house never decays. | +| §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. | +| §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.4 | Player-vendor sales are the only gap needing a core edit. | Still true, and confirmed at `PlayerVendorGumps.cs:96`. | + +--- + +## 9. Implementation phases + +0. **Fix the build** (§3). Nothing below loads until this is done. +1. **Transport.** `BridgeLink`: `TcpClient`, writer thread + bounded queue, reader thread → `Timer.DelayCall`. Emit `ServerStarted` / `Shutdown` / `Crashed` only. Prove the sidecar can restart independently while the shard runs. +2. **Cheap event streams.** `Login`, `Logout`, `AccountGoldChange`, `ValidVendorPurchase`, `ValidVendorSell`, `PlayerDeath`, `PlayerMurdered`, `SkillGain`, `QuestComplete`. +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. +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. +6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. +7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. +8. **Cheat signals.** `FastWalk`, `OnPropertyChanged` audit, vendor-sale anomaly detection in the sidecar. + +### Config keys (`Config/Bridge.cfg`) + +```ini +Host=127.0.0.1 +Port=7788 +QueueCap=10000 +StatSweepSeconds=30 +DecaySweepSeconds=60 +EconomySweepSeconds=300 +``` + +Read in `Configure()` via `Config.Get("Bridge.", default)`. Key scope is the filename: `Bridge.cfg` + `StatSweepSeconds` → `Bridge.StatSweepSeconds`. + +--- + +## 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. +- **Pending link codes are in-memory** and lost on crash. Acceptable — the player re-runs `[link`. +- **`zlibwapi64` `DllNotFoundException`** already crashed this shard once when sending a packed gump. The DLL is present in the repo root, so it is a working-directory / native-load-path problem. Unrelated to the bridge, but it will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing. +- The bridge should carry the resolved `websiteUserId` on every player event once the account tag is read at `Login` and cached sidecar-side, so the website can attribute stats, gold, and sales to a site user. diff --git a/link/RESEARCH.md b/link/RESEARCH.md new file mode 100644 index 0000000..57ca92e --- /dev/null +++ b/link/RESEARCH.md @@ -0,0 +1,544 @@ +# ServUO ⇄ External Service Bridge — Research Findings + +**Status:** Research only, no implementation. +**Architecture:** Rust sidecar owns a bidirectional WebSocket + JSON endpoint for the website; ServUO links to it over a **local loopback socket**. Tracking players/stats/gold/economy/NPC+player-vendor sales, IDOC/house decay, in-game **`[link`** account linking, and website→game town-crier news. See **Part II** (design/transport/tracking/link), **Part III** (player-vendor, IDOC, town crier, config), and **Part IV** (full character profiles — gear/skills/stats, online & offline, up to 5/account). +**Date:** 2026-07-07 +**Codebase:** ServUO 57.4 (this repo, `C:\Users\colby\Desktop\servuo`), target framework **.NET Framework 4.8 / x64**. +**Method:** Grounded in this repo's source. Where the running server would normally be used to confirm behavior, see the note in [§0](#0-note-on-empirical-verification) — the shard was **not running** at research time, so live-boot verification was deliberately skipped and replaced with source-level proof plus evidence from this repo's own crash logs. A ready-to-run empirical probe is included in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself). + +--- + +## 0. Note on empirical verification + +You said the shard was running and to verify against it. At research time **no `ServUO.exe` / `dotnet` process was live** (`Get-Process` returned nothing; `Logs/Console.log` absent). I chose **not** to boot it myself because a cold boot on this machine would: + +- shell out to `dotnet build Scripts.csproj` (per `ScriptCompiler.Compile`, `Compiler.Dynamic=true` by default), +- **bind the live game port** and load/write your actual `Saves/` world (117 mobiles / 2469 items per the last crash report), +- run `EventSink.ServerStarted` and AutoSave against real state. + +That's outward-facing and hard to reverse, so it needs your go-ahead. **It turned out not to be necessary for the core threading claims**, because: + +1. The source pins the threading model exactly (call sites shown below), and +2. **Your own crash log is live evidence.** `Crash 6-5-2026-22-38-3.log` contains this stack: + + ``` + Server.EventSink.InvokeClientVersionReceived(...) + Server.Network.MessagePump.HandleReceive(NetState ns) + Server.Network.MessagePump.Slice() + Server.Core.Main(String[] args) + ``` + + That is a network-triggered EventSink handler executing **inside `MessagePump.Slice()`, called directly from `Core.Main`** — i.e. on the Core (main) thread, synchronously in the game loop. This is exactly the thread-identity fact item 3/5 hinges on, captured from this instance at runtime. + +If you want the live thread-ID trace anyway (Timer + ServerStarted, no client needed), drop in [Appendix A](#appendix-a-drop-in-empirical-probe-run-this-yourself) and start the shard, or tell me to boot it. + +> ⚠️ Unrelated but worth flagging: that crash was `DllNotFoundException: zlibwapi64`. The DLL **is** present in the repo root, so this is a working-directory / native-load-path issue that has already crashed your shard once when sending a packed gump. Not a bridge concern, but it will bite the bridge too if the bridge ever triggers gump sends. Track separately. + +--- + +## PART II — Re-evaluation for the Rust WebSocket sidecar (READ FIRST) + +**Confirmed architecture (from you):** a **Rust sidecar** holds a bidirectional **WebSocket** connection and exposes a **JSON endpoint the website consumes**. Goals: track players + stats, gold, overall economy, vendor sales; and an in-game **`[link`** command that ties a game account to a website account. + +The §1–§5 findings below are unchanged and still govern (lifecycle, events, timers, threading). This part maps them onto *your* design and supersedes the old §6/§7. + +### II.1 Transport: put the WebSocket in Rust, keep the C# side dumb + +``` +ServUO plugin (C#, net48) ──local loopback, newline-JSON──► Rust sidecar ──WebSocket/JSON──► website + (main-thread events) ◄──inbound commands (link, etc.)──┘ (owns WS, buffering, auth, fan-out) +``` + +**Recommendation: ServUO ↔ sidecar = a plain local TCP loopback socket (`127.0.0.1`), newline-delimited JSON, bidirectional. Do NOT make ServUO speak WebSocket.** + +- `System.Net.WebSockets.ClientWebSocket` *does* exist on net48 + Windows 11 and would work, but it's the wrong place for WS complexity. The sidecar already terminates WS for the website; a second WS hop inside the shard buys nothing and adds a heavier, blockier client on the one thread you must never block (§5). A raw `TcpClient` with `\n`-framed JSON is ~30 lines of C#, trivially non-blocking, and lets the **sidecar restart independently** without touching the shard. +- Named pipes (old §6) also work and are fine if you prefer them; loopback TCP is marginally simpler cross-process and cross-language (Rust `tokio::net::TcpListener` ↔ C# `TcpClient`). +- **This split is exactly what §5 demands.** All backpressure, reconnect, retry, website fan-out, and schema validation live in **Rust**. ServUO only ever does: (outbound) format a small JSON line → enqueue → a background writer thread drains to the socket; (inbound) a background read loop parses a line → `Timer.DelayCall` to the main thread. A slow or absent website can never stall the shard, because the Rust side owns the buffer and the socket write from C# is to loopback with a bounded local queue in front of it. + +**Framing:** newline-delimited JSON objects (`{...}\n`), `PipeTransmissionMode`/message-mode not needed. One writer thread on the C# side keeps event ordering intact. Bound the outbound queue (drop-oldest + a dropped-counter) so a stalled sidecar can't OOM the shard. + +### II.2 Tracking targets → concrete hooks (and the gaps) + +| Target | Hook | Freq | Notes / caveats | +|--------|------|------|-----------------| +| **Player online / identity** | `EventSink.Login` / `Logout` | Low | Snapshot `Account.Username`, char name, `Mobile.Serial`, `Map`, `Location`. Best per-player anchor. | +| **Player stats** (Str/Dex/Int, Hits/Mana/Stam, skills, Fame/Karma) | ⚑ **No per-change EventSink** | — | Strategy: full snapshot on `Login`, then a **periodic sweep** (every 15–30 s) of online `PlayerMobile`s pushed as-is; let the **sidecar diff** and forward only changes. Add `FameChange`/`KarmaChange`/`SkillGain` for high-signal jumps. Don't try to hook the per-stat delta system — it's invasive and firehose-y. | +| **Gold (per player)** | `EventSink.AccountGoldChange` | Low–Med | ✔ **AccountGold is ENABLED on this shard** (expansion EJ ≥ TOL, `CurrentExpansion.cs:20`). Args give `IAccount` + `OldAmount`/`NewAmount` (`TotalCurrency`, a `double`). Most gold flow fires this. Caveat: physical coins/checks sitting in a bankbox aren't fully reflected here — see economy row. | +| **Overall economy / money supply** | Periodic account sweep + flow events | Low | Money **supply** = periodic sum of `TotalCurrency` across all `Accounts` (+ optionally bankbox coin/check items) on the main thread, pushed as a snapshot. Money **velocity/flow** = the `AccountGoldChange` + vendor-sale event stream. Sidecar aggregates both. | +| **NPC vendor — player buys** | `EventSink.ValidVendorPurchase` | Med | Args: `Mobile` (buyer), `Vendor`, `Bought` (IEntity/item), `AmountPerUnit`. **Total = AmountPerUnit × stack `Amount`.** Raised from `GenericBuy.cs:379`. | +| **NPC vendor — player sells** | `EventSink.ValidVendorSell` | Med | Args mirror above (`Sold`, `AmountPerUnit`). Raised from `BaseVendor.cs:2209`. | +| **Player vendor sales** | ⚑ **No EventSink (gap)** | Med | Player-vendor buys go through `PlayerVendor.TryToBuy` (`PlayerVendor.cs:447`), not the Valid* events. To capture these you must override/patch the PlayerVendor buy completion. Flag if the spec counts player-vendor commerce as "vendor sales." | +| **Account ↔ website link** | `Account.Username` + `Account.SetTag/GetTag` | — | `SetTag("WebsiteUserId", id)` persists to `accounts.xml` across restarts (`Account.cs:1078,1093`). No schema/DB work needed on the C# side. | + +> ⚠️ The `Valid*` vendor events are **validation-stage veto hooks**, not "sale committed" callbacks. They fire when the purchase is being validated; in rare cases a sale could still fail afterward. For coarse economy metrics that's fine; if you need exact ledger accuracy, treat them as "sale attempted" and reconcile against `AccountGoldChange`, or hook the actual completion path. **Never block or throw in these handlers** — you're inside the transaction path. + +### II.3 The `[link` command flow + +Prefix is `[` (`Commands.cs:131`), so `[link` is registered directly. Everything below runs on the main thread except the socket I/O. + +1. **Register** in your plugin's `Initialize()`: + `CommandSystem.Register("link", AccessLevel.Player, OnLink);` +2. **`[link` handler** (`e.Mobile`): read `e.Mobile.Account as Account`. If already tagged (`GetTag("WebsiteUserId") != null`), tell them so. Otherwise generate a **short, one-time, expiring code** (e.g. 6–8 chars, 5-min TTL), store `code → {accountUsername, expiry}` in an in-memory dict (main thread), and: + - push `{"kind":"link.request","code":"AB12CD","account":"PerryAdimn","char":"Thunderheat"}` to the sidecar, and + - `e.Mobile.SendMessage("Enter code AB12CD at https://yoursite/link to connect your account.")` +3. **Website** (user logged in there) submits the code → sidecar → ServUO inbound line `{"kind":"link.confirm","code":"AB12CD","websiteUserId":"9931"}`. +4. **Inbound handler** marshals to main thread (`Timer.DelayCall`), validates code + TTL, then `account.SetTag("WebsiteUserId","9931")`, drops the code, and replies `{"kind":"link.ok","account":"PerryAdimn","websiteUserId":"9931"}`. Optionally `SendMessage` the player if still online. +5. **Thereafter**, every player event you emit can carry the resolved `websiteUserId` (read the tag on Login and cache account→id in the sidecar), so the website can attribute stats/gold/sales to a site user. + +Security notes: codes one-time + short-TTL; the link socket is **loopback-only** (bind `127.0.0.1`, never `0.0.0.0`); the account write happens on the main thread; rate-limit `[link` per account to avoid code spam. Treat `websiteUserId` from the sidecar as trusted only because the socket is local — if the sidecar is ever exposed, add a shared secret. + +### II.4 Revised flags for THIS architecture + +1. **✔ Threading is a solved problem given the split.** Because Rust owns WS + buffering and the C# side only does loopback fire-and-forget + `Timer.DelayCall` inbound, the "don't block the main thread" hazard (§5) is contained. This is the single most important reason to keep WebSocket out of ServUO. +2. **⚑ Player stats have no change-event** → sweep-and-diff in the sidecar (II.2). Budget for a 15–30 s snapshot of online players; don't expect push-on-change. +3. **⚑ Player-vendor sales aren't covered by any EventSink** (II.2) — **RESOLVED in §III.1.** You've confirmed this stream is critical (economy + cheat detection), so add the small `PlayerVendorSale` EventSink (~15 lines of core instrumentation). It's the one non-drop-in piece. +4. **⚑ "Economy" needs both a periodic supply snapshot and the flow stream.** `AccountGoldChange` alone is flow, not total; physical bank coins/checks aren't in it. Do a periodic `Accounts` `TotalCurrency` sum for money supply. +5. **✔ Linking needs no new persistence layer** — account tags serialize to `accounts.xml` for free (II.3). Survives restarts and saves. +6. **⚑ Commands/inbound don't apply during world saves** (§5 pitfall 3, ~every 5 min). A `[link.confirm` arriving mid-save is delayed a few seconds — fine for linking, but the website UX should show "confirming…" not fail instantly. +7. **⚑ Crash path skips `Shutdown`** (§1): the sidecar must treat socket EOF as normal and reconnect; don't rely on a clean goodbye frame. Pending link codes are in-memory and lost on crash — acceptable (user re-runs `[link`). +8. **⚑ (unchanged) Item pickup/drop and per-hit combat have no EventSink** (§2 gap) — only relevant if the tracking scope grows beyond stats/gold/economy/vendors. + +--- + +## PART III — Player-vendor tracking, IDOC, town-crier news, config + +Follow-ups you added: **(1)** player-vendor tracking is *critical* (economy balance + admin cheat detection); **(2)** the 30 s stat sweep must be config-editable; **(3)** hook **IDOC / house decay**; **(4)** town criers receive **news pushed from the website**. + +### III.1 Player-vendor sales — the one place you need a small core touch + +There is genuinely **no EventSink** on the player-vendor buy path (confirmed). The purchase *completes* in `PlayerVendorBuyGump.OnResponse` (`Scripts/Gumps/PlayerVendorGumps.cs:41`), specifically at the gold transfer: + +```csharp +// PlayerVendorGumps.cs ~line 81-96 (existing code) +leftPrice -= from.Backpack.ConsumeUpTo(typeof(Gold), leftPrice); // buyer pays from pack +if (leftPrice > 0) Banker.Withdraw(from, leftPrice); // ...and bank +... +commission = (int)(m_VI.Price * (m_Vendor.CommissionPerc / 100)); +m_Vendor.HoldGold += m_VI.Price - commission; // seller credited ◄── sale is now committed +``` + +At that point every field cheat-detection wants is in scope: **buyer** (`from`), **vendor** (`m_Vendor`), **vendor owner** (`m_Vendor.Owner` — the real player who profits), **item** (`m_VI.Item`, incl. `Serial`, type, `Amount`), **price** (`m_VI.Price`), and **commission**. This is *better* data than the NPC-vendor `Valid*` events (which lack owner + commission), and unlike them it fires on a **committed** sale, not a validation stage. + +**Recommendation (idiomatic, minimal): add a first-class EventSink event, mirroring the existing vendor events.** Three tiny edits, then the bridge stays pure-subscription like everything else: + +1. In `Server/EventSink.cs`: declare `public static event PlayerVendorSaleEventHandler PlayerVendorSale;`, an `InvokePlayerVendorSale`, and a `PlayerVendorSaleEventArgs { Buyer, Vendor, Owner, Item, Price, Commission }` (copy the `ValidVendorSellEventArgs` shape at `EventSink.cs:1508`). +2. In `PlayerVendorGumps.cs`, one line right after the `HoldGold +=` at ~line 96: + `EventSink.InvokePlayerVendorSale(new PlayerVendorSaleEventArgs(from, m_Vendor, m_Vendor.Owner, m_VI.Item, m_VI.Price, commission));` +3. Bridge subscribes in `Initialize` like any other event. + +This is **the single spot where the bridge can't be pure drop-in** — worth calling out explicitly since I'd earlier listed player vendors as a "gap." It's a ~15-line core instrumentation, not a rework. (Alternative if you refuse to touch core scripts: a periodic diff of every `PlayerVendor`'s inventory + `HoldGold` — but that can't attribute the *buyer*, which is exactly what cheat detection needs, so it's a poor substitute.) + +**For cheat detection specifically**, emit per sale: buyer serial+account, owner serial+account, item type/serial/amount, price, commission, vendor serial, house/region, timestamp. The sidecar can then flag e.g. same-account buyer≈owner (gold laundering), wildly off-market prices, or burst patterns. Note `m_Vendor.Owner` + `from.Account` are the two identities that matter; both are readable synchronously in the handler (main thread). + +### III.2 Config-editable sweep interval (and other tunables) + +Use ServUO's own config system (`Server/Config.cs`), which reads `Config/*.cfg`. Read tunables in `Configure()` (runs before world load): + +```csharp +StatSweep = Config.Get("Bridge.StatSweepSeconds", 30); +DecaySweep = Config.Get("Bridge.DecaySweepSeconds", 60); +``` + +Drop a `Config/Bridge.cfg` with `Bridge.StatSweepSeconds=30` etc. `Config.Get` handles `int`/`TimeSpan`/`bool`. Make the sweep timer re-readable on demand (a `[bridge reload` admin command that re-reads config and re-arms the `Timer`) so you can retune without a restart. Store all bridge knobs (sweep intervals, which event streams are enabled, sidecar host/port, queue cap) in that one cfg. + +### III.3 IDOC / house decay — sweep `BaseHouse.AllHouses`, emit on transition + +Also **no EventSink** here. The model (`Scripts/Multis/BaseHouse.cs`): + +- `DecayLevel` enum (`BaseHouse.cs:4341`): `Ageless, LikeNew, Slightly, Somewhat, Fairly, Greatly, IDOC, Collapsed, DemolitionPending`. **IDOC = 95.0–99.9%** of the decay period elapsed (`GetOldDecayLevel`, `BaseHouse.cs:211-213`); `Collapsed` = 100%. +- `BaseHouse.AllHouses` is a static list of every house; `Decay_OnTick` (`BaseHouse.cs:59`) already periodically calls `CheckDecay()` on all of them. +- The `DecayLevel` getter has internal transition detection (`m_LastDecayLevel`, `BaseHouse.cs:193`) but it's private and only invalidates the sign — **not** exposed as an event. + +**Decision: emit on transition only, tracked plugin-side.** A low-frequency **sweep** (30–60 s, config per III.2) over `BaseHouse.AllHouses` reads `house.DecayLevel` on the main thread. The plugin holds a `Dictionary` of last-known levels and emits **only when a house's level changes** — no per-sweep spam, one message per real transition. Houses number in the hundreds/thousands (not the mobile firehose), so the sweep is cheap even though we scan all of them each pass. + +**State & re-baseline (important, since the plugin now holds state):** +- The last-known map is **in-memory and resets on restart**. On `ServerStarted` (§1), do a **silent baseline pass**: populate the dictionary from the current `DecayLevel` of every house **without emitting** — otherwise every house re-announces its current stage on every boot. Optionally emit a single `idoc.snapshot` of all houses already at IDOC/Collapsed so the website/admin panel is correct immediately after a restart, clearly flagged as a snapshot (not a transition). +- Emit direction matters for cheat/economy signals: include both `from`/`to` levels so the consumer can tell decay progression from a **refresh** (owner logged in → level jumps back toward `LikeNew`; `RefreshDecay`, `BaseHouse.cs`). A house leaving IDOC because someone refreshed it is itself a useful signal. +- `house.DecayLevel` is a computed property — read it **once per house per sweep** into a local, don't call it repeatedly. + +**Payload (home location state you asked for — all readable synchronously in the sweep):** `BaseHouse` is a `BaseMulti` (an item), so it has `Serial`, `Location`/`X`/`Y`/`Z`, `Map`. Plus: + +| Field | Source | +|-------|--------| +| house serial | `house.Serial` | +| decay from → to | tracked dict → `house.DecayLevel` | +| coords + facet | `house.X/Y/Z`, `house.Map` | +| stable landmark (where a player stands) | `house.BanLocation` (`BaseHouse.cs:3637`) | +| region / area name | `house.Region` (`:3672`) → `Region.Name` | +| house name | `house.Sign?.GetName()` (`:2108`) | +| owner | `house.Owner` (`:3564`) → serial + `Owner.Account.Username` (may be null if abandoned) | +| co-owners / friends | `house.CoOwners`, `house.Friends` (`:3679-3680`) — serials/accounts | +| built / last refreshed | `house.BuiltOn`, `house.LastRefreshed` (`:3786,:66`) | +| time-to-collapse | `house.NextDecayStage` and/or derive from `LastRefreshed + DecayPeriod` | + +Example emit: +```jsonc +{ "kind":"house.decay", "serial":"0x40001234", "from":"Greatly", "to":"IDOC", + "map":"Felucca", "x":1420, "y":1631, "z":0, "ban":{"x":1422,"y":1635,"z":0}, + "region":"Britain", "name":"The Silver Anvil", + "owner":{"serial":"0x1A2B","account":"PerryAdimn"}, + "coOwners":[], "builtOn":"2026-01-02T...", "lastRefreshed":"2026-06-30T...", + "collapseEta":"2026-07-08T..." } +``` + +This gives the website a live IDOC feed with exact map pins and the admin side an owner-attributed decay timeline. Guard against `Owner`/`Sign`/`Region` being null (abandoned or mid-demolition houses). + +### III.4 Town-crier news pushed from the website (inbound → main thread) + +Clean API, no core changes needed: `GlobalTownCrierEntryList.Instance.AddEntry(string[] lines, TimeSpan duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`) posts a **global** entry that *every* town crier announces until it expires; `RemoveEntry(entry)` pulls it early. `AddEntry` returns the `TownCrierEntry`. + +**Flow:** website publishes news → sidecar → ServUO inbound `{"kind":"towncrier.add","id":"n123","lines":["Hear ye!","The market tax is now 5%."],"durationSec":3600}` → **marshal to main thread** (`Timer.DelayCall`) → `var e = GlobalTownCrierEntryList.Instance.AddEntry(lines, TimeSpan.FromSeconds(durationSec));` and stash `id → e` so a later `{"kind":"towncrier.remove","id":"n123"}` can call `RemoveEntry(e)`. + +Must run on the main thread (mutates a shared list and sends packets to crier NPCs) — same marshaling rule as `[link` (§II.3 / §5). Guard against abuse: cap line length/count and active-entry count in the handler; the socket being loopback-only is your trust boundary. Note the crier speaks lines on its own timer, so there's a natural delay before players hear it — fine for news. + +### III.5 Updated capability map + +| Capability | Mechanism | Core touch? | Runs on | +|-----------|-----------|:-----------:|---------| +| Player online/stats/gold | EventSink + 30 s sweep (§II.2) | No | main thread | +| NPC vendor sales | `ValidVendorPurchase/Sell` | No | main thread | +| **Player-vendor sales** | **new `PlayerVendorSale` EventSink** (§III.1) | **Yes, ~15 lines** | main thread | +| `[link` account linking | `CommandSystem.Register` + account tags (§II.3) | No | main thread | +| IDOC / house decay | sweep `BaseHouse.AllHouses` on transition (§III.3) | No | main thread | +| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | main thread (marshaled) | +| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` | + +**Net:** everything you listed is doable, and **only player-vendor sales requires a (small, idiomatic) core edit** — which is justified because it's your critical/cheat-detection stream and reflection-based alternatives can't identify the buyer. + +--- + +## PART IV — Full character profiles (armor / weapons / skills / everything) + +You want the site's **player endpoint** to show a whole character — worn gear, weapon/armor detail, every skill, all stats — for **up to 5 characters per account**, online *or* offline, and eventually their vendor stats. The object model supports all of it; the design question is *how to ship it without turning the 30 s sweep into a firehose.* + +### IV.1 It's all on the live `Mobile` — and offline chars stay resident + +- **Account → characters:** `Account` holds `Mobile[] m_Mobiles` with `account.Length` slots and `account[index]` (`Account.cs:592,598`); non-null slots are the characters (max 5, engine allows up to 7). Iterate them to enumerate an account's roster. +- **Offline = still in memory.** Mobiles are removed from `World.Mobiles` **only on `Delete()`, never on logout.** A logged-off character is a live `Mobile` with `NetState == null`; all its gear/skills/stats are intact. **→ the bridge can build a full profile for any character at any time, online or offline** — exactly what "see my characters from the website" needs. `m.NetState != null` (or `m.Player && online`) is your online flag. +- **Stats/vitals** (`Server/Mobile.cs`): `Str/Dex/Int` (`:8276+`), `Hits/HitsMax`, `Mana/ManaMax`, `Stam/StamMax` (`:8554+`), the five resists `PhysicalResistance…EnergyResistance` (`:931+`), `VirtualArmor`, plus `Fame`, `Karma`, `Luck`, `TotalWeight`, `Title`, `Body`, `Hue`, `Name`. +- **Skills** (`Server/Skills.cs`): `m.Skills` is `IEnumerable` (`:1099`) with `Length` + indexer. Each `Skill`: `SkillName`, `Base`, `Value` (base + item/temp bonuses), `Cap`, `Lock` (`Skills.cs:259,322,373,350,269`). Emit all ~58. +- **Worn equipment:** `m.Items` (`Mobile.cs:6695`) is the list of *equipped* items (one per `Layer`); `FindItemOnLayer(Layer)` (`:10545`) fetches a slot. `Layer` enum (`Item.cs:25`) covers the ~25 wearable slots (OneHanded, TwoHanded, Helm, Gloves, Ring, Neck, Arms, InnerTorso, Talisman, …). Filter out non-gear layers (Backpack, Bank, Mount, Hair/FacialHair) unless you want them. +- **Weapon/armor detail** (`BaseWeapon.cs`, `BaseArmor.cs`): rich AOS attribute objects — `Attributes` (`AosAttributes`), `WeaponAttributes`, `ArmorAttributes`, `AosElementDamages`, `ExtendedWeaponAttributes`, `NegativeAttributes`, plus `MinDamage/MaxDamage/StrRequirement` (weapon) and `BaseArmorRating`/resists (armor). **Each attribute bag exposes an enum indexer** — `AosAttributes[AosAttribute]`, `AosWeaponAttributes[AosWeaponAttribute]`, `AosArmorAttributes[AosArmorAttribute]` (`Scripts/Misc/AOS.cs:924,1464,2238`) — so you can **flatten every mod generically** by iterating the enum and emitting non-zero entries, without hardcoding 30+ property names. + +### IV.2 Ship it tiered + on-demand (don't stream heavy profiles blindly) + +A full profile ≈ 58 skills + ~15 gear items each with a mod table. Pushing that for every character every 30 s (× N accounts, most idle/offline, most unviewed) is wasteful. Split by volatility: + +| Tier | Contents | When emitted | +|------|----------|--------------| +| **Vitals** (small, volatile) | hits/mana/stam, current str/dex/int, gold, location, online flag | 30 s sweep of **online** players + events | +| **Profile** (large, semi-static) | all skills, worn equipment + item mods, resists, caps, fame/karma/luck | on `Login`, on equip/skill change, and **on demand** | + +**On-demand request/response drives the website player endpoint.** When the site opens a character page: website → sidecar → ServUO `{"kind":"char.request","account":"PerryAdimn","slot":0}` (or by serial) → marshal to main thread → build the full profile → reply `{"kind":"char.profile", …}`. The **sidecar caches** the last profile so the page renders instantly and the game only rebuilds on request or on change. This scales: you never pay to serialize characters nobody is looking at. (For a "roster" view, a light `{"kind":"account.roster"}` returning name/body/slot/online per character is enough; fetch the heavy profile only when a specific char is opened.) + +### IV.3 Character-profile schema (sketch) + +```jsonc +{ + "kind": "char.profile", + "account": "PerryAdimn", "slot": 0, + "serial": "0x0075", "name": "Thunderheat", "title": "the Legendary", + "body": 400, "hue": 33770, "online": true, + "stats": { "str":100,"dex":90,"int":45, "hits":95,"hitsMax":100, + "mana":40,"manaMax":45,"stam":88,"stamMax":90, + "resist":{"phys":70,"fire":68,"cold":55,"pois":60,"energy":62}, + "gold":124500, "fame":12000,"karma":-4000,"luck":140,"weight":320 }, + "skills": [ {"name":"Swords","base":100.0,"value":120.0,"cap":120.0,"lock":"Up"}, + {"name":"Tactics","base":100.0,"value":110.0,"cap":120.0,"lock":"Locked"} /* …all */ ], + "equipment": [ + { "serial":"0x4001A2","layer":"TwoHanded","itemId":5046,"hue":0, + "name":null,"cliloc":1023721, // resolve name via cliloc (IV.4) + "weapon":{"minDamage":16,"maxDamage":18,"strReq":40}, + "mods":{"WeaponDamage":50,"HitLightning":40,"SwingSpeedIncrement":30,"DefendChance":15} }, + { "serial":"0x4002B3","layer":"InnerTorso","itemId":7168,"hue":1157, + "name":"Ancient Plate","armor":{"baseRating":45}, + "mods":{"ResistFireBonus":15,"LowerManaCost":8,"BonusHits":5} } + ], + "vendorsOwned": 3 // future (IV.5) +} +``` + +Locks/enum values serialize as their names. `mods` is the flattened non-zero union across the item's attribute bags. + +### IV.4 Gotchas for the profile export + +- **⚑ Item names are usually clilocs, not strings.** `Item.Name` (`Item.cs:4860`) is frequently `null`; the real display name is `LabelNumber` (`:3771`), a cliloc ID resolved against `Data/Cliloc.enu`. For the website either (a) resolve cliloc → text server-side from the cliloc file and send the string, or (b) send the number and resolve on the site with a cliloc map. Crafted/renamed items *do* carry a plain `Name`. Send both (`name` + `cliloc`) and prefer `name` when present. +- **⚑ Don't recurse the whole backpack/bank by default.** A pack can hold hundreds of nested items — that's a different (huge) payload than "what they're wearing." Ship **worn equipment** fully; expose backpack/bank as an opt-in or a summarized count, not a default deep dump. +- **Building a profile allocates** (skill list + per-item mod scans). Keep it on-demand / on-change, **not** in the 30 s vitals sweep. A burst of `char.request`s should be fine (main-thread, fast) but rate-limit at the sidecar. +- **`Value` vs `Base` for skills:** `Base` is the trained number; `Value` includes item/temp bonuses (what the client shows in combat). Send both — the site likely wants `Base` for "character sheet" and `Value` for "effective." +- **Read on the main thread only.** Everything above touches live `Mobile`/`Item` state (§5). Build the DTO synchronously in the request handler / sweep, hand the finished JSON to the writer thread. + +### IV.5 Vendor stats per player (the "eventually") + +Ties into §III.1. A character/account can own player vendors; each `PlayerVendor` has `Owner`, an inventory of `VendorItem`s (item, `Price`, description), `HoldGold`, `BankAccount`, and commission. For a player-facing "my vendors" view, enumerate `PlayerVendor`s whose `Owner` is one of the account's mobiles and emit: vendor serial, house/location, held gold, and inventory (item, price, sold-state). Combined with the §III.1 `PlayerVendorSale` stream, the site can show both **current listings** and **sales history**. Same tiered/on-demand rule — fetch on request, refresh on sale. + +### IV.6 Updated capability map (supersedes III.5) + +| Capability | Mechanism | Core touch? | Cadence | +|-----------|-----------|:-----------:|---------| +| Player vitals (hp/mana/stam/gold/loc) | 30 s sweep of online + events | No | periodic/event | +| **Full character profile** (stats/skills/gear/mods) | build from live `Mobile`, **on-demand + on-change** (§IV) | No | request/response + on change | +| Account roster (up to 5 chars) | `account[0..Length]`, incl. offline (§IV.1) | No | on request | +| NPC vendor sales | `ValidVendorPurchase/Sell` | No | event | +| Player-vendor sales | new `PlayerVendorSale` EventSink (§III.1) | **Yes, ~15 lines** | event | +| Player-owned vendor stats | enumerate `PlayerVendor` by owner (§IV.5) | No | on request | +| `[link` account linking | `CommandSystem` + account tags (§II.3) | No | event | +| IDOC / house decay | sweep `AllHouses`, transition-only (§III.3) | No | 30–60 s sweep | +| Town-crier news (inbound) | `GlobalTownCrierEntryList.AddEntry` (§III.4) | No | inbound | +| Config tuning | `Config.Get` + `Config/Bridge.cfg` (§III.2) | No | `Configure()` | + +**Net:** the full-character requirement adds **no** new core touches — it's all readable off live objects. The only structural addition it implies is an **inbound request/response channel** (already needed for `[link` and town-crier), used here as `char.request` / `account.roster`, with the sidecar caching profiles for the website. + +--- + +## 1. Script lifecycle — how `Scripts/Custom` loads and hooks startup/shutdown + +**Compilation model (this is a *modern* ServUO, not the old CodeDom one).** +`Server/ScriptCompiler.cs:18` → when `Compiler.Dynamic` is true (default), the core literally runs: + +``` +dotnet build "Scripts/Scripts.csproj" -c Release (or Debug) +``` + +then `Assembly.LoadFrom("Scripts.dll")` (`ScriptCompiler.cs:63`). `Scripts.csproj` is SDK-style (`Microsoft.NET.Sdk`) with **default globbing**, so **every `.cs` anywhere under `Scripts/` — including `Scripts/Custom/` — is compiled automatically**. There is no per-file registration. A new plugin = drop a `.cs` file in `Scripts/Custom/` and restart (or rebuild `Scripts.dll`). + +- If `dotnet build` fails, the core loops asking to retry (`Main.cs:525`); under `-service` it just returns/exits. So **a compile error in your bridge file takes the whole shard down at boot** — keep the plugin minimal and defensive. +- `-service`/non-interactive suppresses the console prompt (`Main.cs:386`). + +**Lifecycle entry points (in boot order, all on the Core thread — `Main.cs:544-562`):** + +| Order | Mechanism | How you hook it | +|------:|-----------|-----------------| +| 1 | `ScriptCompiler.Invoke("Configure")` | Any `public static void Configure()` in any script type | +| 2 | `World.Load()` | (world state restored from `Saves/`) | +| 3 | `ScriptCompiler.Invoke("Initialize")` | Any `public static void Initialize()` in any script type | +| 4 | `EventSink.InvokeServerStarted()` | `EventSink.ServerStarted += ...` | + +`Invoke()` (`ScriptCompiler.cs:87`) reflects over **all** loaded types, finds the named `public static` method, sorts by `[CallPriority(n)]` (`Server/Attributes.cs:27`), and calls them. **`Configure` runs *before* `World.Load`; `Initialize` runs *after*.** → Register EventSink handlers in `Initialize` (or `Configure`); read config in `Configure`. Canonical example already in-tree: `Scripts/Misc/WeightOverloading.cs:15` subscribes to `EventSink.Movement` inside `Initialize()`. + +**Shutdown.** Two clean hooks, both fire on the Core thread: +- `EventSink.Shutdown` — invoked from `Core.HandleClosed()` (`Main.cs:313`) on normal exit, *after* `World.WaitForWriteCompletion()`. **Not** invoked if `_Crashed`. +- `EventSink.Crashed` — invoked from the unhandled-exception handler (`Main.cs:198`); gives you an `args.Close` vote. +- Windows console-close / Ctrl-C routes through `OnConsoleEvent` → `Kill()` → `HandleClosed()` (`Main.cs:254`), so `Shutdown` normally still fires. + +**Bridge implication:** your named-pipe writer/listener should be **created in `Initialize` (or on `ServerStarted`) and torn down in `Shutdown`**. Don't assume `Shutdown` runs on a crash — the pipe handle may be abandoned; the external service must tolerate an abrupt EOF. + +--- + +## 2. EventSink — available events, subscription, and frequency + +**Subscription pattern:** `EventSink. += handler;` (static multicast delegates, declared `Server/EventSink.cs:1692-1784`). Handlers are plain delegates invoked synchronously via `EventSink.Invoke(args)` from the code path that raises them. **Every handler runs on whatever thread raised the event — in practice always the Core thread** (movement, speech, combat, login all originate from packet handling in `MessagePump.Slice()` or from the main-loop delta processing). + +### Events relevant to a state-export bridge + +| Event | Fires when | Frequency | Notes for export | +|-------|-----------|-----------|------------------| +| `Login` | Player fully in-world | Low | Best "player online" signal; gives `Mobile`. | +| `Logout` | Player disconnect (in-world) | Low | Pair with Login. | +| `Connected` / `Disconnected` | Socket up/down | Low | Lower-level than Login/Logout (fires for char-select too). | +| `PlayerDeath` | Player dies | Low | `PlayerDeathEventArgs` (mobile, corpse-ish context). | +| `CreatureDeath` | NPC/creature dies | **Medium–High** | Fires for *every* mob kill; on a busy shard this is a firehose. Filter/aggregate. | +| `Speech` | Player/NPC speech | Medium | `SpeechEventArgs`; raised from `Mobile.cs:5114`. Includes NPC/system speech. | +| `Movement` | **Any mobile takes a step** | **Very High** | See ⚠️ below. | +| `AggressiveAction` | Combat aggression declared | Medium–High | `AggressiveActionEventArgs` (`EventSink.cs:372`). Not per-swing, per aggression state change. | +| `ItemCreated` / `ItemDeleted` | Item constructed/deleted | **Very High** | Fires for *every* item incl. transient/loot/internal. Huge volume. | +| `MobileCreated` / `MobileDeleted` | Mobile constructed/deleted | High | Same caveat as items. | +| `SkillGain`, `CraftSuccess`, `ResourceHarvestSuccess` | Progression | Medium | Good "interesting player activity" signals. | +| `AccountGoldChange`, `FameChange`, `KarmaChange` | Economy/rep deltas | Low–Medium | Naturally diff-shaped. | +| `QuestComplete`, `JoinGuild`, `TameCreature`, `PlayerMurdered` | Milestone events | Low | Cheap, high-signal — ideal to export. | +| `WorldSave` / `BeforeWorldSave` / `AfterWorldSave` | Save cycle | Low (~5 min) | Natural checkpoint boundary for the bridge. | +| `ServerStarted` / `Shutdown` / `Crashed` | Lifecycle | Once | Bridge connect/disconnect signaling. | + +Full list of 70+ events at `EventSink.cs:1692-1784` (context menus, vendor buy/sell, BOD, virtue, targeting macros, etc.). + +> ⚠️ **`Movement` is the single most dangerous event to naively export.** `EventSink.InvokeMovement` is called from `Mobile.InternalOnMove` (`Mobile.cs:3029`), which runs for **every mobile that takes a step — all NPCs, all creatures, not just players.** On a populated shard that's thousands of invocations/second. It is **synchronous and cancellable** (`args.Blocked` gates the move), so your handler sits *inside the movement decision path* — any latency there (a blocking pipe write!) stalls the whole server. Additionally the args object is **pooled and immediately `Free()`d** (see §5). Rules: filter to `PlayerMobile` at the top of the handler, copy out primitives synchronously, never block, never retain the args reference. + +### ⚑ Gap flag — events with *no* clean EventSink hook + +These are things a bridge spec commonly wants to export but that **do not have a first-class `EventSink`**: + +- **Item pickup / drop / "lift".** There is **no `EventSink` for picking up or dropping items.** It's handled by **virtual methods** on the objects: `Item.OnDragLift` / `Item.OnDragDrop` / `Item.OnDroppedInto` (`Item.cs:4647,2157,5060`) and `Mobile.OnDragDrop` / `Mobile.OnDragLift` (`Mobile.cs:10877,10949`). To observe these you must **override them on your own subclasses** or patch base classes — you can't subscribe globally from `Initialize`. Partial coverage exists via `EventSink.OnItemObtained`, `EventSink.ContainerDroppedTo`, and `EventSink.CorpseLoot`, but none of these is a universal "player moved item X from A to B" hook. **This is the biggest event-availability gap for the bridge.** +- **Per-hit combat damage.** `AggressiveAction` marks aggression, not each swing/damage tick. For damage numbers you'd hook `Mobile.Damage` / weapon `OnHit` paths (virtual/override), not an EventSink. +- **Equip/unequip of items generally.** `CheckEquipItem` exists (a *veto* hook), plus `EquipMacro`/`UnequipMacro` (macro-triggered only). No clean "item equipped" firehose via EventSink. +- **Stat/hits/mana/stam changes.** No EventSink; these move through the delta/`ProcessDeltaQueue` system (§4). You'd poll or hook `Mobile` delta handling. + +--- + +## 3. Timers — mechanism and which thread callbacks run on + +**This is the crux, and the answer is unambiguous.** ServUO splits timers into a *scheduler thread* and *main-thread execution*: + +- **Timer Thread** (`Main.cs:429-434`, named `"Timer Thread"`) runs `Timer.TimerThread.TimerMain` (`Timer.cs:314`). Its *only* job is bookkeeping: walk the priority buckets, decide which timers are due, and **enqueue** them into a shared `m_Queue` (`Timer.cs:354-357`). It **does not execute callbacks.** When anything becomes due it calls `Core.Set()` (`Timer.cs:374`) to wake the main loop. +- **Core / main thread** runs `Timer.Slice()` (`Timer.cs:391`, called from `Core.Main` at `Main.cs:580`). This dequeues due timers and calls **`t.OnTick()` on the main thread** (`Timer.cs:409`). + +**→ Every `Timer` / `Timer.DelayCall` callback executes on the Core (main) game thread.** The separate Timer Thread never touches game state; it's a scheduling clock. This is verifiable live via Appendix A (the probe logs `Thread.CurrentThread` from a Timer tick and from `Initialize` — they match, and match the network path shown in your crash log). + +Other properties worth knowing: +- Timers are bucketed by `TimerPriority` (`EveryTick`, `TenMS`, … `OneMinute`); priority is auto-computed from delay/interval (`Timer.cs:468`). +- `Timer.Slice` has a `BreakCount` (default **20000**, `Timer.cs:383`) — if more than that many timers are due in one slice, the overflow waits for the next slice. Relevant if the bridge ever schedules a flood of one-shot timers. +- **Timers do not fire during world save/load.** `TimerMain` early-continues while `World.Loading || World.Saving` (`Timer.cs:322`). See §5 — this directly affects inbound-command latency. + +--- + +## 4. Object model & serialization — and a diff-friendly state shape + +**Identity.** `Serial` (`Server/Serial.cs:7`) is a `struct` wrapping a single `int`. **Mobiles** get serials `< 0x40000000`; **items** start at `0x40000000` (`Serial.cs:11-12`); `IsItem`/`IsMobile` test that boundary. Serials are stable for an object's lifetime and are the natural **primary key** for any external mirror of state. `World.Mobiles` / `World.Items` are `Dictionary` (`World.cs:19-20`) — O(1) lookup by serial from the main thread. + +**ServUO's own persistence** (`Server/Serialization.cs`, `Server/World.cs`): +- Every `Item`/`Mobile`/`SaveData` implements `Serialize(GenericWriter)` / `Deserialize(GenericReader)` plus a serial-taking ctor. `Core.VerifySerialization` (`Main.cs:679`) enforces this at boot. +- `GenericWriter`/`GenericReader` are a **versioned, positional binary stream** of primitives (`ReadInt`, `ReadString`, `ReadMobile`, `ReadPoint3D`, …; `Serialization.cs:17+`). Each object writes an `int` version first, then fields in a fixed order. It is **compact but *not* diff-friendly**: it's a full positional snapshot with no field names, meaningless without the exact type+version that wrote it, and it encodes the *entire* object every save. +- Saves are orchestrated by `World.Save` (`World.cs:1102`) on the main thread; a `SaveStrategy` may flush bytes to disk on a **background thread**, guarded by `m_DiskWriteHandle` (`ManualResetEvent`, `World.cs:29`). During a save `World.Saving` is true and object add/delete is deferred into `_addQueue`/`_deleteQueue` (`World.cs:1247-1280`). + +**Recommendation for a diff-friendly representation (do NOT reuse the save system):** +The internal serializer is the wrong tool for the bridge — it's full-snapshot, schema-coupled, and versioned per type. Instead, build an **event-sourced delta keyed by `Serial`**: + +```jsonc +// one line per change, main-thread produced, drained by background writer +{ "t": 172..., "kind": "mob.move", "serial": "0x1A2B", "x": 1420, "y": 1631, "z": 0, "dir": "North" } +{ "t": 172..., "kind": "mob.login", "serial": "0x1A2B", "name": "Thunderheat", "acct": "PerryAdimn" } +{ "t": 172..., "kind": "item.gold", "serial": "0x1A2B", "delta": -500, "total": 12000 } +``` + +- Derive fields from the **EventSink args + the live object** at event time (e.g. `m.X/Y/Z/Map/Serial`), not from `Serialize`. +- Keyed by `Serial` so the external service maintains its own mirror and applies deltas. +- Emit a periodic/`ServerStarted` **full snapshot** (iterate `World.Mobiles`/`World.Items` on the main thread) as a baseline the deltas layer onto; `AfterWorldSave` is a natural snapshot boundary. +- Keep each record to primitives copied out **synchronously on the main thread** (pooled args, live objects mutate — see §5). + +--- + +## 5. Thread-safety rules & marshaling onto the main thread + +**Golden rule (RunUO/ServUO-wide):** the world — `World.Mobiles`, `World.Items`, every `Mobile`/`Item`/`Account`, the delta queues, packet sends — is **single-threaded and owned by the Core thread.** None of it is locked for general access. Reading or mutating any of it from another thread is a data race / heisenbug generator. The dictionaries aren't concurrent; `Mobile.ProcessDeltaQueue`/`Item.ProcessDeltaQueue` run on the main loop (`Main.cs:577-578`) with no cross-thread guard. + +**What *is* safe from a non-main thread:** +- `Core.Set()` — wake the main loop (`AutoResetEvent`, `Main.cs:324`). +- **`Timer.DelayCall(...)`** — verified safe cross-thread. `DelayCall`→`Start`→`TimerThread.AddTimer`→`Change` takes `lock (m_Changed)` and signals the timer thread (`Timer.cs:243-251,883-892`). The scheduling call is lock-protected; the **callback then runs on the main thread.** This is the intended marshaling primitive. +- Pushing onto a **`ConcurrentQueue`** you own, then letting the main thread drain it — this is literally how the network stack works: `MessagePump.m_Queue` is a `ConcurrentQueue` (`MessagePump.cs:14`) filled by listener threads and drained by `MessagePump.Slice()` on the main thread (`MessagePump.cs:113`). + +**The two marshaling patterns for inbound named-pipe commands** (pick one; pattern A is simplest): + +- **A — `Timer.DelayCall` from the pipe thread.** On each inbound command, from the pipe read-callback thread call `Timer.DelayCall(TimeSpan.Zero, () => ApplyCommand(cmd))`. The lambda executes on the main thread on the next slice. Zero shared mutable state of your own. Caveat: a burst of commands = a burst of one-shot timers (mind `BreakCount`). +- **B — your own `ConcurrentQueue` + `Core.Slice`.** Pipe thread enqueues; register a handler on the `Core.Slice` delegate (`Main.cs:41,586`) that drains the queue every loop iteration on the main thread. Mirrors the network design; better for high inbound rates. + +**Pitfalls specific to this codebase:** +1. **Pooled event args.** `MovementEventArgs` (and several others) are recycled via a plain `Queue` pool and `Free()`d immediately after the event (`EventSink.cs:802-834`). The pool itself is **not** thread-safe (main-thread-only). **Never** hand an args object to the pipe writer thread; copy primitives out first. Holding the reference = reading fields that belong to an unrelated later mobile. +2. **Blocking the main thread = stalling the shard.** EventSink handlers and Timer ticks run on the Core thread. A synchronous named-pipe **write** that blocks (slow/absent reader, full pipe buffer) will freeze movement, combat, saves — everything. The writer *must* be fire-and-forget onto a background queue (see §6). +3. **Timers pause during save/load.** Because `TimerMain` skips while `World.Saving`/`World.Loading` (`Timer.cs:322`), **inbound commands marshaled via `Timer.DelayCall` are deferred until the save finishes** (typically seconds; longer with background write). If commands must apply during a save window, prefer pattern B (Core.Slice) — but note the main loop also spends the save inside `World.Save`, so nothing script-side really runs mid-save regardless. Treat "commands don't apply during a save" as a design constraint, and have the external side tolerate the latency spike. +4. **Reentrancy / world-mutation during save.** Adding/deleting entities during a save is deferred to safety queues and logs a warning (`World.cs:988,1247`). If a bridge command spawns/deletes, it may silently queue. +5. **Crash path skips `Shutdown`.** Don't rely on graceful pipe teardown (§1). + +--- + +## 6. Local ServUO↔sidecar transport (net48) — non-blocking bridge I/O + +> **Superseded by [Part II.1](#ii1-transport-put-the-websocket-in-rust-keep-the-c-side-dumb).** For the Rust WS sidecar design the recommended C↔Rust link is **loopback TCP + newline-JSON**, not a named pipe, and **ServUO should not speak WebSocket**. The non-blocking principles below still apply verbatim to whichever local transport you pick. + +Target is **net48** (`Scripts.csproj:3`), so you have `System.IO.Pipes` / `System.Net.Sockets` with `async`/`await` and `Begin/End` APIs, but **not** the newer `IAsyncEnumerable`/`CancellationToken` niceties of modern .NET. Design around that. + +**Outbound (fire-and-forget writer) — the important one:** +- The producer is the Core thread (event handlers). It must **never touch the pipe directly.** Producer does only: format the delta record → `ConcurrentQueue.Enqueue` → return. This is a non-blocking, allocation-only operation. +- A **single dedicated background writer thread** (or a long-running `Task`) owns the `NamedPipeServerStream`/`ClientStream` and drains the queue, using `WriteAsync`/`FlushAsync`. One writer = writes stay ordered and you avoid interleaved frames on the pipe. +- Use a **length-prefixed or newline-delimited framing** (`PipeTransmissionMode.Byte` is simplest and most portable; `Message` mode has size/OS quirks). Don't rely on message boundaries. +- **Bound the queue.** If the external reader stalls, an unbounded queue is a memory leak that eventually OOMs the shard. Drop-oldest or drop-on-full with a dropped-count counter is the safe default for telemetry-style data. +- Handle `IOException`/`Broken pipe` by reconnecting in the writer thread; the game keeps running, the queue keeps the newest N records. + +**Inbound (command listener):** +- A separate background thread/loop `WaitForConnectionAsync` → `ReadAsync` loop, parse a framed command, then **marshal to the main thread** via pattern A or B from §5. The read thread must not call any `World`/`Mobile`/`Item` API. +- Server vs client: making ServUO the **`NamedPipeServerStream`** (external service connects in) is usually cleaner for lifecycle — the shard owns the pipe, survives external restarts, and you control `maxNumberOfServerInstances`. Two half-duplex pipes (one in, one out) are simpler to reason about than one duplex pipe shared across your writer and reader threads. +- Set `PipeOptions.Asynchronous` at construction — required for the `*Async` methods to actually overlap I/O rather than block a thread-pool thread. + +**Pitfalls:** +- Don't `await` pipe I/O on the Core thread — there's no synchronization context that returns you to the Core thread anyway, and you'd risk resuming world access on a thread-pool thread. Keep all pipe `await`s on your dedicated background threads. +- Named-pipe ACLs: if the external service runs as a different user/session, set a `PipeSecurity` explicitly or the connect will `UnauthorizedAccessException`. +- First-chance `IOException` on client disconnect is normal; log-and-reconnect, don't crash the writer loop. + +--- + +## 7. Flags against the bridge architecture + +> **See [Part II.4](#ii4-revised-flags-for-this-architecture) for the flags that matter to the Rust WS sidecar + tracking/link design.** The list below is the original generic set (still valid background). + +1. **⚑ Item pickup/drop has no EventSink (§2 gap).** If the spec assumes "subscribe to item move events" the way you subscribe to login/movement, that assumption is wrong. Pickup/drop/lift live on **virtual methods** (`Item.OnDragLift/OnDragDrop/OnDroppedInto`, `Mobile.OnDragDrop`). Exporting them cleanly requires base-class overrides/patching, not `Initialize`-time subscription. This is the item most likely to change the design. +2. **⚑ `Movement` (and `Item/MobileCreated/Deleted`) are firehoses on the main thread (§2, §5).** Any spec that says "export all movement" must add player-filtering + aggregation, and the export path must be non-blocking. `Movement` args are **pooled** — copy-out-synchronously is mandatory, not optional. +3. **⚑ Everything you'd export runs on the single Core thread (§3, §5).** The whole bridge stands or falls on the writer being fire-and-forget. If the spec has event handlers writing to the pipe synchronously, that's a shard-wide stall waiting to happen. Confirmed by your own crash log that even packet-triggered handlers run inline on `Core.Main`. +4. **✔ Inbound commands *can* be safely marshaled to the main thread** via `Timer.DelayCall` (verified thread-safe) or a `ConcurrentQueue` drained on `Core.Slice`. The named-pipe approach is **not** blocked by threading — but: +5. **⚑ Commands don't apply during world saves (§5 pitfall 3).** Timers pause and the main loop is inside `World.Save` (~seconds, every ~5 min by default). If the spec expects sub-second inbound command latency 100% of the time, it needs to tolerate periodic save-window spikes. +6. **⚑ Don't mirror state via ServUO's serializer (§4).** If the spec imagined "reuse ServUO's save format to ship state," reconsider — it's full-snapshot, schema-versioned, and unnamed. Use event-derived deltas keyed by `Serial` + periodic snapshots. +7. **⚑ Crash path skips graceful shutdown (§1).** The external service must treat pipe EOF as normal and re-handshake; don't assume a clean `Shutdown` teardown. +8. **⚑ A compile error in the bridge plugin fails the whole shard boot (§1).** Keep the plugin small, wrap handler bodies in try/catch, and never let a bridge exception escape into a game code path. +9. **(Environmental) The `zlibwapi64` native-load crash (§0)** already downed this shard once. Unrelated to the bridge, but resolve it before load-testing or it will confound results. + +--- + +## Appendix A — Drop-in empirical probe (run this yourself) + +Save as `Scripts/Custom/BridgeThreadProbe.cs`, start the shard, watch the console. **No game client needed** — it proves the thread identity of `Initialize`, `ServerStarted`, a `Timer` tick, and `Core.Slice`. Delete the file afterward. (This is a throwaway diagnostic, not the bridge.) + +```csharp +using System; +using System.Threading; +using Server; + +namespace Server.Custom +{ + public static class BridgeThreadProbe + { + private static void Log(string where) + { + var t = Thread.CurrentThread; + Console.WriteLine("[PROBE] {0,-16} thread id={1} name=\"{2}\"", + where, t.ManagedThreadId, t.Name); + } + + public static void Initialize() + { + Log("Initialize"); // expect: Core Thread + + EventSink.ServerStarted += () => Log("ServerStarted"); // expect: Core Thread + EventSink.Login += e => Log("Login (client)"); // needs a client login + + // Timer tick — proves callbacks run on the main thread, not the Timer Thread. + Timer.DelayCall(TimeSpan.FromSeconds(3), () => Log("Timer.DelayCall")); // expect: Core Thread + + // Cross-thread marshal test: schedule from a raw background thread, + // confirm the callback still lands on Core Thread. + new Thread(() => + { + Log("raw bg thread"); // expect: some worker id, NOT Core Thread + Timer.DelayCall(TimeSpan.Zero, () => Log("marshaled->main")); + }).Start(); + + // Core.Slice runs every main-loop iteration; log once then detach. + Slice one = null; + one = () => { Log("Core.Slice"); Core.Slice -= one; }; + Core.Slice += one; // expect: Core Thread + } + } +} +``` + +**Expected result:** every line except `raw bg thread` reports `name="Core Thread"` with the same managed id as `Initialize` — confirming EventSink handlers, Timer ticks, and `Core.Slice` all execute on the one main thread, and that `Timer.DelayCall` from a background thread correctly hops work onto it. If you connect a client, `Login (client)` also reports `Core Thread`, matching the `MessagePump.Slice` evidence in your crash log. + +--- + +## Key source references + +| Topic | File:line | +|-------|-----------| +| Main game loop / thread setup | `Server/Main.cs:329,410-434,573-599` | +| `Core.Slice` main-thread hook | `Server/Main.cs:41,586` | +| `Core.Set` wake main loop | `Server/Main.cs:322-327` | +| Shutdown / Crashed hooks | `Server/Main.cs:198,313` | +| Script compile (`dotnet build`) | `Server/ScriptCompiler.cs:18-65` | +| `Configure`/`Initialize` invoke + CallPriority | `Server/ScriptCompiler.cs:87-112`, `Server/Attributes.cs:27` | +| EventSink event declarations | `Server/EventSink.cs:1692-1784` | +| Movement raise (all mobiles, pooled, cancellable) | `Server/Mobile.cs:3020-3036`, `Server/EventSink.cs:792-834` | +| Item pickup/drop = virtual, no EventSink | `Server/Item.cs:2157,4647,5060`, `Server/Mobile.cs:10877,10949` | +| Timer scheduler thread (enqueue only) | `Server/Timer.cs:314-379` | +| Timer execution on main thread | `Server/Timer.cs:391-419`, `Server/Main.cs:580` | +| `Timer.DelayCall` cross-thread safety | `Server/Timer.cs:243-251,524-534,883-892` | +| Network marshaling (ConcurrentQueue → main) | `Server/Network/MessagePump.cs:14,108,113` | +| Serial identity | `Server/Serial.cs:7-33` | +| Serialization API | `Server/Serialization.cs:17+` | +| World save threading / safety queues | `Server/World.cs:29,1102-1208,1247-1280` | +| Runtime evidence: EventSink on Core thread | `Crash 6-5-2026-22-38-3.log` | diff --git a/link/SHARD_PREREQS.md b/link/SHARD_PREREQS.md new file mode 100644 index 0000000..ae4d926 --- /dev/null +++ b/link/SHARD_PREREQS.md @@ -0,0 +1,71 @@ +# Shard prerequisites + +Repairs the target shard (`C:\Users\colby\Desktop\servuo`, ServUO 57.4) required before the bridge could load. These are **deletions and edits of existing files**, so they cannot be expressed as an overlay copy. They are recorded here, and where practical as diffs under `patches/`. + +Applied 2026-07-10. Backups on the Desktop: `servuo_saves_backup_2026-07-10_032608`, `servuo_bin_backup_2026-07-10_032608`, `servuo_removed_files_2026-07-10`. + +--- + +## The symptom + +`Scripts.dll` had not been rebuilt since **2026-05-30 17:01**. Every script change after that — including all of `Scripts/Custom/Named/`, `MyStats.cs`, and `SearchAdd.cs` — had never executed. + +`ScriptCompiler.Compile()` (`Server/ScriptCompiler.cs:38-58`) shells out to `dotnet build`, prints the output, ignores the exit code, then `Assembly.LoadFrom("Scripts.dll")` and returns `true`. A failing script build is invisible: the stale DLL simply reloads. The retry loop at `Main.cs:525` never trips. + +Four independent breakages, all introduced between 17:14 and 21:55 on 2026-05-30. + +--- + +## 1. Stray `Server/Gumps/Gumps.cs` + +A **byte-identical copy** of `Scripts/Services/Pet Training/Gumps.cs` (75,468 bytes), sitting in the Server project. It declares `namespace Server.Mobiles` and extends `BaseGump`, referencing `BaseCreature`, `PlayerMobile`, `TrainingPoint` — all defined in Scripts. Server cannot reference Scripts, so `Server.csproj` failed with 35 errors. + +**Action:** deleted. The canonical copy under `Scripts/Services/Pet Training/` was edited 10 minutes later and is the one that matters. + +## 2. Eleven duplicate creature classes + +`Scripts/Custom/{Named,Legendary}/` redefined classes already present in `Scripts/Mobiles/Normal/`, producing `CS0111` / `CS0579`. + +**Named** — `Eowmu`, `SkeletalCat`, `Windrunner`. The stock files each define **two** types: the mount *and* an `ICreatureStatuette` item (`EowmuStatue`, …) that `Scripts/Services/UltimaStore/UltimaStore.cs` references. Deleting the stock files outright would have re-broken the build. + +**Action:** removed only the duplicate mount class from each stock file; kept the statues. + +**Legendary** — `FireSteed`, `Kirin`, `Nightmare`, `OsseinRam`, `Phoenix`, `PolarBear`, `ShadowWyrm`, `TsukiWolf`. Clean 1:1 pairs. All custom versions sit in `namespace Server.Mobiles`, so the serialized type name is unchanged, and each `Deserialize` guards on `version` and migrates from 0 (`ShadowWyrm`: `if (version >= 1)`; `FireSteed`: `if (version < 1)` skill-cap migration; `Kirin`: `if (version == 0)` AI fixup). + +**Action:** deleted the eight stock files. Custom wins. + +## 3. `PolarBear` — a base-class change, not a version bump + +Custom `PolarBear : BaseMount`; stock `PolarBear : BaseCreature`. The saved world contained a bear serialized through the `BaseCreature` chain, so loading it as a `BaseMount` misaligned the stream. World load aborted at `Server.Mobiles.PolarBear` serial `0x00000412` with `Delete the object? (y/n)`. + +**Changing a saved type's base class is not version-migratable.** The custom class also carried `[TypeAlias("Server.Mobiles.Polarbear")]`, which would have hijacked the same records. + +**Action:** restored stock `PolarBear : BaseCreature`; renamed the custom mount to `LegendaryPolarBear` and dropped the `TypeAlias`. Stock scripts referencing `typeof(PolarBear)` (`TalismanSlayer`, `SpeedInfo`, `RoyalZooDonationBox`, `SummonCreature`, `PetTrainingHelper`) continue to resolve to the `BaseCreature`. + +Note: `Scripts/Custom/Legendary/PolarBear.cs` was renamed to `LegendaryPolarBear.cs`. + +## 4. `AnimalLore.cs` referenced a package that does not exist + +`Scripts/Skills/AnimalLore.cs` had `using ShrinkSystem;` and two `IShrinkItem` branches. No `ShrinkSystem` namespace exists anywhere in the repo, and `IShrinkItem` appears nowhere in the stale `Scripts.dll` — **the code had never compiled or run.** (`Scripts/Misc/ShrinkTable.cs` is unrelated stock: `namespace Server`, class `ShrinkTable`.) + +**Action:** removed the `using` and collapsed the shrink branches back to the `BaseCreature` path. This restores exactly the behavior the shard was already running. + +--- + +## Verification + +After the repairs, `dotnet build Scripts/Scripts.csproj -c Release -p:Platform=x64` succeeded with 0 warnings, 0 errors. Rebuilding `ServUO.exe` and `Ultima.dll` from current source produced **byte-identical** binaries (same SHA-256), confirming the core was never stale in content — only `Scripts.dll` was. + +With Phase 0 applied, a plain boot shows: + +``` +Core: Compiling scripts... +Build succeeded. +Core: Verified 6023 item and 1385 mobile types +World: Loading... +...done (206208 items, 42771 mobiles, 0 customs) +``` + +## Unrelated, still open + +`DllNotFoundException: zlibwapi64` crashed this shard once (`Crash 6-5-2026-22-38-3.log`) while sending a packed gump. `zlibwapi64.dll` is present in the repo root, so this is a working-directory / native-load-path problem. It will bite the bridge if the bridge ever triggers a gump send. Resolve before load testing. From 35c85d542a945015b457d9b0f87c44698565e351 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:03:03 -0500 Subject: [PATCH 02/23] Phase 1: loopback transport to the sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeLink owns a TcpClient to 127.0.0.1 and nothing else touches it. Emit() is called from the Core thread; it enqueues onto a bounded drop-oldest queue and returns. A link thread drains the queue and reconnects with backoff; a reader thread parses inbound lines and marshals each to the Core thread via Timer.DelayCall. An absent, slow, or wedged sidecar therefore cannot stall the shard, which is the property the rest of the bridge depends on. Outbound JSON is written by hand into a StringBuilder because it runs on the Core thread for every event and the measured budget assumes that cost. Inbound uses JavaScriptSerializer: commands arrive at human rates, so correctness beats speed, and parsing happens off the Core thread anyway. That needs a System.Web.Extensions reference. server.hello is emitted per connection rather than once at ServerStarted. A sidecar that restarts independently would otherwise never learn which shard it is attached to. It carries a bootId, stable across reconnects and fresh on every shard restart, so the sidecar can tell "I reconnected" from "the shard restarted" and keep or discard its cache accordingly. Two defects found by testing and fixed before commit: - Backoff ceiling was 30s, so a sidecar restart cost up to half a minute of buffering on a loopback socket. Now 5s. - A stale reader could kill a fresh connection: reader.Join(1s) can time out, and the old thread's finally block then set the shared _dead flag, possibly tearing down the connection that had replaced it. Connections now carry an epoch and a reader only marks dead the one it owned. Acceptance evidence recorded in docs/PLAN.md §11: boots with no sidecar, buffers through the outage and drains on connect, round-trips ping/pong on the Core thread, survives unknown kinds and malformed JSON, and reconnects unattended. Co-Authored-By: Claude Opus 4.8 --- link/PLAN.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/link/PLAN.md b/link/PLAN.md index dc3ecb9..37edf26 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -236,6 +236,10 @@ Newline-delimited JSON, one object per line, `serial` as the primary key. ### Outbound (shard → sidecar) ```jsonc +{"t":1752…,"kind":"server.hello","shard":"My Shard","bootId":"8a9f34c5…","connects":2, + "items":206467,"mobiles":42826,"accounts":51} +{"t":1752…,"kind":"server.shutdown"} +{"t":1752…,"kind":"server.crashed","error":"…"} {"t":1752…,"kind":"mob.login","serial":"0x1A2B","name":"Thunderheat","acct":"PerryAdimn","webId":"9931"} {"t":1752…,"kind":"char.vitals","serial":"0x1A2B","hits":95,"hitsMax":100,"mana":40,"stam":88, "str":100,"dex":90,"int":45,"x":1420,"y":1631,"online":true} @@ -268,6 +272,14 @@ Newline-delimited JSON, one object per line, `serial` as the primary key. Every inbound handler marshals to the Core thread before touching world state. +### `server.hello` is per-connection, not per-boot + +The sidecar restarts independently of the shard, so anything it needs up front must be re-sent on **every** connect. An earlier draft emitted `server.started` once at `EventSink.ServerStarted`; a sidecar that came up second never received it and had no idea which shard it was attached to. + +`bootId` is a GUID generated at `ServerStarted`. It is stable across sidecar reconnects and changes on every shard restart, which is how the sidecar distinguishes *"I reconnected"* (keep cached state) from *"the shard restarted"* (discard it). `connects` is the shard's count of successful connections, so the first `hello` of a run carries `connects:1`. + +Counts in `hello` are a live snapshot taken on the Core thread, not a cached value — two hellos from the same boot will disagree, because the world keeps spawning. + ### Item names are clilocs `Item.Name` is frequently `null`; the display name is `LabelNumber`, a cliloc id. **There is no `Data/Cliloc.enu` in this repo** — `BRIDGE_FINDINGS.md` §IV.4 is wrong about this. Cliloc data lives in the client install, which `DataPath` resolves to `D:\Games\Electronic Arts\Ultima Online Classic\`. Ship **both** `name` (when non-null) and `cliloc`, and resolve the number **on the website** against a cliloc map. That avoids a server-side dependency on the client directory. @@ -289,8 +301,8 @@ Every inbound handler marshals to the Core thread before touching world state. ## 9. Implementation phases -0. **Fix the build** (§3). Nothing below loads until this is done. -1. **Transport.** `BridgeLink`: `TcpClient`, writer thread + bounded queue, reader thread → `Timer.DelayCall`. Emit `ServerStarted` / `Shutdown` / `Crashed` only. Prove the sidecar can restart independently while the shard runs. +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. 2. **Cheap event streams.** `Login`, `Logout`, `AccountGoldChange`, `ValidVendorPurchase`, `ValidVendorSell`, `PlayerDeath`, `PlayerMurdered`, `SkillGain`, `QuestComplete`. 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. @@ -314,6 +326,27 @@ Read in `Configure()` via `Config.Get("Bridge.", default)`. Key scope is --- +## 11. Phase 1 acceptance + +Run against the seeded shard with `tools/stub_sidecar.ps1`. Each of these is a claim the rest of the bridge leans on, so each was observed rather than assumed. + +| Claim | Evidence | +|-------|----------| +| The shard boots normally with **no sidecar listening**. | World loaded in 4.53 s, game port up, no stall, no error spam, CPU flat. | +| Events emitted while disconnected are **buffered and delivered on connect**. | `server.hello` carried `t=…070312` (boot) but arrived at `…114209`, 44 s later, when the sidecar first appeared. | +| Inbound commands execute on the **Core thread**. | `{"kind":"ping","id":"t1"}` → `{"kind":"pong","id":"t1"}`. | +| An **unknown kind** is ignored, not fatal. | `[Bridge] no handler for inbound kind 'nonsense.kind'` | +| **Malformed JSON** does not kill the reader. | `[Bridge] malformed inbound line, ignoring`, connection stayed up. | +| Killing the sidecar **does not disturb the shard**. | Shard stayed up, CPU unchanged, no exception, no log spam. | +| The shard **reconnects unattended**. | Second `[Bridge] connected`, `hello` re-sent with `connects:2` and the same `bootId`. | + +Two defects were found this way and fixed: + +- **Backoff ceiling was 30 s**, so a sidecar restart could cost half a minute of buffering on a loopback socket. Now 5 s. +- **A stale reader could kill a fresh connection.** `reader.Join(1s)` can time out, and the old reader's `finally` then set the shared `_dead` flag — potentially tearing down the connection that had already replaced it. Each connection now carries an epoch, and a reader only marks dead the connection it owned. + +--- + ## 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. From 637dedc45e63d05687dd9f85e35ef749716d013b Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 10:46:41 -0500 Subject: [PATCH 03/23] Phase 2: cheap event streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- link/PLAN.md | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/link/PLAN.md b/link/PLAN.md index 37edf26..3cb8d60 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -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. | | §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). | +| §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`. | --- @@ -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.` 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. 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. @@ -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 - **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. From 07e109994d63f6567aa8a45d463344c36ee1d286 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 10:59:21 -0500 Subject: [PATCH 04/23] Phase 3: polled sweeps (vitals, house decay, economy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeSweeps runs three repeating Core-thread timers for the state that has no EventSink. Cost measured in Phase 1 is why they can run on the main thread: a full pass of all three is well under a millisecond at the seeded scale. - Vitals: online players only. Small and volatile; the sidecar diffs snapshots. Offline characters do not move, so they are served on demand as full profiles instead, not swept. - House decay: emits only on a level transition. A silent baseline on ServerStarted records every house's current stage, so a restart does not re-announce them. Payload carries from/to, coords, nested ban location, region, sign name, owner serial+account, and built/refreshed timestamps, all null-guarded. - Economy supply: periodic sum of every account's currency as a snapshot. The level; AccountGoldChange and the vendor events are the flow. All three re-arm on `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters. Sweeps skip emitting while the sidecar is disconnected, since their state is perishable and re-emitted next tick anyway (unlike events, which queue through an outage). Verified on the seeded world with 8s intervals: baseline recorded 29 houses silently, a probe bumped one Somewhat->Fairly, and the next sweep emitted exactly one house.decay and none for the other 28. Economy emitted a supply snapshot per interval. Vitals emitted nothing, correctly, since all seeded characters are offline. Evidence in docs/PLAN.md §13. Adds tools/scaffolding/BridgeSweepProbe.cs (never deployed) to force a decay transition on demand. Co-Authored-By: Claude Opus 4.8 --- link/PLAN.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/link/PLAN.md b/link/PLAN.md index 3cb8d60..76fa16d 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -305,7 +305,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.` 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.**~~ **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.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. 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. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. @@ -348,6 +348,32 @@ Two defects were found this way and fixed: --- +## 13. Phase 3 acceptance + +`BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters. + +Verified on the seeded world with intervals cut to 8 s: + +- **Decay is transition-only.** Baseline recorded 29 houses **silently** on `ServerStarted`. A probe bumped one house `Somewhat → Fairly` with `SetDynamicDecay`; the next sweep emitted **exactly one** `house.decay`, none for the other 28: + + ```json + {"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly", + "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House", + "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0}, + "builtOn":"2026-05-11T…","lastRefreshed":"2026-05-31T…"} + ``` + +- **Economy supply** emitted a snapshot each interval: `{"kind":"economy.supply","accounts":51,"gold":…}`. +- **Vitals** correctly emitted nothing — the seeded characters are all offline (`NetState == null`). The JSON shape is the same field set proven by the Phase 1 probe; the online-emission path is not exercised without a live client. + +Notes from the run: + +- **`region` is null** for the seeded houses — they sit outside any named region. The handler guards `Region`, `Sign`, and `Owner` for null; all three can be absent on abandoned or oddly-placed houses. +- The sweeps **skip emitting when the sidecar is disconnected** (`BridgeLink.Connected`), so a long outage does not fill the bounded queue with perishable snapshots. Events (Phase 2) still queue through an outage because they are not perishable; sweeps re-emit fresh state on the next tick regardless. +- **Config duplicate keys: last write wins** (`Config.cs` does `_Entries[key] = e`), which is why the scaffolding appends test overrides to the end of `Bridge.cfg`. + +--- + ## 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: From edb1fdcbe219c90e1a0f89e2e67d0a1167e04e75 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 11:34:14 -0500 Subject: [PATCH 05/23] Phase 4: character-profile request/response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeProfile builds the read-models the website consumes; BridgeRequests registers the inbound handlers. The sidecar asks, the shard answers on the Core thread (inbound lines are marshaled through Timer.DelayCall before a handler runs), so all of these read live world state safely. - char.request: resolve by serial, or by account + slot, and reply with a full profile (stats, all trained skills, worn equipment with flattened AOS mods, resists). Works for offline characters since a logged-off mobile stays resident until Delete. - account.roster: light per-character summary, offline chars included. - vendor.snapshot: every player vendor owned by an account, with held gold and priced listings. Each request may carry a reqId the reply echoes so the sidecar can correlate. An unresolvable request gets a bridge.error reply rather than silence, so the website can show a real failure instead of hanging. Verified against the real world with a sending stub: all five requests answered, both char lookup paths (account+slot and serial) returning the identical profile, vendor.snapshot returning seed_000's two vendors and 80 listings, and the bad account returning bridge.error. Two real-data findings noted in docs/PLAN.md §14: a GM character can have skill base > cap (the website must not assume otherwise), and the mod-flattening path still wants a genuinely kitted character to exercise against real suffix gear. Adds tools/stub_sidecar_request.ps1 (sends requests) and a hardened tools/stub_sidecar.ps1 (survives reaping/rebind). Co-Authored-By: Claude Opus 4.8 --- link/PLAN.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/link/PLAN.md b/link/PLAN.md index 76fa16d..5c2dc2d 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -306,7 +306,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 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.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12. 3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. -4. **Request/response.** `char.profile`, `account.roster`, `vendor.snapshot`. Sidecar caches profiles; rate-limit requests sidecar-side. +4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. 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. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. @@ -348,6 +348,27 @@ Two defects were found this way and fixed: --- +## 14. Phase 4 acceptance + +`BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence. + +Verified against the **real world** with a sending stub (`tools/stub_sidecar_request.ps1`), five requests, all answered on the Core thread: + +- `account.roster` for `whitlocktech` → one char, Darrow, slot 0, offline. +- `char.request` by account+slot → full profile: stats, all 58 skills, resists, worn equipment, `reqId` echoed. +- `char.request` by `serial:"0x24C"` → byte-identical profile. Both resolution paths agree. +- `vendor.snapshot` for `seed_000` → its two vendors, held gold, all 40 priced listings each. +- `char.request` for a bogus account → `{"kind":"bridge.error","reqId":"r-bad","reason":"unknown account"}`. + +Two things the real character surfaced that the seeded dummies could not: + +- **`base > cap` is possible.** Darrow (a GM character) reports every skill `base:120, cap:100`. The website must not assume `base <= cap`. The profile reports both faithfully. +- **The mod-flattening path was not exercised against real suffix gear.** Darrow wears starter shirt/pants/shoes with empty `mods`. The flattening code is the same path proven by the Phase 1 timing probe, but a genuinely kitted character (weapon/armor with AOS attributes) would be the honest end-to-end test. Not blocking. + +Offline profiles work: Darrow was logged out and the full sheet still built, because a logged-off mobile stays resident until Delete. + +--- + ## 13. Phase 3 acceptance `BridgeSweeps.cs` runs three repeating Core-thread timers: vitals (`StatSweepSeconds`), house decay (`DecaySweepSeconds`), economy supply (`EconomySweepSeconds`). All re-armable via `[bridge reload`; `[bridge sweepnow` runs one of each on demand; `[bridge status` reports sweep counters. From 5eecdea2fc2cc70d1aacb7b8426f77a43afa85c4 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 11:47:17 -0500 Subject: [PATCH 06/23] Phase 5: [link account linking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- link/PLAN.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/link/PLAN.md b/link/PLAN.md index 5c2dc2d..e1c6c65 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -307,6 +307,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 2. ~~**Cheap event streams.**~~ **Done.** `BridgeEvents` subscribes the streams selected below. All observed on the live shard; evidence in §12. 3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. 4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. +5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15. 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. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. @@ -348,6 +349,36 @@ Two defects were found this way and fixed: --- +## 15. Phase 5 acceptance + +`BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`. + +Verified end to end with a smart stub (`tools/scaffolding/BridgeLinkProbe.cs` + a sidecar that reads the code and confirms it): + +``` +<- link.request code=77M9TK account=seed_001 char=Seed001A ttlSec=300 +-> link.confirm code=77M9TK websiteUserId=web-9931 +<- link.ok code=77M9TK account=seed_001 websiteUserId=web-9931 +-> link.confirm code=BADCOD ... +<- link.error code=BADCOD reason="unknown or expired code" +``` + +**The tag persists.** After a `World.Save()`, `accounts.xml` contained: + +```xml + + web-9931 + +``` + +This is ServUO's standard account-tag format, read by `LoadTags` at boot, so the link survives restarts with no new persistence layer — as the plan promised. + +Safeguards in place: codes are one-time and short-TTL; only the newest code per account is valid (a new `[link` drops prior codes); `[link` is rate-limited per account (30 s) against code spam; a 1-minute purge timer bounds the code table; and the `websiteUserId` is trusted only because the socket is loopback-only. `mob.login` now carries `webId` when the account is linked, so the sidecar can attribute the session without a lookup. + +Note: the tag is written to memory on `link.confirm` but only reaches disk on the next world save (AutoSave, clean shutdown, or an explicit save). A hard crash between the two loses it — acceptable, since the player simply re-runs `[link`. + +--- + ## 14. Phase 4 acceptance `BridgeProfile.cs` builds the read-models; `BridgeRequests.cs` registers the inbound handlers (`char.request`, `account.roster`, `vendor.snapshot`). Each request may carry a `reqId` the reply echoes; an unresolvable request gets a `bridge.error` reply, never silence. From db7c0adbf075793f37b2a4936fe5a25f1635c3d9 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 11:56:22 -0500 Subject: [PATCH 07/23] Phase 6: town-crier news (website -> game) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BridgeTownCrier handles inbound towncrier.add / towncrier.remove, pushing website-published news into GlobalTownCrierEntryList so every town crier announces it until it expires. Both run on the Core thread (required: AddEntry mutates a shared list and the criers send packets). An id maps to the created TownCrierEntry so a later remove can pull it, and re-adding an id replaces the prior entry. Caps are enforced before touching the shared list -- line count, line length, active-entry count, duration -- as defense in depth on top of the loopback trust boundary: a buggy or compromised sidecar still cannot flood the criers or pin a message forever. Config: Bridge.TownCrierMax{Lines,LineLength,Active,DurationSec}. Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree: add n1 -> towncrier.ok and the entry appears with the exact lines; add n2 (8 lines over the cap of 6) -> towncrier.error and never enters the list; remove n1 -> towncrier.ok and the entry is gone; remove unknown -> towncrier.error. Evidence in docs/PLAN.md §16. Adds BridgeJson.GetStringList for JSON string arrays, tools/stub_sidecar_crier.ps1, and tools/scaffolding/BridgeCrierProbe.cs. This closes the pure-plugin inbound work; only the PlayerVendorSale core edit (Phase 7) remains on the ServUO side. Co-Authored-By: Claude Opus 4.8 --- link/PLAN.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/link/PLAN.md b/link/PLAN.md index e1c6c65..e886427 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -308,6 +308,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 3. ~~**Sweeps.**~~ **Done.** `BridgeSweeps`: vitals / decay-on-transition / economy, all Core-thread timers, re-armable. Evidence in §13. 4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. 5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15. +6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16. 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. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. @@ -349,6 +350,27 @@ Two defects were found this way and fixed: --- +## 16. Phase 6 acceptance + +`BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary. + +Verified with a sending stub and a probe that logs the actual crier list. Replies and game state agree: + +| Sent | Reply | Crier list | +|------|-------|------------| +| `add n1` (2 lines) | `towncrier.ok` | entry appears with the exact lines | +| `add n2` (8 lines, cap 6) | `towncrier.error "too many lines"` | never enters the list | +| `remove n1` | `towncrier.ok` | entry gone | +| `remove does-not-exist` | `towncrier.error "unknown id"` | no change | + +The probe showed the list at 1 entry after the add and 0 after the remove, with the over-cap add never appearing — so the caps and the add/remove both take real effect, not just acknowledged. + +Harness note: the first run's PowerShell stub missed the replies because it checked `NetworkStream.DataAvailable`, which does not see lines already buffered inside `StreamReader`. Switching to a blocking `ReadLine` with a read timeout captured them. The shard behaved correctly in both runs; only the test reader was wrong. `tools/stub_sidecar_request.ps1` uses the same `DataAvailable` pattern and got lucky on timing — prefer the blocking-read pattern for new stubs. + +No core changes; this closes the pure-plugin inbound work. + +--- + ## 15. Phase 5 acceptance `BridgeAccountLink.cs` implements `[link` and the inbound `link.confirm`. A player runs `[link`; the shard mints a one-time, expiring code (5 min TTL, unambiguous alphabet — no O/0/I/1), holds it in a Core-thread dict keyed to the account, and emits `link.request`. The player enters the code on the website; the sidecar sends `link.confirm`; the shard validates, writes the `WebsiteUserId` account tag, and replies `link.ok`. From ee425f95d9540bc5638a5745fd335eeaccd4f375 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 15:46:07 -0500 Subject: [PATCH 08/23] Phase 7: PlayerVendorSale core event + subscriber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one non-drop-in piece. Player-vendor purchases raise no EventSink, so the sale is invisible to subscription. Two git-format core patches add a PlayerVendorSale event and raise it at the committed sale in PlayerVendorBuyGump.OnResponse (right after HoldGold +=), where buyer, vendor owner, item, price, and commission are all in scope. The subscriber BridgeVendorSale emits vendor.sale. All three are a coupled unit. The subscriber references PlayerVendorSaleEventArgs, which does not exist until the EventSink patch is applied, so it lives in patches/ not overlay/ -- shipping it in overlay would break the build on any unpatched install. patches/README.md documents applying the unit; both patches verified with git apply --check against stock ServUO 57.4. This is the first phase that rebuilds the core (ServUO.exe), not just Scripts.dll. vendor.sale carries buyer and vendor-owner accounts, both present and distinct, which is the pair that flags gold-laundering when they match -- richer than the ownerless NPC ValidVendor* events, and on a committed sale rather than a validation stage. Verified with a probe firing the event on real seeded-vendor data: vendor.sale emitted with buyerAcct=seed_001, ownerAcct=seed_000, Longsword, price 69819. The probe proves the event, args, subscriber, and payload; the literal gump call site firing on a real purchase needs a live buyer with a NetState and is confirmed by an in-game buy. Evidence in docs/PLAN.md §17. This completes every phase on the ServUO side. Phases 0-6 are drop-in (overlay/); 7 is patches/. Cheat signals are folded into existing streams (fastwalk, audit, vendor.sale), not a separate phase. Remaining work is the Rust sidecar. Co-Authored-By: Claude Opus 4.8 --- link/PLAN.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/link/PLAN.md b/link/PLAN.md index e886427..cefc30d 100644 --- a/link/PLAN.md +++ b/link/PLAN.md @@ -309,6 +309,7 @@ Counts in `hello` are a live snapshot taken on the Core thread, not a cached val 4. ~~**Request/response.**~~ **Done.** `BridgeProfile` + `BridgeRequests`: `char.profile` (by account+slot or serial), `account.roster`, `vendor.snapshot`, `bridge.error`. Evidence in §14. Sidecar should cache profiles and rate-limit requests. 5. ~~**`[link` account linking.**~~ **Done.** `BridgeAccountLink`: `[link` → one-time code → `link.confirm` → `WebsiteUserId` tag, persisted to `accounts.xml`. `mob.login` carries `webId`. Evidence in §15. 6. ~~**Town-crier inbound.**~~ **Done.** `BridgeTownCrier`: `towncrier.add` / `remove` into `GlobalTownCrierEntryList`, with abuse caps. Evidence in §16. +7. ~~**Core edit: `PlayerVendorSale`.**~~ **Done.** Two core patches + `BridgeVendorSale` subscriber → `vendor.sale` with buyer + owner + price + commission. Evidence in §17. 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. 6. **Town-crier inbound.** `GlobalTownCrierEntryList.Instance.AddEntry(lines, duration)` (`Scripts/Mobiles/NPCs/TownCrier.cs:96`), marshaled to the Core thread. Cap line count/length and active entries. 7. **Core edit: `PlayerVendorSale`** (§6). Then the cheat-detection feed. @@ -350,6 +351,28 @@ Two defects were found this way and fixed: --- +## 17. Phase 7 acceptance + +The one non-drop-in piece. Two `git`-format core patches (`patches/playervendor-sale-*.patch`) add a `PlayerVendorSale` EventSink event and raise it at the committed sale in `PlayerVendorBuyGump.OnResponse` (right after `HoldGold +=`). The subscriber `patches/BridgeVendorSale.cs` emits `vendor.sale`. All three are a coupled unit — the subscriber references a type the patch creates, so it lives in `patches/`, not `overlay/`. + +Both patches verified with `git apply --check` against stock ServUO 57.4. Applying them rebuilds the **core** (`ServUO.exe`), not just `Scripts.dll` — the first phase to do so. + +Verified end to end with a probe that fired the event using **real seeded-vendor data**: + +```json +{"kind":"vendor.sale","committed":true, + "buyerSerial":"0x1F8","buyerAcct":"seed_001", + "ownerSerial":"0x1F5","ownerAcct":"seed_000", + "vendorSerial":"0x2C0","itemSerial":"0x4001440F","itemType":"Longsword", + "itemId":3937,"amount":1,"price":69819,"commission":0} +``` + +Both **buyer and owner accounts are present and distinct** — the pair that flags gold-laundering when they match, and the reason this event beats the ownerless NPC `ValidVendor*` events. + +**Test boundary, stated honestly:** the probe proves the patched event, its args, the subscriber, and the payload. It does **not** exercise the literal call site in `OnResponse` firing on a real purchase — that needs a live buyer with a `NetState` at a vendor, which cannot be faked. That one line is at the verified committed-sale point; the gold-standard confirmation is an in-game buy from a player vendor (buy from a seeded vendor and watch for `vendor.sale committed:true`). + +--- + ## 16. Phase 6 acceptance `BridgeTownCrier.cs` handles inbound `towncrier.add` / `towncrier.remove`, pushing website news into `GlobalTownCrierEntryList` on the Core thread. Caps (line count, line length, active-entry count, duration) are enforced before touching the shared list — defense in depth on top of the loopback trust boundary. From 52206a73d24d96795998982aff9d88cb721332c5 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 20:47:09 -0500 Subject: [PATCH 09/23] docs: website integration guide docs/INTEGRATION.md is the API reference for building the front end against the sidecar: base URL, auth (Bearer / X-Api-Key / ?token=), protocol versioning, the rich /health, the WebSocket live feed with a full event catalog, every REST query and command (char/roster/vendors/link/towncrier/history/economy), the status-code table, a worked character-page example, and current caveats. Payloads are the real shapes captured during testing. Linked from the top-level README. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 371 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 link/INTEGRATION.md diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md new file mode 100644 index 0000000..b281755 --- /dev/null +++ b/link/INTEGRATION.md @@ -0,0 +1,371 @@ +# uo-link Sidecar — Website Integration Guide + +This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly. + +``` +website ──WebSocket (live feed) + REST (queries/commands)──► sidecar ──loopback──► shard +``` + +- **Base URL** — default `http://127.0.0.1:8080` (WebSocket: `ws://127.0.0.1:8080`). Configurable in `sidecar.toml` (`web.bind`) or `UOLINK_WEB_BIND`. If you serve the site from another host, bind the sidecar to `0.0.0.0:8080` and put it behind TLS. +- **Content type** — all request and response bodies are JSON (`application/json`). +- **Timestamps** — every `t` field is **epoch milliseconds** (UTC). Human-readable timestamps (e.g. `house.decay.builtOn`, `/health.last_event`) are ISO-8601 UTC. +- **Serials** — game object ids are hex strings like `"0x24C"` (mobiles) or `"0x40013AAD"` (items). Treat them as opaque keys. + +--- + +## 1. Authentication + +Every route **except `GET /health`** requires the shared token from `sidecar.toml` (`web.auth_token`). Present it any of these ways: + +| Transport | How | +|-----------|-----| +| REST | `Authorization: Bearer ` | +| REST | `X-Api-Key: ` | +| WebSocket | `?token=` in the connect URL (browsers can't set headers on a WS handshake) | + +Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting. + +--- + +## 2. Protocol version + +The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. + +- Every response carries an **`X-UOLink-Version: 1`** header. +- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 1`. +- **Optionally**, send `X-UOLink-Version: 1` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: + + ```json + { "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" } + ``` + +Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. + +--- + +## 3. Health + +``` +GET /health (no auth) +``` + +```json +{ + "status": "ok", // "ok" when plugin connected AND db reachable, else "degraded" + "protocol": 1, + "plugin_connected": true, // is the shard link up right now? + "database": "ok", // "ok" | "error" + "uptime": "3d 12h", + "last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet +} +``` + +Always returns HTTP 200 (read `status`/`plugin_connected` for real state). Use it for liveness checks and to detect when the shard has dropped (`plugin_connected: false`). + +--- + +## 4. WebSocket live feed + +``` +GET /ws?token= (WebSocket upgrade) +``` + +A push-only stream of game events as they happen. You do **not** send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame. + +**On connect**, the first frame is: + +```json +{ "kind": "ws.hello", "protocol": 1 } +``` + +**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`. + +Notes: +- **Live-only, no replay.** A client that connects now sees events from now on. For history/backfill, use `GET /history`. +- The sidecar sends WebSocket **ping** frames every ~30s for keepalive; browser clients answer automatically. +- You may occasionally see a `{"kind":"pong",...}` frame (the sidecar's internal heartbeat to the shard). Ignore any `kind` you don't handle. +- A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens. + +### Minimal browser client + +```js +const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`); +ws.onmessage = (m) => { + const ev = JSON.parse(m.data); + switch (ev.kind) { + case "ws.hello": /* check ev.protocol === 1 */ break; + case "mob.login": onLogin(ev); break; + case "vendor.sale": onSale(ev); break; + case "house.decay": onIdoc(ev); break; + // ...handle the kinds you care about; ignore the rest + } +}; +ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history +``` + +### Event catalog + +Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"serial","name","acct","player"}` (`acct` present only for player-owned mobiles). + +#### Lifecycle +| kind | fields | notes | +|------|--------|-------| +| `server.hello` | `shard`, `bootId`, `connects`, `items`, `mobiles`, `accounts` | Sent to the sidecar on every shard (re)connect. `bootId` changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". | +| `server.shutdown` | — | Clean shutdown. | +| `server.crashed` | `error` | Not always sent (a hard crash may skip it). | +| `world.save.before` / `world.save.after` | (`after` adds `items`, `mobiles`) | Save-cycle boundaries; a natural consistency checkpoint. | + +#### Sessions & identity +| kind | fields | +|------|--------| +| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) | +| `mob.logout` | `who` | +| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) | + +#### Economy & commerce +| kind | fields | notes | +|------|--------|-------| +| `gold.change` | `acct`, `old`, `new`, `delta` | AccountGold flow (gold in bank/account, not physical coins). | +| `vendor.buy` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor purchase (validation stage). | +| `vendor.sell` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor sale. | +| `vendor.sale` | `buyerSerial`, `buyerAcct`, `ownerSerial`, `ownerAcct`, `vendorSerial`, `itemType`, `itemSerial`, `itemId`, `amount`, `price`, `commission`, `committed:true` | **Player** vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. | +| `vendor.placed` | `owner`, `vendor` | A player vendor was placed. | + +```json +{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0", + "ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1", + "itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1, + "price":100,"commission":0,"t":1783720195626} +``` + +#### Character progression & vitals +| kind | fields | notes | +|------|--------|-------| +| `char.vitals` | `serial`, `hits`,`hitsMax`, `mana`,`manaMax`, `stam`,`stamMax`, `str`,`dex`,`int`, `map`, `x`,`y` | Periodic snapshot of each **online** player (~every 30s; configurable). Diff successive snapshots to detect change. | +| `skill.gain` | `who`, `skill`, `gained`, `base`, `cap` | Player skill gains only (NPC gains are filtered out). | +| `fame.change` / `karma.change` | `who`, `old`, `new` | Player only. | +| `quest.complete` | `who`, `quest` | | + +#### Death & PvP +| kind | fields | +|------|--------| +| `player.death` | `who`, `killer` | +| `player.murdered` | `victim`, `murderer` | +| `mob.killed` | `killed`, `killer` — only kills that involve a player | + +#### Housing / IDOC +| kind | fields | +|------|--------| +| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` | + +`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign. + +```json +{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly", + "map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House", + "ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0}, + "builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"} +``` + +#### Economy supply (periodic) +| kind | fields | +|------|--------| +| `economy.supply` | `accounts`, `gold` — total money supply across all accounts (~every 5 min; configurable) | + +#### Cheat detection & staff audit +| kind | fields | notes | +|------|--------|-------| +| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. | +| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. | +| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. | + +#### Account linking +| kind | fields | notes | +|------|--------|-------| +| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. | + +--- + +## 5. REST — read queries + +These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning **504**. + +### Character profile + +``` +GET /char/{account}/{slot} # by account + character slot (0-based) +GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C +``` + +Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for **offline** characters too. `GET /char/serial/...` falls back to the last **cached** profile if the shard is unreachable (so a page still renders during a shard restart). + +```json +{ + "kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null, + "body": 400, "hue": 33770, "online": false, "acct": "whitlocktech", + "stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110, + "mana":123,"manaMax":123, "stam":120,"stamMax":120, + "fame":0,"karma":0,"luck":0, + "resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} }, + "skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ], + "equipment": [ + { "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33, + "cliloc":1027933,"mods":{} }, + { "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721, + "weapon":{"minDamage":16,"maxDamage":18}, + "mods":{"WeaponDamage":50,"HitLightning":40} } + ] +} +``` + +Field notes: +- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it. +- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items. +- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site. +- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly. + +### Account roster + +``` +GET /roster/{account} +``` + +Lightweight list of an account's characters (up to 5–7), including offline ones. Use this for a character-picker, then fetch the full profile on demand. + +```json +{ "kind":"account.roster", "acct":"whitlocktech", + "chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] } +``` + +### Player vendors + +``` +GET /vendors/{account} +``` + +Every player vendor owned by any character on the account, with held gold and current listings. + +```json +{ "kind":"vendor.snapshot", "acct":"seed_000", + "vendors":[ + { "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186, + "ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604, + "listings":[ + {"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true} + ] } ] } +``` + +--- + +## 6. REST — commands & history + +### Confirm an account link + +The in-game `[link` flow: the player runs `[link`, the shard emits a `link.request` event (over the WebSocket) carrying a one-time `code`. Your site shows the logged-in website user a box to enter that code, then: + +``` +POST /link/confirm +{ "code": "AB12CD", "websiteUserId": "9931" } +``` + +- Success → **200** `{"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}`. The game account is now permanently tagged with your `websiteUserId` (persisted on the shard); subsequent `mob.login` events for that account carry `webId`. +- Bad/expired code → **404** `{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}`. + +Codes are one-time and expire (default 5 min). + +### Look up an existing link + +``` +GET /link/{account} +``` + +- **200** `{"account":"PerryAdimn","websiteUserId":"9931"}` if linked. +- **404** `{"account":"PerryAdimn","linked":false}` if not. + +(This reads the sidecar's mirror of confirmed links — no shard round-trip.) + +### Publish / remove town-crier news + +Push a message that every in-game town crier announces until it expires. + +``` +POST /towncrier +{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 } +``` +→ **200** `{"kind":"towncrier.ok","id":"news-42"}`. Re-posting the same `id` replaces the prior entry. + +``` +DELETE /towncrier/{id} +``` +→ **200** `{"kind":"towncrier.ok","id":"news-42"}`, or **404** `{"kind":"towncrier.error","reason":"unknown id"}`. + +Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`. + +### History (from the sidecar's database) + +``` +GET /history?kind={kind}&limit={n} # kind optional, limit default 100 (max 1000) +GET /economy?limit={n} # the money-supply series (economy.supply events) +``` + +Recent events, **newest first**, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC". + +``` +GET /history?kind=vendor.sale&limit=50 +→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] } + +GET /economy?limit=200 +→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] } +``` + +--- + +## 7. Status codes + +| Code | Meaning | +|------|---------| +| 200 | OK | +| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) | +| 401 | Missing or invalid auth token | +| 404 | Not found (unknown account / character / id) | +| 409 | Protocol version mismatch (you sent `X-UOLink-Version` and it disagreed) | +| 500 | Internal error (e.g. database) | +| 503 | Shard not connected — the query needs the live game and it's down | +| 504 | Shard connected but didn't reply within 10s | + +`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist." + +--- + +## 8. Putting it together + +A typical character page: + +```js +const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" }; + +// 1. render the roster +const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); + +// 2. full sheet for the selected character +const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H }); +if (res.status === 503) showBanner("Game server is restarting…"); +else renderProfile(await res.json()); + +// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives +// (see the WebSocket client in §4) + +// 4. recent sales widget +const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H }) + .then(r => r.json()); +``` + +--- + +## 9. Caveats & current limits + +- **No rate limiting yet.** The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side. +- **WebSocket is push-only and live-only.** No client→server messages, no replay. Backfill via `/history`. +- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down). +- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted. +- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape. From aa329215d8c87daf92d86a8a15fd3f2ec42e8102 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 01:55:11 -0500 Subject: [PATCH 10/23] =?UTF-8?q?feat(admin):=20plugin=20write=20plane=20?= =?UTF-8?q?=E2=80=94=20admin.kick/ban/unban/broadcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (plugin side) of docs/ADMIN_CONTROLS.md: a staff write plane so the website can moderate the live shard. - BridgeAdmin.cs: inbound admin.kick, admin.ban (timed + indefinite), admin.unban, admin.broadcast. Each requires an `actor`, refuses targets at or above AdminAccessFloor (default CoOwner — Owner-only shield), replies admin.ok/admin.error with the reqId echoed, and emits an admin.audit (origin=web) broadcast. Attribution is web: in the console log and the ban BanDealer tag. Kicking enumerates NetState.Instances so a character-select session is caught too. - BridgeConfig/Bridge.cfg: AdminWriteEnabled (default OFF — opt-in), AdminAccessFloor, broadcast/reason length caps, ban duration clamp. - tools/stub_sidecar_admin.ps1: live smoke-test harness; *.log gitignored. Verified: compiles clean against ServUO (0 err/warn); live run on the seeded shard confirms all four verbs, the audit stream, timed-ban fields, and the Owner-floor refusal, with no exceptions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- link/ADMIN_CONTROLS.md | 300 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 link/ADMIN_CONTROLS.md diff --git a/link/ADMIN_CONTROLS.md b/link/ADMIN_CONTROLS.md new file mode 100644 index 0000000..db39185 --- /dev/null +++ b/link/ADMIN_CONTROLS.md @@ -0,0 +1,300 @@ +# Administrative Controls — Research & Integration Plan + +**Status:** Research + design. No code written yet. +**Date:** 2026-07-12 +**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**. +**Companion to** [`PLAN.md`](PLAN.md) (the read/event plane) and [`INTEGRATION.md`](INTEGRATION.md) (the website API). This document covers the **write plane**: staff actions the website should be able to take against the live shard. + +--- + +## 1. The question + +The bridge today is almost entirely *outbound*. It streams events and answers read queries. Its entire inbound (website → shard) surface is three verbs: + +| Verb | File | What it does | +|------|------|--------------| +| `ping` | `BridgeBoot.cs:139` | Liveness echo. | +| `link.confirm` | `BridgeAccountLink.cs` | Ties a game account to a website user. | +| `towncrier.add` / `towncrier.remove` | `BridgeTownCrier.cs` | Publishes news to the in-game criers. | + +None of these are *moderation*. A staff member who wants to kick a cheater, ban an account, answer a help page, or teleport a stuck player still has to be logged into the game client. This document surveys what in-game administrative controls exist, decides which are worth exposing over the bridge, and specifies the protocol and safety model for doing it. + +**The thesis up front:** a small, well-guarded set of account/session-moderation verbs plus the help-page queue covers the overwhelming majority of "why do I have to log in to the game for this" moments. World-building and object manipulation (`[add`, `[set`, `[dupe`, decorate, spawners) should stay in the game client — they are target-driven, high-blast-radius, and gain nothing from a web form. + +--- + +## 2. How ServUO admin controls actually work + +Four mechanisms, all of which the bridge must respect or reuse. + +### 2.1 The AccessLevel ladder + +`Server/Mobile.cs:431`: + +``` +Player, VIP, Counselor, Decorator, Spawner, GameMaster, Seer, Administrator, Developer, CoOwner, Owner +``` + +Every command is gated on a minimum level (`CommandSystem.Register(name, level, handler)`). This ladder is the shard's whole authorization model. **The bridge has no Mobile and therefore no natural place on this ladder** — see §5, the attribution problem. + +### 2.2 The command system + +Two registration styles: + +- **Simple commands** — `CommandSystem.Register("Save", AccessLevel.Administrator, handler)`. The bridge already uses this for `[bridge` (`BridgeBoot.cs:44`, Administrator-gated). +- **Generic/target commands** — `BaseCommand` subclasses in `Commands/Generic/Commands/Commands.cs`, registered as objects (`KillCommand`, `KickCommand`, `FirewallCommand`, …). These are built to be *targeted* in-game (click a mobile). Their **logic** is reusable from the bridge; their **targeting/gump plumbing** is not. + +### 2.3 Command logging (the existing audit trail) + +Staff actions call `CommandLogging.WriteLine(from, ...)`, which writes `Logs/Commands/*.log` **and** is the source of the bridge's own `audit.command` / `audit.set` events (`INTEGRATION.md` §4). Any web-initiated action **must** feed this same trail, or the in-game audit log develops blind spots exactly where remote power is exercised. + +### 2.4 Account model (the moderation state) + +`Scripts/Accounting/Account.cs`. The durable, offline-capable levers live here: + +| Lever | API | Notes | +|-------|-----|-------| +| Ban (indefinite) | `acct.Banned = true; acct.SetUnspecifiedBan(from)` | `Account.cs:440`, `:1098` | +| Ban (timed) | `acct.SetBanTags(from, DateTime.UtcNow, TimeSpan)` then `acct.Banned = true` | `:1103`; `Banned` getter auto-clears when the window lapses (`:454`) | +| Unban | `acct.Banned = false; acct.SetUnspecifiedBan(null)` | clears the tags | +| Read ban | `acct.GetBanTags(out when, out dur)` | `:1133` | +| Staff level | `acct.AccessLevel = …` | `:557` — promotes/demotes a whole account | +| Young status | `acct.Young` | `:471` | + +Account-level state persists and applies whether or not the player is online. Per-*mobile* state (below) generally requires the target resident. + +--- + +## 3. Candidate controls + +Grouped by subsystem. **Tier**: **A** = wire in first, **B** = second wave, **N** = never expose remotely. **~~H~~ = excluded.** The former "hold" items (firewall, kill/res, jail, item/gold grants, set-access-level) were reviewed and **cut from the roadmap entirely** per the 2026-07-12 decision — their rows are kept below for the record but will **not** be built. The write plane is deliberately account/session moderation + support, nothing that manipulates the world or the object graph. + +### 3.1 Session control (target online) + +| Control | In-game | Bridge API | Tier | Notes | +|---------|---------|-----------|------|-------| +| **Kick** | `[Kick` → `KickCommand`, `Commands.cs:1170` | `targ.NetState?.Dispose()` | **A** | Pure disconnect. Reversible (they reconnect). Lowest blast radius of any real moderation action. | +| **Firewall (IP block)** | `[Firewall`, `Commands.cs:1125` | `Firewall.Add(state.Address)` | **H** | Blocks an IP, not an account. Collateral damage on shared IPs/CGNAT; hard to reverse from the same UI. Powerful but sharp. | +| **Locate / who** | `[Where`, `[Client` | already have `char.vitals`/`mob.login` | — | Effectively already covered by the event plane. | + +### 3.2 Account moderation (works offline) + +| Control | In-game | Bridge API | Tier | Notes | +|---------|---------|-----------|------|-------| +| **Ban (indefinite)** | `[Ban` → `KickCommand(ban:true)`, `Commands.cs:1225` | `Banned=true; SetUnspecifiedBan` + kick live sessions | **A** | The headline verb. Note the in-game path *also* opens `BanDurationGump` — we replace that with an explicit duration in the request. | +| **Ban (timed)** | (gump) | `SetBanTags(actor, now, dur); Banned=true` | **A** | Duration in the request body; auto-expires. | +| **Unban** | property edit | `Banned=false; SetUnspecifiedBan(null)` | **A** | | +| **Mute / squelch** | property `Squelched` | `mob.Squelched = true` (`Mobile.cs:5807`) | **B** | Per-**character**, not per-account. **Persists** across relog + restart (serialized, `Mobile.cs:6489`/`:6013`); works on offline chars too. Mute an account = squelch each resident character (§7.1). | +| **Page-mute** | `PagingSquelched` | set on `PlayerMobile` | **B** | Stops help-page spam without a full mute. | +| **Set access level** | property `AccessLevel` | `acct.AccessLevel = …` | **H** | Promoting staff from a web UI is a serious privilege path. Gate hard, or omit. | +| **Comments / notes** | account comments | `acct.Comments` | **B** | A staff notes field — pairs naturally with a web moderation panel. | + +### 3.3 Player actions (target online) + +| Control | In-game | Bridge API | Tier | Notes | +|---------|---------|-----------|------|-------| +| **Kill / Resurrect** | `[Kill` / `[Res`, `Commands.cs:966` | `mob.Kill()` / `mob.Resurrect()` | **H** | Legitimate for stuck/exploit cleanup; also the most "griefable" verb if the web authz ever leaks. | +| **Teleport / Bring** | `[Go`, `[Move`, `[Tele` | `mob.MoveToWorld(p, map)` | **B** | "Bring to me" has no meaning without a staff mobile; "send to coordinates / named location" does. | +| **Jail** | region only — `Regions/Jail.cs`, **no stock command** | custom: move to jail point (+ flag) | **H** | Needs us to *build* the action (pick a jail location, decide on release). Region exists; the verb does not. | +| **Hide / Unhide** | `[Hide`, `Commands.cs:1066` | `mob.Hidden = bool` | **N** | No remote use case. | +| **Set/Get property** | `[Set` / `[Get` / `[Props` | reflection | **N** | Arbitrary property writes = arbitrary power. Keep in-client. | +| **Give item / gold** | `[Add`, `Bank` | construct + place | **H** | Compensation flows are real but this is a duplication/economy risk; if wanted, expose *specific* curated grants, never `[add` by type. | + +### 3.4 Support: the help-page queue ★ + +`Scripts/Services/Help/PageQueue.cs`. When a player uses the in-game Help button they create a `PageEntry` (`Bug`, `Stuck`, `Account`, `Question`, `Suggestion`, `Harassment`, …) carrying **sender, message, type, location/map, timestamp, and assigned handler**. `PageQueue.List` is the live queue; `PageQueue.Enqueue/Remove` mutate it; a staff reply reaches the player via `ResponseEntry` → `MessageSentGump`. + +This is the single **best** tie-in and deserves its own slice of work: + +- **Stream** new pages as a `page.new` event and removals as `page.closed`. +- **Snapshot** the open queue over REST (`GET /pages`). +- **Respond** from the website (`POST /pages/{id}/respond`) → delivers a message to the player in-game, exactly like a staff member typing a response. +- **Close / assign** a page. + +It turns "a staff member must be logged into the game to see the queue" into "the queue is a page on the site." Tier **A**, but scoped as its own phase (§6, Phase 2) because it is read+write+stream, not a single verb. + +### 3.5 Broadcast & messaging + +| Control | In-game | Bridge API | Tier | Notes | +|---------|---------|-----------|------|-------| +| **Server broadcast** | `[BCast`, `Handlers.cs` | `World.Broadcast(hue, ascii, text)` | **A** | Overlaps town-crier but different UX (instant system message vs. crier loop). Cheap, high-value. | +| **Staff message (SMsg)** | `[SMsg`, `Handlers.cs` | send to online staff | **B** | "Post to staff channel" from the site. | +| **Tell / private msg** | `[Tell` | `mob.SendMessage` | **B** | Message one player from the web (e.g. auto-reply to a page). | + +### 3.6 World / server operations + +| Control | In-game | Bridge API | Tier | Notes | +|---------|---------|-----------|------|-------| +| **Save** | `[Save`, `Handlers.cs` (Administrator) | `AutoSave.Save()` | **B** | Trigger a world save from a deploy/admin panel. Emits `world.save.*` we already stream. | +| **Background save** | `[BGSave` | | **B** | Non-blocking variant. | +| **Shutdown / restart** | console | process-level | **N** | Do this at the process/host layer, not through a game plugin. | +| **Freeze / Wipe / DecorateDelete / TelGen** | various | — | **N** | Destructive world-building. In-client only. | + +--- + +## 4. Roadmap (decided) + +> **Build status (2026-07-13):** Phase 1 **plugin side is built and live-verified** on the seeded shard — `BridgeAdmin.cs` + config, branch `feature/admin-controls`. All four verbs, the `web:` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner account was refused) confirmed end-to-end against a booted ServUO, no exceptions. **Remaining for Phase 1 to be usable from the site:** sidecar REST routes (`sidecar/src/web.rs`) and the `INTEGRATION.md` docs. The bidirectional-audit slice (§5.5, incl. the `CommandLogging` patch) is not yet started. + +**Wire in, in order:** + +1. **Phase 1 — Account & session moderation (Tier A).** `admin.kick`, `admin.ban` (timed + indefinite), `admin.unban`, plus `admin.broadcast`. These are the actions a staff member most often wishes they could do from a phone. Ban/unban work offline and are the highest-value; kick and broadcast are trivial and safe. +2. **Phase 2 — Help-page queue (Tier A, own phase).** Stream + snapshot + respond/close. The biggest single quality-of-life win, but it is a read/write/stream subsystem, not one verb. +3. **Phase 3 — Second wave (Tier B).** Mute/page-mute, account comments, teleport-to-location, staff message, manual save. Add as the web moderation panel matures. + +Cross-cutting, lands alongside Phase 1: **bidirectional audit** — in-game use of any of these moderation verbs is forwarded to the website in the same shape as web-initiated ones, so the site has a complete moderation picture (§5.5). + +**Excluded — will not be built:** firewall, set-access-level, kill/res, jail, item/gold grants (the former Tier H), and the Tier-N set — arbitrary `[set`/`[get`, `[add`, hide, freeze, wipe, decorate, shutdown. Sharp, privilege-escalating, or catastrophic; all stay in the game client. + +--- + +## 5. Authorization & attribution (decided) + +Every in-game moderation command carries a `Mobile from` — the staff member — used for two things the bridge has no natural source for: + +1. **Audit** — `CommandLogging.WriteLine(from, …)` and the `SetBanTags(from, …)` "BanDealer" tag record *who did it*. +2. **Authorization** — e.g. `KickCommand` refuses unless `from.AccessLevel > targ.AccessLevel` (`Commands.cs:1200`), so a GM can't ban an Admin. + +The resolved model: + +**Authorization lives on the website.** The website gates these commands behind its own **admin-only** roles (and moderator ability levels). The shard does not — cannot — re-derive per-user permission; it trusts the loopback socket + auth token exactly as it already trusts town-crier. The sidecar is the trust boundary. + +**Sidecar commands carry `CoOwner`-level authority on the shard.** Because the website has already authenticated and authorized the staff user, an inbound `admin.*` is applied as if issued by a synthetic `CoOwner` — the second-highest rung (`Server/Mobile.cs:431`: only `Owner` is above it). This cleanly satisfies the `from.AccessLevel > targ.AccessLevel` guard for every ordinary target. + +**The one shard-side floor: never touch the Owner.** Even at CoOwner authority, an `admin.*` command **refuses any target account whose `AccessLevel >= CoOwner`.** That is the whole defense-in-depth on the plugin side: a compromised or buggy sidecar can moderate players and staff below CoOwner, but can never ban, kick, or demote the Owner (or another CoOwner). *Note the consequence, plainly:* this is a permissive posture — it deliberately lets the web plane act on Administrator/Seer/GM-level accounts, on the assumption that reaching the web admin panel already means near-total trust. If that assumption ever weakens, raise the floor in `Bridge.cfg` (`AdminAccessFloor`). + +**Attribution is an explicit `web:` string.** Every `admin.*` request carries a required `actor` field — the website username/id of the staff member. The shard: +- logs it to the **server console** as `[Bridge][admin] web: …`. *(Note, corrected during implementation: `CommandLogging.WriteLine` cannot be reused for web actions — it dereferences `from.NetState`/`from.Account`/`from.AccessLevel` (`Scripts/Commands/Logging.cs:93-103`) and there is no staff `Mobile`. So web actions do **not** land in `Logs/Commands/`; the console line plus the `admin.audit` stream plus the website's own log are their durable record. `Logs/Commands/` remains the record for **in-game** staff actions, which §5.5 forwards to the site — so the complete picture lives on the website, by design.)* +- stores `web:` in the ban "BanDealer" tag (`SetBanTags` wants a `Mobile from`; we pass `null` for the Mobile and set the tag ourselves — no core edit), +- echoes it back in an `admin.audit` event (§5.5) so the website's own moderation record and the game's audit agree. + +**The website keeps its own durable record.** Independently of the shard, the website persists every moderation action to its own log (who/what/when/why), mirroring the existing admin-activity-log pattern. The shard's `CommandLogging` + `admin.audit` are the game-side truth; the website log is the site-side truth; §5.5 keeps them in sync in both directions. + +### 5.5 Bidirectional audit — one moderation picture, both origins + +The website must see moderation actions **whether they originate on the site or in the game client**, in one consistent schema. Two directions: + +- **Web → game (already in the request path).** Each applied `admin.*` emits an unsolicited `admin.audit` broadcast frame to every connected dashboard, tagged `"origin":"web"`, `"actor":"web:"`. +- **Game → web (the "full picture" requirement).** When a staff member runs one of these same verbs *in the game client* — `[ban`, `[kick`, `[bcast`, a page-queue response, a mute — the plugin forwards it to the website as the **same** `admin.audit` shape, tagged `"origin":"in-game"`, `"actor":""`. + +The raw hook already exists: `BridgeEvents.OnStaffCommand` subscribes to `EventSink.Command` and emits `audit.command` for every staff command (`BridgeEvents.cs:404`), and `OnStaffPropertySet` emits `audit.set`. Those stay as the low-level firehose. On top of them we add a **normalizer** that emits a structured `admin.audit` for the specific moderation verbs, so the website's moderation log has one shape to store, not a freeform command string to parse. + +```json +{ "kind": "admin.audit", "origin": "in-game", "action": "ban", + "actor": "GreyBeard", "target": "griefer42", "reason": null, + "durationSec": 604800, "t": 1783720195626 } +``` + +**The dispatch path — traced and settled (no longer an open question).** `[ban` and `[kick` *are* registered directly in the command table: `SingleCommandImplementor.Register` calls `CommandSystem.Register(name, level, Redirect)` for each command name (`SingleCommandImplementor.cs:22`), so they sit in `m_Entries` and `EventSink.InvokeCommand(e)` fires for them (`Server/Commands.cs:259`). **So the existing `audit.command` hook already sees them** — the earlier worry that generic commands bypass `EventSink.Command` is wrong. + +The genuine subtlety is *when* it fires and *with what*: + +| Verb shape | Example | What `EventSink.Command` carries | Complete? | +|------------|---------|----------------------------------|-----------| +| Arg-bearing, no target | `[bcast Server down in 5` | verb **+ full args** | ✅ fully captured | +| **Target-cursor** | `[ban` → click victim | verb only, **empty args** | ⚠️ **verb but not the victim** | + +For target-cursor verbs, `Handle` runs `entry.Handler(e)` (→ `Redirect` → `Process` → `from.BeginTarget(...)`, which arms the cursor and returns) and *then* `InvokeCommand(e)` (`Commands.cs:255-259`). The event therefore fires the moment `[ban` is **typed**, before the staff clicks anyone. The resolved action — the actual target and `Account.Banned = true` — happens later inside `KickCommand.Execute`, which calls `CommandLogging.WriteLine(from, "… banning {target}")` **with** the victim (`Commands.cs:1211`). + +**Conclusion:** the reliable choke point for a *resolved* in-game moderation action (verb **and** victim) is `CommandLogging.WriteLine` (`Scripts/Commands/Logging.cs:86`), which is where every command already records its outcome — but it has **no event to subscribe to** today. So the "full picture" needs one small hook: + +- **Add a `WriteLine` event to `Scripts/Commands/Logging.cs`** (a 1-line `Action` raised in `WriteLine`). This is a stock file, so it ships as a **`patches/` diff** — the same mechanism Phase 7's `PlayerVendorSale` already established, and arguably the *correct* universal tap for a staff-action feed regardless of this feature. The normalizer subscribes, matches the moderation lines, and emits `admin.audit`. +- Broadcasts and other arg-bearing simple commands need **no** patch — the existing `EventSink.Command` hook already carries their full payload; the normalizer just reshapes them. + +--- + +## 6. Protocol design + +Reuse the existing inbound machinery verbatim — `BridgeBoot.RegisterHandler(kind, handler)`, Core-thread dispatch via `Timer.DelayCall`, `reqId` echo, and `*.ok` / `*.error` replies — exactly as `BridgeRequests` and `BridgeTownCrier` already do. A new `BridgeAdmin.cs` registers the `admin.*` handlers. + +### Request shape (website → sidecar → shard) + +```json +{ "kind": "admin.ban", "reqId": "a1b2", "actor": "whitlocktech", + "account": "griefer42", "durationSec": 604800, "reason": "harassment" } +``` + +- `reqId` — correlation id, echoed on the reply (as in `BridgeRequests`). +- `actor` — **required.** The website staff user. Rejected if absent. +- Target — `account` (offline-capable verbs) or `serial` (online mobiles), resolved with the same `ResolveSerial` / `Accounts.GetAccount` helpers `BridgeRequests` uses. +- `reason` — recorded in the audit trail. + +### Reply shape (shard → sidecar → website) + +```json +{ "kind": "admin.ok", "reqId": "a1b2", "action": "ban", "target": "griefer42" } +{ "kind": "admin.error", "reqId": "a1b2", "reason": "target is staff; refused" } +``` + +Map to REST like the rest of `INTEGRATION.md`: `admin.ok` → 200, unknown target → 404, floor-violation/`actor` missing → 403, malformed → 400. + +### Audit event (shard → website, unsolicited) + +Every applied `admin.*` also emits a broadcast audit frame so *all* connected dashboards see it, not just the caller — parallel to the existing `audit.command`, and (per §5.5) emitted for **in-game** uses of the same verbs too: + +```json +{ "kind": "admin.audit", "origin": "web", "action": "ban", "actor": "web:whitlocktech", + "target": "griefer42", "reason": "harassment", "durationSec": 604800, "t": 1783720195626 } +``` + +`origin` is `"web"` for sidecar-initiated actions or `"in-game"` for actions a staff member took in the game client. + +### Verbs for Phase 1 + +| kind | target | required fields | shard action | +|------|--------|-----------------|--------------| +| `admin.kick` | `serial` or `account` | `actor` | dispose live NetState(s) | +| `admin.ban` | `account` | `actor` (+ `durationSec` optional) | set ban tags/flag, then kick live sessions | +| `admin.unban` | `account` | `actor` | clear ban | +| `admin.broadcast` | — | `actor`, `text` (+ `hue`) | `World.Broadcast` | + +Every one: enforce the **Owner floor** on the target (refuse `AccessLevel >= CoOwner`), apply on the Core thread as a synthetic CoOwner, `CommandLogging.WriteLine("web: …")`, emit `admin.audit` (`origin:"web"`), reply `admin.ok`/`admin.error`. + +### Caps / defense-in-depth (mirroring town-crier) + +- `actor` required and non-empty. +- Target floor: refuse any target with `AccessLevel >= CoOwner` (`AdminAccessFloor` in `Bridge.cfg`, default `CoOwner` → only the Owner/CoOwners are shielded). +- `reason` length cap; `durationSec` clamp (min/max); `broadcast` text length cap. +- Master switch `AdminWriteEnabled` in `Bridge.cfg` (default **off**) so the whole write plane is opt-in per shard. + +--- + +## 7. Verification log (all resolved) + +All resolved by source inspection (ServUO checkout at `C:\Users\colby\Desktop\servuo`). No live-shard run was needed — every path below is unambiguous in the code, and a mute smoke-test would in any case require a real UO client to log in and speak. + +1. **`Mobile.Squelched` persists — confirmed durable.** Serialized unconditionally (`Server/Mobile.cs:6489` write) and read back in the version ladder at case 9 (`:6013`), so it survives relog **and** a full server restart; no need to persist it ourselves. It gates `OnSaid` (`:7591` → *"You can not say anything, you have been muted."*). Two consequences for the plan: (a) it is **per-Mobile (per-character), not per-account** — "mute the account" means squelch each resident character; (b) it works on **offline** characters too, since logged-off mobiles stay resident in `World`. Phase 3 mute is therefore durable and offline-capable out of the box. +2. **Kicking all sessions — settled.** Enumerate `NetState.Instances` (`Server/Network/NetState.cs:583`, a `ReadOnlyCollection`), filter on `ns.Account == acct` (`:574`), and `Dispose()` each. This is **strictly better than walking the account's characters' `NetState`**: a client sitting at character-select has a `NetState` with an `Account` but *no* mobile, and only the `Instances` sweep catches it. `admin.kick` and the live-session cleanup in `admin.ban` both use this. +3. **In-game capture of resolved bans/kicks (was the ★ risk).** Traced through the dispatch path — settled in §5.5. `[ban`/`[kick` *do* raise `EventSink.Command`, but at type-time without the target. The complete capture point is a **1-line event added to `Scripts/Commands/Logging.cs:86`**, shipped as a `patches/` diff. Broadcasts need no patch. +4. **Ban attribution** — pass `null` for the `Mobile from` and set `web:` as the `BanDealer` tag ourselves. No core edit. +5. **Broadcast + town-crier** — keep both; they differ (instant system line vs. looping crier) and both are cheap. +6. **Access floor** — `CoOwner` (Owner-only shield). See §5. + +**Nothing in §7 remains open — the plan is implementation-ready.** + +--- + +## 8. Decisions — locked 2026-07-12 + +- **Scope:** Phase 1 (kick / ban / unban / broadcast) + Phase 2 (help-page queue) + Phase 3 second-wave. **The former Tier-H verbs (firewall, kill/res, jail, item/gold grants, set-access-level) are cut entirely** — not now, not later. +- **Authorization:** enforced on the **website** (admin-only + moderator roles). Inbound sidecar commands are applied on the shard as **CoOwner-level** authority, with a hard floor that refuses any target at `AccessLevel >= CoOwner` (Owner-only shield). Write plane defaults **off** in `Bridge.cfg`. +- **Attribution:** `web:` in `CommandLogging` and the `BanDealer` tag; no core edits. +- **Logging:** the **website keeps its own durable moderation record**; the plugin **forwards in-game uses** of these same verbs to the site as `admin.audit` (`origin:"in-game"`) so the picture is complete from both sides (§5.5). +- **Help-page queue:** confirmed, lands as **Phase 2**. + +--- + +## 9. Where the code goes + +| File | Responsibility | +|------|----------------| +| `overlay/Scripts/Custom/Bridge/BridgeAdmin.cs` | New. Registers `admin.*` handlers; the CoOwner-authority application + Owner floor; `web` `admin.audit` emission. Mirrors `BridgeTownCrier.cs` structure. | +| `overlay/Scripts/Custom/Bridge/BridgeEvents.cs` | Extend: normalize in-game moderation verbs into `admin.audit` (`origin:"in-game"`). Broadcasts reshape from the existing `EventSink.Command` hook; ban/kick subscribe to the new `CommandLogging` event (§5.5). | +| `patches/commandlogging-event.patch` | New. Adds a 1-line `Action` event to `Scripts/Commands/Logging.cs:86` so resolved staff actions (verb **+ target**) are observable. Stock file → ships as a patch, per the Phase-7 precedent. | +| `overlay/Scripts/Custom/Bridge/BridgePages.cs` | New (Phase 2). Streams/snapshots/answers the `PageQueue`. | +| `overlay/Config/Bridge.cfg` | Add `AdminWriteEnabled` (default off), `AdminAccessFloor` (default `CoOwner`), and the caps. | +| `sidecar/src/web.rs` | New REST routes (`POST /admin/*`, `/pages/*`) → inbound lines; map replies to status codes. | +| `docs/INTEGRATION.md` | Document the new endpoints + the `admin.audit` / `page.*` events. | +| *(website, separate repo)* | Admin/moderator-gated UI + a durable moderation log that records both its own actions and inbound `admin.audit` frames. | + +The Phase-1 **verbs** need no core or stock edit — every web-initiated action is an existing script-layer API called from the new `BridgeAdmin.cs` overlay. The only non-overlay change is the **one-line `CommandLogging` event** (`patches/commandlogging-event.patch`), needed solely so *in-game* bans/kicks forward their resolved target to the website (§5.5); it reuses the Phase-7 `patches/` mechanism and touches nothing else. From a9bb5e8641c0b10f01cbc592767991dabe8e474e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 02:00:17 -0500 Subject: [PATCH 11/23] feat(admin): sidecar REST routes for the write plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (sidecar side): POST /admin/{kick,ban,unban,broadcast} forward to the shard, correlated on a fresh reqId, with an admin-specific status mapping — unknown target -> 404, protected target / plane-disabled -> 403, missing actor / bad body -> 400. actor is required and checked up front. Documents the endpoints and the admin.audit event in INTEGRATION.md. Verified end-to-end (real sidecar + booted shard): 200 on success, 403 on the Owner floor, 404 unknown target, 400 missing actor, 401 no token. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- link/ADMIN_CONTROLS.md | 7 +++++- link/INTEGRATION.md | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/link/ADMIN_CONTROLS.md b/link/ADMIN_CONTROLS.md index db39185..cd177f8 100644 --- a/link/ADMIN_CONTROLS.md +++ b/link/ADMIN_CONTROLS.md @@ -134,7 +134,12 @@ It turns "a staff member must be logged into the game to see the queue" into "th ## 4. Roadmap (decided) -> **Build status (2026-07-13):** Phase 1 **plugin side is built and live-verified** on the seeded shard — `BridgeAdmin.cs` + config, branch `feature/admin-controls`. All four verbs, the `web:` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner account was refused) confirmed end-to-end against a booted ServUO, no exceptions. **Remaining for Phase 1 to be usable from the site:** sidecar REST routes (`sidecar/src/web.rs`) and the `INTEGRATION.md` docs. The bidirectional-audit slice (§5.5, incl. the `CommandLogging` patch) is not yet started. +> **Build status (2026-07-13):** Phase 1 is **built and live-verified end-to-end**, branch `feature/admin-controls`. +> - *Plugin* (`BridgeAdmin.cs` + config): all four verbs, `web:` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner was refused) confirmed against a booted ServUO. +> - *Sidecar* (`sidecar/src/web.rs`): `POST /admin/{kick,ban,unban,broadcast}` routes with the status mapping in §6. Verified with the real sidecar + shard: 200 on success, **403** on the Owner floor, **404** unknown target, **400** missing actor, **401** no token. +> - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event. +> +> **Remaining:** the bidirectional-audit slice (§5.5, `BridgeEvents` normalizer + the one-line `CommandLogging` patch) — not yet started. **Wire in, in order:** diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index b281755..17d46db 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -178,6 +178,7 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s | `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. | | `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. | | `audit.command` | `staff`, `command`, `args` | A staff command was invoked. | +| `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. | #### Account linking | kind | fields | notes | @@ -301,6 +302,55 @@ DELETE /towncrier/{id} Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`. +### Staff moderation — the write plane + +Account and session moderation against the live shard. **These are privileged.** The sidecar does +not model per-user roles — **your site must authenticate the staff user and check their permission +before calling.** The shard trusts the loopback socket and applies each command with CoOwner-level +authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner +account) is refused (**403**). The whole plane is **opt-in on the shard** (`AdminWriteEnabled` in +`Bridge.cfg`); when it's off, every call returns **403** `"admin write plane disabled"`. + +Every request requires an **`actor`** — the website username/id of the staff member taking the +action. It is recorded in the shard console log, the ban's `BanDealer` tag, and the `admin.audit` +event, so actions are always attributable. A missing `actor` is **400**. + +``` +POST /admin/kick { "actor":"jane", "account":"griefer42" } # or "serial":"0x2E0" +POST /admin/ban { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" } +POST /admin/unban { "actor":"jane", "account":"griefer42" } +POST /admin/broadcast { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 } +``` + +- **kick** — disconnects every live session of the target account (including one parked at + character-select). Target by `account` or `serial`. Reply carries `sessions` (how many were cut). +- **ban** — bans the account (works offline) and disconnects any live sessions. `durationSec > 0` + is a timed ban that auto-expires; `0`/absent is indefinite. Clamped to the shard's + `AdminBanMaxDurationSec`. +- **unban** — clears the ban. +- **broadcast** — a system message to everyone online. `hue` optional (default `53`, staff green). + Length-capped by the shard. + +Success → **200** with an `admin.ok`: + +```json +{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 } +``` + +Failure → an `admin.error` with a mapped status: + +| Status | When | +|--------|------| +| 400 | missing `actor`, malformed body, or bad parameter | +| 401 | missing/invalid auth token | +| 403 | target is protected (at/above the floor), or the write plane is disabled on the shard | +| 404 | unknown or accountless target | +| 503 / 504 | shard not connected / didn't reply in time | + +Each applied action also emits an unsolicited **`admin.audit`** frame on the WebSocket (§4) with +`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation +by staff in the game client surfaces the same way with `origin:"in-game"`. + ### History (from the sidecar's database) ``` From 5990a2be3a9456cf8c2d6db257273984191d15d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 02:12:30 -0500 Subject: [PATCH 12/23] feat(admin): forward in-game moderation to the website (bidirectional audit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C / §5.5: so the site's moderation log is complete regardless of origin, in-game uses of the write-plane verbs are forwarded as admin.audit (origin:"in-game"). - patches/commandlogging-event.patch: adds CommandLogging.OnWrite, raised in WriteLine before the m_Enabled guard so it fires even when file logging is off. Scripts-layer file -> dynamic build, no core rebuild. - patches/BridgeModerationAudit.cs: subscriber. Taps OnWrite for resolved ban/kick (parsing the target from the log line) and EventSink.Command for [bcast. Lives in patches/ (not overlay/) because it references OnWrite, which only exists post-patch — same rule as BridgeVendorSale.cs. - tools/scaffolding/BridgeAuditProbe.cs: gated headless verification. Verified live: a genuine [bcast plus simulated ban/kick log lines produced admin.audit frames with origin=in-game, actor, and the target parsed (seed_010); a non-moderation line was correctly ignored. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- link/ADMIN_CONTROLS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/link/ADMIN_CONTROLS.md b/link/ADMIN_CONTROLS.md index cd177f8..30af617 100644 --- a/link/ADMIN_CONTROLS.md +++ b/link/ADMIN_CONTROLS.md @@ -138,8 +138,9 @@ It turns "a staff member must be logged into the game to see the queue" into "th > - *Plugin* (`BridgeAdmin.cs` + config): all four verbs, `web:` attribution, the audit stream, and the **Owner-protection floor** (an `admin.ban` on the Owner was refused) confirmed against a booted ServUO. > - *Sidecar* (`sidecar/src/web.rs`): `POST /admin/{kick,ban,unban,broadcast}` routes with the status mapping in §6. Verified with the real sidecar + shard: 200 on success, **403** on the Owner floor, **404** unknown target, **400** missing actor, **401** no token. > - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event. +> - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored. > -> **Remaining:** the bidirectional-audit slice (§5.5, `BridgeEvents` normalizer + the one-line `CommandLogging` patch) — not yet started. +> **Phase 1 + the bidirectional-audit slice are complete.** Remaining is downstream (website UI + moderation log) and the later Phase 2 (help-page queue) / Phase 3 work. **Wire in, in order:** From c9cc8ead1ab64972a60c66340a71f8d37f3cfe9b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 02:54:19 -0500 Subject: [PATCH 13/23] =?UTF-8?q?feat(pages):=20help-page=20(support)=20qu?= =?UTF-8?q?eue=20=E2=80=94=20stream,=20snapshot,=20respond,=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of docs/ADMIN_CONTROLS.md: surface the in-game help-page queue to the website. - BridgePages.cs: the queue has no EventSink, so it is polled (PageSweepSeconds, default 5s) and diffed, keyed by sender serial (one page per player) -> page.new / page.updated / page.closed. Inbound pages.snapshot -> pages.list; page.respond delivers a staff reply to the player (online: a gump now; offline: queued for next login; shows as "Staff") and can close; page.close removes it. - BridgeConfig/Bridge.cfg: PageSweepSeconds. BridgeBoot: reload re-arms the poll, status reports it. - sidecar/src/web.rs: GET /pages, POST /pages/{id}/respond, POST /pages/{id}/close. - INTEGRATION.md: page events (§4) and endpoints (§6). - tools/scaffolding/BridgePageProbe.cs: gated headless verification. Verified live (probe-seeded tickets): snapshot returns the queue, the poll emits page.new for both and page.closed on removal, respond -> 200, close removes the page, unknown page -> 404. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- link/ADMIN_CONTROLS.md | 4 +++- link/INTEGRATION.md | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/link/ADMIN_CONTROLS.md b/link/ADMIN_CONTROLS.md index 30af617..08345e0 100644 --- a/link/ADMIN_CONTROLS.md +++ b/link/ADMIN_CONTROLS.md @@ -140,7 +140,9 @@ It turns "a staff member must be logged into the game to see the queue" into "th > - *Docs*: `INTEGRATION.md` §6 documents the endpoints and the `admin.audit` event. > - *Bidirectional audit* (§5.5): **built and live-verified.** `patches/commandlogging-event.patch` (adds `CommandLogging.OnWrite`) + `patches/BridgeModerationAudit.cs` (the subscriber) forward in-game bans/kicks/broadcasts to the site as `admin.audit` (`origin:"in-game"`). A boot-time probe confirmed a genuine `[bcast` and resolved ban/kick lines produce the right frames with the target parsed, non-moderation lines ignored. > -> **Phase 1 + the bidirectional-audit slice are complete.** Remaining is downstream (website UI + moderation log) and the later Phase 2 (help-page queue) / Phase 3 work. +> **Phase 2 — help-page queue: built and live-verified.** `BridgePages.cs` polls the queue (`PageSweepSeconds`, default 5s) → `page.new`/`page.updated`/`page.closed`; inbound `pages.snapshot`/`page.respond`/`page.close`; sidecar `GET /pages` + `POST /pages/{id}/respond|close`; `INTEGRATION.md` §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both `page.new` emits, respond, close (→ page removed), `page.closed` emit, and 404 on an unknown page. +> +> **Phase 1 + bidirectional audit + Phase 2 are complete.** Remaining: downstream website UI (moderation log + support-queue view), then Phase 3 (mute/notes/teleport/save). **Wire in, in order:** diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 17d46db..3a37309 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -185,6 +185,15 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s |------|--------|-------| | `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. | +#### Help-page (support) queue +| kind | fields | notes | +|------|--------|-------| +| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). | +| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). | +| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). | + +The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close. + --- ## 5. REST — read queries @@ -351,6 +360,31 @@ Each applied action also emits an unsolicited **`admin.audit`** frame on the Web `origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation by staff in the game client surfaces the same way with `origin:"in-game"`. +### Help-page (support) queue + +Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own +roles, like the moderation endpoints above. + +``` +GET /pages # the open queue, newest state +POST /pages/{pageId}/respond { "message":"...", "close": false } +POST /pages/{pageId}/close +``` + +- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new` + event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live + with the `page.new` / `page.updated` / `page.closed` events. +- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if + they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass + `"close": true` to resolve the page in the same call. → **200** `page.ok`. +- **close** removes the page from the queue. → **200** `page.ok`. +- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**. + +```json +POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true } +→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true } +``` + ### History (from the sidecar's database) ``` From b210675f35e636b92ae89d9fdfd645cabd79d886 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 02:58:04 -0500 Subject: [PATCH 14/23] docs(admin): shipped scope is Phase 1 + Phase 2; Phase 3 not planned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the owner decision to stop after the help-page queue: Phase 3 (mute/notes/teleport/save) will not be built. The Tier-B candidates remain catalogued in §3 for the record. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- link/ADMIN_CONTROLS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/link/ADMIN_CONTROLS.md b/link/ADMIN_CONTROLS.md index 08345e0..a856c3a 100644 --- a/link/ADMIN_CONTROLS.md +++ b/link/ADMIN_CONTROLS.md @@ -142,13 +142,13 @@ It turns "a staff member must be logged into the game to see the queue" into "th > > **Phase 2 — help-page queue: built and live-verified.** `BridgePages.cs` polls the queue (`PageSweepSeconds`, default 5s) → `page.new`/`page.updated`/`page.closed`; inbound `pages.snapshot`/`page.respond`/`page.close`; sidecar `GET /pages` + `POST /pages/{id}/respond|close`; `INTEGRATION.md` §4/§6 documented. A live run (probe-seeded tickets) confirmed snapshot, both `page.new` emits, respond, close (→ page removed), `page.closed` emit, and 404 on an unknown page. > -> **Phase 1 + bidirectional audit + Phase 2 are complete.** Remaining: downstream website UI (moderation log + support-queue view), then Phase 3 (mute/notes/teleport/save). +> **Phase 1 + bidirectional audit + Phase 2 are complete — this is the shipped scope.** Phase 3 (below) is **not planned** (owner decision, 2026-07-13). Remaining work is downstream and website-side only: the admin/mod UI (moderation log + support-queue view). **Wire in, in order:** 1. **Phase 1 — Account & session moderation (Tier A).** `admin.kick`, `admin.ban` (timed + indefinite), `admin.unban`, plus `admin.broadcast`. These are the actions a staff member most often wishes they could do from a phone. Ban/unban work offline and are the highest-value; kick and broadcast are trivial and safe. 2. **Phase 2 — Help-page queue (Tier A, own phase).** Stream + snapshot + respond/close. The biggest single quality-of-life win, but it is a read/write/stream subsystem, not one verb. -3. **Phase 3 — Second wave (Tier B).** Mute/page-mute, account comments, teleport-to-location, staff message, manual save. Add as the web moderation panel matures. +3. ~~**Phase 3 — Second wave (Tier B).** Mute/page-mute, account comments, teleport-to-location, staff message, manual save.~~ **Not planned** (owner decision, 2026-07-13). The Tier-B candidates catalogued in §3 stay documented for the record, but the shipped scope is Phase 1 + Phase 2. Cross-cutting, lands alongside Phase 1: **bidirectional audit** — in-game use of any of these moderation verbs is forwarded to the website in the same shape as web-initiated ones, so the site has a complete moderation picture (§5.5). From f8e9ee8cfc749c878aa4ccc5ff8ea30f120e2271 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 05:46:21 -0500 Subject: [PATCH 15/23] feat(champ): stream champion-spawn state to the sidecar board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Champion spawns have no ServUO EventSink, so add a fourth polled stream (BridgeChamps) modeled on BridgeSweeps: enumerate every spawn each tick, fold to a small record, and emit champ.update only on change. No core patch — every field used is public. Covers all three families via a `category` field: - champion: ChampionSpawn (type/level/kills/boss/cooldown ETA) - mini: MiniChamp (type/level; auto-restarts, no kill counter) - sea: BaseSeaChampion (a High Seas world-boss mobile, alive only while summoned; removed via champ.remove when slain) Status folds to active/cooldown/dormant. A (re)connection clears the diff cache so the next sweep re-emits the full board, rebuilding a sidecar that restarted on its own. Transient entries leave via champ.remove. Sidecar: a `champs` current-state table (one row per serial) fed by champ.update (upsert) and champ.remove (delete), exposed at GET /champs as the live board. New ChampSweepSeconds config (default 10s), wired into [bridge reload/sweepnow/status. Documented in docs/INTEGRATION.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ --- link/INTEGRATION.md | 64 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 3a37309..d56f249 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -194,6 +194,47 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close. +#### Champion spawns + +Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`: + +| `category` | source | what it is | +|------------|--------|-----------| +| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown | +| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter | +| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned | + +| kind | fields | notes | +|------|--------|-------| +| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). | +| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. | + +`status` is one of: +- **`active`** — running (or, for `sea`, the boss is alive). +- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA. +- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on). + +Category-specific fields on `champ.update`: + +| category | extra fields | +|----------|--------------| +| `champion` | `level` (0–16), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) | +| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false | +| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage | + +```json +{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss", + "name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120, + "maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0, + "expireAt":"2026-07-14T11:00:00Z","t":1752489280000} + +{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis", + "name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis", + "hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000} +``` + +The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events. + --- ## 5. REST — read queries @@ -402,6 +443,29 @@ GET /economy?limit=200 → { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] } ``` +### Champion-spawn board + +``` +GET /champs +``` + +The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`. + +``` +GET /champs +→ { "spawns": [ + {"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss", + "name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0, + "maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570, + "z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000}, + {"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair", + "name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5, + "bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...} + ] } +``` + +A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain. + --- ## 7. Status codes From d01a49103fa6f8e92537fc528af7c0d7c943edb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:42:06 -0500 Subject: [PATCH 16/23] feat(protocol2): website account provisioning & unlinking (Part A) Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the website can create game accounts and unlink them, gated by a shard-wide signup mode. The existing [link flow is unchanged. Overlay: - BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized falls back to game), AccountCreateEnabled (mode-following default), RequireIpForCreate, name/password caps, and a boot warning when the core Accounts.AutoCreateAccounts setting contradicts the mode. - BridgeAccounts (new): account.create (mode gate, actor required, char-safety mirrored from AccountHandler, collision check, per-IP cap via CanCreate/ LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link, account.audit; password never logged or echoed) and account.unlink (Owner floor via BridgeAdmin.Protected, clears the tag). - BridgeAccountLink: in-game [unlink command, emits account.unlinked. - BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse. Sidecar: - POST /accounts/create, DELETE /link/:account, respond_account status mapping (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400). - store.record_unlink drops the mirrored link row. - PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2). Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429; PROTOCOL_2.md Part A marked built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 63 ++++++- link/PROTOCOL_2.md | 448 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 link/PROTOCOL_2.md diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index d56f249..27e048f 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -31,16 +31,18 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. -- Every response carries an **`X-UOLink-Version: 1`** header. -- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 1`. -- **Optionally**, send `X-UOLink-Version: 1` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: +- Every response carries an **`X-UOLink-Version: 2`** header. +- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`. +- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: ```json - { "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" } + { "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" } ``` Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. +**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. + --- ## 3. Health @@ -180,10 +182,12 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s | `audit.command` | `staff`, `command`, `args` | A staff command was invoked. | | `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. | -#### Account linking +#### Account linking & provisioning | kind | fields | notes | |------|--------|-------| | `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. | +| `account.audit` | `origin`, `action`, `actor`, `target`, `websiteUserId` | A provisioning action was applied from the site (`origin:"web"`, `actor:"web:"`). `action` is `create` or `unlink`; `target` is the account. Broadcast to every dashboard. **Never carries the password.** Emitted alongside the `account.ok` reply; see §6. | +| `account.unlinked` | `origin`, `account`, `websiteUserId`, `char` | A player ran `[unlink` **in game** (`origin:"in-game"`), severing the tie themselves. Drop the link from any roster you cache and reconcile your own record. | #### Help-page (support) queue | kind | fields | notes | @@ -335,6 +339,48 @@ GET /link/{account} (This reads the sidecar's mirror of confirmed links — no shard round-trip.) +### Create a game account (Protocol 2.0) + +Provision a game account from your signup form and link it to the website user in one step. Requires a **v2** sidecar. Whether this is honored depends on the shard's signup mode (`website`/`hybrid` accept it; `game` refuses). + +``` +POST /accounts/create +{ "actor": "whitlocktech", "account": "bob", "password": "hunter2", + "websiteUserId": "9931", "ip": "203.0.113.7" } +``` + +- `actor` — the website user/staff id, recorded in the audit. Required. +- `account`, `password` — the game-client credentials the player chose. The password is hashed on the shard and **never** appears in any reply, event, or log. +- `websiteUserId` — the site user to auto-link. +- `ip` — **the end user's browser IP**, which you read from your own request context (remote-addr, or a trusted `X-Forwarded-For`). The shard enforces its per-IP account cap with this, exactly as it does for in-game signups. The sidecar cannot see the browser's IP (it only sees your server), so you must send it. + +Responses: + +- Success → **200** `{"kind":"account.ok","action":"create","account":"bob","websiteUserId":"9931"}`. The account exists and is linked; subsequent `mob.login` events carry `webId`. +- Name already taken → **409** `{"kind":"account.error","reason":"account already exists"}`. +- Per-IP cap hit → **429** `{"kind":"account.error","reason":"ip account limit reached"}`. +- Signups disabled for this mode → **403** `{"kind":"account.error","reason":"signups disabled for this mode"}`. +- Missing browser IP (when the shard requires it) → **400** `{"kind":"account.error","reason":"client ip required"}`. +- Bad username/password, or a missing field → **400**. + +Abuse control beyond the per-IP cap (captcha, email verification, signup rate) is your site's responsibility. + +### Unlink an account (Protocol 2.0) + +Sever a game account's tie to its website user, from the site side. Requires a **v2** sidecar. + +``` +DELETE /link/{account} +{ "actor": "whitlocktech" } +``` + +- Success → **200** `{"kind":"account.ok","action":"unlink","account":"bob"}`. The `WebsiteUserId` tag is cleared on the shard and the sidecar's link mirror is dropped, so attribution stops immediately. +- Not linked → **404** `{"kind":"account.error","reason":"not linked"}`. +- Protected staff account → **403** `{"kind":"account.error","reason":"target is protected staff; refused"}`. +- Missing `actor` → **400**. + +A player can also unlink themselves in game with `[unlink`; that emits an `account.unlinked` event (see §4) so you can reconcile your record. + ### Publish / remove town-crier news Push a message that every in-game town crier announces until it expires. @@ -475,8 +521,9 @@ A row survives a sidecar restart (it's in SQLite), so the board reflects the las | 200 | OK | | 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) | | 401 | Missing or invalid auth token | -| 404 | Not found (unknown account / character / id) | -| 409 | Protocol version mismatch (you sent `X-UOLink-Version` and it disagreed) | +| 404 | Not found (unknown account / character / id, or a not-linked account) | +| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` | +| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` | | 500 | Internal error (e.g. database) | | 503 | Shard not connected — the query needs the live game and it's down | | 504 | Shard connected but didn't reply within 10s | @@ -490,7 +537,7 @@ A row survives a sidecar restart (it's in SQLite), so the board reflects the las A typical character page: ```js -const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" }; +const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" }; // 1. render the roster const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md new file mode 100644 index 0000000..fef8646 --- /dev/null +++ b/link/PROTOCOL_2.md @@ -0,0 +1,448 @@ +# Protocol 2.0 — Provisioning & World-State Streams + +**Status:** Part A **built** on branch `feat/protocol2-account-provisioning` (2026-07-17), compiles clean both sides. Part B is design. +**Date:** 2026-07-17 +**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**. +**Companion to** [`PLAN.md`](PLAN.md) (read/event plane), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), and [`INTEGRATION.md`](INTEGRATION.md) (website API). + +Protocol 1.0 shipped the read/event plane, the request/reply plane, `[link` account linking, town-crier, the player-vendor-sale core edit, the admin write plane, and the help-page queue. + +2.0 has **two scope areas**: + +- **A — Account provisioning & unlinking (§1–§9).** The website can **create** accounts, **unlink** them, and the shard runs in one of three **signup modes** that decide which side may mint accounts. (1.0 could only *link* an account that already existed, and a link could never be undone.) +- **B — Social & political world-state streams (§10–§11).** Guilds, town governors ("mayors"), factions/VvV, and player titles — the standings a website community page wants. §10 specs the requested streams; §11 is a menu of further integration points to pick from. + +--- + +# Part A — Account provisioning & unlinking + +--- + +## 1. What exists today, and the gap + +| Capability | 1.0 | 2.0 | +|------------|-----|-----| +| Create account in-game (first-login auto-create) | ✔ `AccountHandler.cs:281` | unchanged | +| Link an **existing** game account to a website user | ✔ `[link` → `link.confirm` | unchanged | +| **Create** a game account from the website | ✗ | **new** `account.create` | +| **Unlink** a game account from its website user | ✗ | **new** `account.unlink` + `[unlink` | +| Choose which side may create accounts | ✗ (always in-game) | **new** signup mode | + +**The linking flow is not changing.** `[link`, the one-time code, `link.confirm`, and the `WebsiteUserId` tag all stay exactly as they are (`BridgeAccountLink.cs`). 2.0 only *adds* verbs alongside them. + +### The account-creation facts that shape this + +- `new Account(username, password)` self-registers — its constructor calls `Accounts.Add(this)` (`Account.cs:186`) and `SetPassword` hashes per the shard's `AccountHandler.ProtectPasswords` (`Account.cs:174`). So creating an account from the bridge is `new Account(un, pw)` plus the link tag — no extra persistence layer, same as the `[link` tag reaching disk on the next world save. +- ServUO's in-game auto-create is gated on the **core** config `Accounts.AutoCreateAccounts` (default `true`, read once in `AccountHandler`'s static init, `AccountHandler.cs:29`). The bridge cannot intercept that path without a core edit, so the signup mode governs the **bridge's** `account.create` verb; the in-game side is controlled by pairing it with the matching core config (see §3). +- The core `CreateAccount` path (`AccountHandler.cs:494`) validates the username/password character set (printable ASCII `0x20–0x7F`, no forbidden chars) and enforces `MaxAccountsPerIP` (`Accounts.AccountsPerIp`, default **1**). The website path has **no `NetState`**, so the browser IP must be **passed through explicitly** to enforce that same cap (§3.1); and it must reuse the **character-safety** validation before `new Account`, or it can mint an account no client can log into (or that corrupts serialization). +- There is **no `EventSink.AccountCreated`**. The create path is silent. This is why in-game→website creation sync is an open item, not committed scope (§7). + +--- + +## 2. Signup modes — the model + +A single shard-wide setting, `Bridge.SignupMode`, with three values. **Default `hybrid`.** + +| Mode | `account.create` from website | In-game first-login auto-create | Who is the account authority | +|------|:-----------------------------:|:-------------------------------:|------------------------------| +| `website` | **accepted** | should be **off** | the website | +| `game` | **rejected** (`account.error`) | **on** | the game server | +| `hybrid` *(default)* | **accepted** | **on** | either side | + +The bridge enforces exactly one half of this: whether it **honors `account.create`**. The other half — in-game auto-create — is the core `Accounts.AutoCreateAccounts` config, which the operator sets to match: + +| `Bridge.SignupMode` | pair with `Accounts.AutoCreateAccounts` | +|---------------------|-----------------------------------------| +| `website` | `false` — otherwise any client that types a new name still mints an account, defeating website-only | +| `game` | `true` | +| `hybrid` | `true` | + +On boot the bridge **reads `Accounts.AutoCreateAccounts` and warns** if it contradicts the selected mode (e.g. `SignupMode=website` while auto-create is still on), so a half-configured shard is loud, not silently permissive. The bridge does not try to flip the core setting — it only detects and reports the mismatch, the same defensive posture `BridgeConfig.ParseAccessLevel` already takes. + +**Reconciliation in `hybrid`.** Both paths can race for the same username. `account.create` resolves it the only correct way: `Accounts.GetAccount(un) != null` → refuse with `account.error "account already exists"`. First writer wins; the loser gets a clean error, never a duplicate. + +--- + +## 3. `account.create` — website-driven provisioning + +The website has already authenticated and authorized the user (its own signup form). It hands the shard a username, the password the player chose, and the website user id, and asks for an account that is created **and linked in one step** — no code exchange, because the website *is* the authority here (unlike `[link`, where the game side proves ownership with a code). + +### Request (website → sidecar → shard) + +```json +{ "kind": "account.create", "reqId": "c1", "actor": "whitlocktech", + "account": "bob", "password": "hunter2", "websiteUserId": "9931", + "ip": "203.0.113.7" } +``` + +- `reqId` — correlation id, echoed on the reply (as everywhere else). +- `actor` — the website user/staff id, for the audit line. Required, non-empty (mirrors the admin plane). +- `account` — desired username. +- `password` — the game-client password the player chose on the site. Plaintext over the **loopback + token** socket, the same trust boundary every inbound verb already relies on; the shard hashes it via `SetPassword` immediately. +- `websiteUserId` — the site user to auto-link. +- `ip` — the **end user's browser IP**, so the shard can enforce `MaxAccountsPerIP` on website signups exactly as it does on in-game first-login. The website reads this from its own request context (remote-addr, or a trusted `X-Forwarded-For`); the **sidecar cannot derive it** — the sidecar only sees the website's connection IP, not the browser's, so this must be an explicit field. See §3.1. + +### Shard behavior (Core thread, in `BridgeAccounts.cs`) + +1. **Gate.** `SignupMode == game` → `account.error "signups disabled for this mode"`. Master switch `Bridge.AccountCreateEnabled` (default follows mode) must be on. +2. **Validate `actor`** present (as admin plane does). +3. **Validate username/password** with the same character-safety rules as `AccountHandler.CreateAccount` (printable ASCII, no leading/trailing space, no trailing dot, no forbidden chars). Enforce length caps from config. +4. **Collision check.** `Accounts.GetAccount(account) != null` → `account.error "account already exists"`. +5. **IP cap.** Parse `ip` → `IPAddress`. If `RequireIpForCreate` and it is missing/unparseable/loopback → `account.error "client ip required"` (**fail closed** — a missing IP must never silently bypass the cap; loopback is exempt in `IPLimiter`, so accepting it *is* a bypass). Then `AccountHandler.CanCreate(ip) == false` → `account.error "ip account limit reached"`. This is the same read-side check the in-game path runs at `AccountHandler.cs:510`. +6. **Create + link atomically.** `var a = new Account(account, password); a.LogAccess(ip); a.SetTag("WebsiteUserId", websiteUserId);` — `LogAccess` bumps `AccountHandler.IPTable[ip]` and records the IP into `LoginIPs` (`Account.cs:1251`), which is exactly what an in-game first-login does, so the per-IP count is both live-accurate and durable (it rebuilds from `LoginIPs[0]` on reboot). The `WebsiteUserId` tag persists to `accounts.xml` on the next world save, identical to the `[link` path. +7. **Reply** `account.ok` and **emit** an unsolicited `account.audit` (`origin:"web"`, `action:"create"`) to every dashboard, parallel to `admin.audit`. + +### Reply + +```json +{ "kind": "account.ok", "reqId": "c1", "action": "create", + "account": "bob", "websiteUserId": "9931" } +{ "kind": "account.error", "reqId": "c1", "reason": "account already exists" } +``` + +### 3.1 The IP flow — who sees what + +``` +browser ──HTTP signup──► website ──POST /accounts/create──► sidecar ──account.create──► shard + (real IP) (sees browser IP) (sees WEBSITE's IP, not browser's) (enforces cap) +``` + +The chain hops hosts, so the only party that sees the **end user's** IP is the website, at the edge. By the time the request reaches the sidecar, the socket's peer address is the *website*, not the player — which is why `ip` is a body field, not something the sidecar reads off the connection. The website populates it from its request context (remote-addr, or `X-Forwarded-For` from a proxy it trusts). + +Two consequences to state plainly: + +- **The IP is only as trustworthy as the website's proxy handling.** A compromised or misconfigured website could send a spoofed or wrong IP. That is already inside the 2.0 trust boundary (the website is trusted via loopback + token), but it means the per-IP cap is an *honesty* control against ordinary multi-account signups, not a hard security boundary against a hostile website. +- **IPv4/IPv6 skew.** A browser may present IPv6 while the UO client connects over IPv4; the two are different `IPAddress` keys, so a website account and a later in-game account from the "same" person may not share an `IPTable` bucket. Inherent to keying on raw IP — noted, not solved. + +The sidecar itself does **not** validate or transform `ip`; it forwards the field and lets the shard (which owns `IPTable`) decide. If a shard wants the sidecar to reject obviously-bad input early, that is a later refinement, not required for correctness — the shard fails closed regardless. + +### Sidecar route + +`POST /accounts/create`, body `{actor, account, password, websiteUserId, ip}` → the inbound line, correlated on a fresh `reqId`. Status mapping (new `respond_account`, modeled on `respond_admin`): + +| Reply / reason | HTTP | +|----------------|------| +| `account.ok` | 200 | +| `"account already exists"` | 409 Conflict | +| `"ip account limit reached"` | 429 Too Many Requests | +| `"signups disabled…"` | 403 | +| `"client ip required"`, `"invalid username/password"`, missing field | 400 | +| shard down / timeout | 503 / 504 | + +> ⚠️ The password is a secret in a request body and a shard reply. Keep it off the WebSocket broadcast entirely: `account.audit`/`account.ok` **never carry the password**, and the console/audit log records only `account` + `actor`. This is the same discipline `BridgeEvents` already applies to the plaintext `AccountLoginEventArgs.Password` it deliberately never forwards (`PLAN.md` §12). + +--- + +## 4. Unlinking + +Symmetric with `[link`: either side can sever the tie. Both paths do the same one thing — remove the `WebsiteUserId` account tag (`acct.RemoveTag("WebsiteUserId")`) — and both persist on the next world save. + +### 4.1 Website → `account.unlink` + +```json +{ "kind": "account.unlink", "reqId": "u1", "actor": "whitlocktech", "account": "bob" } +``` + +- Resolve by `account` (username) or `serial` (a player mobile's account), reusing `BridgeAdmin.ResolveTargetAccount`. +- Not linked → `account.error "not linked"` (a no-op is reported honestly, not faked as success). +- Apply the **Owner floor** (`BridgeAdmin.Protected`): refuse to unlink an account at/above `AdminAccessFloor`, same defense-in-depth as the admin verbs. +- Reply `account.ok action:"unlink"`; emit `account.audit action:"unlink"`. + +Sidecar: `DELETE /link/{account}` (the existing `/link/:account` GET already looks a link up; this adds the delete verb next to it) → also clears the sidecar's mirrored link row (`store.record_unlink`), so event attribution stops immediately without waiting on the shard. + +### 4.2 In-game → `[unlink` + +`CommandSystem.Register("unlink", AccessLevel.Player, …)` in `BridgeAccountLink.cs`, next to `[link`: + +- Reads the caller's own account, clears the tag, emits `account.unlinked` (so the site learns of a player-initiated unlink and can reconcile its own record). +- Player-scoped: a player can only unlink **their own** account (no target argument), so it needs no floor. +- Symmetric UX with `[link`: `"Your account is no longer linked."` + +> **Note — `[link` behavior is unchanged.** `[link` still refuses when a tag already exists (`BridgeAccountLink.cs:96`). `[unlink` is what clears it; after unlinking, `[link` works again. That is the whole interaction, and it needs no change to the existing link code — only the new command beside it. + +--- + +## 5. Trust & attribution + +Identical model to the admin write plane (`ADMIN_CONTROLS.md` §5), because these are the same shape of action (website-authorized, applied on the loopback socket): + +- **Authorization lives on the website.** `account.create`/`unlink` are gated behind the site's own roles (self-service signup for create; admin/self for unlink). The shard trusts the loopback + token socket and the required `actor` field. +- **Owner floor** applies to `account.unlink` (never unlink a protected staff account from the web). +- **Attribution** is the `web:` string in the console line and the `account.audit` frame; the website keeps its own durable record, as it already does for `admin.audit`. +- **`account.create` now enforces `MaxAccountsPerIP`** using the browser IP the website forwards (§3.1), via the same `CanCreate` / `LogAccess` path as in-game first-login. But the cap is only as honest as the website's IP reporting, and it fails **closed** on a missing/loopback IP when `RequireIpForCreate` is on. Higher-order abuse control (captcha, email verification, per-account-per-day) remains the website's job — the shard cap is a floor, not the whole defense. + +--- + +## 6. Config keys (`Config/Bridge.cfg`) + +```ini +SignupMode=hybrid # website | game | hybrid (default hybrid) +AccountCreateEnabled=true # master switch for account.create; auto-off when SignupMode=game +RequireIpForCreate=true # fail closed if account.create omits a usable browser IP +AccountNameMaxLength=16 +AccountPasswordMaxLength=30 +``` + +Read in `BridgeConfig.Load()`, re-readable via `[bridge reload`. `SignupMode` parses like `AdminAccessFloor` — unrecognized value falls back to the safest option (`game`, i.e. no website creation) with a console warning, so a typo can never accidentally open provisioning. `RequireIpForCreate` defaults **on**: the per-IP cap only means something if a missing IP is refused rather than waved through. Turn it off only for a deployment that deliberately does not cap website signups by IP (and then `MaxAccountsPerIP` still applies in-game as before). + +--- + +## 7. Open item (not committed) — in-game → website creation sync + +Per the 2026-07-17 decision, **this is not in 2.0's committed scope.** When an account is created *in-game* (first-login auto-create, or staff `[AddAccount`), the website is **not** notified today, and 2.0 does not change that. Recorded here so the tradeoff is explicit, not forgotten: + +- **Why it's hard cleanly:** there is no `EventSink.AccountCreated`. The only faithful tap is a core edit — an `Action` raised in the `Account(string, string)` ctor (safe: the load path is a *separate* ctor, `Account.cs:189`, so it won't fire during world load), shipped as a `patches/` diff exactly like `PlayerVendorSale` and the `CommandLogging` event. +- **Why it may not be needed:** in `website`-mode the website already knows every account (it created them). Sync only matters for `hybrid`/`game` modes where the website wants a roster of game-born accounts — and even then the sidecar can approximate "new account" from the `mob.login` `acct` field it already receives (first-seen = new), lossy but zero core edits. +- **If we do it later:** it becomes an `account.created` event stream (`origin:"in-game"`), the natural mirror of the `account.audit` (`origin:"web"`) that `account.create` emits — the same bidirectional-audit shape §5.5 of `ADMIN_CONTROLS.md` established. Revisit if a shard chooses `hybrid`/`game` and wants a complete website roster. + +--- + +## 8. Where the code goes + +| File | Responsibility | +|------|----------------| +| `overlay/Scripts/Custom/Bridge/BridgeAccounts.cs` | **New.** Registers `account.create` and `account.unlink`; the create+link, char-safety validation, collision check, Owner floor on unlink, `account.audit` emission. Mirrors `BridgeAdmin.cs` structure. | +| `overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs` | **Extend.** Add the `[unlink` player command beside `[link`. No change to existing link behavior. | +| `overlay/Scripts/Custom/Bridge/BridgeConfig.cs` | **Extend.** `SignupMode` (parsed, safe fallback), `AccountCreateEnabled`, `RequireIpForCreate`, name/password length caps; read `Accounts.AutoCreateAccounts` and warn on mode mismatch. | +| `overlay/Scripts/Custom/Bridge/BridgeBoot.cs` | **Extend.** Wire `BridgeAccounts.Initialize()` into `Initialize()` (one line, beside the other subsystems). | +| `overlay/Config/Bridge.cfg` + `.example` | **Extend.** The §6 keys, defaults documented. | +| `sidecar/src/web.rs` | **Extend.** `POST /accounts/create` (forwards `ip` from the body untouched), `DELETE /link/:account`; `respond_account` status mapping (409 on collision, 429 on IP cap, 400 on missing IP); scrub password from any logged/broadcast value. | +| `sidecar/src/store.rs` | **Extend.** `record_unlink` (clear the mirrored link row) beside the existing `record_link`. | +| `docs/INTEGRATION.md` | **Extend.** Document `POST /accounts/create`, `DELETE /link/{account}`, and the `account.audit` event. | +| *(website, separate repo)* | Signup form → `POST /accounts/create`; unlink control → `DELETE /link/{account}`; consume `account.audit`. | + +No new core/stock edits in committed scope — `account.create`/`unlink` are all script-layer (`new Account`, `SetTag`/`RemoveTag`) called from the new overlay. The only core edit contemplated (the `AccountCreated` event, §7) is explicitly deferred. + +--- + +## 9. Phasing + +1. ~~**Config + modes.**~~ **Done.** `BridgeConfig` gains `SignupMode` (parsed, unrecognized → `game`), `AccountCreateEnabled` (mode-following default), `RequireIpForCreate`, name/password caps, and the boot-time `Accounts.AutoCreateAccounts` mismatch warning. `[bridge status` shows `signup=…(create=…)`. +2. ~~**`account.create`.**~~ **Done.** `BridgeAccounts.cs` + `POST /accounts/create` + `respond_account`. Gate on mode, `actor` required, char-safety mirrored from `AccountHandler`, collision → 409, IP cap via `CanCreate`/`LogAccess` (fail-closed on missing/loopback IP when `RequireIpForCreate`), create + link, `account.audit`, password never logged/echoed. *Acceptance below is written for a live run — not yet exercised end-to-end.* +3. ~~**Unlink — both surfaces.**~~ **Done.** `account.unlink` + `DELETE /link/:account` + `store.record_unlink`, and the in-game `[unlink`. Owner floor reuses `BridgeAdmin.Protected`; `[unlink` emits `account.unlinked`. +4. ~~**Docs.**~~ **Done.** `INTEGRATION.md` §2 (protocol bumped to **2**), §4 (`account.audit`/`account.unlinked`), §6 (`POST /accounts/create`, `DELETE /link/{account}`), §7 (409/429). + +**Build verification (2026-07-17):** sidecar `cargo check` clean; overlay compiled in the full ServUO Scripts tree — **0 errors, 0 warnings**. **Live end-to-end run still pending** (needs a booted shard + sidecar): create+link in website/hybrid, game-mode refusal, duplicate 409, the per-IP cap holding (second create same `ip` → 429, different IP succeeds, omitted IP → 400 while `RequireIpForCreate`), `LoginIPs[0]`/`IPTable` incremented, and unlink clearing tag+mirror with the Owner floor refusing a protected target. + +Deferred (revisit only if a shard needs it): the §7 in-game→website `account.created` sync; the §12.3 `account.setpassword`/`account.exists` siblings; §12.5 credential-verb rate limiting. + +--- + +# Part B — Social & political world-state streams + +These are **outbound** streams (shard → website), the natural extension of `PLAN.md`'s event/sweep plane. None needs a write plane; all reuse the transport, the bounded queue, and the sweep/emit-on-change discipline `BridgeSweeps` already established. Each entry below states its **grounded hook situation** so nothing rides an event that doesn't fire. + +## 10. The requested streams + +### 10.1 Guilds + +**Hook reality (verified):** + +- `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`. +- `EventSink.CreateGuild` is **not** a creation notification. It is the load-time deserialization factory: raised only from `Server/World.cs:517` while reading the guild index at boot, where the handler's job is to *construct* the guild instance (`Guild.cs:775` → `new Guild(args.Id)`). Player guild creation (`new Guild(pm, name, abbrev)` at `Create Guild Gump.cs:83`, `GuildDeed.cs:127`) raises **no event**. **Do not use `CreateGuild` for "a guild was created"** — it would fire once per guild at every boot and never on an actual new guild. +- Leave, disband, leader change, alliance change, rename: **no events.** + +**Delivery — a guild sweep + diff, exactly like house decay (`PLAN.md` §5.4).** `BaseGuild.List` is a `Dictionary` (`Server/Guild.cs:54`) — the whole registry, enumerable on the Core thread. Hold a `Dictionary` (name, abbreviation, leader serial, member count, alliance name, member-serial set hash). On each sweep, diff: + +- id present now, absent before → `guild.created` +- id absent now, present before → `guild.disbanded` +- leader / alliance / name / abbreviation changed → `guild.updated` +- member set grew/shrank → `guild.join` / `guild.leave` (the sweep is the reliable source for leaves; `EventSink.JoinGuild` can *also* emit an immediate `guild.join` for joins, with the sweep as the backstop) + +Take a **silent baseline** on `ServerStarted` (populate without emitting), same as decay, or every guild re-announces on every boot. Cost is trivial — a shard has tens to low-hundreds of guilds, and reading `Members.Count` + `Leader` is a handful of field reads each. + +```jsonc +{"kind":"guild.created","id":1234,"name":"The Silver Hand","abbr":"TSH", + "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"},"members":14,"alliance":null} +{"kind":"guild.leave","id":1234,"who":{"serial":"0x77","name":"Bran"},"members":13} +{"kind":"guild.disbanded","id":1234,"name":"The Silver Hand"} +``` + +> If real-time (not next-sweep) leave/disband ever matters, the clean tap is a one-line `patches/` hook in `Scripts/Misc/Guild.cs` `RemoveMember`/`OnDelete` — the Phase-7 `patches/` precedent. Start with the sweep; add the patch only if latency is a real complaint. Guild membership does not move fast enough to justify it up front. + +### 10.2 Town governors ("mayors") + +In modern ServUO the "mayor of a town" is the **Governor** in the City Loyalty System (King Blackthorn's governance). Each `City` (enum, `CityLoyaltySystem.cs:15`) has a `CityLoyaltySystem` instance carrying `Governor` (Mobile), `GovernorElect`, an `Election`, a `Citizens` count, and a herald. The `Governor` setter already broadcasts a herald message on change (`CityLoyaltySystem.cs:193`), confirming a governor transition is a first-class in-game event — there just isn't an `EventSink` for it. + +**Delivery — a city sweep, emit-on-change.** `CityLoyaltySystem.Cities` (static `List`, `CityLoyaltySystem.cs:680`) is the full set, one per city. Sweep, hold `Dictionary`, emit on transition. Governors change on the order of weeks — a slow sweep (e.g. 5 min, or fold into the economy sweep cadence) is ample. Also emit election open/close and, optionally, the standing. + +```jsonc +{"kind":"city.governor","city":"Britain","from":{"serial":"0x55","name":"Old Mayor"}, + "to":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"}} +{"kind":"city.election","city":"Moonglow","phase":"nominate","candidates":3,"endsAt":"2026-07-24T…"} +``` + +> **Gate on `CityLoyaltySystem.Enabled`** (`CityLoyalty.Enabled`, default true). If a shard runs its own custom town-ownership system instead, this sweep should no-op — detect and log, don't assume. + +### 10.3 Player titles + +There is **no title-change event.** Titles are read-model state, best delivered two ways, not as a stream: + +- **Enrich `char.profile`** (`BridgeProfile`) with a `titles` block. Sources on a `PlayerMobile`: the reward-title list `m_RewardTitles` (`List`) + the selected index `m_SelectedTitle` (`PlayerMobile.cs:4194,4595`), the champion title `m_CurrentChampTitle`, plus the computed titles from `Titles.ComputeTitle` / `ComputeFameTitle` / `GetSkillTitle` / veteran titles (`Scripts/Misc/Titles.cs`). `char.profile` already carries all-skills, so titles slot in beside it at near-zero extra cost, and it is a *read* — no hook needed. +- **Optional `title.change`** only if the community page wants a live "so-and-so is now *Grandmaster Blacksmith*" feed — and then it comes from a **profile-diff in the sidecar**, not a shard event (the shard has nothing to subscribe to). Recommend starting with profile enrichment; add the diff feed only if there is demand. + +City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`, `MerchantTitles.cs`) fold into the same `titles` block. + +### 10.4 Factions / Vice vs Virtue + +**Which system is live is a shard decision — verify before building.** Two exist: + +- **Old Factions** (`Scripts/Services/Factions`): `Faction.Commander` (leader, `Faction.cs:160`), `Faction.Election`, `Faction.Members` (`List`), and faction-controlled **Towns** (`Town.cs` — each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, **off**. +- **Vice vs Virtue** (`Scripts/Services/ViceVsVirtue`): the modern replacement. `ViceVsVirtueSystem.Enabled` (`VvV.Enabled`, default **true**), a singleton `Instance`, an active `Battle`, and per-player `VvVPlayerEntry` (score, kills, assists). City control in VvV rides the same city-loyalty/governor rails as §10.2. + +**Delivery — a sweep, gated on whichever is enabled.** Neither system raises membership/leadership `EventSink`s, so it is the same sweep+diff pattern: + +- VvV (recommended default): standings per side, active-battle status (`Battle.OnGoing`, current city), and the top `VvVPlayerEntry` scores → a `vvv.standings` snapshot on change + a `vvv.battle` open/close event. +- Old Factions (only if a shard runs it): `faction.control` (town → owning faction on change), `faction.commander` (leader change from the `Election`). + +```jsonc +{"kind":"vvv.battle","phase":"start","city":"Britain","map":"Felucca","endsAt":"2026-07-17T…"} +{"kind":"vvv.standings","order":142000,"chaos":138500,"leaderSide":"Order"} +``` + +> Start by detecting which system is enabled at boot and streaming only that one; emit a one-time `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing. + +## 11. Further integration points — a menu to pick from + +Everything below is grounded in a hook or a cheap sweep in *this* server. Ranked roughly by value-to-effort. **Pick the ones you want and I'll fold them into the phasing.** (✔ = a real `EventSink` exists; ⟳ = sweep/diff; ⚑ = needs a small `patches/` core tap.) + +| # | Stream | Source | Effort | Why it's worth it | +|---|--------|--------|:------:|-------------------| +| 1 | **Who's-online / population** | ⟳ online sweep over `NetState.Instances` | low | A live "N players online", per-facet population, and a history series. The single most-asked-for website widget. | +| 2 | **Region presence** | ✔ `EventSink.OnEnterRegion` (`Region.cs:1160`, player-filtered) | low | Cheap location stream → town population heatmap, "who's in Despise" — `PLAN.md` §5.6 already flags it as the right answer over `Movement`. | +| 3 | **Crafting feed** | ✔ `EventSink.CraftSuccess` | low | Who crafted what, exceptional/runic — a crafting economy + "notable crafts" feed. | +| 4 | **Taming feed** | ✔ `EventSink.TameCreature` | low | New tames, esp. rares/greaters — high community interest. | +| 5 | **Resource harvesting** | ✔ `EventSink.ResourceHarvestSuccess` | low-med | Mining/lumber/fishing volume → the raw-material side of the economy (pairs with the vendor/gold streams already shipped). | +| 6 | **Virtue progression** | ✔ `EventSink.VirtueLevelChange` | low | Knight/Seeker/etc. virtue ranks — a progression badge system. | +| 7 | **Bulk Order Deeds** | ✔ `EventSink.BODOffered` / `BODUsed` | low | BOD turn-ins and rewards — a crafting-endgame feed and reward-title source. | +| 8 | **Guild wars** | ⟳ from the §10.1 guild sweep (war state on `Guild`) | low | Declared/active/ended wars between guilds — a PvP politics board, nearly free once guilds sweep. | +| 9 | **Player housing registry** | ⟳ extend the existing decay sweep to a full house list | med | Owner → houses map, "houses for sale" (via vendor data already streamed), a housing map. Reuses `PLAN.md` §5.4 machinery. | +| 10 | **Peerless / boss / rare drops** | ⚑ virtual-override or drop-system tap (no `EventSink`) | med | An "epic loot" feed. Honest cost: no clean event (same gap as per-hit damage, `PLAN.md` §5.9) — needs a targeted `patches/` hook, so it is a deliberate pick, not a freebie. | +| 11 | **Secure player trades** | ⚑ `SecureTrade` completion has no `EventSink` | med | Player-to-player item/gold transfers → economy + fraud signal, complements the vendor-sale core edit. Needs a core tap. | +| 12 | **Champion spawn *board*** | already shipped (`BridgeChamps`) — extend, don't rebuild | — | Champs are done in 1.0. Listed so it is not re-proposed; any gap is an extension of the existing sweep. | + +**Selected for Part B (owner pick, 2026-07-17):** guilds (§10.1) + governors (§10.2) + who's-online (#1) + region presence (#2) + **housing registry (#9)** + titles (§10.3, free as profile enrichment). All reuse the sweep pattern and need no core edit; together they give a website its "living world" page — population, guild politics, town leadership, and a housing map. Factions/VvV (§10.4) is deferred until you confirm which system your shard runs. The phasing is §13. + +### Where the Part B code goes + +| File | Responsibility | +|------|----------------| +| `overlay/Scripts/Custom/Bridge/BridgeSocial.cs` | **New.** The guild sweep+diff and the `EventSink.JoinGuild` subscription → `guild.*`. | +| `overlay/Scripts/Custom/Bridge/BridgeGovernance.cs` | **New.** The city sweep → `city.governor`/`city.election`; the VvV/faction standings sweep (gated on enabled) → `vvv.*` / `faction.*`; the one-time `world.systems` frame. | +| `overlay/Scripts/Custom/Bridge/BridgeProfile.cs` | **Extend.** Add the `titles` block to `char.profile`. | +| `overlay/Scripts/Custom/Bridge/BridgeSweeps.cs` | **Extend / mirror.** New sweep timers (guild, city, presence), re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`, counters in `[bridge status` — same shape as the existing sweeps. | +| `overlay/Config/Bridge.cfg` | **Extend.** `GuildSweepSeconds`, `CitySweepSeconds`, `PresenceSweepSeconds` (+ enable flags). | +| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the snapshots that back boards (guild roster, governors, population history); `GET /guilds`, `/governors`, `/online` served from the store so they survive a shard outage, exactly like `/champs` and `/economy` do today. | +| `docs/INTEGRATION.md` | **Extend.** New event catalog entries + the read endpoints. | + +--- + +## 12. Cross-cutting additions (recommended) + +Five things that are not new *streams* but make 2.0 correct and complete. The first two I consider **essential**; the rest are high-value companions to what's already specced. + +### 12.1 Bump the protocol version to 2 — **essential** + +The sidecar is `PROTOCOL_VERSION = 1` (`sidecar/src/main.rs:23`), and every response carries `X-UOLink-Version`; the gate 409s a client that declares a different one (`web.rs:146`). 2.0 adds inbound verbs (`account.create`, `account.unlink`, …) and event kinds, so it must bump to `2`. + +The compatibility rule to write down: **new outbound event kinds are additive** — a 1.x website ignores unknown kinds and keeps working, so the live feed stays backward-compatible. What is *not* compatible is a client that calls a **new inbound verb** against an old sidecar, or a new sidecar that a strict old client rejects on the version header. So: bump to `2`, keep the feed additive, and document that the new *verbs/endpoints* require a v2 sidecar while the *event feed* degrades gracefully. + +### 12.2 Every diff stream needs a REST snapshot companion — **essential** + +The Part B streams are **diff-based**: `guild.created`/`disbanded`, `city.governor`, housing changes emit only on transition (like house decay). That means a website that connects fresh — or a **sidecar that restarts** — has seen *no* deltas yet and therefore has **no current state**. The live feed alone can never answer "what are the guilds *right now*." + +So every board-backed stream ships with a REST snapshot served from the sidecar's store, exactly as `/champs` and `/economy` already are (`web.rs`): `GET /guilds`, `/governors`, `/online`, `/houses`. The shard emits deltas; the sidecar persists the latest snapshot; the website hydrates from REST on load and then live-updates from the feed. This is the single most important robustness rule for Part B — without it, a sidecar restart silently blanks the community page until the next guild happens to change. + +> Concretely: the sidecar keeps a `guilds` / `governors` / `population` table updated from the stream (upsert on each delta, plus a periodic full snapshot the shard can push), and the REST route reads that table. The shard should also support an on-demand full re-emit (a `snapshot.request` inbound, or just re-run the sweep with baseline suppression off) so a sidecar that lost its store can rebuild. + +### 12.3 Round out the provisioning surface — password reset, existence check + +`account.create` sets the account's **initial** password (§3) — that part is done. What it does not cover is the rest of the credential lifecycle. Two small siblings close it, both trivially grounded: + +- **`account.setpassword`** — the **later** password *change/reset* for an account that already exists (a player who forgot theirs), distinct from the initial password `account.create` sets. `acct.SetPassword(newpw)` (`Account.cs:676`) is public; the verb takes `{actor, account, password}`, applies the Owner floor, emits `account.audit action:"setpassword"`, and — like create — **never echoes the password**. `POST /accounts/{account}/password`. Only worth building if the site will offer a "forgot password" flow. +- **`account.exists`** — the signup form wants to say "that name is taken" before submit. A read: `Accounts.GetAccount(un) != null`. `GET /accounts/{account}` → `{exists: true|false, linked: bool}`. Cheap, and it prevents the worse UX of finding out via a 409 on submit. + +Both reuse the `account.*` machinery from Part A verbatim. `account.setpassword` is the higher-value of the two. + +### 12.4 Mirror hygiene — deletion & link teardown + +If the website mirrors rosters/links (it does — `store.record_link`), it must learn when the game side removes things, or the mirror rots: + +- **Character deletion.** `EventSink.DeleteRequest` (`EventSink.cs:1754`) fires when a player deletes a character at the select screen. Emit `char.deleted` so the website drops it from any roster it caches. (`PLAN.md` §5.1 already lists this hook as a roster-honesty signal — 2.0 is where it earns its place, now that the website keeps rosters.) +- **Account deletion.** `Account.Delete()` exists (`Account.cs:642`); an optional `account.delete` verb (Owner-floor-guarded, `origin:"web"` audit) closes the lifecycle. Lower priority — most shards ban rather than delete — but list it so the option is on record. +- On any unlink **or** account delete, the sidecar clears its link mirror (the `record_unlink` already specced in §4.1), so event attribution stops immediately. + +### 12.5 Rate-limit the credential verbs + +`account.create` and `account.setpassword` mint/change persistent credentials. The per-IP cap (§3.1) blocks multi-accounting from one IP, but a compromised or buggy website could still hammer distinct IPs. Add a **sidecar-side rate limit** on the credential verbs — a global create-per-minute ceiling and a per-`actor` cooldown — mirroring the caps philosophy town-crier and the admin plane already follow (`BridgeConfig.TownCrier*`, `Admin*`). Cheap insurance; the shard stays the last line of defense (collision + IP cap), the sidecar is the first. + +--- + +## 13. Part B phasing + +1. **Guilds + governors.** `BridgeSocial.cs` (guild sweep + `JoinGuild`) and `BridgeGovernance.cs` (city sweep), their `GuildSweepSeconds`/`CitySweepSeconds` config, and the `world.systems` frame. Ship with their REST snapshots (`GET /guilds`, `/governors`, §12.2) from day one — a diff stream without its snapshot is half-built. +2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store. +3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.) +4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. +5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. + +Cross-cutting, lands with Phase 1: the **protocol bump to 2** (§12.1) and the **snapshot-companion rule** (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the `account.*` surface. + +--- + +## 14. Built-in reports — replace the FTP/HTML path with JSON over the sidecar + +ServUO ships a **Reports engine** (`Server.Engines.Reports`, `Scripts/Services/Reports/`) that already compiles exactly the dashboard data a website wants — it just delivers it the way RunUO did in 2004: render static HTML and **FTP it to your website**. The bridge can tap the *compiled data* directly and ship JSON, retiring the file/FTP path entirely. **No core edit** — the compile methods are `public static`. + +### 14.1 What the engine produces (verified) + +`Reports.Generate()` runs hourly on the Core thread and builds a `Snapshot` from public static compile methods (`Reports.cs`): + +| Method | Returns | Content | +|--------|---------|---------| +| `CompileGeneralStats()` | `Report` | NPCs, Players, Clients, Accounts, Items | +| `CompileStatChart()` | `Chart` | population over time | +| `CompileSkillReports()` | `PersistableObject[]` | **skill distribution — GM count per skill** | +| `CompileFactionReports()` | `PersistableObject[]` | faction membership / stats | +| `Reports.StaffHistory` | `StaffHistory` | staff activity per account (`StaffInfo`/`UserInfo` hashtables), **help-page-queue length over time** (`QueueStats`), page history | + +Each `Report` is structured (`Columns` + `Items`), so it serializes to JSON cleanly with the hand-rolled `BridgeJson` writers — no reflection serializer. The engine also persists an hourly **`SnapshotHistory`** series to disk, so a backfill of historical points is available if wanted. + +### 14.2 How it's delivered today (the file path you flagged) + +- **HTML + FTP.** `UpdateOutput` (`Reports.cs:406`, on a ThreadPool thread) runs `HtmlRenderer` into `/reports/stats/` and `reports/staff/` (`Reports.Path`, default `reports`), then `Upload()` writes an `upload.ftp` job to FTP the HTML to a website. Gated on `Reports.AutoGenerate` (**default off**). +- **WebStatus.** A *separate* mechanism (`Scripts/Misc/WebStatus.cs`): an in-process `HttpListener` on `:80/status/` serving a live status HTML page. Default `Enabled = false`. + +Both are the "report goes to a file / gets pushed out-of-band" pattern. The sidecar already replaces the second one (`/health` + the live feed cover what `WebStatus` served); §14 replaces the first. + +### 14.3 The tap — a report sweep, JSON out + +`BridgeReports.cs` runs a **Core-thread timer** (`ReportSweepSeconds`, e.g. hourly to match stock, or faster) that calls the same public compile methods, serializes the `Report`/`Chart` objects to JSON, emits `report.*`, and hands the sidecar a snapshot to persist and serve over REST: + +```jsonc +{"kind":"report.skills","t":1752…,"skills":[ + {"skill":"Swordsmanship","gms":42},{"skill":"Magery","gms":88}, …]} +{"kind":"report.general","players":142,"npcs":42826,"clients":150,"accounts":51,"items":206467} +{"kind":"report.staff","window":"7d","staff":[ + {"account":"GreyBeard","actions":318}],"pageQueue":[{"t":…,"open":4}, …]} +``` + +Served for hydration (the §12.2 snapshot rule): `GET /reports/skills`, `/reports/general`, `/reports/staff`. + +Key points, all grounded: + +- **No core edit, no HTML, no FTP.** Calling `Compile*` directly skips `HtmlRenderer`/`Upload` entirely. Leave `Reports.AutoGenerate` **off** (no HTML files written) and run the bridge tap instead. The FTP `upload.ftp` path and `Reports.Path` become dead weight for a bridge-connected shard. +- **Threading.** `Compile*` read `World.Mobiles`/`Skills`, so they must run on the Core thread — which the bridge's sweep timers already are (`PLAN.md` non-negotiables). Stock only offloaded the *HTML rendering* (slow string work) to a ThreadPool; the bridge skips that step, so there's nothing to offload. Skill distribution walks all mobiles once — treat it like the profile-bulk warning in `PLAN.md` §1: run it on a slow cadence (hourly is plenty), never in a fast sweep. +- **Dedupe against Part B.** `report.general` and the population chart overlap with who's-online (§11 #1); faction reports overlap with §10.4. The **unique** wins here are **skill distribution** (a GM-per-skill leaderboard available nowhere else in the bridge) and the **staff-activity + page-queue-length history** (aggregates that complement the per-action `admin.audit` we already stream). Prioritize those two; treat the rest as "already covered, don't double-emit." +- **Optional backfill.** On first connect the sidecar could ingest the engine's persisted `SnapshotHistory` (`Reports.StaffHistory`/stats history) to seed the historical series instead of starting empty. Nice-to-have, not required. + +### 14.4 Where the code goes + +| File | Responsibility | +|------|----------------| +| `overlay/Scripts/Custom/Bridge/BridgeReports.cs` | **New.** Core-thread report sweep calling `Reports.Compile*` + `Reports.StaffHistory`; serialize to `report.*`; re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`. | +| `overlay/Config/Bridge.cfg` | **Extend.** `ReportSweepSeconds` (+ enable flag). | +| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the report snapshots; `GET /reports/{skills,general,staff}` served from the store (survives shard outage, like `/champs`). | +| `docs/INTEGRATION.md` | **Extend.** `report.*` events + endpoints; note they supersede the stock FTP/HTML reports and `WebStatus`. | + +> **Recommendation:** fold this in as **Part B, Phase 6** (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all. From 9183bf748fa0e3e87591ff22d2849a71ff304ef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:54:11 -0500 Subject: [PATCH 17/23] feat(protocol2): guild and town-governor world-state streams (Part B ph.1) Adds the first Part B streams from docs/PROTOCOL_2.md: guild rosters and town governors ("mayors"), both outbound diff-board sweeps mirroring the existing champ board. Overlay: - BridgeSocial (new): guild sweep+diff over BaseGuild.List -> guild.update / guild.remove (full-state upsert; disband detected via Disbanded), plus a real-time guild.join from EventSink.JoinGuild. (EventSink.CreateGuild is only the load-time factory, so creation is derived sidecar-side from a first-seen id, as champs do.) - BridgeGovernance (new): city sweep over CityLoyaltySystem.Cities -> city.update (governor / governor-elect / election phase), gated on CityLoyaltySystem.Enabled. - BridgeJson.Actor: shared serial/name/acct/webId/player writer used by both. - BridgeConfig: GuildSweepSeconds (60s), CitySweepSeconds (300s). - BridgeBoot: both wired into [bridge reload|sweepnow|status. Sidecar: - store: guilds + governors board tables with upsert/delete/all. - main: route guild.update/remove and city.update into the boards. - web: GET /guilds, GET /governors served from the store (snapshot-companion rule, so a fresh page or a restarted sidecar hydrates without the shard). Docs: INTEGRATION.md event catalog (guild.*, city.update) + board endpoints; PROTOCOL_2.md Part B phase 1 marked built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 64 +++++++++++++++++++++++++++++++++++++++++++++ link/PROTOCOL_2.md | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 27e048f..3960840 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -239,6 +239,47 @@ Category-specific fields on `champ.update`: The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events. +#### Guilds (Protocol 2.0) + +Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board. + +| kind | fields | notes | +|------|--------|-------| +| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. | +| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. | +| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). | + +The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. + +```json +{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14, + "online":3,"alliance":"Britannian Pact", + "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "t":1752489280000} +{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH", + "who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000} +``` + +Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events. + +#### Town governors (Protocol 2.0) + +In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set of cities is polled (`CitySweepSeconds`, default 300s); each city emits `city.update` (full-state upsert) only when its governor, governor-elect, or election phase changes. **No events at all unless the shard runs the City Loyalty system.** + +| kind | fields | notes | +|------|--------|-------| +| `city.update` | `city`, `governor` (actor or null), `governorElect` (actor or null), `electionPhase`, `candidates`, `autoPickAt` (ISO-8601 UTC, when an election is ongoing) | A city's governance changed. Derive "the governor changed" by comparing to your stored board. | + +`electionPhase` is one of `none` / `nominate` / `vote` / `pending`. Cities: Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia. + +```json +{"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0, + "governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "governorElect":null,"t":1752489280000} +``` + +Render the current board from `GET /governors` (§6) on connect, then keep it live with these events. + --- ## 5. REST — read queries @@ -512,6 +553,29 @@ GET /champs A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain. +### Guild board (Protocol 2.0) + +``` +GET /guilds +→ { "guilds": [ {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH", + "members":14,"online":3,"alliance":"Britannian Pact", + "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "t":1752489280000}, ... ] } +``` + +Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart. + +### Governor board (Protocol 2.0) + +``` +GET /governors +→ { "cities": [ {"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0, + "governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "governorElect":null,"t":1752489280000}, ... ] } +``` + +Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city. + --- ## 7. Status codes diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index fef8646..5455b73 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -380,7 +380,7 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must ## 13. Part B phasing -1. **Guilds + governors.** `BridgeSocial.cs` (guild sweep + `JoinGuild`) and `BridgeGovernance.cs` (city sweep), their `GuildSweepSeconds`/`CitySweepSeconds` config, and the `world.systems` frame. Ship with their REST snapshots (`GET /guilds`, `/governors`, §12.2) from day one — a diff stream without its snapshot is half-built. +1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* 2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store. 3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.) 4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. From fba337e5303c98956b8b6f6ad2d812c587cd93d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:02:05 -0500 Subject: [PATCH 18/23] =?UTF-8?q?feat(protocol2):=20presence=20stream=20?= =?UTF-8?q?=E2=80=94=20online=20population=20+=20region=20transitions=20(P?= =?UTF-8?q?art=20B=20ph.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay BridgePresence (new): - presence.online sweep over online PlayerMobiles: total plus per-facet and per-region counts, emitted only when the population changes. - region.enter real-time from EventSink.OnEnterRegion (player-filtered), the cheap location signal PLAN.md prefers over Movement. - PresenceSweepSeconds (30s); wired into [bridge reload|sweepnow|status. Sidecar: - GET /online serves the latest presence.online snapshot from the event store (survives restart); population time series via /history?kind=presence.online. Docs: INTEGRATION.md presence events + /online endpoint; PROTOCOL_2 ph.2 built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 26 ++++++++++++++++++++++++++ link/PROTOCOL_2.md | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 3960840..5d5f012 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -280,6 +280,22 @@ In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set Render the current board from `GET /governors` (§6) on connect, then keep it live with these events. +#### Presence (Protocol 2.0) + +Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, default 30s) and emitted **only when it changes**; region transitions arrive in real time. + +| kind | fields | notes | +|------|--------|-------| +| `presence.online` | `count`, `byFacet` `{map: n}`, `byRegion` `{region: n}` | The current online population. Emitted when the count or any breakdown changes. `GET /online` gives the latest; `GET /history?kind=presence.online` the time series. | +| `region.enter` | `from` (or null), `to` (or null), `map`, `who` (actor object) | A player crossed into a new named region. `from`/`to` are region names (`Wilderness` is unnamed). Cheap "who's where" feed. | + +```json +{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30}, + "byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000} +{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca", + "who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...} +``` + --- ## 5. REST — read queries @@ -576,6 +592,16 @@ GET /governors Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city. +### Online population (Protocol 2.0) + +``` +GET /online +→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30}, + "byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000} +``` + +The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`. + --- ## 7. Status codes diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index 5455b73..9a5b993 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -381,8 +381,8 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must ## 13. Part B phasing 1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* -2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store. -3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.) +2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.* +3. **Housing registry.** Extend the decay sweep to a full owner→houses list → `GET /houses`. (Selected from the §11 menu. Note: stock ServUO has no "for sale" flag on houses, so the registry is owner→houses; for-sale is dropped.) 4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. 5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. From bba7cc77140e80820607759b75018674bc67f5b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:05:47 -0500 Subject: [PATCH 19/23] feat(protocol2): house registry board (Part B ph.3) Overlay BridgeHousing (new): a diff sweep over BaseHouse.AllHouses -> house.update / house.remove (owner, region, location, decay level, co-owners, friends, placement price), complementing the existing house.decay transition feed. HousingSweepSeconds (300s); wired into [bridge reload|sweepnow|status. Stock ServUO has no "for sale" flag, so this is an owner->houses registry; price is the placement value, not a listing. Sidecar: houses board table with upsert/delete/all; main routes house.update/ remove into it; GET /houses served from the store. Docs: INTEGRATION.md house.* events + /houses endpoint; PROTOCOL_2 ph.3 built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 31 +++++++++++++++++++++++++++++++ link/PROTOCOL_2.md | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 5d5f012..ed397e2 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -296,6 +296,25 @@ Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, "who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...} ``` +#### Houses (Protocol 2.0) + +The house registry — one row per house, complementing the `house.decay` *transition* feed (§ above). Polled (`HousingSweepSeconds`, default 300s) and diffed like the other boards. + +| kind | fields | notes | +|------|--------|-------| +| `house.update` | `serial`, `name`, `owner` (actor or null), `coOwners`, `friends`, `region`, `map`, `x`,`y`,`z`, `decay`, `price`, `builtOn`, `lastRefreshed` | A house's owner/region/decay/co-owners changed, or first sight this connection. `decay` is the level name (e.g. `LikeNew`). `price` is the placement value — **stock ServUO has no "for sale" flag**, so this is not a listing. | +| `house.remove` | `serial` | The house was demolished or no longer exists. Drop the row. | + +```json +{"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil","decay":"LikeNew", + "price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain", + "owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "coOwners":2,"friends":5,"builtOn":"2026-01-02T00:00:00Z","lastRefreshed":"2026-07-10T00:00:00Z", + "t":1752489280000} +``` + +Render from `GET /houses` (§6) on connect, then keep live with these events. + --- ## 5. REST — read queries @@ -602,6 +621,18 @@ GET /online The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`. +### House registry (Protocol 2.0) + +``` +GET /houses +→ { "houses": [ {"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil", + "decay":"LikeNew","price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain", + "owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "coOwners":2,"friends":5,"builtOn":"...","lastRefreshed":"...","t":...}, ... ] } +``` + +Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the `house.*` stream (§4). Ordered by name. Survives a sidecar restart. + --- ## 7. Status codes diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index 9a5b993..c03bb93 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -382,7 +382,7 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must 1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* 2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.* -3. **Housing registry.** Extend the decay sweep to a full owner→houses list → `GET /houses`. (Selected from the §11 menu. Note: stock ServUO has no "for sale" flag on houses, so the registry is owner→houses; for-sale is dropped.) +3. ~~**Housing registry.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeHousing.cs`: a house sweep over `BaseHouse.AllHouses` → `house.update`/`house.remove` (owner, region, location, decay, co-owners, friends, price), complementing the existing `house.decay` transition feed. `HousingSweepSeconds` (300s), wired into `[bridge`. Sidecar `houses` board + `GET /houses`. (Stock ServUO has no "for sale" flag, so this is owner→houses; `price` is the placement value, not a listing.) *Live run pending.* 4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. 5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. From 2d579a0b4de5c68d7add317a656468e1bd01b16a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 10:12:01 -0500 Subject: [PATCH 20/23] feat(protocol2): titles in char.profile (Part B ph.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay BridgeProfile: char.profile gains a titles block (selected index, fameKarma, skill, and the raw reward-title list) read from PlayerMobile's public title accessors. No new stream, no sidecar change — it rides the existing char.profile served by GET /char. Reward entries may be a cliloc number as a string or a literal; resolve numeric ones website-side like item names. Docs: INTEGRATION.md char.profile titles field; PROTOCOL_2 ph.4 built. Part B phase 5 (Factions/VvV) remains deferred by owner decision. Verified: overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 5 ++++- link/PROTOCOL_2.md | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index ed397e2..9b2f943 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -345,7 +345,9 @@ Full character sheet: stats, all trained skills, worn equipment with flattened i { "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721, "weapon":{"minDamage":16,"maxDamage":18}, "mods":{"WeaponDamage":50,"HitLightning":40} } - ] + ], + "titles": { "selected": 0, "fameKarma": "Lord", "skill": "Grandmaster Swordsman", + "reward": ["1154060", "The Bold"] } } ``` @@ -353,6 +355,7 @@ Field notes: - `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it. - `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items. - Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site. +- `titles` (Protocol 2.0): `selected` is the index into `reward` currently displayed (`-1` if none). `fameKarma`/`skill` are computed display titles, omitted when the character has none. `reward` entries may be a **cliloc number as a string** or a literal string — resolve numeric ones against your cliloc table, same as item names. - Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly. ### Account roster diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index c03bb93..2635c11 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -383,8 +383,8 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must 1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* 2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.* 3. ~~**Housing registry.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeHousing.cs`: a house sweep over `BaseHouse.AllHouses` → `house.update`/`house.remove` (owner, region, location, decay, co-owners, friends, price), complementing the existing `house.decay` transition feed. `HousingSweepSeconds` (300s), wired into `[bridge`. Sidecar `houses` board + `GET /houses`. (Stock ServUO has no "for sale" flag, so this is owner→houses; `price` is the placement value, not a listing.) *Live run pending.* -4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. -5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. +4. ~~**Titles.**~~ **Built (2026-07-17), compiles clean.** `char.profile` gains a `titles` block (`selected`, `fameKarma`, `skill`, `reward[]`) from `PlayerMobile` accessors — no new stream, folds into `BridgeProfile`. *Live run pending.* +5. **Factions/VvV** — **deferred** (owner decision): only after confirming which system the shard runs; stream just the enabled one. Cross-cutting, lands with Phase 1: the **protocol bump to 2** (§12.1) and the **snapshot-companion rule** (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the `account.*` surface. From e34606dcccdca3e062fdd6c1108de15076b252fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 10:23:07 -0500 Subject: [PATCH 21/23] docs(protocol2): record live smoke-test results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Booted ServUO + the real sidecar (protocol 2, plugin connected) and exercised every Protocol 2.0 endpoint end-to-end. PROTOCOL_2.md §15 records the results: - Part A: account.create 200 + link; duplicate 409; per-IP cap enforced at the shard's real AccountsPerIp=3 (4th from one IP -> 429); loopback IP -> 400 (fail-closed); unlink 200 then lookup 404. - Part B: /houses (28, full data), /governors (9 cities), /guilds ([]), /online (count 0, headless), /char titles block present. Not exercised (needs a live UO client): presence.online with players, region.enter, real-time guild.join, char.vitals. Also documents the Scripts.dll boot-recompile lock quirk (build offline with the server stopped). World save left untouched; test accounts did not persist. Co-Authored-By: Claude Opus 4.8 --- link/PROTOCOL_2.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index 2635c11..0a8c86c 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -1,6 +1,6 @@ # Protocol 2.0 — Provisioning & World-State Streams -**Status:** Part A **built** on branch `feat/protocol2-account-provisioning` (2026-07-17), compiles clean both sides. Part B is design. +**Status:** Parts A + B (phases 1–4) **built and smoke-tested live** on branch `feat/protocol2-account-provisioning` (2026-07-17) — booted ServUO + the real sidecar and exercised every endpoint (see §15). Part B phase 5 (Factions/VvV) deferred by owner decision. **Date:** 2026-07-17 **Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**. **Companion to** [`PLAN.md`](PLAN.md) (read/event plane), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), and [`INTEGRATION.md`](INTEGRATION.md) (website API). @@ -446,3 +446,34 @@ Key points, all grounded: | `docs/INTEGRATION.md` | **Extend.** `report.*` events + endpoints; note they supersede the stock FTP/HTML reports and `WebStatus`. | > **Recommendation:** fold this in as **Part B, Phase 6** (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all. + +--- + +## 15. Smoke test — live run (2026-07-17) + +Deployed the overlay to the ServUO checkout, booted the shard and the real sidecar (protocol 2, `plugin_connected: true`), and exercised every new surface over REST against the live game. All green. + +**Part A — provisioning (through the real shard):** + +| Check | Result | +|-------|--------| +| `POST /accounts/create` (fresh IP) | **200** `account.ok`, account created + linked | +| duplicate name | **409** `account already exists` | +| per-IP cap | shard's real `AccountsPerIp=3` enforced: 3rd from one IP allowed, **4th → 429** `ip account limit reached` | +| loopback IP with `RequireIpForCreate` | **400** `client ip required` (fails closed) | +| `GET /link/{acct}` after create | **200**, linked to the website id | +| `DELETE /link/{acct}` | **200** `unlink`; lookup then **404** | + +**Part B — world-state boards (through the real shard):** + +| Endpoint | Result | +|----------|--------| +| `GET /houses` | **28 houses**, full owner/decay/co-owner/built-on data (shard → `house.update` → board → REST) | +| `GET /governors` | **9 cities**, `governor: null`/`electionPhase: none` on this unseeded world | +| `GET /guilds` | `[]` — no guilds on this world; the sweep ran without error | +| `GET /online` | `count: 0` — headless (no UO client), snapshot emitted and stored | +| `GET /char/{acct}/0` | full profile incl. the new `titles` block | + +**Not exercised (needs a live UO client, not a headless boot):** `presence.online` with real players, `region.enter`, real-time `guild.join`, and `char.vitals`. And `guild.join`/guild board content needs a guild to exist. These are inherent to a clientless smoke test — the board *plumbing* is proven by `/houses`, which uses the identical path. + +**One operational note surfaced:** the boot-time `Dynamic` script recompile **cannot replace `Scripts.dll` while the server is running**, because the Scripts build tries to copy the locked `ServUO.exe` and fails (the `PLAN.md §3` trap). The fix used here: build `Scripts/Scripts.csproj` once with the server **stopped**, then boot — the offline build produces a fresh `Scripts.dll` the boot then loads. Rely on this, not the in-process rebuild, when deploying new bridge code. The shard's world save was left untouched (hard-kill, no autosave), so the test accounts did not persist. From fdd67bff69747d57e835712de47b6d72246dc852 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:15:24 -0500 Subject: [PATCH 22/23] =?UTF-8?q?docs(protocol2):=20add=20Town=20Cryer=20n?= =?UTF-8?q?ews-gump=20integration=20design=20(=C2=A716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds §16: sync website news articles into the modern Town Cryer News gump (TownCryerSystem.NewsEntries), distinct from the Protocol 1.0 scrolling-crier lines (GlobalTownCrierEntryList). Grounded in the shard's Town Cryer source. Key findings / decisions: - NewsEntries is a public mutable List and TownCryerNewsEntry's ctor is public, and the display gumps already branch on Title/Body .Number>0 (cliloc) vs string (AddLabelCropped / AddHtml with HTML support). So website content needs NO gump changes and NO stock patch — the overlay inserts/removes directly and tracks its own entries, leaving stock uo.com news intact. (Refines the pasted guidance, which proposed adding methods to the stock TownCryerSystem.cs = a patch.) - Ties the two surfaces together: full article -> news gump; crier "says" just the title via the existing GlobalTownCrierEntryList path. - news.add/news.remove verbs (id-correlated, idempotent), POST /news + DELETE /news/{id}; website is source of truth, re-synced on shard reconnect since NewsEntries isn't persisted across reboot. Co-Authored-By: Claude Opus 4.8 --- link/PROTOCOL_2.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index 0a8c86c..a3f0d46 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -477,3 +477,71 @@ Deployed the overlay to the ServUO checkout, booted the shard and the real sidec **Not exercised (needs a live UO client, not a headless boot):** `presence.online` with real players, `region.enter`, real-time `guild.join`, and `char.vitals`. And `guild.join`/guild board content needs a guild to exist. These are inherent to a clientless smoke test — the board *plumbing* is proven by `/houses`, which uses the identical path. **One operational note surfaced:** the boot-time `Dynamic` script recompile **cannot replace `Scripts.dll` while the server is running**, because the Scripts build tries to copy the locked `ServUO.exe` and fails (the `PLAN.md §3` trap). The fix used here: build `Scripts/Scripts.csproj` once with the server **stopped**, then boot — the offline build produces a fresh `Scripts.dll` the boot then loads. Rely on this, not the in-process rebuild, when deploying new bridge code. The shard's world save was left untouched (hard-kill, no autosave), so the test accounts did not persist. + +--- + +## 16. Town Cryer news — website articles into the news gump (Protocol 2.1) + +**Status:** Design, grounded in the shard's `Scripts/Services/Town Cryer/` files. Not yet built. + +There are **two** distinct town-crier surfaces in ServUO, and 2.0 has so far touched only the first: + +1. **The scrolling crier** (`GlobalTownCrierEntryList`) — the wandering Town Crier NPC that *says* short announcement lines. Protocol 1.0 phase 6 (`BridgeTownCrier.cs`, `towncrier.add`/`remove`) already drives this. +2. **The Town Cryer News gump** (`TownCryerSystem.NewsEntries`) — the paged news UI with title + body + image + a "more info" URL per article. **Nothing drives this yet.** This section adds it. + +The ask: a website news article should land as a full article in the **news gump** (2), and the crier should also *say* just the **title** through the existing say feature (1) — so players get the audible "Hear ye!" proclamation while the full write-up lives in the gump. + +### 16.1 The hook (verified in the shard's source) + +- **`TownCryerSystem.NewsEntries`** (`TownCryerSystem.cs:40`) — `public static List`. The setter is private, but the **list is public and mutable**, so it can be inserted into and removed from directly. +- **`TownCryerNewsEntry(TextDefinition title, TextDefinition body, int gumpImage, Type questType, string url)`** (`TownCryerNewsEntry.cs`) — public ctor. Pass `questType: null` for website news. +- **The display gumps already handle string content**, so no gump edits are needed: + - List view (`TownCryerGump.cs:97-103`): `if (entry.Title.Number > 0) AddHtmlLocalized(...) else AddLabelCropped(..., entry.Title)`. + - Detail view (`TownCryerNewsGump.cs:27-42`): `if (Entry.Body.Number > 0) AddHtmlLocalized(...) else AddHtml(..., Entry.Body.String, ..., true)` — a **string body renders as HTML** (so `


…` works), `AddImage(..., Entry.GumpImage)`, and `InfoUrl` becomes a `LaunchBrowser` button. +- **Stock news is live on this shard.** `TownCryerSystem.Initialize()` adds ~18 hardcoded `uo.com` entries whenever `TownCryerSystem.Enabled` (`TownCryerSystem.cs:93-120`) — *not* gated by `UsePreloadedMessages` (that only gates a reload command). So the list is not empty, and our sync must not clobber it (see §16.3). + +### 16.2 Evaluating the pasted guidance + +The pasted analysis is **substantially correct** and useful — it identifies the right hook (`NewsEntries`), the right constructor, the cliloc-vs-string branching the gump already does, the image/url fields, and the important instinct to keep stock news separate. Two adjustments for *this* architecture: + +- **No stock patch is needed.** The pasted plan adds `AddNewsEntry` / `ClearExternalNews` methods to the stock `TownCryerSystem.cs`. That file is stock ServUO, so editing it would ship as a `patches/` diff (like `PlayerVendorSale`). We can avoid that entirely: because `NewsEntries` is a **public mutable list**, the bridge overlay inserts and removes directly — `TownCryerSystem.NewsEntries.Insert(0, entry)` / `.Remove(entry)` — and keeps the "which entries are ours" bookkeeping in an **overlay-side list**, not in a new field on the stock class. This is exactly how `BridgeTownCrier` already mutates `GlobalTownCrierEntryList` from the overlay. Pure overlay, zero stock edits. +- **Track our entries to keep stock intact.** Rather than the pasted `ExternalNewsEntries` field on the stock class, the overlay holds `List _ours`. On a sync we `Remove` our previous entries from `NewsEntries` and insert the new set — the stock `uo.com` articles are never touched. `MaxNewsEntries` is 100 (`TownCryerSystem.cs:26`); the overlay caps its own contribution well under that. + +Everything else in the pasted note stands, and the "this is one of the easier integrations — you're replacing the content provider" framing is right. + +### 16.3 The two surfaces, tied together + +On an inbound article the bridge does two things on the Core thread: + +1. **News gump** — build `new TownCryerNewsEntry(new TextDefinition(title), new TextDefinition(body), image, null, url)` and `Insert(0, …)` at the top of `TownCryerSystem.NewsEntries`, tracking it in `_ours`; trim `_ours` past the cap by removing the oldest (from both `_ours` and `NewsEntries`). +2. **Say the title** — reuse the scrolling-crier path (`GlobalTownCrierEntryList`, as `BridgeTownCrier` does) to announce a single line, the **title only**, for a short duration, so the crier proclaims it in-world. Optional per article (`announce: true`), so silent corrections don't re-proclaim. + +### 16.4 Protocol + +```jsonc +// website → sidecar → shard +{"kind":"news.add","id":"42","title":"Double XP Weekend", + "body":"
Double XP Weekend


Starts Friday 7PM.", + "image":1614,"url":"https://uomysticmoon.com/news/42","announce":true} +{"kind":"news.remove","id":"42"} +``` + +- Correlated by `id` (echoed on the reply), like town-crier. Re-adding an `id` **replaces** the prior entry (find-by-id in `_ours`, remove, re-insert) — idempotent. +- `title` required; `body`/`image`/`url` optional (a title-only blurb is valid). `image` defaults to a neutral scroll gump id when absent. +- Caps (defense in depth, mirroring `TownCrier*`): title/body length, max external entries. Replies `news.ok` / `news.error`. +- Sidecar: `POST /news` (add/replace), `DELETE /news/{id}`. Same `respond`-style status mapping as town-crier. + +### 16.5 Restart & re-sync (the source-of-truth rule) + +`NewsEntries` is **not persisted** by ServUO — it is rebuilt at every boot from stock `Initialize()` plus whatever we have inserted since. So our external articles vanish on a shard restart until re-pushed. The **website is the source of truth**: the sidecar re-sends the current external news set on every shard (re)connect, the same discipline §12.2 uses for the diff boards. (The sidecar persists the external set in its store so it can replay it without the website being up.) + +### 16.6 Where the code goes + +| File | Responsibility | +|------|----------------| +| `overlay/Scripts/Custom/Bridge/BridgeNews.cs` | **New.** `news.add` / `news.remove`: insert/remove `TownCryerNewsEntry` in the public `NewsEntries` list, track `_ours`, cap; optional title announcement via `GlobalTownCrierEntryList`; replies + caps. No stock edit. | +| `overlay/Config/Bridge.cfg` | **Extend.** `NewsMaxTitleLength`, `NewsMaxBodyLength`, `NewsMaxExternal`, default announce duration. | +| `sidecar/src/web.rs` + `store.rs` | **Extend.** `POST /news`, `DELETE /news/{id}`; persist the external-news set; replay it on shard (re)connect. | +| `docs/INTEGRATION.md` | **Extend.** The `news.*` verbs + endpoints. | + +No core or stock ServUO change — the whole integration rides the public `TownCryerSystem.NewsEntries` list and the existing crier say path. From 2d04b4808b7b9a2650509e87ba979bba2351ecb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:27:13 -0500 Subject: [PATCH 23/23] =?UTF-8?q?feat(protocol2):=20Town=20Cryer=20news-gu?= =?UTF-8?q?mp=20integration=20(=C2=A716,=20Protocol=202.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Website news articles now land in the modern Town Cryer News gump (TownCryerSystem.NewsEntries), separate from the scrolling-crier lines. Overlay BridgeNews (new): news.add / news.remove insert/remove a TownCryerNewsEntry directly in the public NewsEntries list (no stock edit), tracking our own id->entry map so stock uo.com news is left intact. Title, HTML body, image, and URL are all supported (the stock gumps already branch on TextDefinition.Number, so string content renders). On add the article title is also proclaimed via GlobalTownCrierEntryList (announce defaults on; set announce:false to suppress). Config caps: NewsMaxTitleLength/BodyLength/ External, NewsAnnounceDurationSec. Sidecar: POST /news (add/replace, id-correlated), DELETE /news/{id}; news table stores each article as its news.add command; on shard server.hello the sidecar replays the stored set with announce:false (the shard rebuilds NewsEntries each boot and does not persist ours, so the website is the source of truth). Docs: PROTOCOL_2 §16 (design + verified), INTEGRATION.md /news endpoints. Verified live: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors); booted shard + sidecar and exercised add/replace/ remove/error paths and the reconnect replay end-to-end. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 22 ++++++++++++++++++++++ link/PROTOCOL_2.md | 7 ++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 9b2f943..ef9105f 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -477,6 +477,28 @@ DELETE /towncrier/{id} Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`. +### Publish / remove Town Cryer **news** (Protocol 2.1) + +Distinct from the scrolling-crier lines above: this puts a full article — title, HTML body, image, and a "more info" URL — into the in-game **Town Cryer News gump**, and (by default) has the criers proclaim the **title** in-world. + +``` +POST /news +{ "id": "42", "title": "Double XP Weekend", + "body": "
Double XP Weekend


Starts Friday 7PM.", + "image": 1614, "url": "https://yoursite/news/42" } +``` +→ **200** `{"kind":"news.ok","id":"42"}`. Re-posting the same `id` **replaces** the prior article in place. + +- `id`, `title` required. `body` (HTML supported), `image` (a UO gump id; a neutral scroll if omitted), `url` (a browser button in the gump) optional. +- `announce` defaults to **true** — the criers proclaim the title. Send `"announce": false` to post silently (e.g. a correction). + +``` +DELETE /news/{id} +``` +→ **200** `{"kind":"news.ok","id":"42"}`, or **404** `{"kind":"news.error","reason":"unknown id"}`. + +Caps apply (title/body length, max active articles). The **website is the source of truth**: the shard rebuilds its news list on restart and does not persist yours, so the sidecar automatically re-pushes your articles (silently) whenever the shard reconnects. Stock ServUO news is left intact — your articles are tracked separately. + ### Staff moderation — the write plane Account and session moderation against the live shard. **These are privileged.** The sidecar does diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index a3f0d46..7da8a15 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -482,7 +482,7 @@ Deployed the overlay to the ServUO checkout, booted the shard and the real sidec ## 16. Town Cryer news — website articles into the news gump (Protocol 2.1) -**Status:** Design, grounded in the shard's `Scripts/Services/Town Cryer/` files. Not yet built. +**Status:** **Built and smoke-tested live** (2026-07-17). `BridgeNews.cs` (pure overlay, no stock edit) + `POST /news` / `DELETE /news/{id}` + reconnect replay. Verified against a booted shard: `news.add` (full + title-only) → `news.ok`, missing title → 400, idempotent replace, `news.remove` → `news.ok`, unknown id → `news.error`, no shard exceptions, and the **reconnect replay** confirmed (after a shard restart the stored article was re-pushed with `announce:false` and re-accepted). The gump rendering itself is verified by source inspection (needs a UO client to view). There are **two** distinct town-crier surfaces in ServUO, and 2.0 has so far touched only the first: @@ -514,7 +514,7 @@ Everything else in the pasted note stands, and the "this is one of the easier in On an inbound article the bridge does two things on the Core thread: 1. **News gump** — build `new TownCryerNewsEntry(new TextDefinition(title), new TextDefinition(body), image, null, url)` and `Insert(0, …)` at the top of `TownCryerSystem.NewsEntries`, tracking it in `_ours`; trim `_ours` past the cap by removing the oldest (from both `_ours` and `NewsEntries`). -2. **Say the title** — reuse the scrolling-crier path (`GlobalTownCrierEntryList`, as `BridgeTownCrier` does) to announce a single line, the **title only**, for a short duration, so the crier proclaims it in-world. Optional per article (`announce: true`), so silent corrections don't re-proclaim. +2. **Say the title** — reuse the scrolling-crier path (`GlobalTownCrierEntryList`, as `BridgeTownCrier` does) to announce a single line, the **title only**, for a short duration, so the crier proclaims it in-world. **On by default**; set `announce: false` on an article to suppress it (e.g. a silent correction that should not re-proclaim). ### 16.4 Protocol @@ -522,7 +522,8 @@ On an inbound article the bridge does two things on the Core thread: // website → sidecar → shard {"kind":"news.add","id":"42","title":"Double XP Weekend", "body":"
Double XP Weekend


Starts Friday 7PM.", - "image":1614,"url":"https://uomysticmoon.com/news/42","announce":true} + "image":1614,"url":"https://uomysticmoon.com/news/42"} +// announce defaults to true; add "announce":false to suppress the crier proclamation {"kind":"news.remove","id":"42"} ```