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 <noreply@anthropic.com>
449 lines
43 KiB
Markdown
449 lines
43 KiB
Markdown
# 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:<actor>` 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<Account>` 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<int, BaseGuild>` (`Server/Guild.cs:54`) — the whole registry, enumerable on the Core thread. Hold a `Dictionary<int, GuildSnapshot>` (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>`, `CityLoyaltySystem.cs:680`) is the full set, one per city. Sweep, hold `Dictionary<City, governorSerial>`, 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<object>`) + 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<PlayerState>`), 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 `<BaseDir>/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.
|