From d01a49103fa6f8e92537fc528af7c0d7c943edb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:42:06 -0500 Subject: [PATCH] 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.