feat(sidecar)!: Protocol 3.0 cutover — X-UOLink-Version 2 → 3 #21

Merged
whitlocktech merged 8 commits from edge into main 2026-08-01 06:34:21 +00:00

8 Commits

Author SHA1 Message Date
cfe9ec9017 Merge pull request 'feat(sidecar)!: bump PROTOCOL_VERSION to 3' (#20) from chore/protocol-3-cutover into edge
Reviewed-on: #20
2026-07-30 03:03:32 +00:00
5f50b881ca feat(sidecar)!: bump PROTOCOL_VERSION to 3
Protocol 3.0 is feature-complete on `edge` -- world.ruleset, points.board and
vendor.listing / vendor.listing.remove all landed there while the sidecar kept
declaring 2, because a bump is an operator-visible hard break (409 on every
protected route via web.rs::gate, and the website closes the WS on the ws.hello
mismatch). Doing it per phase would have broken the site four times; this is the
one time it happens.

Nothing that existed in v2 changed shape, so the version constant and its doc
comment are the whole change here. The README's worked example moves with it --
it still claimed "currently 1", two bumps stale.

Verified against the release binary: /health reports "protocol": 3, every
response carries `X-UOLink-Version: 3`, an authenticated request declaring 2 is
refused 409 {"sidecar_protocol":3,"client_protocol":"2"}, and one declaring 3
gets 200 off /ruleset. cargo build --release + cargo clippy --all-targets clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:03:31 -05:00
05e192ca70 Merge pull request 'feat(sidecar): store and serve the player-vendor market index' (#19) from feat/vendor-listing into edge
Reviewed-on: #19
2026-07-29 20:03:40 +00:00
480423090a feat(sidecar): store and serve the player-vendor market index
Protocol 3.0 §8. Ingests vendor.listing / vendor.listing.remove into a `vendors`
table and serves GET /market.

The frame is authoritative for one vendor, so the upsert is a whole-row
overwrite. Unlike the other 3.0 boards there IS a remove: a vendor is dismissed,
expires, or its owner switches off the in-game Vendor Search flag — the last of
those is a privacy control, so dropping the row promptly is the point.

Items ride inside the stored blob and are deliberately not normalized into a
vendor_items table. The sidecar's job for the market is outage resilience
(PROTOCOL_2.md §12.2), not search; search lives in MariaDB on the website side,
where the query surface, the indexes and the cliloc-resolved names already are.

/market is the only PAGED read the sidecar serves, because it is the only board
that can be a whole world's inventory. limit clamps to 1..1000 (default 200) and
`total` comes back 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 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 not /vendors: /vendors/:account next door is the
per-account RPC, and two routes a prefix apart meaning "this player's shops" and
"every shop on the shard" is a readability trap.

Frames are served verbatim, owner names and coordinates included — the sidecar
defines no audiences (v3.md §3.2).

Verified against the live shard: 27 vendors / 1,040 listings ingested from the
plugin, plus a synthetic insert-then-remove confirming the delete path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 09:51:14 -05:00
2e386a9d5c Merge pull request 'feat(store): persist points.board and serve it from GET /points' (#18) from feat/points-board into edge
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-29 07:53:17 +00:00
21a1462e62 feat(store): persist points.board and serve it from GET /points
Protocol 3.0 §7 (docs/link/v3.md). The shard publishes ~25 points/loyalty
leaderboards as one points.board frame per system; the sidecar folds each into a
projection table and serves them back, so the site's leaderboards page renders
during a shard outage.

  - points_boards(system PK, name, json, updated_t), keyed by the shard's own
    PointsType name. `name` is hoisted only for the ORDER BY.
  - main.rs gains a points.board arm keyed on `system`, alongside the existing
    champ/guild/governor/house/ruleset projections. There is deliberately no
    delete counterpart: the shard's set of point systems is fixed at startup, so
    it emits no points.remove — the same shape the governor board already has.
  - GET /points returns every board ordered by display name; GET /points/:system
    returns one, or 404 when the shard has never published that system. 404 and
    "a published board nobody has scored in yet" (200, empty top) are different
    answers, and the website renders them differently.

Store-backed rather than an RPC for the same reason as the other boards, and it
matters more here: these are standings accumulated over months, so blanking them
during a shard restart reads as data loss rather than as staleness.

PROTOCOL_VERSION stays at 2 — the bump to 3 is the one-time edge → main cutover
in v3.md §4, not a per-phase change.

cargo build and cargo clippy --all-targets are clean. Smoke-tested against a
driver on the loopback link: two boards stored and served, a re-emitted system
overwriting rather than accumulating, 404 for an unknown system, 401
unauthenticated. Also verified against the real ServUO shard, which fed five
live boards through this path.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 21:04:23 -05:00
41811d40af Merge pull request 'feat(store): persist world.ruleset and serve it from GET /ruleset' (#17) from feat/ruleset-endpoint into edge
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-28 20:35:31 +00:00
8e018a01e9 feat(store): persist world.ruleset and serve it from GET /ruleset
Protocol 3.0 §5 (docs/link/v3.md). The shard publishes one world.ruleset frame
per connect describing how it is configured; the sidecar folds it into a
singleton row and serves it back.

Store-backed rather than an RPC, for the same reason /guilds and /houses are
(PROTOCOL_2.md §12.2): a rules page that goes blank while the shard restarts is
worse than one that is briefly stale. `{"ruleset": null}` distinguishes "the
shard has never published one" — an old plugin, or Bridge.RulesetEnabled=false —
from a published ruleset, which the website renders differently.

`rev` (the shard's FNV-1a of the body) is kept alongside the JSON so a reader can
tell "same ruleset, re-sent on reconnect" from "the operator changed something"
without diffing.

PROTOCOL_VERSION stays 2. The 2→3 bump is a hard operator-visible cutover and
happens exactly once, at the end of v3 (§4), not per phase.

Smoke-tested against a fake shard on loopback: frame ingested, GET /ruleset
returns it with plugin_connected=false (outage path), and the route sits behind
the gate (409 on a version mismatch, 401 unauthenticated). cargo build + clippy
clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 11:19:10 -05:00