From d01a49103fa6f8e92537fc528af7c0d7c943edb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:42:06 -0500 Subject: [PATCH 1/8] feat(protocol2): website account provisioning & unlinking (Part A) Adds the account-provisioning plane from docs/PROTOCOL_2.md Part A: the website can create game accounts and unlink them, gated by a shard-wide signup mode. The existing [link flow is unchanged. Overlay: - BridgeConfig: SignupMode (website|game|hybrid, default hybrid; unrecognized falls back to game), AccountCreateEnabled (mode-following default), RequireIpForCreate, name/password caps, and a boot warning when the core Accounts.AutoCreateAccounts setting contradicts the mode. - BridgeAccounts (new): account.create (mode gate, actor required, char-safety mirrored from AccountHandler, collision check, per-IP cap via CanCreate/ LogAccess with fail-closed missing/loopback IP, create + WebsiteUserId link, account.audit; password never logged or echoed) and account.unlink (Owner floor via BridgeAdmin.Protected, clears the tag). - BridgeAccountLink: in-game [unlink command, emits account.unlinked. - BridgeAdmin: Protected / ResolveTargetAccount promoted to public for reuse. Sidecar: - POST /accounts/create, DELETE /link/:account, respond_account status mapping (409 collision / 429 ip cap / 403 disabled|protected / 404 not-linked / 400). - store.record_unlink drops the mirrored link row. - PROTOCOL_VERSION -> 2 (outbound events additive; new endpoints need v2). Docs: INTEGRATION.md protocol bump, account.* events, endpoints, 409/429; PROTOCOL_2.md Part A marked built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 63 ++++++- link/PROTOCOL_2.md | 448 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 link/PROTOCOL_2.md diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index d56f249..27e048f 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -31,16 +31,18 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. -- Every response carries an **`X-UOLink-Version: 1`** header. -- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 1`. -- **Optionally**, send `X-UOLink-Version: 1` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: +- Every response carries an **`X-UOLink-Version: 2`** header. +- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`. +- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: ```json - { "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" } + { "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" } ``` Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. +**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above. + --- ## 3. Health @@ -180,10 +182,12 @@ Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"s | `audit.command` | `staff`, `command`, `args` | A staff command was invoked. | | `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. | -#### Account linking +#### Account linking & provisioning | kind | fields | notes | |------|--------|-------| | `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. | +| `account.audit` | `origin`, `action`, `actor`, `target`, `websiteUserId` | A provisioning action was applied from the site (`origin:"web"`, `actor:"web:"`). `action` is `create` or `unlink`; `target` is the account. Broadcast to every dashboard. **Never carries the password.** Emitted alongside the `account.ok` reply; see §6. | +| `account.unlinked` | `origin`, `account`, `websiteUserId`, `char` | A player ran `[unlink` **in game** (`origin:"in-game"`), severing the tie themselves. Drop the link from any roster you cache and reconcile your own record. | #### Help-page (support) queue | kind | fields | notes | @@ -335,6 +339,48 @@ GET /link/{account} (This reads the sidecar's mirror of confirmed links — no shard round-trip.) +### Create a game account (Protocol 2.0) + +Provision a game account from your signup form and link it to the website user in one step. Requires a **v2** sidecar. Whether this is honored depends on the shard's signup mode (`website`/`hybrid` accept it; `game` refuses). + +``` +POST /accounts/create +{ "actor": "whitlocktech", "account": "bob", "password": "hunter2", + "websiteUserId": "9931", "ip": "203.0.113.7" } +``` + +- `actor` — the website user/staff id, recorded in the audit. Required. +- `account`, `password` — the game-client credentials the player chose. The password is hashed on the shard and **never** appears in any reply, event, or log. +- `websiteUserId` — the site user to auto-link. +- `ip` — **the end user's browser IP**, which you read from your own request context (remote-addr, or a trusted `X-Forwarded-For`). The shard enforces its per-IP account cap with this, exactly as it does for in-game signups. The sidecar cannot see the browser's IP (it only sees your server), so you must send it. + +Responses: + +- Success → **200** `{"kind":"account.ok","action":"create","account":"bob","websiteUserId":"9931"}`. The account exists and is linked; subsequent `mob.login` events carry `webId`. +- Name already taken → **409** `{"kind":"account.error","reason":"account already exists"}`. +- Per-IP cap hit → **429** `{"kind":"account.error","reason":"ip account limit reached"}`. +- Signups disabled for this mode → **403** `{"kind":"account.error","reason":"signups disabled for this mode"}`. +- Missing browser IP (when the shard requires it) → **400** `{"kind":"account.error","reason":"client ip required"}`. +- Bad username/password, or a missing field → **400**. + +Abuse control beyond the per-IP cap (captcha, email verification, signup rate) is your site's responsibility. + +### Unlink an account (Protocol 2.0) + +Sever a game account's tie to its website user, from the site side. Requires a **v2** sidecar. + +``` +DELETE /link/{account} +{ "actor": "whitlocktech" } +``` + +- Success → **200** `{"kind":"account.ok","action":"unlink","account":"bob"}`. The `WebsiteUserId` tag is cleared on the shard and the sidecar's link mirror is dropped, so attribution stops immediately. +- Not linked → **404** `{"kind":"account.error","reason":"not linked"}`. +- Protected staff account → **403** `{"kind":"account.error","reason":"target is protected staff; refused"}`. +- Missing `actor` → **400**. + +A player can also unlink themselves in game with `[unlink`; that emits an `account.unlinked` event (see §4) so you can reconcile your record. + ### Publish / remove town-crier news Push a message that every in-game town crier announces until it expires. @@ -475,8 +521,9 @@ A row survives a sidecar restart (it's in SQLite), so the board reflects the las | 200 | OK | | 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) | | 401 | Missing or invalid auth token | -| 404 | Not found (unknown account / character / id) | -| 409 | Protocol version mismatch (you sent `X-UOLink-Version` and it disagreed) | +| 404 | Not found (unknown account / character / id, or a not-linked account) | +| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` | +| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` | | 500 | Internal error (e.g. database) | | 503 | Shard not connected — the query needs the live game and it's down | | 504 | Shard connected but didn't reply within 10s | @@ -490,7 +537,7 @@ A row survives a sidecar restart (it's in SQLite), so the board reflects the las A typical character page: ```js -const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "1" }; +const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" }; // 1. render the roster const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md new file mode 100644 index 0000000..fef8646 --- /dev/null +++ b/link/PROTOCOL_2.md @@ -0,0 +1,448 @@ +# Protocol 2.0 — Provisioning & World-State Streams + +**Status:** Part A **built** on branch `feat/protocol2-account-provisioning` (2026-07-17), compiles clean both sides. Part B is design. +**Date:** 2026-07-17 +**Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**. +**Companion to** [`PLAN.md`](PLAN.md) (read/event plane), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), and [`INTEGRATION.md`](INTEGRATION.md) (website API). + +Protocol 1.0 shipped the read/event plane, the request/reply plane, `[link` account linking, town-crier, the player-vendor-sale core edit, the admin write plane, and the help-page queue. + +2.0 has **two scope areas**: + +- **A — Account provisioning & unlinking (§1–§9).** The website can **create** accounts, **unlink** them, and the shard runs in one of three **signup modes** that decide which side may mint accounts. (1.0 could only *link* an account that already existed, and a link could never be undone.) +- **B — Social & political world-state streams (§10–§11).** Guilds, town governors ("mayors"), factions/VvV, and player titles — the standings a website community page wants. §10 specs the requested streams; §11 is a menu of further integration points to pick from. + +--- + +# Part A — Account provisioning & unlinking + +--- + +## 1. What exists today, and the gap + +| Capability | 1.0 | 2.0 | +|------------|-----|-----| +| Create account in-game (first-login auto-create) | ✔ `AccountHandler.cs:281` | unchanged | +| Link an **existing** game account to a website user | ✔ `[link` → `link.confirm` | unchanged | +| **Create** a game account from the website | ✗ | **new** `account.create` | +| **Unlink** a game account from its website user | ✗ | **new** `account.unlink` + `[unlink` | +| Choose which side may create accounts | ✗ (always in-game) | **new** signup mode | + +**The linking flow is not changing.** `[link`, the one-time code, `link.confirm`, and the `WebsiteUserId` tag all stay exactly as they are (`BridgeAccountLink.cs`). 2.0 only *adds* verbs alongside them. + +### The account-creation facts that shape this + +- `new Account(username, password)` self-registers — its constructor calls `Accounts.Add(this)` (`Account.cs:186`) and `SetPassword` hashes per the shard's `AccountHandler.ProtectPasswords` (`Account.cs:174`). So creating an account from the bridge is `new Account(un, pw)` plus the link tag — no extra persistence layer, same as the `[link` tag reaching disk on the next world save. +- ServUO's in-game auto-create is gated on the **core** config `Accounts.AutoCreateAccounts` (default `true`, read once in `AccountHandler`'s static init, `AccountHandler.cs:29`). The bridge cannot intercept that path without a core edit, so the signup mode governs the **bridge's** `account.create` verb; the in-game side is controlled by pairing it with the matching core config (see §3). +- The core `CreateAccount` path (`AccountHandler.cs:494`) validates the username/password character set (printable ASCII `0x20–0x7F`, no forbidden chars) and enforces `MaxAccountsPerIP` (`Accounts.AccountsPerIp`, default **1**). The website path has **no `NetState`**, so the browser IP must be **passed through explicitly** to enforce that same cap (§3.1); and it must reuse the **character-safety** validation before `new Account`, or it can mint an account no client can log into (or that corrupts serialization). +- There is **no `EventSink.AccountCreated`**. The create path is silent. This is why in-game→website creation sync is an open item, not committed scope (§7). + +--- + +## 2. Signup modes — the model + +A single shard-wide setting, `Bridge.SignupMode`, with three values. **Default `hybrid`.** + +| Mode | `account.create` from website | In-game first-login auto-create | Who is the account authority | +|------|:-----------------------------:|:-------------------------------:|------------------------------| +| `website` | **accepted** | should be **off** | the website | +| `game` | **rejected** (`account.error`) | **on** | the game server | +| `hybrid` *(default)* | **accepted** | **on** | either side | + +The bridge enforces exactly one half of this: whether it **honors `account.create`**. The other half — in-game auto-create — is the core `Accounts.AutoCreateAccounts` config, which the operator sets to match: + +| `Bridge.SignupMode` | pair with `Accounts.AutoCreateAccounts` | +|---------------------|-----------------------------------------| +| `website` | `false` — otherwise any client that types a new name still mints an account, defeating website-only | +| `game` | `true` | +| `hybrid` | `true` | + +On boot the bridge **reads `Accounts.AutoCreateAccounts` and warns** if it contradicts the selected mode (e.g. `SignupMode=website` while auto-create is still on), so a half-configured shard is loud, not silently permissive. The bridge does not try to flip the core setting — it only detects and reports the mismatch, the same defensive posture `BridgeConfig.ParseAccessLevel` already takes. + +**Reconciliation in `hybrid`.** Both paths can race for the same username. `account.create` resolves it the only correct way: `Accounts.GetAccount(un) != null` → refuse with `account.error "account already exists"`. First writer wins; the loser gets a clean error, never a duplicate. + +--- + +## 3. `account.create` — website-driven provisioning + +The website has already authenticated and authorized the user (its own signup form). It hands the shard a username, the password the player chose, and the website user id, and asks for an account that is created **and linked in one step** — no code exchange, because the website *is* the authority here (unlike `[link`, where the game side proves ownership with a code). + +### Request (website → sidecar → shard) + +```json +{ "kind": "account.create", "reqId": "c1", "actor": "whitlocktech", + "account": "bob", "password": "hunter2", "websiteUserId": "9931", + "ip": "203.0.113.7" } +``` + +- `reqId` — correlation id, echoed on the reply (as everywhere else). +- `actor` — the website user/staff id, for the audit line. Required, non-empty (mirrors the admin plane). +- `account` — desired username. +- `password` — the game-client password the player chose on the site. Plaintext over the **loopback + token** socket, the same trust boundary every inbound verb already relies on; the shard hashes it via `SetPassword` immediately. +- `websiteUserId` — the site user to auto-link. +- `ip` — the **end user's browser IP**, so the shard can enforce `MaxAccountsPerIP` on website signups exactly as it does on in-game first-login. The website reads this from its own request context (remote-addr, or a trusted `X-Forwarded-For`); the **sidecar cannot derive it** — the sidecar only sees the website's connection IP, not the browser's, so this must be an explicit field. See §3.1. + +### Shard behavior (Core thread, in `BridgeAccounts.cs`) + +1. **Gate.** `SignupMode == game` → `account.error "signups disabled for this mode"`. Master switch `Bridge.AccountCreateEnabled` (default follows mode) must be on. +2. **Validate `actor`** present (as admin plane does). +3. **Validate username/password** with the same character-safety rules as `AccountHandler.CreateAccount` (printable ASCII, no leading/trailing space, no trailing dot, no forbidden chars). Enforce length caps from config. +4. **Collision check.** `Accounts.GetAccount(account) != null` → `account.error "account already exists"`. +5. **IP cap.** Parse `ip` → `IPAddress`. If `RequireIpForCreate` and it is missing/unparseable/loopback → `account.error "client ip required"` (**fail closed** — a missing IP must never silently bypass the cap; loopback is exempt in `IPLimiter`, so accepting it *is* a bypass). Then `AccountHandler.CanCreate(ip) == false` → `account.error "ip account limit reached"`. This is the same read-side check the in-game path runs at `AccountHandler.cs:510`. +6. **Create + link atomically.** `var a = new Account(account, password); a.LogAccess(ip); a.SetTag("WebsiteUserId", websiteUserId);` — `LogAccess` bumps `AccountHandler.IPTable[ip]` and records the IP into `LoginIPs` (`Account.cs:1251`), which is exactly what an in-game first-login does, so the per-IP count is both live-accurate and durable (it rebuilds from `LoginIPs[0]` on reboot). The `WebsiteUserId` tag persists to `accounts.xml` on the next world save, identical to the `[link` path. +7. **Reply** `account.ok` and **emit** an unsolicited `account.audit` (`origin:"web"`, `action:"create"`) to every dashboard, parallel to `admin.audit`. + +### Reply + +```json +{ "kind": "account.ok", "reqId": "c1", "action": "create", + "account": "bob", "websiteUserId": "9931" } +{ "kind": "account.error", "reqId": "c1", "reason": "account already exists" } +``` + +### 3.1 The IP flow — who sees what + +``` +browser ──HTTP signup──► website ──POST /accounts/create──► sidecar ──account.create──► shard + (real IP) (sees browser IP) (sees WEBSITE's IP, not browser's) (enforces cap) +``` + +The chain hops hosts, so the only party that sees the **end user's** IP is the website, at the edge. By the time the request reaches the sidecar, the socket's peer address is the *website*, not the player — which is why `ip` is a body field, not something the sidecar reads off the connection. The website populates it from its request context (remote-addr, or `X-Forwarded-For` from a proxy it trusts). + +Two consequences to state plainly: + +- **The IP is only as trustworthy as the website's proxy handling.** A compromised or misconfigured website could send a spoofed or wrong IP. That is already inside the 2.0 trust boundary (the website is trusted via loopback + token), but it means the per-IP cap is an *honesty* control against ordinary multi-account signups, not a hard security boundary against a hostile website. +- **IPv4/IPv6 skew.** A browser may present IPv6 while the UO client connects over IPv4; the two are different `IPAddress` keys, so a website account and a later in-game account from the "same" person may not share an `IPTable` bucket. Inherent to keying on raw IP — noted, not solved. + +The sidecar itself does **not** validate or transform `ip`; it forwards the field and lets the shard (which owns `IPTable`) decide. If a shard wants the sidecar to reject obviously-bad input early, that is a later refinement, not required for correctness — the shard fails closed regardless. + +### Sidecar route + +`POST /accounts/create`, body `{actor, account, password, websiteUserId, ip}` → the inbound line, correlated on a fresh `reqId`. Status mapping (new `respond_account`, modeled on `respond_admin`): + +| Reply / reason | HTTP | +|----------------|------| +| `account.ok` | 200 | +| `"account already exists"` | 409 Conflict | +| `"ip account limit reached"` | 429 Too Many Requests | +| `"signups disabled…"` | 403 | +| `"client ip required"`, `"invalid username/password"`, missing field | 400 | +| shard down / timeout | 503 / 504 | + +> ⚠️ The password is a secret in a request body and a shard reply. Keep it off the WebSocket broadcast entirely: `account.audit`/`account.ok` **never carry the password**, and the console/audit log records only `account` + `actor`. This is the same discipline `BridgeEvents` already applies to the plaintext `AccountLoginEventArgs.Password` it deliberately never forwards (`PLAN.md` §12). + +--- + +## 4. Unlinking + +Symmetric with `[link`: either side can sever the tie. Both paths do the same one thing — remove the `WebsiteUserId` account tag (`acct.RemoveTag("WebsiteUserId")`) — and both persist on the next world save. + +### 4.1 Website → `account.unlink` + +```json +{ "kind": "account.unlink", "reqId": "u1", "actor": "whitlocktech", "account": "bob" } +``` + +- Resolve by `account` (username) or `serial` (a player mobile's account), reusing `BridgeAdmin.ResolveTargetAccount`. +- Not linked → `account.error "not linked"` (a no-op is reported honestly, not faked as success). +- Apply the **Owner floor** (`BridgeAdmin.Protected`): refuse to unlink an account at/above `AdminAccessFloor`, same defense-in-depth as the admin verbs. +- Reply `account.ok action:"unlink"`; emit `account.audit action:"unlink"`. + +Sidecar: `DELETE /link/{account}` (the existing `/link/:account` GET already looks a link up; this adds the delete verb next to it) → also clears the sidecar's mirrored link row (`store.record_unlink`), so event attribution stops immediately without waiting on the shard. + +### 4.2 In-game → `[unlink` + +`CommandSystem.Register("unlink", AccessLevel.Player, …)` in `BridgeAccountLink.cs`, next to `[link`: + +- Reads the caller's own account, clears the tag, emits `account.unlinked` (so the site learns of a player-initiated unlink and can reconcile its own record). +- Player-scoped: a player can only unlink **their own** account (no target argument), so it needs no floor. +- Symmetric UX with `[link`: `"Your account is no longer linked."` + +> **Note — `[link` behavior is unchanged.** `[link` still refuses when a tag already exists (`BridgeAccountLink.cs:96`). `[unlink` is what clears it; after unlinking, `[link` works again. That is the whole interaction, and it needs no change to the existing link code — only the new command beside it. + +--- + +## 5. Trust & attribution + +Identical model to the admin write plane (`ADMIN_CONTROLS.md` §5), because these are the same shape of action (website-authorized, applied on the loopback socket): + +- **Authorization lives on the website.** `account.create`/`unlink` are gated behind the site's own roles (self-service signup for create; admin/self for unlink). The shard trusts the loopback + token socket and the required `actor` field. +- **Owner floor** applies to `account.unlink` (never unlink a protected staff account from the web). +- **Attribution** is the `web:` string in the console line and the `account.audit` frame; the website keeps its own durable record, as it already does for `admin.audit`. +- **`account.create` now enforces `MaxAccountsPerIP`** using the browser IP the website forwards (§3.1), via the same `CanCreate` / `LogAccess` path as in-game first-login. But the cap is only as honest as the website's IP reporting, and it fails **closed** on a missing/loopback IP when `RequireIpForCreate` is on. Higher-order abuse control (captcha, email verification, per-account-per-day) remains the website's job — the shard cap is a floor, not the whole defense. + +--- + +## 6. Config keys (`Config/Bridge.cfg`) + +```ini +SignupMode=hybrid # website | game | hybrid (default hybrid) +AccountCreateEnabled=true # master switch for account.create; auto-off when SignupMode=game +RequireIpForCreate=true # fail closed if account.create omits a usable browser IP +AccountNameMaxLength=16 +AccountPasswordMaxLength=30 +``` + +Read in `BridgeConfig.Load()`, re-readable via `[bridge reload`. `SignupMode` parses like `AdminAccessFloor` — unrecognized value falls back to the safest option (`game`, i.e. no website creation) with a console warning, so a typo can never accidentally open provisioning. `RequireIpForCreate` defaults **on**: the per-IP cap only means something if a missing IP is refused rather than waved through. Turn it off only for a deployment that deliberately does not cap website signups by IP (and then `MaxAccountsPerIP` still applies in-game as before). + +--- + +## 7. Open item (not committed) — in-game → website creation sync + +Per the 2026-07-17 decision, **this is not in 2.0's committed scope.** When an account is created *in-game* (first-login auto-create, or staff `[AddAccount`), the website is **not** notified today, and 2.0 does not change that. Recorded here so the tradeoff is explicit, not forgotten: + +- **Why it's hard cleanly:** there is no `EventSink.AccountCreated`. The only faithful tap is a core edit — an `Action` raised in the `Account(string, string)` ctor (safe: the load path is a *separate* ctor, `Account.cs:189`, so it won't fire during world load), shipped as a `patches/` diff exactly like `PlayerVendorSale` and the `CommandLogging` event. +- **Why it may not be needed:** in `website`-mode the website already knows every account (it created them). Sync only matters for `hybrid`/`game` modes where the website wants a roster of game-born accounts — and even then the sidecar can approximate "new account" from the `mob.login` `acct` field it already receives (first-seen = new), lossy but zero core edits. +- **If we do it later:** it becomes an `account.created` event stream (`origin:"in-game"`), the natural mirror of the `account.audit` (`origin:"web"`) that `account.create` emits — the same bidirectional-audit shape §5.5 of `ADMIN_CONTROLS.md` established. Revisit if a shard chooses `hybrid`/`game` and wants a complete website roster. + +--- + +## 8. Where the code goes + +| File | Responsibility | +|------|----------------| +| `overlay/Scripts/Custom/Bridge/BridgeAccounts.cs` | **New.** Registers `account.create` and `account.unlink`; the create+link, char-safety validation, collision check, Owner floor on unlink, `account.audit` emission. Mirrors `BridgeAdmin.cs` structure. | +| `overlay/Scripts/Custom/Bridge/BridgeAccountLink.cs` | **Extend.** Add the `[unlink` player command beside `[link`. No change to existing link behavior. | +| `overlay/Scripts/Custom/Bridge/BridgeConfig.cs` | **Extend.** `SignupMode` (parsed, safe fallback), `AccountCreateEnabled`, `RequireIpForCreate`, name/password length caps; read `Accounts.AutoCreateAccounts` and warn on mode mismatch. | +| `overlay/Scripts/Custom/Bridge/BridgeBoot.cs` | **Extend.** Wire `BridgeAccounts.Initialize()` into `Initialize()` (one line, beside the other subsystems). | +| `overlay/Config/Bridge.cfg` + `.example` | **Extend.** The §6 keys, defaults documented. | +| `sidecar/src/web.rs` | **Extend.** `POST /accounts/create` (forwards `ip` from the body untouched), `DELETE /link/:account`; `respond_account` status mapping (409 on collision, 429 on IP cap, 400 on missing IP); scrub password from any logged/broadcast value. | +| `sidecar/src/store.rs` | **Extend.** `record_unlink` (clear the mirrored link row) beside the existing `record_link`. | +| `docs/INTEGRATION.md` | **Extend.** Document `POST /accounts/create`, `DELETE /link/{account}`, and the `account.audit` event. | +| *(website, separate repo)* | Signup form → `POST /accounts/create`; unlink control → `DELETE /link/{account}`; consume `account.audit`. | + +No new core/stock edits in committed scope — `account.create`/`unlink` are all script-layer (`new Account`, `SetTag`/`RemoveTag`) called from the new overlay. The only core edit contemplated (the `AccountCreated` event, §7) is explicitly deferred. + +--- + +## 9. Phasing + +1. ~~**Config + modes.**~~ **Done.** `BridgeConfig` gains `SignupMode` (parsed, unrecognized → `game`), `AccountCreateEnabled` (mode-following default), `RequireIpForCreate`, name/password caps, and the boot-time `Accounts.AutoCreateAccounts` mismatch warning. `[bridge status` shows `signup=…(create=…)`. +2. ~~**`account.create`.**~~ **Done.** `BridgeAccounts.cs` + `POST /accounts/create` + `respond_account`. Gate on mode, `actor` required, char-safety mirrored from `AccountHandler`, collision → 409, IP cap via `CanCreate`/`LogAccess` (fail-closed on missing/loopback IP when `RequireIpForCreate`), create + link, `account.audit`, password never logged/echoed. *Acceptance below is written for a live run — not yet exercised end-to-end.* +3. ~~**Unlink — both surfaces.**~~ **Done.** `account.unlink` + `DELETE /link/:account` + `store.record_unlink`, and the in-game `[unlink`. Owner floor reuses `BridgeAdmin.Protected`; `[unlink` emits `account.unlinked`. +4. ~~**Docs.**~~ **Done.** `INTEGRATION.md` §2 (protocol bumped to **2**), §4 (`account.audit`/`account.unlinked`), §6 (`POST /accounts/create`, `DELETE /link/{account}`), §7 (409/429). + +**Build verification (2026-07-17):** sidecar `cargo check` clean; overlay compiled in the full ServUO Scripts tree — **0 errors, 0 warnings**. **Live end-to-end run still pending** (needs a booted shard + sidecar): create+link in website/hybrid, game-mode refusal, duplicate 409, the per-IP cap holding (second create same `ip` → 429, different IP succeeds, omitted IP → 400 while `RequireIpForCreate`), `LoginIPs[0]`/`IPTable` incremented, and unlink clearing tag+mirror with the Owner floor refusing a protected target. + +Deferred (revisit only if a shard needs it): the §7 in-game→website `account.created` sync; the §12.3 `account.setpassword`/`account.exists` siblings; §12.5 credential-verb rate limiting. + +--- + +# Part B — Social & political world-state streams + +These are **outbound** streams (shard → website), the natural extension of `PLAN.md`'s event/sweep plane. None needs a write plane; all reuse the transport, the bounded queue, and the sweep/emit-on-change discipline `BridgeSweeps` already established. Each entry below states its **grounded hook situation** so nothing rides an event that doesn't fire. + +## 10. The requested streams + +### 10.1 Guilds + +**Hook reality (verified):** + +- `EventSink.JoinGuild` is real — raised at `Scripts/Misc/Guild.cs:1597` when a mobile joins a guild. Usable as a live `guild.join`. +- `EventSink.CreateGuild` is **not** a creation notification. It is the load-time deserialization factory: raised only from `Server/World.cs:517` while reading the guild index at boot, where the handler's job is to *construct* the guild instance (`Guild.cs:775` → `new Guild(args.Id)`). Player guild creation (`new Guild(pm, name, abbrev)` at `Create Guild Gump.cs:83`, `GuildDeed.cs:127`) raises **no event**. **Do not use `CreateGuild` for "a guild was created"** — it would fire once per guild at every boot and never on an actual new guild. +- Leave, disband, leader change, alliance change, rename: **no events.** + +**Delivery — a guild sweep + diff, exactly like house decay (`PLAN.md` §5.4).** `BaseGuild.List` is a `Dictionary` (`Server/Guild.cs:54`) — the whole registry, enumerable on the Core thread. Hold a `Dictionary` (name, abbreviation, leader serial, member count, alliance name, member-serial set hash). On each sweep, diff: + +- id present now, absent before → `guild.created` +- id absent now, present before → `guild.disbanded` +- leader / alliance / name / abbreviation changed → `guild.updated` +- member set grew/shrank → `guild.join` / `guild.leave` (the sweep is the reliable source for leaves; `EventSink.JoinGuild` can *also* emit an immediate `guild.join` for joins, with the sweep as the backstop) + +Take a **silent baseline** on `ServerStarted` (populate without emitting), same as decay, or every guild re-announces on every boot. Cost is trivial — a shard has tens to low-hundreds of guilds, and reading `Members.Count` + `Leader` is a handful of field reads each. + +```jsonc +{"kind":"guild.created","id":1234,"name":"The Silver Hand","abbr":"TSH", + "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"},"members":14,"alliance":null} +{"kind":"guild.leave","id":1234,"who":{"serial":"0x77","name":"Bran"},"members":13} +{"kind":"guild.disbanded","id":1234,"name":"The Silver Hand"} +``` + +> If real-time (not next-sweep) leave/disband ever matters, the clean tap is a one-line `patches/` hook in `Scripts/Misc/Guild.cs` `RemoveMember`/`OnDelete` — the Phase-7 `patches/` precedent. Start with the sweep; add the patch only if latency is a real complaint. Guild membership does not move fast enough to justify it up front. + +### 10.2 Town governors ("mayors") + +In modern ServUO the "mayor of a town" is the **Governor** in the City Loyalty System (King Blackthorn's governance). Each `City` (enum, `CityLoyaltySystem.cs:15`) has a `CityLoyaltySystem` instance carrying `Governor` (Mobile), `GovernorElect`, an `Election`, a `Citizens` count, and a herald. The `Governor` setter already broadcasts a herald message on change (`CityLoyaltySystem.cs:193`), confirming a governor transition is a first-class in-game event — there just isn't an `EventSink` for it. + +**Delivery — a city sweep, emit-on-change.** `CityLoyaltySystem.Cities` (static `List`, `CityLoyaltySystem.cs:680`) is the full set, one per city. Sweep, hold `Dictionary`, emit on transition. Governors change on the order of weeks — a slow sweep (e.g. 5 min, or fold into the economy sweep cadence) is ample. Also emit election open/close and, optionally, the standing. + +```jsonc +{"kind":"city.governor","city":"Britain","from":{"serial":"0x55","name":"Old Mayor"}, + "to":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech"}} +{"kind":"city.election","city":"Moonglow","phase":"nominate","candidates":3,"endsAt":"2026-07-24T…"} +``` + +> **Gate on `CityLoyaltySystem.Enabled`** (`CityLoyalty.Enabled`, default true). If a shard runs its own custom town-ownership system instead, this sweep should no-op — detect and log, don't assume. + +### 10.3 Player titles + +There is **no title-change event.** Titles are read-model state, best delivered two ways, not as a stream: + +- **Enrich `char.profile`** (`BridgeProfile`) with a `titles` block. Sources on a `PlayerMobile`: the reward-title list `m_RewardTitles` (`List`) + the selected index `m_SelectedTitle` (`PlayerMobile.cs:4194,4595`), the champion title `m_CurrentChampTitle`, plus the computed titles from `Titles.ComputeTitle` / `ComputeFameTitle` / `GetSkillTitle` / veteran titles (`Scripts/Misc/Titles.cs`). `char.profile` already carries all-skills, so titles slot in beside it at near-zero extra cost, and it is a *read* — no hook needed. +- **Optional `title.change`** only if the community page wants a live "so-and-so is now *Grandmaster Blacksmith*" feed — and then it comes from a **profile-diff in the sidecar**, not a shard event (the shard has nothing to subscribe to). Recommend starting with profile enrichment; add the diff feed only if there is demand. + +City titles and faction/VvV merchant titles (`CityLoyaltySystem.ApplyCityTitle`, `MerchantTitles.cs`) fold into the same `titles` block. + +### 10.4 Factions / Vice vs Virtue + +**Which system is live is a shard decision — verify before building.** Two exist: + +- **Old Factions** (`Scripts/Services/Factions`): `Faction.Commander` (leader, `Faction.cs:160`), `Faction.Election`, `Faction.Members` (`List`), and faction-controlled **Towns** (`Town.cs` — each town has an owning faction, a sheriff, and finance). Config-gated and, on most modern shards, **off**. +- **Vice vs Virtue** (`Scripts/Services/ViceVsVirtue`): the modern replacement. `ViceVsVirtueSystem.Enabled` (`VvV.Enabled`, default **true**), a singleton `Instance`, an active `Battle`, and per-player `VvVPlayerEntry` (score, kills, assists). City control in VvV rides the same city-loyalty/governor rails as §10.2. + +**Delivery — a sweep, gated on whichever is enabled.** Neither system raises membership/leadership `EventSink`s, so it is the same sweep+diff pattern: + +- VvV (recommended default): standings per side, active-battle status (`Battle.OnGoing`, current city), and the top `VvVPlayerEntry` scores → a `vvv.standings` snapshot on change + a `vvv.battle` open/close event. +- Old Factions (only if a shard runs it): `faction.control` (town → owning faction on change), `faction.commander` (leader change from the `Election`). + +```jsonc +{"kind":"vvv.battle","phase":"start","city":"Britain","map":"Felucca","endsAt":"2026-07-17T…"} +{"kind":"vvv.standings","order":142000,"chaos":138500,"leaderSide":"Order"} +``` + +> Start by detecting which system is enabled at boot and streaming only that one; emit a one-time `world.systems` frame (what's on: cityLoyalty, vvv, factions) so the website renders the right panels instead of guessing. + +## 11. Further integration points — a menu to pick from + +Everything below is grounded in a hook or a cheap sweep in *this* server. Ranked roughly by value-to-effort. **Pick the ones you want and I'll fold them into the phasing.** (✔ = a real `EventSink` exists; ⟳ = sweep/diff; ⚑ = needs a small `patches/` core tap.) + +| # | Stream | Source | Effort | Why it's worth it | +|---|--------|--------|:------:|-------------------| +| 1 | **Who's-online / population** | ⟳ online sweep over `NetState.Instances` | low | A live "N players online", per-facet population, and a history series. The single most-asked-for website widget. | +| 2 | **Region presence** | ✔ `EventSink.OnEnterRegion` (`Region.cs:1160`, player-filtered) | low | Cheap location stream → town population heatmap, "who's in Despise" — `PLAN.md` §5.6 already flags it as the right answer over `Movement`. | +| 3 | **Crafting feed** | ✔ `EventSink.CraftSuccess` | low | Who crafted what, exceptional/runic — a crafting economy + "notable crafts" feed. | +| 4 | **Taming feed** | ✔ `EventSink.TameCreature` | low | New tames, esp. rares/greaters — high community interest. | +| 5 | **Resource harvesting** | ✔ `EventSink.ResourceHarvestSuccess` | low-med | Mining/lumber/fishing volume → the raw-material side of the economy (pairs with the vendor/gold streams already shipped). | +| 6 | **Virtue progression** | ✔ `EventSink.VirtueLevelChange` | low | Knight/Seeker/etc. virtue ranks — a progression badge system. | +| 7 | **Bulk Order Deeds** | ✔ `EventSink.BODOffered` / `BODUsed` | low | BOD turn-ins and rewards — a crafting-endgame feed and reward-title source. | +| 8 | **Guild wars** | ⟳ from the §10.1 guild sweep (war state on `Guild`) | low | Declared/active/ended wars between guilds — a PvP politics board, nearly free once guilds sweep. | +| 9 | **Player housing registry** | ⟳ extend the existing decay sweep to a full house list | med | Owner → houses map, "houses for sale" (via vendor data already streamed), a housing map. Reuses `PLAN.md` §5.4 machinery. | +| 10 | **Peerless / boss / rare drops** | ⚑ virtual-override or drop-system tap (no `EventSink`) | med | An "epic loot" feed. Honest cost: no clean event (same gap as per-hit damage, `PLAN.md` §5.9) — needs a targeted `patches/` hook, so it is a deliberate pick, not a freebie. | +| 11 | **Secure player trades** | ⚑ `SecureTrade` completion has no `EventSink` | med | Player-to-player item/gold transfers → economy + fraud signal, complements the vendor-sale core edit. Needs a core tap. | +| 12 | **Champion spawn *board*** | already shipped (`BridgeChamps`) — extend, don't rebuild | — | Champs are done in 1.0. Listed so it is not re-proposed; any gap is an extension of the existing sweep. | + +**Selected for Part B (owner pick, 2026-07-17):** guilds (§10.1) + governors (§10.2) + who's-online (#1) + region presence (#2) + **housing registry (#9)** + titles (§10.3, free as profile enrichment). All reuse the sweep pattern and need no core edit; together they give a website its "living world" page — population, guild politics, town leadership, and a housing map. Factions/VvV (§10.4) is deferred until you confirm which system your shard runs. The phasing is §13. + +### Where the Part B code goes + +| File | Responsibility | +|------|----------------| +| `overlay/Scripts/Custom/Bridge/BridgeSocial.cs` | **New.** The guild sweep+diff and the `EventSink.JoinGuild` subscription → `guild.*`. | +| `overlay/Scripts/Custom/Bridge/BridgeGovernance.cs` | **New.** The city sweep → `city.governor`/`city.election`; the VvV/faction standings sweep (gated on enabled) → `vvv.*` / `faction.*`; the one-time `world.systems` frame. | +| `overlay/Scripts/Custom/Bridge/BridgeProfile.cs` | **Extend.** Add the `titles` block to `char.profile`. | +| `overlay/Scripts/Custom/Bridge/BridgeSweeps.cs` | **Extend / mirror.** New sweep timers (guild, city, presence), re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`, counters in `[bridge status` — same shape as the existing sweeps. | +| `overlay/Config/Bridge.cfg` | **Extend.** `GuildSweepSeconds`, `CitySweepSeconds`, `PresenceSweepSeconds` (+ enable flags). | +| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the snapshots that back boards (guild roster, governors, population history); `GET /guilds`, `/governors`, `/online` served from the store so they survive a shard outage, exactly like `/champs` and `/economy` do today. | +| `docs/INTEGRATION.md` | **Extend.** New event catalog entries + the read endpoints. | + +--- + +## 12. Cross-cutting additions (recommended) + +Five things that are not new *streams* but make 2.0 correct and complete. The first two I consider **essential**; the rest are high-value companions to what's already specced. + +### 12.1 Bump the protocol version to 2 — **essential** + +The sidecar is `PROTOCOL_VERSION = 1` (`sidecar/src/main.rs:23`), and every response carries `X-UOLink-Version`; the gate 409s a client that declares a different one (`web.rs:146`). 2.0 adds inbound verbs (`account.create`, `account.unlink`, …) and event kinds, so it must bump to `2`. + +The compatibility rule to write down: **new outbound event kinds are additive** — a 1.x website ignores unknown kinds and keeps working, so the live feed stays backward-compatible. What is *not* compatible is a client that calls a **new inbound verb** against an old sidecar, or a new sidecar that a strict old client rejects on the version header. So: bump to `2`, keep the feed additive, and document that the new *verbs/endpoints* require a v2 sidecar while the *event feed* degrades gracefully. + +### 12.2 Every diff stream needs a REST snapshot companion — **essential** + +The Part B streams are **diff-based**: `guild.created`/`disbanded`, `city.governor`, housing changes emit only on transition (like house decay). That means a website that connects fresh — or a **sidecar that restarts** — has seen *no* deltas yet and therefore has **no current state**. The live feed alone can never answer "what are the guilds *right now*." + +So every board-backed stream ships with a REST snapshot served from the sidecar's store, exactly as `/champs` and `/economy` already are (`web.rs`): `GET /guilds`, `/governors`, `/online`, `/houses`. The shard emits deltas; the sidecar persists the latest snapshot; the website hydrates from REST on load and then live-updates from the feed. This is the single most important robustness rule for Part B — without it, a sidecar restart silently blanks the community page until the next guild happens to change. + +> Concretely: the sidecar keeps a `guilds` / `governors` / `population` table updated from the stream (upsert on each delta, plus a periodic full snapshot the shard can push), and the REST route reads that table. The shard should also support an on-demand full re-emit (a `snapshot.request` inbound, or just re-run the sweep with baseline suppression off) so a sidecar that lost its store can rebuild. + +### 12.3 Round out the provisioning surface — password reset, existence check + +`account.create` sets the account's **initial** password (§3) — that part is done. What it does not cover is the rest of the credential lifecycle. Two small siblings close it, both trivially grounded: + +- **`account.setpassword`** — the **later** password *change/reset* for an account that already exists (a player who forgot theirs), distinct from the initial password `account.create` sets. `acct.SetPassword(newpw)` (`Account.cs:676`) is public; the verb takes `{actor, account, password}`, applies the Owner floor, emits `account.audit action:"setpassword"`, and — like create — **never echoes the password**. `POST /accounts/{account}/password`. Only worth building if the site will offer a "forgot password" flow. +- **`account.exists`** — the signup form wants to say "that name is taken" before submit. A read: `Accounts.GetAccount(un) != null`. `GET /accounts/{account}` → `{exists: true|false, linked: bool}`. Cheap, and it prevents the worse UX of finding out via a 409 on submit. + +Both reuse the `account.*` machinery from Part A verbatim. `account.setpassword` is the higher-value of the two. + +### 12.4 Mirror hygiene — deletion & link teardown + +If the website mirrors rosters/links (it does — `store.record_link`), it must learn when the game side removes things, or the mirror rots: + +- **Character deletion.** `EventSink.DeleteRequest` (`EventSink.cs:1754`) fires when a player deletes a character at the select screen. Emit `char.deleted` so the website drops it from any roster it caches. (`PLAN.md` §5.1 already lists this hook as a roster-honesty signal — 2.0 is where it earns its place, now that the website keeps rosters.) +- **Account deletion.** `Account.Delete()` exists (`Account.cs:642`); an optional `account.delete` verb (Owner-floor-guarded, `origin:"web"` audit) closes the lifecycle. Lower priority — most shards ban rather than delete — but list it so the option is on record. +- On any unlink **or** account delete, the sidecar clears its link mirror (the `record_unlink` already specced in §4.1), so event attribution stops immediately. + +### 12.5 Rate-limit the credential verbs + +`account.create` and `account.setpassword` mint/change persistent credentials. The per-IP cap (§3.1) blocks multi-accounting from one IP, but a compromised or buggy website could still hammer distinct IPs. Add a **sidecar-side rate limit** on the credential verbs — a global create-per-minute ceiling and a per-`actor` cooldown — mirroring the caps philosophy town-crier and the admin plane already follow (`BridgeConfig.TownCrier*`, `Admin*`). Cheap insurance; the shard stays the last line of defense (collision + IP cap), the sidecar is the first. + +--- + +## 13. Part B phasing + +1. **Guilds + governors.** `BridgeSocial.cs` (guild sweep + `JoinGuild`) and `BridgeGovernance.cs` (city sweep), their `GuildSweepSeconds`/`CitySweepSeconds` config, and the `world.systems` frame. Ship with their REST snapshots (`GET /guilds`, `/governors`, §12.2) from day one — a diff stream without its snapshot is half-built. +2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store. +3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.) +4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. +5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. + +Cross-cutting, lands with Phase 1: the **protocol bump to 2** (§12.1) and the **snapshot-companion rule** (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the `account.*` surface. + +--- + +## 14. Built-in reports — replace the FTP/HTML path with JSON over the sidecar + +ServUO ships a **Reports engine** (`Server.Engines.Reports`, `Scripts/Services/Reports/`) that already compiles exactly the dashboard data a website wants — it just delivers it the way RunUO did in 2004: render static HTML and **FTP it to your website**. The bridge can tap the *compiled data* directly and ship JSON, retiring the file/FTP path entirely. **No core edit** — the compile methods are `public static`. + +### 14.1 What the engine produces (verified) + +`Reports.Generate()` runs hourly on the Core thread and builds a `Snapshot` from public static compile methods (`Reports.cs`): + +| Method | Returns | Content | +|--------|---------|---------| +| `CompileGeneralStats()` | `Report` | NPCs, Players, Clients, Accounts, Items | +| `CompileStatChart()` | `Chart` | population over time | +| `CompileSkillReports()` | `PersistableObject[]` | **skill distribution — GM count per skill** | +| `CompileFactionReports()` | `PersistableObject[]` | faction membership / stats | +| `Reports.StaffHistory` | `StaffHistory` | staff activity per account (`StaffInfo`/`UserInfo` hashtables), **help-page-queue length over time** (`QueueStats`), page history | + +Each `Report` is structured (`Columns` + `Items`), so it serializes to JSON cleanly with the hand-rolled `BridgeJson` writers — no reflection serializer. The engine also persists an hourly **`SnapshotHistory`** series to disk, so a backfill of historical points is available if wanted. + +### 14.2 How it's delivered today (the file path you flagged) + +- **HTML + FTP.** `UpdateOutput` (`Reports.cs:406`, on a ThreadPool thread) runs `HtmlRenderer` into `/reports/stats/` and `reports/staff/` (`Reports.Path`, default `reports`), then `Upload()` writes an `upload.ftp` job to FTP the HTML to a website. Gated on `Reports.AutoGenerate` (**default off**). +- **WebStatus.** A *separate* mechanism (`Scripts/Misc/WebStatus.cs`): an in-process `HttpListener` on `:80/status/` serving a live status HTML page. Default `Enabled = false`. + +Both are the "report goes to a file / gets pushed out-of-band" pattern. The sidecar already replaces the second one (`/health` + the live feed cover what `WebStatus` served); §14 replaces the first. + +### 14.3 The tap — a report sweep, JSON out + +`BridgeReports.cs` runs a **Core-thread timer** (`ReportSweepSeconds`, e.g. hourly to match stock, or faster) that calls the same public compile methods, serializes the `Report`/`Chart` objects to JSON, emits `report.*`, and hands the sidecar a snapshot to persist and serve over REST: + +```jsonc +{"kind":"report.skills","t":1752…,"skills":[ + {"skill":"Swordsmanship","gms":42},{"skill":"Magery","gms":88}, …]} +{"kind":"report.general","players":142,"npcs":42826,"clients":150,"accounts":51,"items":206467} +{"kind":"report.staff","window":"7d","staff":[ + {"account":"GreyBeard","actions":318}],"pageQueue":[{"t":…,"open":4}, …]} +``` + +Served for hydration (the §12.2 snapshot rule): `GET /reports/skills`, `/reports/general`, `/reports/staff`. + +Key points, all grounded: + +- **No core edit, no HTML, no FTP.** Calling `Compile*` directly skips `HtmlRenderer`/`Upload` entirely. Leave `Reports.AutoGenerate` **off** (no HTML files written) and run the bridge tap instead. The FTP `upload.ftp` path and `Reports.Path` become dead weight for a bridge-connected shard. +- **Threading.** `Compile*` read `World.Mobiles`/`Skills`, so they must run on the Core thread — which the bridge's sweep timers already are (`PLAN.md` non-negotiables). Stock only offloaded the *HTML rendering* (slow string work) to a ThreadPool; the bridge skips that step, so there's nothing to offload. Skill distribution walks all mobiles once — treat it like the profile-bulk warning in `PLAN.md` §1: run it on a slow cadence (hourly is plenty), never in a fast sweep. +- **Dedupe against Part B.** `report.general` and the population chart overlap with who's-online (§11 #1); faction reports overlap with §10.4. The **unique** wins here are **skill distribution** (a GM-per-skill leaderboard available nowhere else in the bridge) and the **staff-activity + page-queue-length history** (aggregates that complement the per-action `admin.audit` we already stream). Prioritize those two; treat the rest as "already covered, don't double-emit." +- **Optional backfill.** On first connect the sidecar could ingest the engine's persisted `SnapshotHistory` (`Reports.StaffHistory`/stats history) to seed the historical series instead of starting empty. Nice-to-have, not required. + +### 14.4 Where the code goes + +| File | Responsibility | +|------|----------------| +| `overlay/Scripts/Custom/Bridge/BridgeReports.cs` | **New.** Core-thread report sweep calling `Reports.Compile*` + `Reports.StaffHistory`; serialize to `report.*`; re-armable via `[bridge reload`, one-shot via `[bridge sweepnow`. | +| `overlay/Config/Bridge.cfg` | **Extend.** `ReportSweepSeconds` (+ enable flag). | +| `sidecar/src/store.rs` + `web.rs` | **Extend.** Persist the report snapshots; `GET /reports/{skills,general,staff}` served from the store (survives shard outage, like `/champs`). | +| `docs/INTEGRATION.md` | **Extend.** `report.*` events + endpoints; note they supersede the stock FTP/HTML reports and `WebStatus`. | + +> **Recommendation:** fold this in as **Part B, Phase 6** (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all. From 9183bf748fa0e3e87591ff22d2849a71ff304ef9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 07:54:11 -0500 Subject: [PATCH 2/8] feat(protocol2): guild and town-governor world-state streams (Part B ph.1) Adds the first Part B streams from docs/PROTOCOL_2.md: guild rosters and town governors ("mayors"), both outbound diff-board sweeps mirroring the existing champ board. Overlay: - BridgeSocial (new): guild sweep+diff over BaseGuild.List -> guild.update / guild.remove (full-state upsert; disband detected via Disbanded), plus a real-time guild.join from EventSink.JoinGuild. (EventSink.CreateGuild is only the load-time factory, so creation is derived sidecar-side from a first-seen id, as champs do.) - BridgeGovernance (new): city sweep over CityLoyaltySystem.Cities -> city.update (governor / governor-elect / election phase), gated on CityLoyaltySystem.Enabled. - BridgeJson.Actor: shared serial/name/acct/webId/player writer used by both. - BridgeConfig: GuildSweepSeconds (60s), CitySweepSeconds (300s). - BridgeBoot: both wired into [bridge reload|sweepnow|status. Sidecar: - store: guilds + governors board tables with upsert/delete/all. - main: route guild.update/remove and city.update into the boards. - web: GET /guilds, GET /governors served from the store (snapshot-companion rule, so a fresh page or a restarted sidecar hydrates without the shard). Docs: INTEGRATION.md event catalog (guild.*, city.update) + board endpoints; PROTOCOL_2.md Part B phase 1 marked built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live end-to-end run still pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 64 +++++++++++++++++++++++++++++++++++++++++++++ link/PROTOCOL_2.md | 2 +- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 27e048f..3960840 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -239,6 +239,47 @@ Category-specific fields on `champ.update`: The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events. +#### Guilds (Protocol 2.0) + +Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board. + +| kind | fields | notes | +|------|--------|-------| +| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. | +| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. | +| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). | + +The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. + +```json +{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14, + "online":3,"alliance":"Britannian Pact", + "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "t":1752489280000} +{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH", + "who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000} +``` + +Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events. + +#### Town governors (Protocol 2.0) + +In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set of cities is polled (`CitySweepSeconds`, default 300s); each city emits `city.update` (full-state upsert) only when its governor, governor-elect, or election phase changes. **No events at all unless the shard runs the City Loyalty system.** + +| kind | fields | notes | +|------|--------|-------| +| `city.update` | `city`, `governor` (actor or null), `governorElect` (actor or null), `electionPhase`, `candidates`, `autoPickAt` (ISO-8601 UTC, when an election is ongoing) | A city's governance changed. Derive "the governor changed" by comparing to your stored board. | + +`electionPhase` is one of `none` / `nominate` / `vote` / `pending`. Cities: Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia. + +```json +{"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0, + "governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "governorElect":null,"t":1752489280000} +``` + +Render the current board from `GET /governors` (§6) on connect, then keep it live with these events. + --- ## 5. REST — read queries @@ -512,6 +553,29 @@ GET /champs A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain. +### Guild board (Protocol 2.0) + +``` +GET /guilds +→ { "guilds": [ {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH", + "members":14,"online":3,"alliance":"Britannian Pact", + "leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true}, + "t":1752489280000}, ... ] } +``` + +Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart. + +### Governor board (Protocol 2.0) + +``` +GET /governors +→ { "cities": [ {"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0, + "governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "governorElect":null,"t":1752489280000}, ... ] } +``` + +Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city. + --- ## 7. Status codes diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index fef8646..5455b73 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -380,7 +380,7 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must ## 13. Part B phasing -1. **Guilds + governors.** `BridgeSocial.cs` (guild sweep + `JoinGuild`) and `BridgeGovernance.cs` (city sweep), their `GuildSweepSeconds`/`CitySweepSeconds` config, and the `world.systems` frame. Ship with their REST snapshots (`GET /guilds`, `/governors`, §12.2) from day one — a diff stream without its snapshot is half-built. +1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* 2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store. 3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.) 4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. From fba337e5303c98956b8b6f6ad2d812c587cd93d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:02:05 -0500 Subject: [PATCH 3/8] =?UTF-8?q?feat(protocol2):=20presence=20stream=20?= =?UTF-8?q?=E2=80=94=20online=20population=20+=20region=20transitions=20(P?= =?UTF-8?q?art=20B=20ph.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay BridgePresence (new): - presence.online sweep over online PlayerMobiles: total plus per-facet and per-region counts, emitted only when the population changes. - region.enter real-time from EventSink.OnEnterRegion (player-filtered), the cheap location signal PLAN.md prefers over Movement. - PresenceSweepSeconds (30s); wired into [bridge reload|sweepnow|status. Sidecar: - GET /online serves the latest presence.online snapshot from the event store (survives restart); population time series via /history?kind=presence.online. Docs: INTEGRATION.md presence events + /online endpoint; PROTOCOL_2 ph.2 built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 26 ++++++++++++++++++++++++++ link/PROTOCOL_2.md | 4 ++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 3960840..5d5f012 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -280,6 +280,22 @@ In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set Render the current board from `GET /governors` (§6) on connect, then keep it live with these events. +#### Presence (Protocol 2.0) + +Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, default 30s) and emitted **only when it changes**; region transitions arrive in real time. + +| kind | fields | notes | +|------|--------|-------| +| `presence.online` | `count`, `byFacet` `{map: n}`, `byRegion` `{region: n}` | The current online population. Emitted when the count or any breakdown changes. `GET /online` gives the latest; `GET /history?kind=presence.online` the time series. | +| `region.enter` | `from` (or null), `to` (or null), `map`, `who` (actor object) | A player crossed into a new named region. `from`/`to` are region names (`Wilderness` is unnamed). Cheap "who's where" feed. | + +```json +{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30}, + "byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000} +{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca", + "who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...} +``` + --- ## 5. REST — read queries @@ -576,6 +592,16 @@ GET /governors Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city. +### Online population (Protocol 2.0) + +``` +GET /online +→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30}, + "byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000} +``` + +The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`. + --- ## 7. Status codes diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index 5455b73..9a5b993 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -381,8 +381,8 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must ## 13. Part B phasing 1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* -2. **Presence.** Who's-online/population sweep + region presence (`OnEnterRegion`) → `GET /online`, population history in the store. -3. **Housing registry.** Extend the decay sweep to a full owner→houses list + houses-for-sale → `GET /houses`. (Selected from the §11 menu.) +2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.* +3. **Housing registry.** Extend the decay sweep to a full owner→houses list → `GET /houses`. (Selected from the §11 menu. Note: stock ServUO has no "for sale" flag on houses, so the registry is owner→houses; for-sale is dropped.) 4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. 5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. From bba7cc77140e80820607759b75018674bc67f5b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 08:05:47 -0500 Subject: [PATCH 4/8] feat(protocol2): house registry board (Part B ph.3) Overlay BridgeHousing (new): a diff sweep over BaseHouse.AllHouses -> house.update / house.remove (owner, region, location, decay level, co-owners, friends, placement price), complementing the existing house.decay transition feed. HousingSweepSeconds (300s); wired into [bridge reload|sweepnow|status. Stock ServUO has no "for sale" flag, so this is an owner->houses registry; price is the placement value, not a listing. Sidecar: houses board table with upsert/delete/all; main routes house.update/ remove into it; GET /houses served from the store. Docs: INTEGRATION.md house.* events + /houses endpoint; PROTOCOL_2 ph.3 built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 31 +++++++++++++++++++++++++++++++ link/PROTOCOL_2.md | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index 5d5f012..ed397e2 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -296,6 +296,25 @@ Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, "who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...} ``` +#### Houses (Protocol 2.0) + +The house registry — one row per house, complementing the `house.decay` *transition* feed (§ above). Polled (`HousingSweepSeconds`, default 300s) and diffed like the other boards. + +| kind | fields | notes | +|------|--------|-------| +| `house.update` | `serial`, `name`, `owner` (actor or null), `coOwners`, `friends`, `region`, `map`, `x`,`y`,`z`, `decay`, `price`, `builtOn`, `lastRefreshed` | A house's owner/region/decay/co-owners changed, or first sight this connection. `decay` is the level name (e.g. `LikeNew`). `price` is the placement value — **stock ServUO has no "for sale" flag**, so this is not a listing. | +| `house.remove` | `serial` | The house was demolished or no longer exists. Drop the row. | + +```json +{"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil","decay":"LikeNew", + "price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain", + "owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "coOwners":2,"friends":5,"builtOn":"2026-01-02T00:00:00Z","lastRefreshed":"2026-07-10T00:00:00Z", + "t":1752489280000} +``` + +Render from `GET /houses` (§6) on connect, then keep live with these events. + --- ## 5. REST — read queries @@ -602,6 +621,18 @@ GET /online The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`. +### House registry (Protocol 2.0) + +``` +GET /houses +→ { "houses": [ {"kind":"house.update","serial":"0x40001234","name":"The Silver Anvil", + "decay":"LikeNew","price":432100,"map":"Felucca","x":1420,"y":1631,"z":0,"region":"Britain", + "owner":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true}, + "coOwners":2,"friends":5,"builtOn":"...","lastRefreshed":"...","t":...}, ... ] } +``` + +Every house's latest snapshot — owner→houses map. Served from the sidecar's projection, kept current by the `house.*` stream (§4). Ordered by name. Survives a sidecar restart. + --- ## 7. Status codes diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index 9a5b993..c03bb93 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -382,7 +382,7 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must 1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* 2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.* -3. **Housing registry.** Extend the decay sweep to a full owner→houses list → `GET /houses`. (Selected from the §11 menu. Note: stock ServUO has no "for sale" flag on houses, so the registry is owner→houses; for-sale is dropped.) +3. ~~**Housing registry.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeHousing.cs`: a house sweep over `BaseHouse.AllHouses` → `house.update`/`house.remove` (owner, region, location, decay, co-owners, friends, price), complementing the existing `house.decay` transition feed. `HousingSweepSeconds` (300s), wired into `[bridge`. Sidecar `houses` board + `GET /houses`. (Stock ServUO has no "for sale" flag, so this is owner→houses; `price` is the placement value, not a listing.) *Live run pending.* 4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. 5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. From 2d579a0b4de5c68d7add317a656468e1bd01b16a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 10:12:01 -0500 Subject: [PATCH 5/8] feat(protocol2): titles in char.profile (Part B ph.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overlay BridgeProfile: char.profile gains a titles block (selected index, fameKarma, skill, and the raw reward-title list) read from PlayerMobile's public title accessors. No new stream, no sidecar change — it rides the existing char.profile served by GET /char. Reward entries may be a cliloc number as a string or a literal; resolve numeric ones website-side like item names. Docs: INTEGRATION.md char.profile titles field; PROTOCOL_2 ph.4 built. Part B phase 5 (Factions/VvV) remains deferred by owner decision. Verified: overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 5 ++++- link/PROTOCOL_2.md | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md index ed397e2..9b2f943 100644 --- a/link/INTEGRATION.md +++ b/link/INTEGRATION.md @@ -345,7 +345,9 @@ Full character sheet: stats, all trained skills, worn equipment with flattened i { "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721, "weapon":{"minDamage":16,"maxDamage":18}, "mods":{"WeaponDamage":50,"HitLightning":40} } - ] + ], + "titles": { "selected": 0, "fameKarma": "Lord", "skill": "Grandmaster Swordsman", + "reward": ["1154060", "The Bold"] } } ``` @@ -353,6 +355,7 @@ Field notes: - `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it. - `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items. - Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site. +- `titles` (Protocol 2.0): `selected` is the index into `reward` currently displayed (`-1` if none). `fameKarma`/`skill` are computed display titles, omitted when the character has none. `reward` entries may be a **cliloc number as a string** or a literal string — resolve numeric ones against your cliloc table, same as item names. - Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly. ### Account roster diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index c03bb93..2635c11 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -383,8 +383,8 @@ If the website mirrors rosters/links (it does — `store.record_link`), it must 1. ~~**Guilds + governors.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeSocial.cs` (guild sweep + `JoinGuild` → `guild.update`/`guild.remove`/`guild.join`) and `BridgeGovernance.cs` (city sweep → `city.update`, gated on `CityLoyaltySystem.Enabled`), `GuildSweepSeconds` (60s) / `CitySweepSeconds` (300s) config, both wired into `[bridge reload|sweepnow|status`. Sidecar `guilds`/`governors` board tables + `GET /guilds`, `/governors` served from the store (the §12.2 snapshot rule). Shared `BridgeJson.Actor` writer (serial/name/acct/webId/player). **Deviation from the §10 sketch:** the wire uses full-state `guild.update`/`city.update` upserts (website derives "created"/"governor changed" from the board) rather than discrete `guild.created`/`city.governor` events — this avoids a reconnect re-emit looking like a storm of creations, matching the proven `champ.update` model. *Live end-to-end run still pending.* 2. ~~**Presence.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgePresence.cs`: a `presence.online` sweep (total + per-facet + per-region, emitted on change) and real-time `region.enter` (`EventSink.OnEnterRegion`, player-filtered). `PresenceSweepSeconds` (30s), wired into `[bridge`. `GET /online` serves the latest snapshot from the event store (population series via `/history?kind=presence.online`). *Live run pending.* 3. ~~**Housing registry.**~~ **Built (2026-07-17), compiles clean both sides.** `BridgeHousing.cs`: a house sweep over `BaseHouse.AllHouses` → `house.update`/`house.remove` (owner, region, location, decay, co-owners, friends, price), complementing the existing `house.decay` transition feed. `HousingSweepSeconds` (300s), wired into `[bridge`. Sidecar `houses` board + `GET /houses`. (Stock ServUO has no "for sale" flag, so this is owner→houses; `price` is the placement value, not a listing.) *Live run pending.* -4. **Titles.** `char.profile` `titles` block (§10.3) — no new stream, folds into `BridgeProfile`. -5. **Factions/VvV** — only after confirming which system the shard runs; stream just the enabled one. +4. ~~**Titles.**~~ **Built (2026-07-17), compiles clean.** `char.profile` gains a `titles` block (`selected`, `fameKarma`, `skill`, `reward[]`) from `PlayerMobile` accessors — no new stream, folds into `BridgeProfile`. *Live run pending.* +5. **Factions/VvV** — **deferred** (owner decision): only after confirming which system the shard runs; stream just the enabled one. Cross-cutting, lands with Phase 1: the **protocol bump to 2** (§12.1) and the **snapshot-companion rule** (§12.2). The provisioning siblings (§12.3) and mirror-hygiene (§12.4) attach to Part A's phasing since they extend the `account.*` surface. From e34606dcccdca3e062fdd6c1108de15076b252fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 10:23:07 -0500 Subject: [PATCH 6/8] docs(protocol2): record live smoke-test results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Booted ServUO + the real sidecar (protocol 2, plugin connected) and exercised every Protocol 2.0 endpoint end-to-end. PROTOCOL_2.md §15 records the results: - Part A: account.create 200 + link; duplicate 409; per-IP cap enforced at the shard's real AccountsPerIp=3 (4th from one IP -> 429); loopback IP -> 400 (fail-closed); unlink 200 then lookup 404. - Part B: /houses (28, full data), /governors (9 cities), /guilds ([]), /online (count 0, headless), /char titles block present. Not exercised (needs a live UO client): presence.online with players, region.enter, real-time guild.join, char.vitals. Also documents the Scripts.dll boot-recompile lock quirk (build offline with the server stopped). World save left untouched; test accounts did not persist. Co-Authored-By: Claude Opus 4.8 --- link/PROTOCOL_2.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index 2635c11..0a8c86c 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -1,6 +1,6 @@ # Protocol 2.0 — Provisioning & World-State Streams -**Status:** Part A **built** on branch `feat/protocol2-account-provisioning` (2026-07-17), compiles clean both sides. Part B is design. +**Status:** Parts A + B (phases 1–4) **built and smoke-tested live** on branch `feat/protocol2-account-provisioning` (2026-07-17) — booted ServUO + the real sidecar and exercised every endpoint (see §15). Part B phase 5 (Factions/VvV) deferred by owner decision. **Date:** 2026-07-17 **Codebase:** ServUO 57.4, `C:\Users\colby\Desktop\servuo`, net48 / x64, Expansion **EJ**. **Companion to** [`PLAN.md`](PLAN.md) (read/event plane), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), and [`INTEGRATION.md`](INTEGRATION.md) (website API). @@ -446,3 +446,34 @@ Key points, all grounded: | `docs/INTEGRATION.md` | **Extend.** `report.*` events + endpoints; note they supersede the stock FTP/HTML reports and `WebStatus`. | > **Recommendation:** fold this in as **Part B, Phase 6** (after the world-state streams), scoped to skill distribution + staff/page-queue history first. It is low-effort (public methods, existing sweep pattern) and directly answers "get the admin reports onto the site instead of a file" — by tapping the data the engine already computes and never letting it become a file at all. + +--- + +## 15. Smoke test — live run (2026-07-17) + +Deployed the overlay to the ServUO checkout, booted the shard and the real sidecar (protocol 2, `plugin_connected: true`), and exercised every new surface over REST against the live game. All green. + +**Part A — provisioning (through the real shard):** + +| Check | Result | +|-------|--------| +| `POST /accounts/create` (fresh IP) | **200** `account.ok`, account created + linked | +| duplicate name | **409** `account already exists` | +| per-IP cap | shard's real `AccountsPerIp=3` enforced: 3rd from one IP allowed, **4th → 429** `ip account limit reached` | +| loopback IP with `RequireIpForCreate` | **400** `client ip required` (fails closed) | +| `GET /link/{acct}` after create | **200**, linked to the website id | +| `DELETE /link/{acct}` | **200** `unlink`; lookup then **404** | + +**Part B — world-state boards (through the real shard):** + +| Endpoint | Result | +|----------|--------| +| `GET /houses` | **28 houses**, full owner/decay/co-owner/built-on data (shard → `house.update` → board → REST) | +| `GET /governors` | **9 cities**, `governor: null`/`electionPhase: none` on this unseeded world | +| `GET /guilds` | `[]` — no guilds on this world; the sweep ran without error | +| `GET /online` | `count: 0` — headless (no UO client), snapshot emitted and stored | +| `GET /char/{acct}/0` | full profile incl. the new `titles` block | + +**Not exercised (needs a live UO client, not a headless boot):** `presence.online` with real players, `region.enter`, real-time `guild.join`, and `char.vitals`. And `guild.join`/guild board content needs a guild to exist. These are inherent to a clientless smoke test — the board *plumbing* is proven by `/houses`, which uses the identical path. + +**One operational note surfaced:** the boot-time `Dynamic` script recompile **cannot replace `Scripts.dll` while the server is running**, because the Scripts build tries to copy the locked `ServUO.exe` and fails (the `PLAN.md §3` trap). The fix used here: build `Scripts/Scripts.csproj` once with the server **stopped**, then boot — the offline build produces a fresh `Scripts.dll` the boot then loads. Rely on this, not the in-process rebuild, when deploying new bridge code. The shard's world save was left untouched (hard-kill, no autosave), so the test accounts did not persist. From fdd67bff69747d57e835712de47b6d72246dc852 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 11:15:24 -0500 Subject: [PATCH 7/8] =?UTF-8?q?docs(protocol2):=20add=20Town=20Cryer=20new?= =?UTF-8?q?s-gump=20integration=20design=20(=C2=A716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds §16: sync website news articles into the modern Town Cryer News gump (TownCryerSystem.NewsEntries), distinct from the Protocol 1.0 scrolling-crier lines (GlobalTownCrierEntryList). Grounded in the shard's Town Cryer source. Key findings / decisions: - NewsEntries is a public mutable List and TownCryerNewsEntry's ctor is public, and the display gumps already branch on Title/Body .Number>0 (cliloc) vs string (AddLabelCropped / AddHtml with HTML support). So website content needs NO gump changes and NO stock patch — the overlay inserts/removes directly and tracks its own entries, leaving stock uo.com news intact. (Refines the pasted guidance, which proposed adding methods to the stock TownCryerSystem.cs = a patch.) - Ties the two surfaces together: full article -> news gump; crier "says" just the title via the existing GlobalTownCrierEntryList path. - news.add/news.remove verbs (id-correlated, idempotent), POST /news + DELETE /news/{id}; website is source of truth, re-synced on shard reconnect since NewsEntries isn't persisted across reboot. Co-Authored-By: Claude Opus 4.8 --- link/PROTOCOL_2.md | 68 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/link/PROTOCOL_2.md b/link/PROTOCOL_2.md index 0a8c86c..a3f0d46 100644 --- a/link/PROTOCOL_2.md +++ b/link/PROTOCOL_2.md @@ -477,3 +477,71 @@ Deployed the overlay to the ServUO checkout, booted the shard and the real sidec **Not exercised (needs a live UO client, not a headless boot):** `presence.online` with real players, `region.enter`, real-time `guild.join`, and `char.vitals`. And `guild.join`/guild board content needs a guild to exist. These are inherent to a clientless smoke test — the board *plumbing* is proven by `/houses`, which uses the identical path. **One operational note surfaced:** the boot-time `Dynamic` script recompile **cannot replace `Scripts.dll` while the server is running**, because the Scripts build tries to copy the locked `ServUO.exe` and fails (the `PLAN.md §3` trap). The fix used here: build `Scripts/Scripts.csproj` once with the server **stopped**, then boot — the offline build produces a fresh `Scripts.dll` the boot then loads. Rely on this, not the in-process rebuild, when deploying new bridge code. The shard's world save was left untouched (hard-kill, no autosave), so the test accounts did not persist. + +--- + +## 16. Town Cryer news — website articles into the news gump (Protocol 2.1) + +**Status:** Design, grounded in the shard's `Scripts/Services/Town Cryer/` files. Not yet built. + +There are **two** distinct town-crier surfaces in ServUO, and 2.0 has so far touched only the first: + +1. **The scrolling crier** (`GlobalTownCrierEntryList`) — the wandering Town Crier NPC that *says* short announcement lines. Protocol 1.0 phase 6 (`BridgeTownCrier.cs`, `towncrier.add`/`remove`) already drives this. +2. **The Town Cryer News gump** (`TownCryerSystem.NewsEntries`) — the paged news UI with title + body + image + a "more info" URL per article. **Nothing drives this yet.** This section adds it. + +The ask: a website news article should land as a full article in the **news gump** (2), and the crier should also *say* just the **title** through the existing say feature (1) — so players get the audible "Hear ye!" proclamation while the full write-up lives in the gump. + +### 16.1 The hook (verified in the shard's source) + +- **`TownCryerSystem.NewsEntries`** (`TownCryerSystem.cs:40`) — `public static List`. The setter is private, but the **list is public and mutable**, so it can be inserted into and removed from directly. +- **`TownCryerNewsEntry(TextDefinition title, TextDefinition body, int gumpImage, Type questType, string url)`** (`TownCryerNewsEntry.cs`) — public ctor. Pass `questType: null` for website news. +- **The display gumps already handle string content**, so no gump edits are needed: + - List view (`TownCryerGump.cs:97-103`): `if (entry.Title.Number > 0) AddHtmlLocalized(...) else AddLabelCropped(..., entry.Title)`. + - Detail view (`TownCryerNewsGump.cs:27-42`): `if (Entry.Body.Number > 0) AddHtmlLocalized(...) else AddHtml(..., Entry.Body.String, ..., true)` — a **string body renders as HTML** (so `


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


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


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


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