Overlay BridgePresence (new): - presence.online sweep over online PlayerMobiles: total plus per-facet and per-region counts, emitted only when the population changes. - region.enter real-time from EventSink.OnEnterRegion (player-filtered), the cheap location signal PLAN.md prefers over Movement. - PresenceSweepSeconds (30s); wired into [bridge reload|sweepnow|status. Sidecar: - GET /online serves the latest presence.online snapshot from the event store (survives restart); population time series via /history?kind=presence.online. Docs: INTEGRATION.md presence events + /online endpoint; PROTOCOL_2 ph.2 built. Verified: sidecar cargo check clean; overlay compiles in the full ServUO Scripts tree (0 errors, 0 warnings). Live run pending. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
657 lines
34 KiB
Markdown
657 lines
34 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 (the sidecar logs it); rotate by editing `sidecar.toml` and restarting.
|
||
|
||
---
|
||
|
||
## 2. Protocol version
|
||
|
||
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
|
||
|
||
- Every response carries an **`X-UOLink-Version: 2`** header.
|
||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
|
||
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||
|
||
```json
|
||
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" }
|
||
```
|
||
|
||
Pin the version you built against and compare it to the header (or `/health.protocol`) at startup.
|
||
|
||
**v2 (Protocol 2.0)** added the account-provisioning surface (§6.x: `POST /accounts/create`, `DELETE /link/{account}`) and the `account.*` events. Outbound event kinds are **additive** — a v1 client that ignores unknown kinds keeps working against the live feed — but the new *endpoints* require a v2 sidecar. If you send `X-UOLink-Version: 1`, calls to the new endpoints are refused with the 409 above.
|
||
|
||
---
|
||
|
||
## 3. Health
|
||
|
||
```
|
||
GET /health (no auth)
|
||
```
|
||
|
||
```json
|
||
{
|
||
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
|
||
"protocol": 1,
|
||
"plugin_connected": true, // is the shard link up right now?
|
||
"database": "ok", // "ok" | "error"
|
||
"uptime": "3d 12h",
|
||
"last_event": "2026-07-10T22:08:27Z" // last line received from the shard; null if none yet
|
||
}
|
||
```
|
||
|
||
Always returns HTTP 200 (read `status`/`plugin_connected` for real state). Use it for liveness checks and to detect when the shard has dropped (`plugin_connected: false`).
|
||
|
||
---
|
||
|
||
## 4. WebSocket live feed
|
||
|
||
```
|
||
GET /ws?token=<token> (WebSocket upgrade)
|
||
```
|
||
|
||
A push-only stream of game events as they happen. You do **not** send commands over the WebSocket — use REST for that. The socket carries one JSON object per text frame.
|
||
|
||
**On connect**, the first frame is:
|
||
|
||
```json
|
||
{ "kind": "ws.hello", "protocol": 1 }
|
||
```
|
||
|
||
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
|
||
|
||
Notes:
|
||
- **Live-only, no replay.** A client that connects now sees events from now on. For history/backfill, use `GET /history`.
|
||
- The sidecar sends WebSocket **ping** frames every ~30s for keepalive; browser clients answer automatically.
|
||
- You may occasionally see a `{"kind":"pong",...}` frame (the sidecar's internal heartbeat to the shard). Ignore any `kind` you don't handle.
|
||
- A client that falls far behind is dropped rather than allowed to stall others — reconnect and backfill via REST if that happens.
|
||
|
||
### Minimal browser client
|
||
|
||
```js
|
||
const ws = new WebSocket(`ws://127.0.0.1:8080/ws?token=${TOKEN}`);
|
||
ws.onmessage = (m) => {
|
||
const ev = JSON.parse(m.data);
|
||
switch (ev.kind) {
|
||
case "ws.hello": /* check ev.protocol === 1 */ break;
|
||
case "mob.login": onLogin(ev); break;
|
||
case "vendor.sale": onSale(ev); break;
|
||
case "house.decay": onIdoc(ev); break;
|
||
// ...handle the kinds you care about; ignore the rest
|
||
}
|
||
};
|
||
ws.onclose = () => setTimeout(connect, 2000); // reconnect + backfill via /history
|
||
```
|
||
|
||
### Event catalog
|
||
|
||
Every event has `t` (epoch ms) and `kind`. A nested actor object looks like `{"serial","name","acct","player"}` (`acct` present only for player-owned mobiles).
|
||
|
||
#### Lifecycle
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `server.hello` | `shard`, `bootId`, `connects`, `items`, `mobiles`, `accounts` | Sent to the sidecar on every shard (re)connect. `bootId` changes on a shard restart; stable across sidecar reconnects — use it to tell "shard restarted" (drop caches) from "sidecar reconnected". |
|
||
| `server.shutdown` | — | Clean shutdown. |
|
||
| `server.crashed` | `error` | Not always sent (a hard crash may skip it). |
|
||
| `world.save.before` / `world.save.after` | (`after` adds `items`, `mobiles`) | Save-cycle boundaries; a natural consistency checkpoint. |
|
||
|
||
#### Sessions & identity
|
||
| kind | fields |
|
||
|------|--------|
|
||
| `mob.login` | `who`, `map`, `x`, `y`, `z`, `webId` (present if the account is linked) |
|
||
| `mob.logout` | `who` |
|
||
| `account.login.attempt` | `acct`, `ip` — an authentication attempt (no password ever leaves the shard) |
|
||
|
||
#### Economy & commerce
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `gold.change` | `acct`, `old`, `new`, `delta` | AccountGold flow (gold in bank/account, not physical coins). |
|
||
| `vendor.buy` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor purchase (validation stage). |
|
||
| `vendor.sell` | `who`, `vendor`, `item`, `itemSerial`, `amount`, `perUnit`, `total`, `committed:false` | **NPC** vendor sale. |
|
||
| `vendor.sale` | `buyerSerial`, `buyerAcct`, `ownerSerial`, `ownerAcct`, `vendorSerial`, `itemType`, `itemSerial`, `itemId`, `amount`, `price`, `commission`, `committed:true` | **Player** vendor sale, at the committed transaction. Carries both buyer and owner accounts — the pair that flags laundering when they match. |
|
||
| `vendor.placed` | `owner`, `vendor` | A player vendor was placed. |
|
||
|
||
```json
|
||
{"kind":"vendor.sale","committed":true,"buyerAcct":"wttest","buyerSerial":"0x2E0",
|
||
"ownerAcct":"seed_000","ownerSerial":"0x1F5","vendorSerial":"0x2E1",
|
||
"itemType":"Longsword","itemSerial":"0x40015218","itemId":3937,"amount":1,
|
||
"price":100,"commission":0,"t":1783720195626}
|
||
```
|
||
|
||
#### Character progression & vitals
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `char.vitals` | `serial`, `hits`,`hitsMax`, `mana`,`manaMax`, `stam`,`stamMax`, `str`,`dex`,`int`, `map`, `x`,`y` | Periodic snapshot of each **online** player (~every 30s; configurable). Diff successive snapshots to detect change. |
|
||
| `skill.gain` | `who`, `skill`, `gained`, `base`, `cap` | Player skill gains only (NPC gains are filtered out). |
|
||
| `fame.change` / `karma.change` | `who`, `old`, `new` | Player only. |
|
||
| `quest.complete` | `who`, `quest` | |
|
||
|
||
#### Death & PvP
|
||
| kind | fields |
|
||
|------|--------|
|
||
| `player.death` | `who`, `killer` |
|
||
| `player.murdered` | `victim`, `murderer` |
|
||
| `mob.killed` | `killed`, `killer` — only kills that involve a player |
|
||
|
||
#### Housing / IDOC
|
||
| kind | fields |
|
||
|------|--------|
|
||
| `house.decay` | `serial`, `from`, `to`, `map`, `x`,`y`,`z`, `region`, `name`, `ownerSerial`, `ownerAcct`, `ban:{x,y,z}`, `builtOn`, `lastRefreshed` |
|
||
|
||
`from`/`to` are decay stages (`LikeNew`, `Slightly`, `Somewhat`, `Fairly`, `Greatly`, `IDOC`, `Collapsed`, …). Emitted only on a **transition**, so watch for `to == "IDOC"`. `ban` is where a player would stand to see the sign.
|
||
|
||
```json
|
||
{"kind":"house.decay","serial":"0x4004705F","from":"Somewhat","to":"Fairly",
|
||
"map":"Trammel","x":1119,"y":1794,"z":0,"region":null,"name":"An Unnamed House",
|
||
"ownerSerial":"0x75","ban":{"x":1112,"y":1804,"z":0},
|
||
"builtOn":"2026-05-11T03:12:24Z","lastRefreshed":"2026-05-31T02:36:51Z"}
|
||
```
|
||
|
||
#### Economy supply (periodic)
|
||
| kind | fields |
|
||
|------|--------|
|
||
| `economy.supply` | `accounts`, `gold` — total money supply across all accounts (~every 5 min; configurable) |
|
||
|
||
#### Cheat detection & staff audit
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `cheat.fastwalk` | `who`, `ip` | The shard's own speed-hack detector fired. |
|
||
| `audit.set` | `staff`, `prop`, `target`, `targetSerial`, `old`, `new` | A staff member used `[set` to change a property. `staff` may be null. |
|
||
| `audit.command` | `staff`, `command`, `args` | A staff command was invoked. |
|
||
| `admin.audit` | `origin`, `action`, `actor`, `target`, `reason`, plus action-specific (`durationSec`, `sessions`, `hue`, `text`) | A moderation action was applied. `origin` is `"web"` (from the site, `actor:"web:<user>"`) or `"in-game"` (a staff member in the game client). Broadcast to every dashboard so your moderation log stays complete regardless of who acted. Emitted alongside the `admin.ok` reply for web actions; see §6. |
|
||
|
||
#### Account linking & provisioning
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `link.request` | `code`, `account`, `char`, `ttlSec` | A player ran `[link` in game. Show them a prompt to enter `code` on the site; you then confirm it via `POST /link/confirm`. See §6. |
|
||
| `account.audit` | `origin`, `action`, `actor`, `target`, `websiteUserId` | A provisioning action was applied from the site (`origin:"web"`, `actor:"web:<user>"`). `action` is `create` or `unlink`; `target` is the account. Broadcast to every dashboard. **Never carries the password.** Emitted alongside the `account.ok` reply; see §6. |
|
||
| `account.unlinked` | `origin`, `account`, `websiteUserId`, `char` | A player ran `[unlink` **in game** (`origin:"in-game"`), severing the tie themselves. Drop the link from any roster you cache and reconcile your own record. |
|
||
|
||
#### Help-page (support) queue
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `page.new` | `pageId`, `sender`, `type`, `message`, `map`, `x`,`y`,`z`, `sentMs`, `handled`, `handler` | A player opened a help page (support ticket). `pageId` is the sender's serial (one page per player). `type` is `Bug`/`Stuck`/`Account`/`Question`/`Suggestion`/`Other`/`VerbalHarassment`/`PhysicalHarassment`. `sender` is the usual actor object (with `webId` if the account is linked). |
|
||
| `page.updated` | same as `page.new` | A page's handled state changed (a staffer claimed/released it in game). |
|
||
| `page.closed` | `pageId` | The page left the queue (resolved, cancelled, or the player logged out). |
|
||
|
||
The queue has no in-game event, so it's polled (`PageSweepSeconds`, default 5s) — expect a few seconds' latency, and use `GET /pages` for the authoritative current queue on connect. See §6 to snapshot, respond, and close.
|
||
|
||
#### Champion spawns
|
||
|
||
Champion spawns have no in-game event either, so they're polled (`ChampSweepSeconds`, default 10s) and emitted **only on change**. Three families share the `champ.update` kind, told apart by `category`:
|
||
|
||
| `category` | source | what it is |
|
||
|------------|--------|-----------|
|
||
| `champion` | `ChampionSpawn` | the classic altar spawn (Felucca-style): type, level, kills, boss, cooldown |
|
||
| `mini` | `MiniChamp` | the TerMur mini-champ controller: type, level; auto-restarts, no kill counter |
|
||
| `sea` | `BaseSeaChampion` | a High Seas world-boss **mobile**, alive only while summoned |
|
||
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `champ.update` | `serial`, `category`, `type`, `name`, `status`, `active`, `map`, `x`,`y`,`z`, `bossUp` — **plus category-specific fields below** | A spawn's state changed (or its first sight this connection). |
|
||
| `champ.remove` | `serial` | The spawn left the board: a controller was deleted, or a `sea` boss was slain/despawned. Drop the row. |
|
||
|
||
`status` is one of:
|
||
- **`active`** — running (or, for `sea`, the boss is alive).
|
||
- **`cooldown`** — stopped with a restart pending. For `champion`, `restartAt` (ISO-8601 UTC) is the ETA; `mini` always re-arms but exposes no ETA.
|
||
- **`dormant`** — stopped with nothing scheduled (`champion` only; a GM must turn it back on).
|
||
|
||
Category-specific fields on `champ.update`:
|
||
|
||
| category | extra fields |
|
||
|----------|--------------|
|
||
| `champion` | `level` (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.
|
||
|
||
#### Guilds (Protocol 2.0)
|
||
|
||
Guilds expose only one in-game event (a member joining), so the roster is polled (`GuildSweepSeconds`, default 60s) and diffed. Like champion spawns, `guild.update` is a **full-state upsert** emitted only on change — treat a guild id you've never seen as "newly created", and drop one on `guild.remove`. `guild.join` is the one real-time event, on top of the board.
|
||
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `guild.update` | `id`, `name`, `abbr`, `members`, `online`, `alliance` (or null), `leader` (actor object or null) | A guild's roster/leader/alliance changed, or its first sight this connection. A **leave** shows up here as `members` dropping. |
|
||
| `guild.remove` | `id` | The guild disbanded (leader gone) or was removed. Drop the row. |
|
||
| `guild.join` | `id`, `name`, `abbr`, `who` (actor object) | Real-time: a player joined a guild (`EventSink.JoinGuild`). |
|
||
|
||
The `leader`/`who` **actor object** is `{serial, name, acct?, webId?, player}` — `acct`/`webId` present when the mobile has an account / a linked website user.
|
||
|
||
```json
|
||
{"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH","members":14,
|
||
"online":3,"alliance":"Britannian Pact",
|
||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||
"t":1752489280000}
|
||
{"kind":"guild.join","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
||
"who":{"serial":"0x77","name":"Bran","acct":"bran","player":true},"t":1752489281000}
|
||
```
|
||
|
||
Render the current board from `GET /guilds` (§6) on connect, then keep it live with these events.
|
||
|
||
#### Town governors (Protocol 2.0)
|
||
|
||
In modern ServUO the "mayor" of a town is the **City Loyalty Governor**. The set of cities is polled (`CitySweepSeconds`, default 300s); each city emits `city.update` (full-state upsert) only when its governor, governor-elect, or election phase changes. **No events at all unless the shard runs the City Loyalty system.**
|
||
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `city.update` | `city`, `governor` (actor or null), `governorElect` (actor or null), `electionPhase`, `candidates`, `autoPickAt` (ISO-8601 UTC, when an election is ongoing) | A city's governance changed. Derive "the governor changed" by comparing to your stored board. |
|
||
|
||
`electionPhase` is one of `none` / `nominate` / `vote` / `pending`. Cities: Moonglow, Britain, Jhelom, Yew, Minoc, Trinsic, SkaraBrae, NewMagincia.
|
||
|
||
```json
|
||
{"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
|
||
"governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||
"governorElect":null,"t":1752489280000}
|
||
```
|
||
|
||
Render the current board from `GET /governors` (§6) on connect, then keep it live with these events.
|
||
|
||
#### Presence (Protocol 2.0)
|
||
|
||
Who's online and where. A population snapshot is polled (`PresenceSweepSeconds`, default 30s) and emitted **only when it changes**; region transitions arrive in real time.
|
||
|
||
| kind | fields | notes |
|
||
|------|--------|-------|
|
||
| `presence.online` | `count`, `byFacet` `{map: n}`, `byRegion` `{region: n}` | The current online population. Emitted when the count or any breakdown changes. `GET /online` gives the latest; `GET /history?kind=presence.online` the time series. |
|
||
| `region.enter` | `from` (or null), `to` (or null), `map`, `who` (actor object) | A player crossed into a new named region. `from`/`to` are region names (`Wilderness` is unnamed). Cheap "who's where" feed. |
|
||
|
||
```json
|
||
{"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
|
||
"byRegion":{"Britain":18,"Wilderness":9,"Despise":2},"t":1752489280000}
|
||
{"kind":"region.enter","from":"Britain","to":"Despise","map":"Felucca",
|
||
"who":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},"t":...}
|
||
```
|
||
|
||
---
|
||
|
||
## 5. REST — read queries
|
||
|
||
These fetch live state from the shard (correlated round-trip). Typical latency is a few milliseconds; the sidecar waits up to 10s for the shard before returning **504**.
|
||
|
||
### Character profile
|
||
|
||
```
|
||
GET /char/{account}/{slot} # by account + character slot (0-based)
|
||
GET /char/serial/{serial} # by serial, e.g. /char/serial/0x24C
|
||
```
|
||
|
||
Full character sheet: stats, all trained skills, worn equipment with flattened item mods. Works for **offline** characters too. `GET /char/serial/...` falls back to the last **cached** profile if the shard is unreachable (so a page still renders during a shard restart).
|
||
|
||
```json
|
||
{
|
||
"kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
|
||
"body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
|
||
"stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
|
||
"mana":123,"manaMax":123, "stam":120,"stamMax":120,
|
||
"fame":0,"karma":0,"luck":0,
|
||
"resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
|
||
"skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
|
||
"equipment": [
|
||
{ "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
|
||
"cliloc":1027933,"mods":{} },
|
||
{ "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
|
||
"weapon":{"minDamage":16,"maxDamage":18},
|
||
"mods":{"WeaponDamage":50,"HitLightning":40} }
|
||
]
|
||
}
|
||
```
|
||
|
||
Field notes:
|
||
- `skills[].base` is trained value, `value` includes item/temp bonuses, `cap` is the cap. **Do not assume `base <= cap`** — GM characters can exceed it.
|
||
- `equipment[].mods` is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty `{}` for plain items.
|
||
- Item names are usually **clilocs**, not strings: use `name` when present, otherwise resolve `cliloc` against a UO cliloc table on the site.
|
||
- Errors: unknown account → **404** `{"kind":"bridge.error","reason":"unknown account"}`; bad slot → **404**/**400** similarly.
|
||
|
||
### Account roster
|
||
|
||
```
|
||
GET /roster/{account}
|
||
```
|
||
|
||
Lightweight list of an account's characters (up to 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}
|
||
] } ] }
|
||
```
|
||
|
||
---
|
||
|
||
## 6. REST — commands & history
|
||
|
||
### Confirm an account link
|
||
|
||
The in-game `[link` flow: the player runs `[link`, the shard emits a `link.request` event (over the WebSocket) carrying a one-time `code`. Your site shows the logged-in website user a box to enter that code, then:
|
||
|
||
```
|
||
POST /link/confirm
|
||
{ "code": "AB12CD", "websiteUserId": "9931" }
|
||
```
|
||
|
||
- Success → **200** `{"kind":"link.ok","code":"AB12CD","account":"PerryAdimn","websiteUserId":"9931"}`. The game account is now permanently tagged with your `websiteUserId` (persisted on the shard); subsequent `mob.login` events for that account carry `webId`.
|
||
- Bad/expired code → **404** `{"kind":"link.error","code":"AB12CD","reason":"unknown or expired code"}`.
|
||
|
||
Codes are one-time and expire (default 5 min).
|
||
|
||
### Look up an existing link
|
||
|
||
```
|
||
GET /link/{account}
|
||
```
|
||
|
||
- **200** `{"account":"PerryAdimn","websiteUserId":"9931"}` if linked.
|
||
- **404** `{"account":"PerryAdimn","linked":false}` if not.
|
||
|
||
(This reads the sidecar's mirror of confirmed links — no shard round-trip.)
|
||
|
||
### Create a game account (Protocol 2.0)
|
||
|
||
Provision a game account from your signup form and link it to the website user in one step. Requires a **v2** sidecar. Whether this is honored depends on the shard's signup mode (`website`/`hybrid` accept it; `game` refuses).
|
||
|
||
```
|
||
POST /accounts/create
|
||
{ "actor": "whitlocktech", "account": "bob", "password": "hunter2",
|
||
"websiteUserId": "9931", "ip": "203.0.113.7" }
|
||
```
|
||
|
||
- `actor` — the website user/staff id, recorded in the audit. Required.
|
||
- `account`, `password` — the game-client credentials the player chose. The password is hashed on the shard and **never** appears in any reply, event, or log.
|
||
- `websiteUserId` — the site user to auto-link.
|
||
- `ip` — **the end user's browser IP**, which you read from your own request context (remote-addr, or a trusted `X-Forwarded-For`). The shard enforces its per-IP account cap with this, exactly as it does for in-game signups. The sidecar cannot see the browser's IP (it only sees your server), so you must send it.
|
||
|
||
Responses:
|
||
|
||
- Success → **200** `{"kind":"account.ok","action":"create","account":"bob","websiteUserId":"9931"}`. The account exists and is linked; subsequent `mob.login` events carry `webId`.
|
||
- Name already taken → **409** `{"kind":"account.error","reason":"account already exists"}`.
|
||
- Per-IP cap hit → **429** `{"kind":"account.error","reason":"ip account limit reached"}`.
|
||
- Signups disabled for this mode → **403** `{"kind":"account.error","reason":"signups disabled for this mode"}`.
|
||
- Missing browser IP (when the shard requires it) → **400** `{"kind":"account.error","reason":"client ip required"}`.
|
||
- Bad username/password, or a missing field → **400**.
|
||
|
||
Abuse control beyond the per-IP cap (captcha, email verification, signup rate) is your site's responsibility.
|
||
|
||
### Unlink an account (Protocol 2.0)
|
||
|
||
Sever a game account's tie to its website user, from the site side. Requires a **v2** sidecar.
|
||
|
||
```
|
||
DELETE /link/{account}
|
||
{ "actor": "whitlocktech" }
|
||
```
|
||
|
||
- Success → **200** `{"kind":"account.ok","action":"unlink","account":"bob"}`. The `WebsiteUserId` tag is cleared on the shard and the sidecar's link mirror is dropped, so attribution stops immediately.
|
||
- Not linked → **404** `{"kind":"account.error","reason":"not linked"}`.
|
||
- Protected staff account → **403** `{"kind":"account.error","reason":"target is protected staff; refused"}`.
|
||
- Missing `actor` → **400**.
|
||
|
||
A player can also unlink themselves in game with `[unlink`; that emits an `account.unlinked` event (see §4) so you can reconcile your record.
|
||
|
||
### Publish / remove town-crier news
|
||
|
||
Push a message that every in-game town crier announces until it expires.
|
||
|
||
```
|
||
POST /towncrier
|
||
{ "id": "news-42", "lines": ["Hear ye!", "Market tax is now 5%."], "durationSec": 3600 }
|
||
```
|
||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`. Re-posting the same `id` replaces the prior entry.
|
||
|
||
```
|
||
DELETE /towncrier/{id}
|
||
```
|
||
→ **200** `{"kind":"towncrier.ok","id":"news-42"}`, or **404** `{"kind":"towncrier.error","reason":"unknown id"}`.
|
||
|
||
Caps apply (line count/length, active entries, duration); an over-cap post returns `towncrier.error`.
|
||
|
||
### Staff moderation — the write plane
|
||
|
||
Account and session moderation against the live shard. **These are privileged.** The sidecar does
|
||
not model per-user roles — **your site must authenticate the staff user and check their permission
|
||
before calling.** The shard trusts the loopback socket and applies each command with CoOwner-level
|
||
authority, with one hard floor it enforces itself: any target at or above CoOwner (e.g. the Owner
|
||
account) is refused (**403**). The whole plane is **opt-in on the shard** (`AdminWriteEnabled` in
|
||
`Bridge.cfg`); when it's off, every call returns **403** `"admin write plane disabled"`.
|
||
|
||
Every request requires an **`actor`** — the website username/id of the staff member taking the
|
||
action. It is recorded in the shard console log, the ban's `BanDealer` tag, and the `admin.audit`
|
||
event, so actions are always attributable. A missing `actor` is **400**.
|
||
|
||
```
|
||
POST /admin/kick { "actor":"jane", "account":"griefer42" } # or "serial":"0x2E0"
|
||
POST /admin/ban { "actor":"jane", "account":"griefer42", "durationSec":604800, "reason":"harassment" }
|
||
POST /admin/unban { "actor":"jane", "account":"griefer42" }
|
||
POST /admin/broadcast { "actor":"jane", "text":"Server restart in 5 minutes", "hue":53 }
|
||
```
|
||
|
||
- **kick** — disconnects every live session of the target account (including one parked at
|
||
character-select). Target by `account` or `serial`. Reply carries `sessions` (how many were cut).
|
||
- **ban** — bans the account (works offline) and disconnects any live sessions. `durationSec > 0`
|
||
is a timed ban that auto-expires; `0`/absent is indefinite. Clamped to the shard's
|
||
`AdminBanMaxDurationSec`.
|
||
- **unban** — clears the ban.
|
||
- **broadcast** — a system message to everyone online. `hue` optional (default `53`, staff green).
|
||
Length-capped by the shard.
|
||
|
||
Success → **200** with an `admin.ok`:
|
||
|
||
```json
|
||
{ "kind":"admin.ok", "reqId":"r-2", "action":"ban", "target":"griefer42", "durationSec":604800, "sessions":1 }
|
||
```
|
||
|
||
Failure → an `admin.error` with a mapped status:
|
||
|
||
| Status | When |
|
||
|--------|------|
|
||
| 400 | missing `actor`, malformed body, or bad parameter |
|
||
| 401 | missing/invalid auth token |
|
||
| 403 | target is protected (at/above the floor), or the write plane is disabled on the shard |
|
||
| 404 | unknown or accountless target |
|
||
| 503 / 504 | shard not connected / didn't reply in time |
|
||
|
||
Each applied action also emits an unsolicited **`admin.audit`** frame on the WebSocket (§4) with
|
||
`origin:"web"`, so every connected dashboard — not just the caller — sees it. In-game moderation
|
||
by staff in the game client surfaces the same way with `origin:"in-game"`.
|
||
|
||
### Help-page (support) queue
|
||
|
||
Read the open queue, respond to a player, or close a page. Staff-facing — gate behind your own
|
||
roles, like the moderation endpoints above.
|
||
|
||
```
|
||
GET /pages # the open queue, newest state
|
||
POST /pages/{pageId}/respond { "message":"...", "close": false }
|
||
POST /pages/{pageId}/close
|
||
```
|
||
|
||
- **GET /pages** → `pages.list` with a `pages` array; each entry is the same shape as a `page.new`
|
||
event's fields (§4). This is the authoritative queue — use it on (re)connect, then keep it live
|
||
with the `page.new` / `page.updated` / `page.closed` events.
|
||
- **respond** delivers a message to the player exactly as an in-game staff reply does: a gump now if
|
||
they're online, otherwise queued for their next login. It shows as coming from "Staff". Pass
|
||
`"close": true` to resolve the page in the same call. → **200** `page.ok`.
|
||
- **close** removes the page from the queue. → **200** `page.ok`.
|
||
- Unknown `pageId` → **404** `page.error`; a respond with no `message` → **400**.
|
||
|
||
```json
|
||
POST /pages/0x24C/respond { "message": "A GM is on the way.", "close": true }
|
||
→ { "kind":"page.ok", "action":"respond", "pageId":"0x24C", "closed":true }
|
||
```
|
||
|
||
### History (from the sidecar's database)
|
||
|
||
```
|
||
GET /history?kind={kind}&limit={n} # kind optional, limit default 100 (max 1000)
|
||
GET /economy?limit={n} # the money-supply series (economy.supply events)
|
||
```
|
||
|
||
Recent events, **newest first**, served from SQLite (no shard needed). This is your backfill when a WebSocket client (re)connects, and the source for feeds like "recent sales" or "latest IDOC".
|
||
|
||
```
|
||
GET /history?kind=vendor.sale&limit=50
|
||
→ { "events": [ {"kind":"vendor.sale", "...": "...", "t": 1783720195626}, ... ] }
|
||
|
||
GET /economy?limit=200
|
||
→ { "series": [ {"kind":"economy.supply","accounts":52,"gold":110502898,"t":...}, ... ] }
|
||
```
|
||
|
||
### Champion-spawn board
|
||
|
||
```
|
||
GET /champs
|
||
```
|
||
|
||
The current state of **every** champion spawn at once — the live board. Served from the sidecar's own projection (no shard round-trip), kept current by the `champ.update` / `champ.remove` stream (§4). Render this on page load, then subscribe to those events to update in place. Each entry is exactly a `champ.update` payload (same fields, same `category` split); the list is ordered by `name`.
|
||
|
||
```
|
||
GET /champs
|
||
→ { "spawns": [
|
||
{"kind":"champ.update","serial":"0x40012345","category":"champion","type":"Abyss",
|
||
"name":"Abyss","status":"cooldown","active":false,"level":0,"rank":0,"kills":0,
|
||
"maxKills":256,"bossUp":false,"autoRestart":true,"map":"Felucca","x":5187,"y":570,
|
||
"z":0,"restartAt":"2026-07-14T10:45:00Z","t":1752489280000},
|
||
{"kind":"champ.update","serial":"0x40099999","category":"mini","type":"AbyssalLair",
|
||
"name":"AbyssalLair","status":"active","active":true,"level":2,"maxLevel":5,
|
||
"bossUp":false,"autoRestart":true,"map":"TerMur","x":987,"y":328,"z":11,"t":...}
|
||
] }
|
||
```
|
||
|
||
A row survives a sidecar restart (it's in SQLite), so the board reflects the last-known state even during a shard outage. A `sea` boss appears when summoned and is removed when slain.
|
||
|
||
### Guild board (Protocol 2.0)
|
||
|
||
```
|
||
GET /guilds
|
||
→ { "guilds": [ {"kind":"guild.update","id":1042,"name":"The Silver Hand","abbr":"TSH",
|
||
"members":14,"online":3,"alliance":"Britannian Pact",
|
||
"leader":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","webId":"9931","player":true},
|
||
"t":1752489280000}, ... ] }
|
||
```
|
||
|
||
Every guild's latest roster snapshot at once — the live board. Served from the sidecar's projection (no shard round-trip), kept current by the `guild.*` stream (§4). Render on load, then subscribe. Each entry is exactly a `guild.update` payload; ordered by name. Survives a sidecar restart.
|
||
|
||
### Governor board (Protocol 2.0)
|
||
|
||
```
|
||
GET /governors
|
||
→ { "cities": [ {"kind":"city.update","city":"Britain","electionPhase":"none","candidates":0,
|
||
"governor":{"serial":"0x1A2B","name":"Darrow","acct":"whitlocktech","player":true},
|
||
"governorElect":null,"t":1752489280000}, ... ] }
|
||
```
|
||
|
||
Every city's latest governance snapshot — the live board, kept current by the `city.update` stream (§4). Empty if the shard does not run the City Loyalty system. Ordered by city.
|
||
|
||
### Online population (Protocol 2.0)
|
||
|
||
```
|
||
GET /online
|
||
→ {"kind":"presence.online","count":42,"byFacet":{"Felucca":12,"Trammel":30},
|
||
"byRegion":{"Britain":18,"Wilderness":9},"t":1752489280000}
|
||
```
|
||
|
||
The current online population — total plus per-facet and per-region breakdowns. The latest `presence.online` snapshot (from SQLite, so it survives a sidecar restart); keep it live with the `presence.online` stream (§4). `count: 0` with empty maps if the shard hasn't reported yet. For the population time series, `GET /history?kind=presence.online`.
|
||
|
||
---
|
||
|
||
## 7. Status codes
|
||
|
||
| Code | Meaning |
|
||
|------|---------|
|
||
| 200 | OK |
|
||
| 400 | Bad request (malformed body, invalid parameter, or a shard `*.error` that isn't a not-found) |
|
||
| 401 | Missing or invalid auth token |
|
||
| 404 | Not found (unknown account / character / id, or a not-linked account) |
|
||
| 409 | Conflict — protocol version mismatch, or an account name already taken on `POST /accounts/create` |
|
||
| 429 | Too many requests — the shard's per-IP account cap was hit on `POST /accounts/create` |
|
||
| 500 | Internal error (e.g. database) |
|
||
| 503 | Shard not connected — the query needs the live game and it's down |
|
||
| 504 | Shard connected but didn't reply within 10s |
|
||
|
||
`503` vs `404`: a `503` is transient (shard restarting — retry), a `404` is a real "doesn't exist."
|
||
|
||
---
|
||
|
||
## 8. Putting it together
|
||
|
||
A typical character page:
|
||
|
||
```js
|
||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
|
||
|
||
// 1. render the roster
|
||
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());
|
||
|
||
// 2. full sheet for the selected character
|
||
const res = await fetch(`${BASE}/char/${account}/${slot}`, { headers: H });
|
||
if (res.status === 503) showBanner("Game server is restarting…");
|
||
else renderProfile(await res.json());
|
||
|
||
// 3. live vitals: subscribe to the feed and update hp/mana as char.vitals arrives
|
||
// (see the WebSocket client in §4)
|
||
|
||
// 4. recent sales widget
|
||
const sales = await fetch(`${BASE}/history?kind=vendor.sale&limit=20`, { headers: H })
|
||
.then(r => r.json());
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Caveats & current limits
|
||
|
||
- **No rate limiting yet.** The sidecar does not throttle callers; put it behind your own gateway if it's public. Profile/roster/vendor queries hit the live shard, so cache them site-side.
|
||
- **WebSocket is push-only and live-only.** No client→server messages, no replay. Backfill via `/history`.
|
||
- **Cache freshness.** `GET /char/serial/...` may serve a stale cached profile when the shard is down; the account+slot form always goes live (503 if down).
|
||
- **`bootId`** on `server.hello` is your signal to invalidate site-side caches: if it changed, the shard restarted.
|
||
- **Protocol changes** bump `X-UOLink-Version`. Compare it on startup and fail fast rather than mis-parsing a newer shape.
|