The Asset Bridge's docs pass, and the acceptance walk that shaped it (v8.md §16 row 9a, §17.13-14). Phase 9 is three legs now: this one, the edge->main cutover, and the site. ## INTEGRATION.md had stopped at 6 and contradicted itself Its §2 said "the current version is 6" above examples already carrying `X-UOLink-Version: 8`, there was no protocol-7 paragraph, and `assets.` appeared zero times in 1,306 lines. It is the only document an integrator outside this org has, so it is carried the whole way: the version block corrected, v7 (the event plane's command half) and v8 (the asset plane) written, a §5 section for the five routes, 425/422 in the status table, and a caveat that the asset plane is a working set rather than a stream. Protocol 7's absence is the Events workstream's debt rather than this one's, but it cannot be stepped over on the way to 8. ## The operator-facing half `UPGRADE_NOTES.md` gains the entry an operator reads when this ships: what changed, the one required action on a Linux host, and the thing that will not announce itself -- nothing here happens on a restart, so a patched client keeps serving the old pictures until somebody presses a button. `installer/INSTALL.md` gains libgdiplus as a prerequisite row and the `doctor` row that checks it. The index rows for SPAWN_ATLAS and CLILOCS described the workflows this protocol deleted; v8.md now has an index row of its own, and v7 is marked as the released protocol. ## The walk Wiped every asset row and every imported sprite, then walked it as a new operator: 1,095 portraits in 3.18 s, 67,496 names in 1.42 s, 313 item pictures in 1.38 s, the atlas over the bridge in ~2.0 s, an Update with no drift answered in 0.99 s. Bestiary portraits are the right animals by eye; the marketplace shows hued item art with cliloc names. It found two defects (§17.14) and one cutover hazard: module-uo's `edge` is behind its `main`, missing #35, so the walk measured 0 of 6,455 spawners carrying a UniqueId. 9b's row says to sync before merging or the cutover ships a regression. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
1435 lines
86 KiB
Markdown
1435 lines
86 KiB
Markdown
# 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; rotate by editing `sidecar.toml` and restarting.
|
||
|
||
To read it back afterwards, ask the sidecar rather than hunting through the startup log or the TOML:
|
||
|
||
```console
|
||
$ uo-link-sidecar --print-config --config /etc/runicgateway/sidecar.toml
|
||
{
|
||
"component": "uo-link-sidecar",
|
||
"config_created": false,
|
||
"config_path": "/etc/runicgateway/sidecar.toml",
|
||
"protocol": 4,
|
||
"shard": { "bind": "127.0.0.1:7788" },
|
||
"store": { "path": "/var/lib/runicgateway/uo-link.db" },
|
||
"token_generated": false,
|
||
"version": "2.0.0",
|
||
"web": {
|
||
"auth_required": true,
|
||
"auth_token": "c0f04ace…",
|
||
"bind": "127.0.0.1:8080",
|
||
"ws_path": "/ws"
|
||
}
|
||
}
|
||
```
|
||
|
||
That is the same set of values Admin → Shard asks for — base URL and WS URL are `web.bind` (substituting a reachable host if it is `0.0.0.0`) plus `web.ws_path`. The output **contains the token in clear text**, so treat it as a secret: it belongs in a terminal, not in a log or a CI artifact. `--print-config` also performs first-run setup, writing the config file and generating a token if there is none, and reports whether it did via `config_created` / `token_generated`.
|
||
|
||
---
|
||
|
||
## 2. Protocol version
|
||
|
||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
||
|
||
The current version is **8**. It is not released yet — it lives on `edge` and ships with the Asset Bridge's cutover; the last released pairing is protocol **7**, sidecar **v2.2.0** + overlay **v1.2.0**, resolved as bundle **2026.09.10**, never as "latest of each".
|
||
|
||
- Every response carries an **`X-UOLink-Version: 8`** header.
|
||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 8`.
|
||
- **Optionally**, send `X-UOLink-Version: 8` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||
|
||
```json
|
||
{ "error": "protocol version mismatch", "sidecar_protocol": 8, "client_protocol": "7" }
|
||
```
|
||
|
||
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.
|
||
|
||
**v6 (Protocol 6)** is the first bump that adds a **promise** rather than data: a command carrying
|
||
an `idempotencyKey` is executed at most once (see *Retrying a command safely* in §6). It also adds
|
||
`champ.boss.killed`, and the **event plane** — leases and the run-scoped participation ledger, six
|
||
endpoints, all of them gated on the shard by `Bridge.EventsEnabled` and answering **403** when an
|
||
operator has not switched it on. See [`v6.md`](v6.md).
|
||
|
||
**v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
|
||
`vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
|
||
the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
|
||
ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3
|
||
sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the
|
||
version number alone tells you what is available.
|
||
|
||
**v4 (Protocol 4.0)** adds `guild.roster` and `guild.leave`, and grows `GET /guilds` a `roster` key
|
||
([`v4.md`](v4.md)). Before it, a guild's membership was a *count*; now the members themselves are on
|
||
the wire. Additive in the same shape as the previous two bumps — nothing that existed in v3 changed,
|
||
so a v3 consumer that ignores the new kinds and the new key keeps working against a v4 sidecar, once
|
||
it declares `4`.
|
||
|
||
**v5 (Protocol 5)** adds three things at once ([`v5.md`](v5.md)) — `house.decay` gains `ownerName`
|
||
and a `schedule`, `vendor.listing` gains `ownerAcct` and a `fees` block, and `account.login.result`
|
||
is a new kind carrying the verdict its long-standing `account.login.attempt` companion fires too
|
||
early to know. Three at once because a bump costs a release, a bundle and an operator update on
|
||
every shard, so a field left out costs a whole second round of that.
|
||
|
||
Additive again: no existing field changed shape, and **no new endpoint** — every v5 addition rides
|
||
kinds that already existed or a kind that behaves like any other on the feed. Two consumer notes,
|
||
both about ABSENCE rather than presence, because both are easy to read as an error:
|
||
|
||
- `schedule.estimatedCollapse` is **omitted whenever it is not exactly knowable** — which, on a
|
||
dynamic-decay shard, is every stage before IDOC.
|
||
- `fees` is omitted entirely by a pre-v5 overlay, and reduces to `{"exempt": true}` for a
|
||
commission vendor. Neither means "this vendor has no money".
|
||
|
||
**v6 (Protocol 6)** is the first bump that is mostly about a **guarantee** rather than about data
|
||
([`v6.md`](v6.md)). A command may now carry an **`idempotencyKey`**, and the shard promises to
|
||
execute a key **at most once**: a repeat is answered with the original reply rather than re-run. That
|
||
is what makes a world-writing command safe to retry at all — before it, a lost acknowledgement and a
|
||
command that never applied were the same event as seen from the caller. See §6's write plane for how
|
||
to send one, and §7 for the one new status code it introduces.
|
||
|
||
It also adds **`champ.boss.killed`**, a champion's defeat with the damage table only the shard ever
|
||
sees. Previously this was inferable from `champ.update` losing its `bossUp` alongside a nearby
|
||
`mob.killed` — a signal that also fires when a GM resets a spawn and that says nothing about who did
|
||
the work.
|
||
|
||
Additive again: no existing field changed shape, **no new endpoint**, and a client that sends no key
|
||
behaves exactly as it did under v5.
|
||
|
||
**v7 (Protocol 7)** adds the **event plane's command half** ([`v7.md`](v7.md)) — the verbs a
|
||
website-authored event needs in order to happen in the world: spawn a bounded, named, hued set of
|
||
creatures; place decoration from the shard's own `Data/Decoration` vocabulary; open a temporary
|
||
gate; run an oracle NPC's dialogue; grant an item to a run's participants; lease a property on an
|
||
existing spawner; toggle one of ServUO's seasonal events; start a world save. Every one of them is
|
||
capped on the shard, ledgered so it can be reverted, and gated by `Bridge.EventsEnabled` — an
|
||
operator who has not switched the plane on gets **403**, not a half-applied event.
|
||
|
||
Additive on the feed, like every bump before it, and the new endpoints require a v7 sidecar. The
|
||
one thing to know as a consumer is that these are **world writes**, so they are exactly the calls
|
||
v6's `idempotencyKey` exists for: send one, and a retry after a lost acknowledgement is answered by
|
||
the original reply instead of spawning the creatures twice.
|
||
|
||
**v8 (Protocol 8)** adds the **asset plane** ([`v8.md`](v8.md)) — the shard reads its own UO client
|
||
files and serves what is in them, so nothing has to be converted on somebody's desktop and no
|
||
component but the shard ever needs a copy of the client. It carries four things: creature and
|
||
player-body artwork, item and land art on demand, the decompressed cliloc table, and the shard's own
|
||
`Spawns/*.xml` tree. See §5's *Client assets* for the routes.
|
||
|
||
Three properties of this plane are unlike the rest of this API, and a consumer that does not know
|
||
them will misread healthy behaviour as failure:
|
||
|
||
- **The shard serves one asset request at a time**, and says so: `425 Too Early` is *flow control*,
|
||
the ordinary answer during an import rather than a rare collision. Back off and retry; do not
|
||
treat it as an error and do not abandon a transfer over it.
|
||
- **Every response is paged, and the caller drives the paging** — echo the previous reply's
|
||
`cursor` until one says `more: false`, then read `cut` to learn why that page was the last. Only
|
||
`cut: "end"` means you have the whole thing.
|
||
- **A key the shard cannot serve comes back as a row with a `status`, not as a failed request.** A
|
||
body this client has no art for is the expected answer for most ghost and gargoyle bodies on a
|
||
stock client; failing a page over one would make an import impossible.
|
||
|
||
Additive again — no existing field changed shape, and a v7 client that never calls `/assets/*` or
|
||
`/cliloc` behaves exactly as it did.
|
||
|
||
**Upgrading a pinned client.** Every bump is an operator-visible hard break in one direction only: a
|
||
client still declaring the old number gets a 409 on every protected route and, on the WebSocket, a
|
||
closed connection on the `ws.hello` mismatch. So update the pinned version at the same time you
|
||
deploy the new sidecar. Nothing existing has ever changed shape across a bump, so that is the whole
|
||
migration — the website does it with a one-shot boot migration of its `uo_link_config.protocol` row
|
||
([`v3.md`](v3.md) §4.1); a third-party client changes the constant it sends.
|
||
|
||
---
|
||
|
||
## 3. Health
|
||
|
||
```
|
||
GET /health (no auth)
|
||
```
|
||
|
||
```json
|
||
{
|
||
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
|
||
"protocol": 4,
|
||
"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": 4 }
|
||
```
|
||
|
||
**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 === 4 */ 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, fired from a sink that runs **before** the auth decision, so it fires on successful logins too. Use `account.login.result` for the verdict. No password ever leaves the shard |
|
||
| `account.login.result` | `acct`, `ip`, `accepted`, `reason` — **Protocol 5.** The verdict of the attempt above, which the attempt structurally cannot carry. `reason` is an `ALRReason` (`BadPass`, `Invalid`, `Blocked`, `InUse`, `BadComm`) and is **present only when `accepted` is false**, because the enum's zero value would read as a failure reason on an accept. Build "someone tried to get into your account" on THIS kind |
|
||
|
||
#### 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`, `ownerName`*, `ownerAcct`, `schedule:{...}`*, `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.
|
||
|
||
\* **Protocol 5.** `ownerName` is the owner's character name (`ownerAcct` is the game account, and
|
||
the only one of the two that identifies a person). `schedule` is a nested object:
|
||
|
||
| field | meaning |
|
||
|---|---|
|
||
| `dynamicDecay` | whether this shard runs ServUO's dynamic decay (`Core.ML`). Always present |
|
||
| `nextStage` | ISO-8601 UTC: when the house leaves its current stage. Absent under static decay, which keeps no stage clock |
|
||
| `decayPeriodSec` | seconds from a full refresh to collapse; lets a reader turn `lastRefreshed` into a percentage |
|
||
| `estimatedCollapse` | ISO-8601 UTC — **present only when it is exact**, see below |
|
||
|
||
**`estimatedCollapse` is absent far more often than not, and that is deliberate.** Under dynamic
|
||
decay ServUO draws each stage's duration at *random* when the stage is entered, so collapse is
|
||
exactly knowable only once the house is already at `IDOC` — at which point the next transition is
|
||
the collapse. Under static decay it is a pure function of `lastRefreshed + decayPeriodSec` and is
|
||
exact at every stage. It is omitted rather than approximated, because an absent field is honest
|
||
where a wrong date is a dated promise. **Treat its absence as "not knowable", never as "not yet
|
||
read"** — and never fall back to computing one yourself under dynamic decay.
|
||
|
||
```json
|
||
{"kind":"house.decay","serial":"0x400142F9","from":"Greatly","to":"IDOC",
|
||
"map":"Felucca","x":1480,"y":1600,"z":0,"region":null,"name":"Millrace",
|
||
"ownerSerial":"0x1FB","ownerName":"Zara Crowe","ownerAcct":"seed_002",
|
||
"schedule":{"dynamicDecay":true,"nextStage":"2026-09-01T20:33:15.7525479Z",
|
||
"decayPeriodSec":432000,"estimatedCollapse":"2026-09-01T20:33:15.7525479Z"},
|
||
"ban":{"x":1482,"y":1604,"z":0},
|
||
"builtOn":"2026-06-03T14:02:44Z","lastRefreshed":"2026-08-25T17:21:14Z"}
|
||
```
|
||
|
||
#### 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. |
|
||
| `champ.boss.killed` (Protocol 6) | `category`, `bossSerial`, `boss`, `bossType`, `map`, `x`,`y`,`z`, `region`, `killer`, `damagers` — plus `serial`, `type`, `level` naming the ALTAR when the kill could be attributed to one | The boss went down. A real event, not a polled diff — see below. |
|
||
|
||
`status` is one of:
|
||
- **`active`** — running (or, for `sea`, the boss is alive).
|
||
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
|
||
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
|
||
|
||
Category-specific fields on `champ.update`:
|
||
|
||
| category | extra fields |
|
||
|----------|--------------|
|
||
| `champion` | `level` (0–16), `rank`, `kills`, `maxKills`, `autoRestart`, `boss` (when `bossUp`), `restartAt` (when `cooldown`), `expireAt` (ISO-8601 UTC — when the current level times out if kills stall, present while `active`) |
|
||
| `mini` | `level`, `maxLevel`, `autoRestart` (always true); `bossUp` is always false |
|
||
| `sea` | `boss` (its name), `hits`, `hitsMax`; `bossUp` is always true; roams, so `x`,`y`,`z` and `hits` update as it moves/takes damage |
|
||
|
||
```json
|
||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
||
"name":"Abyss","status":"active","active":true,"level":9,"rank":3,"kills":120,
|
||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,"z":0,
|
||
"expireAt":"2026-07-14T11:00:00Z","t":1752489280000}
|
||
|
||
{"kind":"champ.update","serial":"0x0002ABCD","category":"sea","type":"Charybdis",
|
||
"name":"Charybdis","status":"active","active":true,"bossUp":true,"boss":"Charybdis",
|
||
"hits":4200,"hitsMax":5000,"map":"Trammel","x":4123,"y":2311,"z":-5,"t":1752489280000}
|
||
```
|
||
|
||
The events are live deltas; for the current board of all spawns at once, use `GET /champs` (§6) — that's what you render on connect, then keep live with these events.
|
||
|
||
##### `champ.boss.killed` (Protocol 6)
|
||
|
||
The one champion frame that is **not** polled. It fires on the death itself, so unlike everything
|
||
above it is an event rather than a difference between two snapshots — which means a first sighting
|
||
of it is the thing being reported rather than a baseline to compare against.
|
||
|
||
```json
|
||
{"kind":"champ.boss.killed","category":"champion","bossSerial":"0xD8D","boss":"Semidar",
|
||
"bossType":"Semidar","map":"Felucca","x":1496,"y":1628,"z":-5,"region":"Britain",
|
||
"serial":"0x400150E8","type":"Abyss","level":0,
|
||
"killer":{"serial":"0x2E0","name":"tester","player":true},
|
||
"damagers":[{"serial":"0x2E0","name":"tester","player":true,"damage":100240},
|
||
{"serial":"0x24C","name":"Darrow","player":true,"damage":120}],
|
||
"t":1788551315000}
|
||
```
|
||
|
||
- **`serial` means the ALTAR here**, matching `champ.update`, so the two join without a rule about
|
||
which of two serials means what. It is **absent** — with `type` and `level` — when the boss could
|
||
not be attributed to a spawn, which happens when one pops and dies inside a single sweep interval.
|
||
The kill is still reported; it simply arrives without its altar.
|
||
- **`damagers` is every player who damaged it, highest first**, each the standard actor object plus
|
||
a `damage` total. Totals are summed per player, so nobody appears twice. Entries are included
|
||
whether or not the shard still considers them valid for **looting rights** — someone who fought
|
||
two thirds of the fight and then died took part in it. Capped at 20.
|
||
- **`region` is the nearest NAMED region** and is **absent** in open countryside. It is not the most
|
||
specific region containing the boss: an active champion altar registers an unnamed region of its
|
||
own over its spawn area, so the innermost answer is always nameless. Absent means "nowhere with a
|
||
name", never "the shard would not say".
|
||
- `category` is `champion` or `sea`. There is no `mini` — a `MiniChamp` has no boss.
|
||
|
||
**This does not replace `champ.remove`.** A slain `sea` boss still produces one, because it also
|
||
leaves the board. A `champion` altar stays on the board and goes to `cooldown` as usual.
|
||
|
||
**`damagers` names players and ranks them.** The sidecar serves it verbatim, as it serves
|
||
everything; deciding who may see a damage table is the consuming site's job. The website's own
|
||
answer is `staff` by default with the kill itself public — see [`v6.md`](v6.md) §4.
|
||
|
||
#### 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 leader/alliance/name changed, its member count moved, or its first sight this connection. |
|
||
| `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`). |
|
||
| `guild.roster` **(4)** | `id`, `name`, `abbr`, `total`, `seq`, `more`, `members` (array of actor objects **carrying rank**) | The full member list. Emitted whenever the member set changes. **`seq` 0 supersedes whatever roster you hold for that guild; `more: false` ends it.** |
|
||
| `guild.leave` **(4)** | `id`, `name`, `who` (serial string) | Real-time: a member left. Advisory — see below. |
|
||
|
||
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user. Note `guild.leave`'s `who` is a bare **serial string**, not an actor object: the mobile has already left, so there is nothing to attribute.
|
||
|
||
**A roster member carries rank as well.** `rank` is 0–4 with 4 being Leader, plus `rankCliloc` (the cliloc the game names that rank with) or `rankName` when a shard uses custom rank definitions with literal names. Only the raw rank is sent: ServUO ships no text for those clilocs, so turning 1062960 into "Warlord" is the consumer's job.
|
||
|
||
Two things to get right, both of which bite:
|
||
|
||
- **Several members can hold rank 4.** `guild.update`'s single `leader` is the guild's founder-leader; it is not the set of leaders. If you need "who leads this guild", read the roster's ranks and treat `leader` as one more entry rather than the answer.
|
||
- **An absent `rank` means "not known" — never 0, and never leadership.** It has one deliberate cause: `PlayerMobile.GuildRank` reports Leader for any account at GameMaster or above whatever their real rank, so the bridge omits the rank for staff rather than publishing a claim it knows is false. Defaulting a missing rank to 0 silently demotes them; reading absence as leadership republishes exactly the lie the bridge avoided.
|
||
|
||
**On Protocol 4.** Before it, a guild's membership was a *count* and a leave surfaced only as that count dropping. `guild.roster` carries the members themselves, and `guild.leave` names who went.
|
||
|
||
`guild.leave` is **advisory**: any change to the member set re-emits the whole roster, so a consumer holding a membership table stays correct even if it ignores every leave event. Handle it when you want a "so-and-so left" feed to update without waiting for the sweep.
|
||
|
||
**Rosters can arrive in several frames.** Members per frame are capped so a large guild cannot produce an unbounded line (~69 bytes per member; the default cap is 500). Every realistic guild arrives as one frame with `seq: 0, more: false` and needs no special handling — but if you consume the raw stream, accumulate from `seq` 0 and apply on `more: false`, discarding a partial roster if a frame arrives out of order or the shard reconnects. `GET /guilds` hands you rosters already reassembled. A guild with no members emits one frame with an empty array, so an emptied roster is distinguishable from an absent one.
|
||
|
||
```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}
|
||
{"kind":"guild.roster","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
||
"total":14,"seq":0,"more":false,
|
||
"members":[{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||
{"serial":"0x77","name":"Bran","acct":"bran","player":true}],
|
||
"t":1752489282000}
|
||
{"kind":"guild.leave","id":1042,"name":"The Silver Hand","who":"0x77","t":1752489283000}
|
||
```
|
||
|
||
`acct` is genuinely optional on a member — a character can have no account at all — so do not assume it is present.
|
||
|
||
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.
|
||
|
||
#### 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":...}
|
||
```
|
||
|
||
#### 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.
|
||
|
||
#### Shard ruleset (Protocol 3.0)
|
||
|
||
How the shard is actually configured, published by the shard itself. **Not a sweep** — it changes only
|
||
when an operator edits `Config/*.cfg`, so it is emitted once per shard↔sidecar connect (and on
|
||
`[bridge reload`), exactly like `server.hello`.
|
||
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `world.ruleset` | `rev`, `shard`, `expansion`, `connect?`, `systems`, `caps`, `housing`, `accounts`, `vetRewards`, `loot`, `vendors`, `champions?`, `treasureMaps`, `vvv?`, `store`, `schedule?` | The whole ruleset, always complete — **never a delta**, so the latest frame replaces the previous one outright. Every block except `shard`/`expansion` is optional and is **omitted when its system is off**, so absence means "not applicable here", not "unknown". |
|
||
|
||
`rev` is the shard's FNV-1a of the body: identical `rev` means the ruleset is unchanged and this frame
|
||
is just a reconnect re-send, so a consumer can skip the write. It is deliberately **not**
|
||
`String.GetHashCode()`, which is seeded per process and would change on every shard restart.
|
||
|
||
```json
|
||
{"kind":"world.ruleset","rev":"1a2b3c4d","shard":"UOMysticmoon","expansion":"EJ",
|
||
"systems":{"cityLoyalty":true,"vvv":true,"factions":false,"siege":false,"chat":true,
|
||
"store":true,"dailyRares":true,"honesty":true,"shadowguard":true,
|
||
"treasureMaps":true,"vetRewards":true,"testCenter":false},
|
||
"caps":{"skill":1000,"totalSkill":7000,"stat":225,"str":125,"dex":125,"int":125,
|
||
"strMax":150,"dexMax":150,"intMax":150},
|
||
"housing":{"accountHouseLimit":1},
|
||
"accounts":{"perIp":3,"charSlots":7,"autoCreate":true},
|
||
"vetRewards":{"enabled":true,"rewardIntervalDays":30},
|
||
"loot":{"feluccaLuckBonus":1000,"feluccaBudgetBonus":100,"feluccaMaxProps":11},
|
||
"vendors":{"restockDelayMinutes":60,"maxSell":500,"economyStockAmount":500},
|
||
"champions":{"powerScrolls":6,"statScrolls":16,"scrollChance":0.1,
|
||
"transcendenceChance":50.0,"rankThresholds":[5,10,13]},
|
||
"treasureMaps":{"enabled":true,"lootChance":0.01,"resetDays":30},
|
||
"vvv":{"enabled":true,"startSilver":2000,"enhancedRules":false},
|
||
"store":{"enabled":true,"currencyName":"Sovereigns"},
|
||
"schedule":{"autoSaveEnabled":true,"autoSaveFrequencyMinutes":15,"autoRestartEnabled":false},
|
||
"t":1752489280000}
|
||
```
|
||
|
||
**Two things consumers get wrong.**
|
||
|
||
1. **`caps.skill` and `caps.totalSkill` are in tenths**, the way ServUO stores them: `1000` is `100.0`
|
||
skill and `7000` is `700.0` total. Rendering the raw number is actively misleading. The other caps
|
||
(`stat`, `str`, …) are plain integers.
|
||
2. **`connect` is present only if the operator set `Bridge.PublicConnectAddress`.** The shard's real
|
||
listen address (`Server.cfg`) is never published; nor are `Staff.cfg`, `Email.cfg`, `DataPath.cfg`,
|
||
`Bridge.cfg`, `Compiler.cfg`, `Reports.cfg` or `Client.cfg`. The frame is built from an explicit
|
||
allowlist in `BridgeRuleset.cs` — `Config.Entries` is never enumerated, because that would sweep in
|
||
every key on the server.
|
||
|
||
Absent entirely if the shard runs `Bridge.RulesetEnabled=false` or an older plugin. Render from
|
||
`GET /ruleset` (§6) on connect, then keep live with this event.
|
||
|
||
This **supersedes the `world.systems` frame** sketched in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.4 and
|
||
never implemented; the `systems` block above is what that asked for.
|
||
|
||
#### Points / loyalty leaderboards (Protocol 3.0)
|
||
|
||
ServUO carries ~25 separate point currencies — Queen's Loyalty, Void Pool, Casino, Clean Up Britannia,
|
||
the nine city loyalties, Blackthorn, the Doom / Khaldun / Kotl treasure systems — every one a standing
|
||
players accumulate over months, and none of them visible outside an in-game gump before 3.0.
|
||
|
||
A diff sweep (default 300 s), **one frame per system** rather than one large frame for all of them,
|
||
matching `champ.update` / `guild.update`. A system is emitted only when its top N or its participant
|
||
count actually changes.
|
||
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `points.board` | `system`, `nameString`, `nameNumber`, `maxPoints`, `showOnGump`, `players`, `top[]` | One system's complete board — **never a delta**. The latest frame for a `system` replaces the previous one outright. `top[]` entries are `{rank, serial, name, points}`. |
|
||
|
||
`system` is the shard's own `PointsType` enum name (`QueensLoyalty`, `CleanUpBritannia`, …) and is the
|
||
board's stable key. There is deliberately **no `points.remove`**: the set of systems is fixed at startup
|
||
by `PointsSystem.Configure`, so a system cannot disappear at runtime — the same argument `city.update`
|
||
makes for cities.
|
||
|
||
```json
|
||
{"kind":"points.board","system":"QueensLoyalty",
|
||
"nameString":"Queen's Loyalty","nameNumber":1114938,
|
||
"maxPoints":15000,"showOnGump":true,"players":842,
|
||
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500},
|
||
{"rank":2,"serial":"0x1A2C","name":"Mireille","points":21000}],
|
||
"t":1752489280000}
|
||
```
|
||
|
||
**Four things consumers get wrong.**
|
||
|
||
1. **`maxPoints` of `0` means UNCAPPED, not "zero points allowed".** ServUO's idiom for an uncapped
|
||
system is `double.MaxValue` (`DespiseCrystals`, `ShameCrystals` and `VoidPool` all use it), which
|
||
the plugin normalises to `0` rather than emitting a nonsense integer. On a real shard **most
|
||
systems are uncapped**, so a UI that renders `points / maxPoints` must special-case this or it will
|
||
divide by zero on the common path.
|
||
2. **`nameString` is usually `null`.** The shard's `Name` is a `TextDefinition`, which may carry a
|
||
literal *or* a cliloc id, and in practice most systems use the cliloc — so `nameNumber` is set and
|
||
`nameString` is `null`. Resolve clilocs consumer-side; failing that, humanising the `system` key
|
||
("CleanUpBritannia" → "Clean Up Britannia") reads better than showing a bare number. This is the
|
||
same contract `titles.reward` already documents.
|
||
3. **`players` counts players who actually hold points**, not the size of the system's table. Ten of
|
||
the ~25 systems have `AutoAdd = true` and therefore keep a zero-point row for every character that
|
||
has ever logged in, so the raw table size would report the shard's entire character census as that
|
||
system's participants.
|
||
4. **Entries carry `serial` and `name` only — never `acct` or `webId`.** A board is the widest-audience
|
||
surface the bridge has, so the account name of every ranked player deliberately does not cross the
|
||
wire; resolve serial → site user from your own link mirror if you need it.
|
||
|
||
Absent entirely if the shard runs `Bridge.PointsLeaderboardEnabled=false` or an older plugin. Render
|
||
from `GET /points` (§6) on connect, then keep live with this event.
|
||
|
||
##### `char.profile` gains a `points` block
|
||
|
||
Read-model enrichment on the existing kind — there is **no** request kind for one character's points,
|
||
the same precedent `titles` set in [`PROTOCOL_2.md`](PROTOCOL_2.md) §10.3:
|
||
|
||
```json
|
||
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty","nameNumber":1114938,
|
||
"points":29500,"maxPoints":15000}]
|
||
```
|
||
|
||
Systems where the character has no entry, or an entry at zero, are **omitted** — otherwise every sheet
|
||
would carry ~25 zeroes. `maxPoints` follows the same `0 == uncapped` rule as the board.
|
||
|
||
`rank` is **absent by default** and appears only when the shard runs `Bridge.PointsProfileRank=true`:
|
||
a points lookup stops at the character's own row, but a rank must count every row that beats them, in
|
||
every system, on every profile build. Derive rank from `points.board` instead for anyone in the top N.
|
||
|
||
#### Player-vendor marketplace (Protocol 3.0)
|
||
|
||
The shard-wide shop index: every player vendor's shop name, owner, location and priced inventory —
|
||
the same set the in-game **Vendor Search** gump reads, published so a site can offer the same search
|
||
from outside the game.
|
||
|
||
An **amortized round-robin diff sweep**, not a snapshot RPC, and the distinction is load-bearing:
|
||
`rpc.rs::try_route` correlates a reply on the FIRST frame carrying a matching `reqId`, so a chunked
|
||
reply sharing one `reqId` would deliver chunk 1 to the HTTP caller and leak chunks 2..N onto the
|
||
broadcast feed. A whole-world snapshot could not fit in one frame inside the 10 s reply timeout
|
||
either. The per-account `vendor.snapshot` RPC (§5) is unaffected and still serves the player portal.
|
||
|
||
Each tick inventories at most `Bridge.MarketSweepBatch` vendors (default 25) starting from a
|
||
persistent cursor, so **per-tick cost is bounded independently of world size**; full coverage takes
|
||
`ceil(vendors / batch) × MarketSweepSeconds`. A vendor is emitted only when its contents, prices,
|
||
shop name or location actually change.
|
||
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `vendor.listing` | `serial`, `shopName`, `ownerSerial`, `ownerName`, `ownerAcct`*, `location{}`, `fees{}`*, `count`, `total`, `truncated`, `items[]` | One vendor's complete shop — **never a delta**. The latest frame for a `serial` replaces the previous one outright. |
|
||
| `vendor.listing.remove` | `serial` | The shop is gone from the index: dismissed, expired, or its owner switched off the in-game Vendor Search flag. |
|
||
|
||
\* **Protocol 5.** `ownerAcct` is the owner's game account — `ownerName` is a character name and
|
||
identifies nobody, so this is the field that makes a shop resolvable to a person at all.
|
||
|
||
`fees` describes ServUO's vendor dismissal rule (`PlayerVendor.PayTimer`: at each tick the charge
|
||
is compared with the funds, and the vendor is destroyed when the charge wins):
|
||
|
||
| field | meaning |
|
||
|---|---|
|
||
| `exempt` | `true` for a commission vendor, which has no pay timer and is **never** dismissed for fees. When true, no other field is present |
|
||
| `newVendorSystem` | which of ServUO's two vendor systems is in force; it decides all three quantities below |
|
||
| `chargePerPeriod` | what is deducted at each tick |
|
||
| `funds` | gold available to pay it (`holdGold` and `bankAccount` are the raw parts) |
|
||
| `payIntervalSec` | seconds between ticks: 86400 under the new system, **one UO day (≈2 real hours)** under the old |
|
||
| `nextPayAt` | ISO-8601 UTC: the next tick |
|
||
| `periodsRemaining` | ticks survived before the one that finds the charge unpayable |
|
||
| `dismissalAt` | ISO-8601 UTC: the tick the vendor is destroyed on. **This is the field to build on** |
|
||
|
||
**There is deliberately no `daysRemaining`**: under the old vendor system a pay period is a UO day,
|
||
so a "days" field would be wrong by a factor of twelve on exactly the shards least likely to notice.
|
||
`dismissalAt` is an instant and needs no units. It assumes no further sales or deposits — but
|
||
unlike `house.decay`'s `estimatedCollapse` there is no randomness in it: given the current funds
|
||
it is exact.
|
||
|
||
**Treat `exempt: true` and a distant `dismissalAt` as different things.** "Never dismissed" and
|
||
"dismissed in 400 days" render differently, and conflating them is how a vendor that cannot expire
|
||
ends up in an expiry warning.
|
||
|
||
```json
|
||
{"kind":"vendor.listing","serial":"0x40001234",
|
||
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
||
"ownerAcct":"darrow_acct",
|
||
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
||
"region":"Britain","house":"Darrow's Villa"},
|
||
"fees":{"exempt":false,"newVendorSystem":true,"chargePerPeriod":10548,
|
||
"funds":82504,"holdGold":82504,"bankAccount":0,"payIntervalSec":86400,
|
||
"nextPayAt":"2026-09-01T21:01:21Z","periodsRemaining":7,
|
||
"dismissalAt":"2026-09-08T21:01:21Z"},
|
||
"count":2,"total":2,"truncated":false,
|
||
"items":[{"serial":"0x40012ABC","itemId":3922,"hue":0,"amount":1,
|
||
"price":25000,"name":null,"cliloc":1023721},
|
||
{"serial":"0x40012ABD","itemId":7026,"hue":1157,"amount":3,
|
||
"price":500,"name":"a shard sigil","cliloc":1041243}],
|
||
"t":1752489280000}
|
||
```
|
||
|
||
**Six things consumers get wrong.**
|
||
|
||
1. **`name` is `null` for nearly every item; `cliloc` is the real label.** Items carry a
|
||
`LabelNumber`, not a name. The plugin deliberately never calls `VendorSearch.GetItemName`, which
|
||
builds an `ObjectPropertyList`, serialises it and byte-parses the packet **per item** — a
|
||
multi-hundred-millisecond stall across a full pass. (It would not work anyway: every current
|
||
client ships its cliloc files compressed and ServUO's bundled `Ultima.StringList` cannot read
|
||
them, so the in-game gump has the same gap.) Resolve clilocs consumer-side; a non-null `name` is a
|
||
player-set literal and is strictly more specific, so **prefer it over the cliloc**.
|
||
2. **`location` is one nested object, and it may be absent entirely.** It is nested so that a
|
||
consumer gating vendor whereabouts gates one field rather than five that can drift apart — the
|
||
website's `market.location` rule removes the whole object. Treat a missing `location` as "not
|
||
published", not as an error.
|
||
3. **`truncated` means the shop holds more than the frame carries.** `count` is what was published,
|
||
`total` is what the shop actually holds, capped by `Bridge.MarketMaxListings` (default 250). A
|
||
commodity reseller with thousands of stacked resources is real and an uncapped frame for one is
|
||
measured in megabytes. Say "showing 250 of 3,104" rather than presenting a partial shop as
|
||
complete.
|
||
4. **`child: true` means the price buys the ENCLOSING CONTAINER.** ServUO prices a container as a
|
||
unit and everything inside inherits that price with no `VendorItem` of its own; `DoSearch`
|
||
surfaces the same flag. A UI that prints the container's price against each item inside it is
|
||
lying about the shard.
|
||
5. **Opted-out vendors are absent, and that is a privacy control.** `pv.VendorSearch` is the player's
|
||
own in-game toggle and the sweep honours it — hide your vendor in game and it is hidden here too.
|
||
The same goes for `Map.Internal` and a null backpack, matching `DoSearch`. Process
|
||
`vendor.listing.remove` promptly: it is how a player *revoking* that consent reaches you.
|
||
6. **Prices are inherently stale, by design.** The round-robin sweep means a shop can be a full cycle
|
||
behind. Any UI over this must say how old the data may be — the website derives it from the oldest
|
||
vendor row.
|
||
|
||
Entries carry `ownerSerial`/`ownerName` and **never `acct` or `webId`**, the same rule `points.board`
|
||
follows. Absent entirely if the shard runs `Bridge.MarketEnabled=false` or an older plugin. Render
|
||
from `GET /market` (§6) on connect, then keep live with these events — though note that a live
|
||
firehose of whole vendor inventories is the largest stream the bridge produces, and a consumer that
|
||
only needs a browsable index (as the website does) is better served by the REST read plus the
|
||
periodic re-sweep.
|
||
|
||
---
|
||
|
||
## 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} }
|
||
],
|
||
"titles": { "selected": 0, "fameKarma": "Lord", "skill": "Grandmaster Swordsman",
|
||
"reward": ["1154060", "The Bold"] }
|
||
}
|
||
```
|
||
|
||
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. **Do not expect the shard to resolve them for you per item** — on any modern client ServUO's own `Ultima.StringList` cannot read the client's compressed cliloc files, so `VendorSearch.GetItemName` returns `item.Name` and the in-game Vendor Search gump has the same gap. As of Protocol 8 the shard will hand you the whole table instead: `GET /cliloc` (§5), which is where the website's own comes from ([`website/CLILOCS.md`](../website/CLILOCS.md)).
|
||
- `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
|
||
|
||
```
|
||
GET /roster/{account}
|
||
```
|
||
|
||
Lightweight list of an account's characters (up to 5–7), including offline ones. Use this for a character-picker, then fetch the full profile on demand.
|
||
|
||
```json
|
||
{ "kind":"account.roster", "acct":"whitlocktech",
|
||
"chars":[ {"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false} ] }
|
||
```
|
||
|
||
### Player vendors
|
||
|
||
```
|
||
GET /vendors/{account}
|
||
```
|
||
|
||
Every player vendor owned by any character on the account, with held gold and current listings.
|
||
|
||
```json
|
||
{ "kind":"vendor.snapshot", "acct":"seed_000",
|
||
"vendors":[
|
||
{ "serial":"0x2C0", "shopName":"Seed Shop 810", "holdGold":24186,
|
||
"ownerSerial":"0x1F5", "map":"Felucca", "x":1402, "y":1604,
|
||
"listings":[
|
||
{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}
|
||
] } ] }
|
||
```
|
||
|
||
### Client assets — artwork, names and the shard's own files (Protocol 8)
|
||
|
||
Five routes, and they are unlike everything above them: they read the **UO client installed on the
|
||
shard host** rather than the live world. Nothing here changes unless an operator patches that
|
||
client, so these are the only reads on this API you should cache indefinitely and refresh on an
|
||
event you decide, not on a timer. The design is [`v8.md`](v8.md); what follows is what a consumer
|
||
needs.
|
||
|
||
Everything on this plane obeys three rules stated in §2: **425 is flow control**, **the caller
|
||
drives the paging** (`more` / `cursor` / `cut`), and **an unserveable key is a row, not a failure**.
|
||
|
||
```
|
||
GET /assets/sources # what the shard's client files currently are
|
||
GET /assets/manifest?family=&cursor= # one row per asset: key, hash, size — no pixels
|
||
POST /assets/fetch # the bytes, for keys you name
|
||
POST /assets/bodies # creature class name → body id
|
||
GET /cliloc?lang=&cursor= # the decompressed cliloc table
|
||
```
|
||
|
||
**`GET /assets/sources` is the gate, and you call it first.** It is cheap, it touches no pixels, and
|
||
its answer decides whether there is anything to do at all:
|
||
|
||
```json
|
||
{ "kind":"assets.sources.ok", "assetsEnabled":true, "treeEnabled":true,
|
||
"extractorVersion":3, "families":["body","land","static","tree"],
|
||
"hashing":false, "complete":true, "imaging":{"ok":true},
|
||
"files":[ {"name":"anim.mul","size":194950053,"mtime":1778566017000,"sha256":"2d89…"} ],
|
||
"more":false, "cut":"end" }
|
||
```
|
||
|
||
- **`files[].sha256` is the whole update story.** Store the set; on the next run, compare. Equal
|
||
means the client has not been patched and there is nothing to transfer — which is the normal case
|
||
and must cost one round trip, not a re-download.
|
||
- **`hashing: true` means "not computed yet", never "changed".** The shard fingerprints a few
|
||
hundred megabytes in the background after a restart; a null hash during that window is an absence
|
||
of an answer, not an answer of absence.
|
||
- **`extractorVersion` is ours, not the client's.** It changes when the shard's *derivation* changes
|
||
— a better reader, a different frame — so a bump invalidates stored pictures even though the
|
||
client files are byte-identical. Treat a change in it exactly like a changed hash.
|
||
- **`families` says what this overlay can serve.** An older overlay answers `["body"]`; asking it
|
||
for `static` fails per key, per pass, forever. Read this and say "update your plugin" instead.
|
||
- **`imaging.ok: false` is `NO_IMAGING`** — a Linux shard host with no `libgdiplus` cannot decode a
|
||
sprite at all ([`SHARD_PREREQS.md`](SHARD_PREREQS.md)). Names and tree files are unaffected: they
|
||
have no pixels in them.
|
||
|
||
**`GET /assets/manifest?family=body`** lists what exists, with a hash and a size and no pixels — so
|
||
you can diff it against what you hold and fetch only what moved. Rows carry
|
||
`{key, sha256, bytes, width, height, body, action, source}`; `source` is `legacy` or `uop` (which
|
||
reader produced the bytes) and `action` is which animation action the thumbnail came from, because a
|
||
body with nothing at action 0 is catalogued at the first action that has anything — `body/820/a23`
|
||
is a horse. This family pages on the shard's wall clock, so expect several pages of a few hundred
|
||
rows.
|
||
|
||
**`POST /assets/fetch`** takes `{"keys":[…], "catalog":"…", "cursor":"…"}` and returns a row per key
|
||
carrying the sprite as **base64 PNG** (the `tree` family returns gzipped chunks instead). Two things
|
||
are load-bearing:
|
||
|
||
- **Pass back the `catalog` id the manifest gave you.** It is derived from the client files
|
||
themselves, and it makes the shard refuse (**422**) if those files moved mid-import. Without it,
|
||
an operator who patches their client halfway through gets one asset set stitched out of two, with
|
||
no error anywhere.
|
||
- **A batch must be of one family**, derived from the keys rather than named as a field. Mixing is
|
||
refused (400), because a reply carries a single `catalog` and two families have two fingerprints.
|
||
|
||
**`POST /assets/bodies`** takes `{"types":["GiantSpider", …]}` and answers the one question only code
|
||
running inside ServUO can: which body id a creature class actually uses. It runs on the Core thread,
|
||
so the shard **caps the batch and refuses rather than truncates** — a 400 here names the cap; chunk
|
||
your list.
|
||
|
||
**`GET /cliloc?lang=enu`** is the client's id → text table, decompressed on the shard and delivered
|
||
as rows (`[{n, f, t}]`) inside the ordinary paging envelope. Blank entries are omitted — roughly
|
||
56,000 of them — because every consumer discards them anyway. This is the table you resolve
|
||
`cliloc` numbers against in `vendor.listing`, `char.profile` equipment and title rows; §6's note
|
||
that the shard cannot resolve them for you is **why this route exists**.
|
||
|
||
Two absences worth stating, because both look like bugs and neither is:
|
||
|
||
- **A body with no art is normal**, and on a stock client that is most ghost and gargoyle bodies.
|
||
The row says so; render text.
|
||
- **The shard never sweeps file types looking for a hit.** If the client's own mapping yields
|
||
nothing, the answer is nothing — asking the other animation files for the same index returns a
|
||
*different creature's* art that reports success, which is a wrong picture nothing downstream can
|
||
detect ([`v8.md`](v8.md) §4.6).
|
||
|
||
---
|
||
|
||
## 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`.
|
||
|
||
### 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": "<CENTER>Double XP Weekend</CENTER><BR><BR>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
|
||
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"`.
|
||
|
||
### Retrying a command safely — `idempotencyKey` (Protocol 6)
|
||
|
||
Any command in this section may carry an **`idempotencyKey`**, and the shard promises to execute a
|
||
key **at most once**. A repeat is not re-run: it is answered with the **original reply**, restamped
|
||
with the repeat's own correlation id and marked `"replayed": true`.
|
||
|
||
```json
|
||
POST /admin/broadcast
|
||
{ "actor":"event:412", "text":"The gates open at dusk.", "idempotencyKey":"5f2c…" }
|
||
|
||
→ 200 { "kind":"admin.ok", "reqId":"r-1", "action":"broadcast", "t":1788550182074 }
|
||
→ 200 { "kind":"admin.ok", "reqId":"r-2", "action":"broadcast", "t":1788550182074, "replayed":true }
|
||
```
|
||
|
||
Note the second reply's `t`: it is the **first** attempt's, because it is the stored answer rather
|
||
than a fresh execution. The world write happened once.
|
||
|
||
This is what makes a command safe to retry after a timeout. Without a key, a lost acknowledgement
|
||
and a command that never applied are the same event as seen from here, and the only safe policy is
|
||
to give up on the announcement rather than risk sending it twice.
|
||
|
||
Four rules for a caller:
|
||
|
||
- **A key belongs to your unit of work, not to the attempt.** Derive it from something stable — the
|
||
website's event runner uses `sha256(runId|stepId)` — so every retry of one action carries the
|
||
same key and a different action never collides with it. A fresh value per call satisfies the field
|
||
and defeats the entire mechanism.
|
||
- **A key is remembered for one hour**, bounded at 4096 keys per shard. Retry inside that window.
|
||
- **A repeat that arrives while the original is still in flight** is answered **425 Too Early**
|
||
(`{"kind":"bridge.busy"}`). Nothing ran; come back. It is transient by construction.
|
||
- **A replayed reply is an ordinary 200.** Treat it exactly as you would have treated the answer you
|
||
lost; `replayed` is for your log.
|
||
|
||
Sending no key is exactly the pre-protocol-6 behaviour, which is the right choice for a command a
|
||
human just pressed a button for and can see the result of.
|
||
|
||
The DELETE forms (`/towncrier/{id}`, `/news/{id}`) take no key: their idempotency is inherent — the
|
||
second removal of an entry is a no-op the shard is already happy to perform.
|
||
|
||
### The event plane — leases (Protocol 6)
|
||
|
||
**Off by default.** Every endpoint below answers **403** unless the operator has set
|
||
`Bridge.EventsEnabled` on the shard. That is deliberately not the admin write plane's switch:
|
||
enabling admin writes is consenting to staff moderation from a screen a human is looking at, and
|
||
enabling this is consenting to your world being changed and watched on a schedule, unattended.
|
||
|
||
A **lease** is a live configuration value held at a new setting for a bounded time. The shard
|
||
restores the baseline when the deadline passes **whether or not you are ever heard from again** —
|
||
so the worst case is a world back at baseline early, never one stuck changed indefinitely.
|
||
|
||
```json
|
||
GET /lease
|
||
→ 200 { "kind":"lease.list.ok", "leases":[
|
||
{ "key":"PlayerCaps.SkillCap", "label":"Starting skill cap", "type":"float",
|
||
"min":1000, "max":1500, "default":"1000", "current":"1000", "held":false } ] }
|
||
|
||
POST /lease
|
||
{ "key":"PlayerCaps.SkillCap", "value":"1200", "holdMs":600000,
|
||
"untilMs":1788567000000, "runId":"77", "idempotencyKey":"…" }
|
||
→ 200 { "kind":"lease.ok", "baseline":"1000", "applied":"1200", "untilMs":1788567000000 }
|
||
|
||
POST /lease/release
|
||
{ "key":"PlayerCaps.SkillCap", "expected":"1200", "baseline":"1000" }
|
||
→ 200 { "kind":"lease.ok", "released":true, "current":"1000" }
|
||
```
|
||
|
||
Five things a caller needs:
|
||
|
||
- **The catalog is an allowlist and it is short.** A shard advertises only keys it has verified take
|
||
effect. Most of ServUO's configuration is cached at type initialisation, where a lease would apply
|
||
cleanly and do nothing — the worst failure this feature has — so `lease.list` is the authority and
|
||
"any config key" is not offered.
|
||
- **`holdMs` is what the shard honours; `untilMs` is for display.** Send both. An absolute deadline
|
||
is measured against two clocks, and a shard whose clock runs fast would restore your lease the
|
||
moment it took it.
|
||
- **Values cross as text, whatever `type` says.** `"1200"`, not `1200`. Comparison is done on parsed
|
||
values at the other end; the text is so a compare-and-set is comparing what you sent.
|
||
- **`released` can answer `lease.drifted` at 200.** That means somebody moved the value while you
|
||
held it, the shard **did not overwrite them**, and `current` is what is there now. It is not an
|
||
error: the mechanism did its job, and only a human can decide what should happen next.
|
||
- **`held` means the shard still has a record of the lease, not that the value is still overridden.**
|
||
A lease whose deadline has fired stays listed with `expired: true` until you release it, so a
|
||
reconcile in that window does not read a working backstop as a lost resource. A shard restart, by
|
||
contrast, reverts every lease and clears the record — `held: false` is how you learn that.
|
||
|
||
### The event plane — participation (Protocol 6)
|
||
|
||
A run-scoped tally of who took part: presence in a declared area, plus kill credit inside it, keyed
|
||
by **character serial**. The shard computes a score and you store it; the components ride along so
|
||
you can explain it.
|
||
|
||
```json
|
||
POST /participation
|
||
{ "runId":"99", "map":"Felucca", "x":1496, "y":1628, "radius":40, "holdMs":3600000 }
|
||
→ 200 { "kind":"participation.ok", "runId":"99", "members":0, "closed":false }
|
||
|
||
POST /participation/99/snapshot
|
||
{ "idempotencyKey":"…" }
|
||
→ 200 { "kind":"participation.snapshot.ok", "runId":"99", "members":2, "killWeight":5,
|
||
"participants":[ { "serial":"0xCB20", "name":"Jarvis", "acct":"seed_001", "webId":"17",
|
||
"seconds":3600, "minutes":"60.00", "kills":3, "score":"75.0000",
|
||
"firstMs":1788550182074, "lastMs":1788553782074 } ] }
|
||
|
||
POST /participation/99/close
|
||
→ 200 { "kind":"participation.ok", "closed":true, "known":true, "members":2 }
|
||
```
|
||
|
||
- **The area is a point and a radius, not a region name.** The most specific region containing an
|
||
event is routinely anonymous on a UO map — an active champion spawn registers a nameless region
|
||
over its own area — so a region-named venue would be undeclarable for exactly the places events
|
||
happen.
|
||
- **`snapshot` is a POST for a read**, because it carries your `idempotencyKey`. On a well-attended
|
||
run the shard walks its members across game ticks rather than in one call, so a repeat arriving
|
||
mid-walk is answered **425**. Come back.
|
||
- **The tally is persisted in the world save**, so it survives a restart mid-event. `close` on a run
|
||
the shard has forgotten answers `known: false` at 200 rather than an error: nothing is being
|
||
counted for it either way.
|
||
- **`refused`** on a snapshot is the number of members the shard's cap turned away. A truncated tally
|
||
says so rather than quietly being short.
|
||
- **`webId`** is present only where the character's game account is linked to a website user. Most
|
||
characters carry none; treat its absence as ordinary.
|
||
|
||
### 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 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. Ordered by name. Survives a sidecar restart.
|
||
|
||
Each entry is a `guild.update` payload **plus, from Protocol 4, a `roster` key** holding the member list — already reassembled, so the frame-splitting described in §4 never reaches this endpoint:
|
||
|
||
```
|
||
→ { "guilds": [ {"kind":"guild.update","id":1042, ..., "roster":[
|
||
{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||
{"serial":"0x77","name":"Bran","acct":"bran","player":true} ]}, ... ] }
|
||
```
|
||
|
||
A guild that has had a `guild.update` but no roster yet has **no `roster` key at all** — deliberately distinct from `"roster": []`, which means the guild is genuinely empty. Do not conflate "not known" with "known to be empty".
|
||
|
||
### 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.
|
||
|
||
### 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`.
|
||
|
||
### 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.
|
||
|
||
### Shard ruleset (Protocol 3.0)
|
||
|
||
```
|
||
GET /ruleset
|
||
→ { "ruleset": {"kind":"world.ruleset","rev":"1a2b3c4d","shard":"UOMysticmoon",
|
||
"expansion":"EJ","systems":{...},"caps":{...},"accounts":{...}, ... } }
|
||
```
|
||
|
||
The shard's published ruleset (§4 for the full frame and its two gotchas). Served from the sidecar's
|
||
store, so it **answers while the shard is down** — a rules page that goes blank during a restart is
|
||
worse than one that is briefly stale. Keep it current with the `world.ruleset` stream.
|
||
|
||
`{"ruleset": null}` means the shard has never published one — an older plugin, or
|
||
`Bridge.RulesetEnabled=false`. That is a real answer distinct from a published ruleset, and worth
|
||
rendering differently ("not published yet") rather than as an empty ruleset.
|
||
|
||
### Points / loyalty leaderboards (Protocol 3.0)
|
||
|
||
```
|
||
GET /points
|
||
→ { "boards": [ {"kind":"points.board","system":"QueensLoyalty","nameString":"Queen's Loyalty",
|
||
"nameNumber":1114938,"maxPoints":15000,"showOnGump":true,"players":842,
|
||
"top":[{"rank":1,"serial":"0x1A2B","name":"Darrow","points":29500}, ...],"t":...}, ... ] }
|
||
|
||
GET /points/{system} # e.g. /points/QueensLoyalty
|
||
→ {"kind":"points.board","system":"QueensLoyalty", ... }
|
||
```
|
||
|
||
Every system's latest board, or one by its `PointsType` name (§4 for the frame and its four gotchas).
|
||
Served from the sidecar's projection, kept current by the `points.board` stream, ordered by display
|
||
name. Survives a sidecar restart — which matters more here than for live state, since these are
|
||
standings built over months and blanking them during a restart reads as data loss.
|
||
|
||
`GET /points/{system}` returns **404** for a system the shard has never published (an unknown name, or
|
||
one excluded by `Bridge.PointsSystems`). That is distinct from a published board nobody has scored in
|
||
yet, which is **200** with an empty `top[]` — and the two are worth rendering differently.
|
||
|
||
### Player-vendor marketplace (Protocol 3.0)
|
||
|
||
```
|
||
GET /market?limit=200&offset=0
|
||
→ { "vendors": [ {"kind":"vendor.listing","serial":"0x40001234",
|
||
"shopName":"Darrow's Bargains","ownerSerial":"0x1A2B","ownerName":"Darrow",
|
||
"location":{"map":"Trammel","x":1421,"y":1699,"z":0,
|
||
"region":"Britain","house":"Darrow's Villa"},
|
||
"count":2,"total":2,"truncated":false,"items":[ ... ],"t":...}, ... ],
|
||
"total": 137, "limit": 200, "offset": 0 }
|
||
```
|
||
|
||
Every vendor's latest shop, exactly as `vendor.listing` published it (§4 for the frame and its six
|
||
gotchas). Served from the sidecar's projection, so it answers while the shard is down.
|
||
|
||
**This is the only PAGED read the sidecar serves**, because it is the only board that can be a whole
|
||
world's inventory. `limit` is clamped to 1..1000 (default 200); `total` is returned so a caller knows
|
||
when to stop rather than paging until it sees a short page, which would race a concurrent sweep.
|
||
Ordering is by **serial**, not by shop name — a serial is stable while a shop name is renameable, so
|
||
a rename mid-walk cannot make a vendor skip or repeat a page.
|
||
|
||
The route is `/market` and deliberately **not** `/vendors`: `/vendors/{account}` next door is the
|
||
per-account RPC (§5), and two routes a prefix apart meaning "this player's shops" and "every shop on
|
||
the shard" is a trap nobody wins.
|
||
|
||
Frames are served **verbatim**, owner names and coordinates included. That is not an oversight: the
|
||
sidecar defines no audiences. Deciding who may see what is the consuming site's job — see
|
||
[`v3.md`](v3.md) §3 for how the website does it.
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| 403 | Refused by the operator — the admin write plane, the event plane (`Bridge.EventsEnabled`), or the asset plane (`Bridge.AssetsEnabled` / `Bridge.TreeEnabled`), is switched off on the shard |
|
||
| 404 | Not found (unknown account / character / id, a not-linked account, an unoffered lease key, a run the shard is not counting, or a client file this install does not have) |
|
||
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
|
||
| 425 | Too Early — a command with this `idempotencyKey` is still in flight (Protocol 6), or the shard's single asset slot is held by another `/assets/*` or `/cliloc` call (Protocol 8). Nothing ran; back off and retry |
|
||
| 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. On the asset plane it also carries `NO_IMAGING`: the host cannot render images at all |
|
||
| 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."
|
||
|
||
`425` vs `409`: both are conflicts of a sort and they want **opposite** responses. A `409` is a
|
||
deployment fault — your pinned protocol version disagrees with the sidecar's — and retrying it will
|
||
never help. A `425` is a retry that will succeed on its own. They are deliberately different codes
|
||
so a retry loop cannot quietly swallow a mismatched deployment.
|
||
|
||
`422` exists only on the asset plane, and it means one of two things, both of which are the client
|
||
files moving under you: a file the shard cannot decode, or a `catalog` id that no longer describes
|
||
what is on disk — the mid-import guard. Start the import again rather than retrying the page.
|
||
|
||
---
|
||
|
||
## 8. Putting it together
|
||
|
||
A typical character page:
|
||
|
||
```js
|
||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "8" };
|
||
|
||
// 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.
|
||
- **The asset plane is a working set, not a stream.** `/assets/*` and `/cliloc` read files that only
|
||
change when an operator patches the shard's UO client, and the shard serves one such request at a
|
||
time. Import on an operator's action or a hash change — never on your own boot, and never on a
|
||
timer: a client-file pass costs hundreds of megabytes of hashing to discover that nothing moved,
|
||
and it holds the slot every other caller is waiting for.
|