# 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 ` | | REST | `X-Api-Key: ` | | WebSocket | `?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: 3`** header. - `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`. - **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: ```json { "error": "protocol version mismatch", "sidecar_protocol": 3, "client_protocol": "2" } ``` 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. **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. **Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a client still declaring `2` 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 v3 sidecar. Nothing that existed in v2 changed shape, 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": 3, "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= (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": 3 } ``` **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:"`) 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:"`). `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":...} ``` #### 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`, `location{}`, `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. | ```json {"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":[{"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** — 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. Building that table is a consumer-side job; the website's is described in [`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} ] } ] } ``` --- ## 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": "
Double XP Weekend


Starts Friday 7PM.", "image": 1614, "url": "https://yoursite/news/42" } ``` → **200** `{"kind":"news.ok","id":"42"}`. Re-posting the same `id` **replaces** the prior article in place. - `id`, `title` required. `body` (HTML supported), `image` (a UO gump id; a neutral scroll if omitted), `url` (a browser button in the gump) optional. - `announce` defaults to **true** — the criers proclaim the title. Send `"announce": false` to post silently (e.g. a correction). ``` DELETE /news/{id} ``` → **200** `{"kind":"news.ok","id":"42"}`, or **404** `{"kind":"news.error","reason":"unknown id"}`. Caps apply (title/body length, max active articles). The **website is the source of truth**: the shard rebuilds its news list on restart and does not persist yours, so the sidecar automatically re-pushes your articles (silently) whenever the shard reconnects. Stock ServUO news is left intact — your articles are tracked separately. ### Staff moderation — the write plane Account and session moderation against the live shard. **These are privileged.** The sidecar does 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`. ### 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 | | 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": "3" }; // 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.