From 52206a73d24d96795998982aff9d88cb721332c5 Mon Sep 17 00:00:00 2001 From: colby Date: Fri, 10 Jul 2026 20:47:09 -0500 Subject: [PATCH] docs: website integration guide docs/INTEGRATION.md is the API reference for building the front end against the sidecar: base URL, auth (Bearer / X-Api-Key / ?token=), protocol versioning, the rich /health, the WebSocket live feed with a full event catalog, every REST query and command (char/roster/vendors/link/towncrier/history/economy), the status-code table, a worked character-page example, and current caveats. Payloads are the real shapes captured during testing. Linked from the top-level README. Co-Authored-By: Claude Opus 4.8 --- link/INTEGRATION.md | 371 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 link/INTEGRATION.md diff --git a/link/INTEGRATION.md b/link/INTEGRATION.md new file mode 100644 index 0000000..b281755 --- /dev/null +++ b/link/INTEGRATION.md @@ -0,0 +1,371 @@ +# 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: 1`** header. +- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 1`. +- **Optionally**, send `X-UOLink-Version: 1` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: + + ```json + { "error": "protocol version mismatch", "sidecar_protocol": 1, "client_protocol": "2" } + ``` + +Pin the version you built against and compare it to the header (or `/health.protocol`) at startup. + +--- + +## 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= (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. | + +#### Account linking +| 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. | + +--- + +## 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.) + +### 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`. + +### 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":...}, ... ] } +``` + +--- + +## 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) | +| 409 | Protocol version mismatch (you sent `X-UOLink-Version` and it disagreed) | +| 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": "1" }; + +// 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.