Files
link/docs/INTEGRATION.md
Claude b258ee3e60 feat(champ): stream champion-spawn state to the sidecar board
Champion spawns have no ServUO EventSink, so add a fourth polled stream
(BridgeChamps) modeled on BridgeSweeps: enumerate every spawn each tick,
fold to a small record, and emit champ.update only on change. No core
patch — every field used is public.

Covers all three families via a `category` field:
  - champion: ChampionSpawn (type/level/kills/boss/cooldown ETA)
  - mini:     MiniChamp (type/level; auto-restarts, no kill counter)
  - sea:      BaseSeaChampion (a High Seas world-boss mobile, alive only
              while summoned; removed via champ.remove when slain)

Status folds to active/cooldown/dormant. A (re)connection clears the diff
cache so the next sweep re-emits the full board, rebuilding a sidecar that
restarted on its own. Transient entries leave via champ.remove.

Sidecar: a `champs` current-state table (one row per serial) fed by
champ.update (upsert) and champ.remove (delete), exposed at GET /champs as
the live board. New ChampSweepSeconds config (default 10s), wired into
[bridge reload/sweepnow/status. Documented in docs/INTEGRATION.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114TpmrNW4wNXsHq5CR72jQ
2026-07-14 05:46:21 -05:00

25 KiB
Raw Permalink Blame History

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: 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:

    { "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)
{
  "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:

{ "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

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.
{"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.

{"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

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.

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, bossUpplus category-specific fields below A spawn's state changed (or its first sight this connection).
champ.remove serial The spawn left the board: a controller was deleted, or a sea boss was slain/despawned. Drop the row.

status is one of:

  • active — running (or, for sea, the boss is alive).
  • cooldown — stopped with a restart pending. For champion, restartAt (ISO-8601 UTC) is the ETA; mini always re-arms but exposes no ETA.
  • dormant — stopped with nothing scheduled (champion only; a GM must turn it back on).

Category-specific fields on champ.update:

category extra fields
champion level (016), rank, kills, maxKills, autoRestart, boss (when bossUp), restartAt (when cooldown), expireAt (ISO-8601 UTC — when the current level times out if kills stall, present while active)
mini level, maxLevel, autoRestart (always true); bossUp is always false
sea boss (its name), hits, hitsMax; bossUp is always true; roams, so x,y,z and hits update as it moves/takes damage
{"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.


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).

{
  "kind": "char.profile", "serial": "0x24C", "name": "Darrow", "title": null,
  "body": 400, "hue": 33770, "online": false, "acct": "whitlocktech",
  "stats": { "str":120,"dex":120,"int":123, "hits":110,"hitsMax":110,
             "mana":123,"manaMax":123, "stam":120,"stamMax":120,
             "fame":0,"karma":0,"luck":0,
             "resist": {"phys":44,"fire":44,"cold":44,"pois":44,"energy":44} },
  "skills": [ {"n":"Swords","base":120.0,"value":120.0,"cap":120.0,"lock":"Up"}, "..." ],
  "equipment": [
    { "serial":"0x40013AAD","layer":"Shirt","itemId":7933,"hue":33,
      "cliloc":1027933,"mods":{} },
    { "serial":"0x4002B3","layer":"OneHanded","itemId":5046,"hue":0,"cliloc":1023721,
      "weapon":{"minDamage":16,"maxDamage":18},
      "mods":{"WeaponDamage":50,"HitLightning":40} }
  ]
}

Field notes:

  • skills[].base is trained value, value includes item/temp bonuses, cap is the cap. Do not assume base <= cap — GM characters can exceed it.
  • equipment[].mods is a flattened map of every non-zero AOS attribute on the item (weapon or armor). Empty {} for plain items.
  • Item names are usually clilocs, not strings: use name when present, otherwise resolve cliloc against a UO cliloc table on the site.
  • Errors: unknown account → 404 {"kind":"bridge.error","reason":"unknown account"}; bad slot → 404/400 similarly.

Account roster

GET /roster/{account}

Lightweight list of an account's characters (up to 57), including offline ones. Use this for a character-picker, then fetch the full profile on demand.

{ "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.

{ "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

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).

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.

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:

{ "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 /pagespages.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 pageId404 page.error; a respond with no message400.
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.


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:

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.