Files
docs/link/INTEGRATION.md
Claude 9183bf748f 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 <noreply@anthropic.com>
2026-07-17 07:54:11 -05:00

631 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# uo-link Sidecar — Website Integration Guide
This is the API the website talks to. The sidecar is the only thing the site connects to; it relays to and from the ServUO shard over a private loopback socket. The game itself exposes no ports and is never reachable directly.
```
website ──WebSocket (live feed) + REST (queries/commands)──► sidecar ──loopback──► shard
```
- **Base URL** — default `http://127.0.0.1:8080` (WebSocket: `ws://127.0.0.1:8080`). Configurable in `sidecar.toml` (`web.bind`) or `UOLINK_WEB_BIND`. If you serve the site from another host, bind the sidecar to `0.0.0.0:8080` and put it behind TLS.
- **Content type** — all request and response bodies are JSON (`application/json`).
- **Timestamps** — every `t` field is **epoch milliseconds** (UTC). Human-readable timestamps (e.g. `house.decay.builtOn`, `/health.last_event`) are ISO-8601 UTC.
- **Serials** — game object ids are hex strings like `"0x24C"` (mobiles) or `"0x40013AAD"` (items). Treat them as opaque keys.
---
## 1. Authentication
Every route **except `GET /health`** requires the shared token from `sidecar.toml` (`web.auth_token`). Present it any of these ways:
| Transport | How |
|-----------|-----|
| REST | `Authorization: Bearer <token>` |
| REST | `X-Api-Key: <token>` |
| WebSocket | `?token=<token>` in the connect URL (browsers can't set headers on a WS handshake) |
Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`. The token is compared in constant time. It is generated automatically on first run (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
---
## 2. Protocol version
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
- Every response carries an **`X-UOLink-Version: 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": 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
```
GET /health (no auth)
```
```json
{
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
"protocol": 1,
"plugin_connected": true, // is the shard link up right now?
"database": "ok", // "ok" | "error"
"uptime": "3d 12h",
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
}
```
Always returns HTTP 200 (read `status`/`plugin_connected` for real state). Use it for liveness checks and to detect when the shard has dropped (`plugin_connected: false`).
---
## 4. WebSocket live feed
```
GET /ws?token=<token> (WebSocket upgrade)
```
A push-only stream of game events as they happen. You do **not** send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.
**On connect**, the first frame is:
```json
{ "kind": "ws.hello", "protocol": 1 }
```
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
Notes:
- **Live-only, no replay.** A client that connects now sees events from now on. For history/backfill, use `GET /history`.
- The sidecar sends WebSocket **ping** frames every ~30s for keepalive; browser clients answer automatically.
- You may occasionally see a `{"kind":"pong",...}` frame (the sidecar's internal heartbeat to the shard). Ignore any `kind` you don't handle.
- A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.
### Minimal browser client
```js
const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
ws.onmessage = (m) => {
const ev = JSON.parse(m.data);
switch (ev.kind) {
case "ws.hello": /* check ev.protocol === 1 */ break;
case "mob.login": onLogin(ev); break;
case "vendor.sale": onSale(ev); break;
case "house.decay": onIdoc(ev); break;
// ...handle the kinds you care about; ignore the rest
}
};
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history
```
### Event catalog
Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"serial","name","acct","player"}` (`acct` present only for player-owned mobiles).
#### Lifecycle
| kind | fields | notes |
|------|--------|-------|
| `server.hello` | `shard`, `bootId`, `connects`, `items`, `mobiles`, `accounts` | Sent to the sidecar on every shard (re)connect. `bootId` changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". |
| `server.shutdown` | — | Clean shutdown. |
| `server.crashed` | `error` | Not always sent (a hard crash may skip it). |
| `world.save.before` / `world.save.after` | (`after` adds `items`, `mobiles`) | Save-cycle boundaries; a natural consistency checkpoint. |
#### Sessions & identity
| kind | fields |
|------|--------|
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
| `mob.logout` | `who` |
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
#### Economy & commerce
| kind | fields | notes |
|------|--------|-------|
| `gold.change` | `acct`, `old`, `new`, `delta` | AccountGold flow (gold in bank/account, not physical coins). |
| `vendor.buy` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor purchase (validation stage). |
| `vendor.sell` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor sale. |
| `vendor.sale` | `buyerSerial`, `buyerAcct`, `ownerSerial`, `ownerAcct`, `vendorSerial`, `itemType`, `itemSerial`, `itemId`, `amount`, `price`, `commission`, `committed:true` | **Player** vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. |
| `vendor.placed` | `owner`, `vendor` | A player vendor was placed. |
```json
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
"ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
"itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
"price":100,"commission":0,"t":1783720195626}
```
#### Character progression & vitals
| kind | fields | notes |
|------|--------|-------|
| `char.vitals` | `serial`, `hits`,`hitsMax`, `mana`,`manaMax`, `stam`,`stamMax`, `str`,`dex`,`int`, `map`, `x`,`y` | Periodic snapshot of each **online** player (~every 30s; configurable). Diff successive snapshots to detect change. |
| `skill.gain` | `who`, `skill`, `gained`, `base`, `cap` | Player skill gains only (NPC gains are filtered out). |
| `fame.change` / `karma.change` | `who`, `old`, `new` | Player only. |
| `quest.complete` | `who`, `quest` | |
#### Death & PvP
| kind | fields |
|------|--------|
| `player.death` | `who`, `killer` |
| `player.murdered` | `victim`, `murderer` |
| `mob.killed` | `killed`, `killer` — only kills that involve a player |
#### Housing / IDOC
| kind | fields |
|------|--------|
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
```json
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
```
#### Economy supply (periodic)
| kind | fields |
|------|--------|
| `economy.supply` | `accounts`, `gold` — total money supply across all accounts (~every 5 min; configurable) |
#### Cheat detection & staff audit
| kind | fields | notes |
|------|--------|-------|
| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. |
| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. |
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. |
| `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:<user>"`) 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 & 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:<user>"`). `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 |
|------|--------|-------|
| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). |
| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). |
| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). |
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
#### Champion spawns
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
| `category` | source | what it is |
|------------|--------|-----------|
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
| kind | fields | notes |
|------|--------|-------|
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
`status` is one of:
- **`active`** — running (or, for `sea`, the boss is alive).
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
Category-specific fields on `champ.update`:
| category | extra fields |
|----------|--------------|
| `champion` | `level` (016), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
```json
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
```
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
#### 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
These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning **504**.
### Character profile
```
GET /char/{account}/{slot} # by account + character slot (0-based)
GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C
```
Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for **offline** characters too. `GET /char/serial/...` falls back to the last **cached** profile if the shard is unreachable (so a page still renders during a shard restart).
```json
{
"kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
"body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
"stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
"mana":123,"manaMax":123, "stam":120,"stamMax":120,
"fame":0,"karma":0,"luck":0,
"resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
"skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
"equipment": [
{ "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
"cliloc":1027933,"mods":{} },
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
"weapon":{"minDamage":16,"maxDamage":18},
"mods":{"WeaponDamage":50,"HitLightning":40} }
]
}
```
Field notes:
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site.
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
### Account roster
```
GET /roster/{account}
```
Lightweight list of an account's characters (up to 57), including offline ones. Use this for a character-picker, then fetch the full profile on demand.
```json
{ "kind":"account.roster", "acct":"whitlocktech",
"chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }
```
### Player vendors
```
GET /vendors/{account}
```
Every player vendor owned by any character on the account, with held gold and current listings.
```json
{ "kind":"vendor.snapshot", "acct":"seed_000",
"vendors":[
{ "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
"ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
"listings":[
{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
] } ] }
```
---
## 6. REST — commands & history
### Confirm an account link
The in-game `[link` flow: the player runs `[link`, the shard emits a `link.request` event (over the WebSocket) carrying a one-time `code`. Your site shows the logged-in website user a box to enter that code, then:
```
POST /link/confirm
{ "code": "AB12CD", "websiteUserId": "9931" }
```
- Success → **200** `{"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}`. The game account is now permanently tagged with your `websiteUserId` (persisted on the shard); subsequent `mob.login` events for that account carry `webId`.
- Bad/expired code → **404** `{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}`.
Codes are one-time and expire (default 5 min).
### Look up an existing link
```
GET /link/{account}
```
- **200** `{"account":"PerryAdimn","websiteUserId":"9931"}` if linked.
- **404** `{"account":"PerryAdimn","linked":false}` if not.
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
### 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.
```
POST /towncrier
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }
```
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`. Re-posting the same `id` replaces the prior entry.
```
DELETE /towncrier/{id}
```
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`, or **404** `{"kind":"towncrier.error","reason":"unknown id"}`.
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
### Staff moderation — the write plane
Account and session moderation against the live shard. **These are privileged.** The sidecar does
not model per-user roles — **your site must authenticate the staff user and check their permission
before calling.** The shard trusts the loopback socket and applies each command with CoOwner-level
authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner
account) is refused (**403**). The whole plane is **opt-in on the shard** (`AdminWriteEnabled` in
`Bridge.cfg`); when it's off, every call returns **403** `"admin write plane disabled"`.
Every request requires an **`actor`** — the website username/id of the staff member taking the
action. It is recorded in the shard console log, the ban's `BanDealer` tag, and the `admin.audit`
event, so actions are always attributable. A missing `actor` is **400**.
```
POST /admin/kick { "actor":"jane", "account":"griefer42" } # or "serial":"0x2E0"
POST /admin/ban { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
POST /admin/unban { "actor":"jane", "account":"griefer42" }
POST /admin/broadcast { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
```
- **kick** — disconnects every live session of the target account (including one parked at
character-select). Target by `account` or `serial`. Reply carries `sessions` (how many were cut).
- **ban** — bans the account (works offline) and disconnects any live sessions. `durationSec > 0`
is a timed ban that auto-expires; `0`/absent is indefinite. Clamped to the shard's
`AdminBanMaxDurationSec`.
- **unban** — clears the ban.
- **broadcast** — a system message to everyone online. `hue` optional (default `53`, staff green).
Length-capped by the shard.
Success → **200** with an `admin.ok`:
```json
{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }
```
Failure → an `admin.error` with a mapped status:
| Status | When |
|--------|------|
| 400 | missing `actor`, malformed body, or bad parameter |
| 401 | missing/invalid auth token |
| 403 | target is protected (at/above the floor), or the write plane is disabled on the shard |
| 404 | unknown or accountless target |
| 503 / 504 | shard not connected / didn't reply in time |
Each applied action also emits an unsolicited **`admin.audit`** frame on the WebSocket (§4) with
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
by staff in the game client surfaces the same way with `origin:"in-game"`.
### Help-page (support) queue
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
roles, like the moderation endpoints above.
```
GET /pages # the open queue, newest state
POST /pages/{pageId}/respond { "message":"...", "close": false }
POST /pages/{pageId}/close
```
- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new`
event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live
with the `page.new` / `page.updated` / `page.closed` events.
- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if
they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass
`"close": true` to resolve the page in the same call. → **200** `page.ok`.
- **close** removes the page from the queue. → **200** `page.ok`.
- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**.
```json
POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true }
→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true }
```
### History (from the sidecar's database)
```
GET /history?kind={kind}&limit={n} # kind optional, limit default 100 (max 1000)
GET /economy?limit={n} # the money-supply series (economy.supply events)
```
Recent events, **newest first**, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC".
```
GET /history?kind=vendor.sale&limit=50
→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] }
GET /economy?limit=200
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
```
### Champion-spawn board
```
GET /champs
```
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
```
GET /champs
→ { "spawns": [
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
] }
```
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
### 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
| Code | Meaning |
|------|---------|
| 200 | OK |
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
| 401 | Missing or invalid auth token |
| 404 | Not found (unknown account / character / id, 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 |
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
---
## 8. Putting it together
A typical character page:
```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
// 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
// 2. full sheet for the selected character
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
if (res.status === 503) showBanner("Game server is restarting…");
else renderProfile(await res.json());
// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
// (see the WebSocket client in §4)
// 4. recent sales widget
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
.then(r => r.json());
```
---
## 9. Caveats & current limits
- **No rate limiting yet.** The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
- **WebSocket is push-only and live-only.** No client→server messages, no replay. Backfill via `/history`.
- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted.
- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape.