5 Commits

Author SHA1 Message Date
f6a22734d3 docs(android): link the M11 app PRs and name the remaining gate
Both parts are built: Android-app#30 (the visibility rules + read-model adds)
and #31 (the four screens, stacked on it). The on-device five-rung walk against
a website on the cutover branch is what edge->main is now actually waiting on.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 02:51:53 -05:00
5a091157d6 docs(android): scope M11 — Protocol 3.0 shard parity for the app
The v3 work added four shard features and an admin-configurable visibility
framework the Android client knows nothing about. v3.md §10 deferred the app
side as a follow-up; re-examining it before the cutover found the gap is wider
than nav hiding:

  - no consumer for any of ruleset / leaderboards / market / atlas,
  - no `points` block on the character sheet (§7.3),
  - no cliloc-resolved item names (§8.6), and
  - shard nav gated on session role alone, so an admin who disables a feature
    or raises its audience leaves the app rendering entries that 404/403 into a
    generic error where the web client hides them.

Scoped as PLAN.md §9 M11 in two PRs (the visibility rules + read-model adds,
then the four screens), with the traps a real shard exposes recorded inline:
uncapped `maxPoints: 0`, cliloc-named boards with a null `nameString`, skill
caps in tenths, the required market staleness banner, the market stream being
off by default, atlas delays in seconds, and `points`-count vs `spawners`-list.

edge → main is held until both land so web and app surface the same shard on
the same day. Neither PR is coupled to the merge order — on a pre-v3 website
every new route and /public/shard/features 404s and the app falls back to
today's behavior — so holding the cutover is a schedule decision, not a
technical dependency.

Also records two things verified as already correct, so they are not
re-derived: the app's SSE request rides the authenticated client (same audience
rung as the same account on web), and every shard DTO is nullable-with-defaults
(field projection cannot cause a decode failure).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 00:46:16 -05:00
3a1bbdd165 Merge pull request 'docs(link): the Protocol 3.0 cutover (v3.md order 6)' (#72) from docs/protocol-3-cutover into edge
Reviewed-on: #72
2026-07-30 03:03:10 +00:00
32def88c4e docs(link): fill in the cutover PR numbers
The order-6 row was written before the seven PRs existed. Same follow-up as the
cliloc row got.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:09:50 -05:00
71207cef16 docs(link): the Protocol 3.0 cutover (v3.md order 6)
INTEGRATION.md was written for the window that just closed -- it told integrators
the version had NOT been bumped yet and that a sidecar on `edge` reports 2 while
already carrying v3 kinds. That guidance is now wrong in the direction that
matters, so the version section states 3 (header, /health, ws.hello, the 409
example and the §8 worked example) and replaces the "until then" paragraph with
what a v2 integration actually has to do to upgrade: change the constant it
sends, and nothing else, because nothing that existed in v2 changed shape.

v3.md gains §4.1 for what the bump touches and, more importantly, WHY the
website's boot migration is gated on a marker row: schema.sql is re-run on every
boot and uo_link_config.protocol is admin-editable, so an ungated UPDATE would
silently un-pin an operator running an older sidecar. That is the one piece of
the cutover a reader could not infer from the code being one constant.

Progress tables: 5b done, 6 in review.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-29 18:04:00 -05:00
4 changed files with 198 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
# Android App — Plan # Android App — Plan
Status: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)).** This document is the Status: **M0M7 landed; M7 (push notifications) both parts done — Part 1 backend (website#78) and Part 2 app (Android-app#15) plus a small `push.ntfyUrl` settings addition (website#79). Remaining: set the shard's `NTFY_*` deploy config so push lights up, and cut the v1 tag. M9 (native SSO login) is now underway backend-first — the Mobile SSO Authorization Bridge is being built in `website/` + `docs/` ahead of the app-side client (§4.2, §9 M9); custom-scheme callback only for now, App Links deferred (see [`APP_LINKS.md`](./APP_LINKS.md)). **M11 (Protocol 3.0 shard parity)** is scoped and next: the app sees none of the four shard features v3 added (`ruleset`, `leaderboards`, `market`, `atlas`) and does not consult `GET /public/shard/features`, so it gates shard nav on session role alone while an admin can switch any of those surfaces off or raise its audience — the v3 `edge``main` cutover is held until it lands (§9 M11).** This document is the
design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the design contract for the `RunicGateway/Android-app` repo. It was written before implementation so the
API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API API changes it depends on could be landed in `website/` and `docs/` first. The authoritative API
reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated reference is the committed OpenAPI spec at `website/server/swagger/swagger-output.json` (regenerated
@@ -631,6 +631,8 @@ not rank).
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` | | News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` | | Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
| Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) | | Shard (live) | everyone | `/public/shard/*` + `/public/shard/stream` (SSE) |
| **Rules / Leaderboards / Market** | everyone, *if the shard publishes them* | `/public/shard/{ruleset,points,market}` (M11) |
| **Atlas** (bestiary) | everyone, *if the shard publishes it* | `/public/atlas/*` (M11) |
| Contact | everyone | `/public/contact` | | Contact | everyone | `/public/contact` |
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) | | **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` | | **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
@@ -639,6 +641,12 @@ not rank).
Guidelines: Guidelines:
- The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries - The menu is **declarative + data-driven**, not a pile of `if role ==` checks — one list of entries
with a `minAccess`/`requiredCapability` field, filtered by the session. with a `minAccess`/`requiredCapability` field, filtered by the session.
- **Session role is not the only gate on shard surfaces (M11).** Every shard-derived feature is
*admin-configurable* — it can be switched off or raised to a higher audience rung — so a shard entry
is filtered by the session role **and** by `GET /public/shard/features`, which reports the features
the caller may actually reach. While that answer is unknown (in flight, or the lookup failed) the app
shows everything: the server gates regardless, and a nav that flickers in on every load is worse than
a link that briefly `403`s.
- Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public - Never hide the fact that more exists behind auth in a way that misleads; anonymous users see public
groups and a "Sign in" affordance. groups and a "Sign in" affordance.
- The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still - The server is the source of truth: a hidden/greyed item is a UX convenience; every gated call still
@@ -663,9 +671,15 @@ Guidelines:
### 6.2 Public shard (live) ### 6.2 Public shard (live)
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the - Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
`/public/shard/*` GETs. `/public/shard/*` GETs.
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE, safe kinds only) and patch the - **Live updates** — subscribe to `GET /public/shard/stream` (SSE) and patch the in-memory boards in
in-memory boards in place (champ/guild/city/house/presence update+remove frames). Reconnect with place (champ/guild/city/house/presence update+remove frames). Reconnect with backoff; fall back to
backoff; fall back to poll if SSE drops. poll if SSE drops. What arrives on the stream is **resolved from the caller's audience rung at
subscribe time**, not from a fixed allowlist (Protocol 3.0 §3.6) — the stream request carries the
bearer like every other call, so a signed-in app session sees exactly what the same account sees on
the web.
- **Visibility + the Protocol 3.0 surfaces (M11)** — `GET /public/shard/features` drives which of these
the menu offers; `GET /public/shard/{ruleset,points,points/:system,market,market/meta,market/vendors/:serial}`
and `GET /public/atlas/*` are the new reads. Full contract and traps in §9 M11.
### 6.3 Player self-service & game data (bearer) ### 6.3 Player self-service & game data (bearer)
- **Account** — `GET /player/account`; `PATCH /player/account/username`; - **Account** — `GET /player/account`; `PATCH /player/account/username`;
@@ -675,6 +689,11 @@ Guidelines:
- **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`, - **My game data** — `GET /player/shard/roster/:account`, `/char/:serial`, `/vendors/:account`,
`/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an `/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
"offline, retry" state (see §7). "offline, retry" state (see §7).
- **The character sheet carries two things the app does not yet read (M11):** the `points` block
(per-character loyalty/points standings, Protocol 3.0 §7.3) and the server-resolved cliloc names on
`equipment[].clilocName` / `titles.rewardResolved` (§8.6). Both are served **ungated** on this route —
a character's own standings are self-service data and do not depend on the public `leaderboards`
feature being visible, which is the behavior the app must mirror rather than re-gate.
- **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no - **Presentation is text-only for v1.** Character sheets and vendor listings render as data/text — no
item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the item icons or paperdoll art. A richer "pretty paperdoll" view is a **future** enhancement (pending the
art/asset work on the platform side) and is explicitly out of the first release. art/asset work on the platform side) and is explicitly out of the first release.
@@ -883,10 +902,94 @@ push, and Play (M6M8) follow the designed app.
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
editor, Discord-bot config, uo-link config, OAuth-provider setup. editor, Discord-bot config, uo-link config, OAuth-provider setup.
12. **M11 — Protocol 3.0 shard parity** (post-v1; scoped 2026-07-30). The website's Protocol 3.0 work
added four shard features and, with them, an **admin-configurable visibility framework** the app
knows nothing about. `link/v3.md` §10 deferred the app side as a follow-up; it is now scoped
deliberately, and **the v3 `edge` → `main` cutover is held until both parts land** so web and app
surface the same shard on the same day (decided 2026-07-30).
Neither part is coupled to the cutover *merge order*, which is what makes holding it a schedule
decision rather than a technical one: against a pre-v3 website every new route and
`/public/shard/features` simply `404`s, and each consumer below falls back to exactly today's
behavior. The app declares no protocol version and never talks to the sidecar.
**Both parts are built and in review:** Part 1 `RunicGateway/Android-app#30`, Part 2 (stacked on
it) `#31`. 336 unit tests pass and lint is clean on both; the on-device five-rung walk below is
the remaining gate, and it is what the cutover is actually waiting on.
- **Part 1 — the visibility rules + the read-model adds.** The security-shaped half, reviewed on
its own:
- `GET /public/shard/features` → `{ level, features[] }`: the features **this caller** may reach.
A new singleton cache mirrors the web client's (`lib/useShardFeatures.js`): per-viewer but
stable for a session, invalidated on sign-in/out and on a server switch.
- `MenuEntry` gains `feature: String?` beside its existing `access`, so the one declarative menu
(§5) filters on the session role **and** the shard's live feature config. While the lookup is
in flight or has failed, **show everything** — the same deliberate fail-open the web client
takes, because the server gates regardless and a nav that flickers in on every load is worse
than a link that briefly `403`s. The gate is server-side; hiding is presentation.
- **`404` and `403` mean different things here** and neither is a generic error:
`requireFeature` `404`s a *disabled* feature (deliberately not disclosing that it exists) and
`403`s a viewer *below its audience*. Both render "not available on this shard", alongside the
existing `503` = shard offline (§7).
- **The `level` from `/features` is authoritative — do not re-derive the rung from the role.**
The server's ladder is `anonymous → logged_in → player → staff → admin`, where `player` means
*a linked game account* and staff always satisfy `player` (the same superset rule `Menu.kt`
already encodes as `isPlayer || isStaff`).
- **`char.profile.points`** → the "Loyalty & Points" block the web character sheet gained:
`CharProfileDto.points[{system, nameString, points, maxPoints, rank?}]`. Three traps, all of
them things a real shard does and a fake one does not (`v3.md` §7.5): `maxPoints == 0` means
**uncapped** and is the *common* case, so nothing may divide by it; `nameString` is usually
`null` because most systems name themselves with a cliloc, making the humanise-the-`system`-key
path the **primary** one rather than a fallback; and `rank` is absent unless the shard runs
`PointsProfileRank=true` — absent and "unranked" are different answers.
- **Cliloc-resolved names** (`v3.md` §8.6, already live on the website): `EquipmentDto` gains
`name` + `clilocName` and `TitlesDto` gains `rewardResolved`, so equipment stops rendering as a
layer or a bare id. Precedence is `name → clilocName → layer`: a player-given name outranks the
resolved type name, and the server applies the same order. A shard with no cliloc table
configured sends neither field and the sheet renders exactly as it does today.
- `ActorDto` keeps its `acct` / `webId` fields (nullable, so nothing breaks) but its KDoc stops
describing them as available: they are **locked to the admin rung**, always, and stripped from
every response below it.
- **Part 2 — the four new screens**, each hidden by its feature name in the menu:
- **Rules** — `GET /public/shard/ruleset` (`ruleset`). A `null` body means "the shard has not
published its ruleset yet", which is a different state from the feature being disabled. Every
block is optional and omitted when its system is off. **`caps.skill` / `caps.totalSkill` are in
tenths** (1000 = 100.0) and must be converted — the raw number is actively misleading, not
merely unhelpful. Live via the `world.ruleset` frame, which is on the public stream by default.
- **Leaderboards** — `GET /public/shard/points`, `/points/:system` (`leaderboards`). The same
`maxPoints`/`nameString` traps as the profile block. Live via `points.board`.
- **Market** — `GET /public/shard/market` (`q`, `minPrice`, `maxPrice`, `itemId`, `map`, `region`,
`sort`, `limit`, `offset`), `/market/meta` for the filter options + staleness, and
`/market/vendors/:serial` (`market`). Four things this screen must get right: it is the site's
first **rate-limited** public endpoint, so handle `429` the way the contact form does; the
*"prices last refreshed N minutes ago"* banner is **required, not decoration** — the shard
sweeps vendors round-robin, so a listing can legitimately be a full cycle stale and a page
implying live prices sends people to an item that sold twenty minutes ago; a `truncated` shop
must say so; and `location` is a **nested object** that an admin may gate away entirely, which
the vendor screen renders as "hidden by the shard" (a real answer) rather than as blank
coordinates — same for `ownerName` / `ownerSerial`. **The `market` SSE fan-out is off by
default** (a live firehose of vendor inventories would be the site's biggest bandwidth
consumer), so the screen is a plain paginated read and must never depend on live frames.
- **Atlas** — `GET /public/atlas/{creatures,creatures/:slug,regions,landmarks,champions,meta}`
(`atlas`). Note the path: `/public/atlas`, **not** `/public/shard` — the atlas is static shard
*content*, not live shard *state*, and unlike `/shard/*` it **is** `siteMode`-gated like
`/posts` and `/wiki`, so a site in maintenance mode withholds it independently of the sidecar.
Two units/naming traps from `v3.md` §6.3: respawn delays are **seconds** throughout, and
`points` is a *count* on the search route while `spawners` is the *list* on the detail route.
- **Verification** — the five-rung walk (`anonymous`, `logged_in`, `player`, `staff`, `admin`)
against a local website on the cutover branch, per
[`../link/v3.md`](../link/v3.md) §11 and the shard-visibility smoke harness; plus one pass with
**every feature disabled** in Admin → Shard Visibility, confirming the app *hides* each surface
instead of erroring on it. Unit tests cover the menu filter (role × feature set), the
`404`/`403`/`503` mapping, and DTO decode for each new shape.
- **Excluded**, in the same class as M10's exclusions: the admin *configuration* panels — Shard
Visibility, Spawn Atlas and Cliloc import — alongside the hero/CMS block editor, Discord-bot
config, uo-link config and OAuth-provider setup.
### Deferred (not a milestone) ### Deferred (not a milestone)
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now - **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone
**deferred with no app work scheduled**. The website's router refactor is being done in place with (2026-07-22), now **deferred with no app work scheduled**. The website's router refactor is being done in place with
every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…` every URL byte-identical and `/api/v1` is not being retired, so the app's ~70 hardcoded `api/v1/…`
endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs endpoints, its SSE path, and its SSO URLs keep working untouched. If the mobile contract ever needs
to diverge from web, the migration comes back — starting from a one-line alias mount on the server, to diverge from web, the migration comes back — starting from a one-line alias mount on the server,

View File

@@ -31,31 +31,31 @@ Missing or wrong token → **401** `{"error":"missing or invalid auth token"}`.
The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly. The wire protocol is versioned so a mismatch is caught immediately instead of failing weirdly.
- Every response carries an **`X-UOLink-Version: 2`** header. - Every response carries an **`X-UOLink-Version: 3`** header.
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`. - `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 3`.
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**: - **Optionally**, send `X-UOLink-Version: 3` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
```json ```json
{ "error": "protocol version mismatch", "sidecar_protocol": 2, "client_protocol": "1" } { "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. 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. **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) is being built and the version has not been bumped yet.** It is defined as *adds **v3 (Protocol 3.0)** adds `world.ruleset`, `points.board` and `vendor.listing` /
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to `vendor.listing.remove`, with the `GET /ruleset`, `/points` and `/market` reads that serve them from
`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main` the sidecar's store. Same shape as the v2 bump: the event kinds are additive, so a v2 client that
cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and ignores unknown kinds keeps working against the live feed, but the three new endpoints require a v3
the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site sidecar. There is deliberately **no feature-negotiation array** — v3 implies all three kinds, so the
repeatedly. version number alone tells you what is available.
Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints. **Upgrading a v2 integration.** The bump is an operator-visible hard break in one direction only: a
That is safe in the direction that matters: event kinds are additive, and a client that ignores client still declaring `2` gets a 409 on every protected route and, on the WebSocket, a closed
unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not** connection on the `ws.hello` mismatch. So update the pinned version at the same time you deploy the
do is infer feature availability from the version number during this window — probe the endpoint, or v3 sidecar. Nothing that existed in v2 changed shape, so that is the whole migration — the website
treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no does it with a one-shot boot migration of its `uo_link_config.protocol` row ([`v3.md`](v3.md) §4.1);
feature-negotiation array**: v3 implies all three kinds. a third-party client changes the constant it sends.
--- ---
@@ -68,7 +68,7 @@ GET /health (no auth)
```json ```json
{ {
"status": "ok", // "ok" when plugin connected AND db reachable, else "degraded" "status": "ok", // "ok" when plugin connected AND db reachable, else "degraded"
"protocol": 1, "protocol": 3,
"plugin_connected": true, // is the shard link up right now? "plugin_connected": true, // is the shard link up right now?
"database": "ok", // "ok" | "error" "database": "ok", // "ok" | "error"
"uptime": "3d 12h", "uptime": "3d 12h",
@@ -91,7 +91,7 @@ A push-only stream of game events as they happen. You do **not** send commands o
**On connect**, the first frame is: **On connect**, the first frame is:
```json ```json
{ "kind": "ws.hello", "protocol": 1 } { "kind": "ws.hello", "protocol": 3 }
``` ```
**Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`. **Then** a continuous stream of event frames, each with at least `t` (epoch ms) and `kind`. Route on `kind`.
@@ -955,7 +955,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
A typical character page: A typical character page:
```js ```js
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" }; const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "3" };
// 1. render the roster // 1. render the roster
const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json()); const roster = await fetch(`${BASE}/roster/${account}`, { headers: H }).then(r => r.json());

View File

@@ -347,6 +347,12 @@ of 25 vendors x 40 listings and **0.3 ms** in steady state (the per-vendor diff)
items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own items / 43k mobiles. It is also the first stream to honour a per-player privacy toggle: ServUO's own
`PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site. `PlayerVendor.VendorSearch` flag, so a shop hidden in game is hidden on the site.
That completes 3.0's feature work, so the last step is the version itself: `PROTOCOL_VERSION` **2 →
3** and the coordinated `edge``main` merge across all four repos ([`v3.md`](v3.md) §4 and §4.1).
The bump is deliberately the *only* thing that happens at that moment — v3 adds kinds and endpoints
but changes nothing that already existed in v2 — so the operator-visible break is limited to
re-pinning the version, which the website does for itself in a one-shot boot migration.
### Config keys (`Config/Bridge.cfg`) ### Config keys (`Config/Bridge.cfg`)
```ini ```ini

View File

@@ -1,6 +1,6 @@
# Protocol 3.0 — Shard content, standings & the visibility framework # Protocol 3.0 — Shard content, standings & the visibility framework
**Status:** In progress. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover. **Status:** Feature-complete on `edge`; the cutover (order 6) is in review. All work lands on an `edge` branch in each repo; `edge``main` is the v3 cutover.
**Date:** 2026-07-28 **Date:** 2026-07-28
**Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**. **Codebase:** ServUO 57.4, `<servuo>`, net48 / x64, Expansion **EJ**.
**Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API). **Companion to** [`PLAN.md`](PLAN.md) (1.0 read/event plane), [`PROTOCOL_2.md`](PROTOCOL_2.md) (2.0 provisioning + world-state streams), [`ADMIN_CONTROLS.md`](ADMIN_CONTROLS.md) (staff write plane), [`INTEGRATION.md`](INTEGRATION.md) (website API).
@@ -16,13 +16,18 @@ Each part is marked off here as it lands on `edge`. §9 carries the same state p
| 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) | | 3 | **C** — spawn atlas (§6) | ✅ **Done** | website [#112](https://gitea.whitlocktech.com/RunicGateway/website/pulls/112) (parsers + CLI + tables) + [#113](https://gitea.whitlocktech.com/RunicGateway/website/pulls/113) (API + pages + admin panel), docs [#67](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/67) + [#68](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/68) |
| 4 | **B/2**`points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) | | 4 | **B/2**`points.board` (§7) | ✅ **Done** | servuo-plugins [#4](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/4), link [#18](https://gitea.whitlocktech.com/RunicGateway/link/pulls/18), website [#114](https://gitea.whitlocktech.com/RunicGateway/website/pulls/114), docs [#69](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/69) |
| 5a | **B/3 dependency** — cliloc table (§8.6) | ✅ **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) | | 5a | **B/3 dependency** — cliloc table (§8.6) | ✅ **Done** | website [#115](https://gitea.whitlocktech.com/RunicGateway/website/pulls/115), docs [#70](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/70) |
| 5b | **B/3**`vendor.listing` (§8) | 🟨 In review | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) | | 5b | **B/3**`vendor.listing` (§8) | **Done** | servuo-plugins [#5](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/5), link [#19](https://gitea.whitlocktech.com/RunicGateway/link/pulls/19), website [#116](https://gitea.whitlocktech.com/RunicGateway/website/pulls/116), docs [#71](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/71) |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — | | 6 | **Cutover**`PROTOCOL_VERSION` 2→3 (§4) | 🟨 In review | the bump: link [#20](https://gitea.whitlocktech.com/RunicGateway/link/pulls/20), website [#117](https://gitea.whitlocktech.com/RunicGateway/website/pulls/117), docs [#72](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/72) — then `edge``main`: servuo-plugins [#6](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/6), link [#21](https://gitea.whitlocktech.com/RunicGateway/link/pulls/21), website [#118](https://gitea.whitlocktech.com/RunicGateway/website/pulls/118), docs [#73](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/73) |
Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather Order 5 split in two once §8.6's cliloc dependency turned out to be a client-format problem rather
than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item than a parser (see §8.6). 5a is website-only and lands first so the marketplace ships with real item
names; 5b is the four-repo wire change. names; 5b is the four-repo wire change.
**The `edge` → `main` half of order 6 is held for Android parity** (decided 2026-07-30, see §10): the
app sees none of the four new features and gates shard nav on session role alone, so merging the
cutover first would ship a shard whose app client silently disagrees with the web client about what is
public. The **bump** PRs into `edge` are unaffected and merge normally.
--- ---
## 1. Why 3.0 ## 1. Why 3.0
@@ -242,6 +247,39 @@ admin-set `uo_link_config.protocol` column — so it happens **exactly once**, a
from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides. from 2 to 3, so the cutover doesn't require a manual admin edit. `UOLINK_PROTOCOL` still overrides.
- No feature-negotiation array anywhere — v3 implies all three kinds. - No feature-negotiation array anywhere — v3 implies all three kinds.
### 4.1 What the bump actually touches
The version lives in five places, and all five move together:
| Where | Change |
|---|---|
| `link/sidecar/src/main.rs` | `PROTOCOL_VERSION` 2 → 3 (with the v3 note beside the v2 one), plus the sidecar README's worked example |
| `website/server/db/schema.sql` | `uo_link_config.protocol` column default 1 → 3, plus the boot migration below |
| `website/server/src/model/uoLinkConfig/uoLinkConfig.model.js` | `DEFAULT_PROTOCOL` — what a site with nothing saved yet declares |
| `website/server/src/utils/uoLinkClient.js` + `uoLinkSocket.js` | the `config.protocol || …` fallbacks, so an unset value can never quietly send `1` and 409 with a confusing message |
| `website/client/.../ShardAdmin.jsx`, `website/.env.example` | the admin form's initial value and the documented env default |
**The migration has to be one-shot, and that is the only subtle part.** `schema.sql` is re-run on
*every* boot (`utils/db.js::ensureSchema`), and every other statement in its migration block is an
idempotent `ADD COLUMN IF NOT EXISTS` / `MODIFY`. A bare `UPDATE uo_link_config SET protocol = 3`
would not be idempotent in the sense that matters: `protocol` is **admin-editable**, so an operator
who deliberately pins an older sidecar in Admin → Shard would silently be un-pinned on the next
restart. It is therefore gated on a marker row in `settings`:
```sql
ALTER TABLE uo_link_config MODIFY COLUMN protocol INT NOT NULL DEFAULT 3;
UPDATE uo_link_config SET protocol = 3
WHERE id = 1 AND protocol < 3
AND NOT EXISTS (SELECT 1 FROM settings WHERE `key` = 'uo_link_protocol_3_migrated');
INSERT IGNORE INTO settings (`key`, value) VALUES ('uo_link_protocol_3_migrated', '1');
```
The marker is written *after* the `UPDATE`, so the first boot on the new build migrates and every
later boot is a no-op. A fresh install has no `uo_link_config` row to update and simply gets the
marker plus the new column default. `protocol < 3` rather than `= 2` so an install that never left
the old default of `1` is carried across too — it could not have been talking to a v2 sidecar
anyway.
--- ---
## 5. Part B/1 — `world.ruleset` ✅ Done ## 5. Part B/1 — `world.ruleset` ✅ Done
@@ -887,8 +925,8 @@ not a blocker here.)
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done | | 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ Done |
| 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done | | 4 | **B/2**`points.board` (§7) | all four | new kind + `char.profile` field | ✅ Done |
| 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done | | 5a | **B/3 dependency** — cliloc table (§8.6) | website, docs | none | ✅ Done |
| 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | 🟨 In review | | 5b | **B/3**`vendor.listing` (§8) | all four | new kinds | ✅ Done |
| 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | | | 6 | **Cutover**`PROTOCOL_VERSION` 2→3, `edge``main` | all four | the bump | 🟨 In review — `edge``main` held for Android parity (§10) |
--- ---
@@ -910,8 +948,27 @@ not a blocker here.)
- `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed - `npm run swagger` **and** `npm run routes:manifest` on every route-touching PR — both are committed
artifacts, and `test/routeManifest.test.js` fails on drift. artifacts, and `test/routeManifest.test.js` fails on drift.
**Follow-up, not scoped for 3.0:** the Android app consumes the same public/player shard API and will **Android parity — now scoped, and it gates the cutover (decided 2026-07-30).** This was written as a
need `/public/shard/features` to hide its own nav. Track separately against `android-app/`. "track separately" follow-up. It was re-examined before the cutover and the gap is wider than nav
hiding: the app consumes the same public/player shard API but has **no consumer for any of the four new
features** (`ruleset`, `leaderboards`, `market`, `atlas`), no `points` block on its character sheet, no
cliloc-resolved item names (§8.6), and — the part that matters for §3 — **it gates shard navigation on
session role alone**, so an admin who disables a feature or raises its audience leaves the app
rendering entries that `404`/`403` into a generic error where the web client hides them.
Two things were verified as already correct and are recorded so they are not re-derived: the app's SSE
request rides the same authenticated OkHttp client as every other call, so an app session resolves to
the same audience rung as the same account on the web; and every shard DTO in the app is
nullable-with-defaults, so field projection strips fields without a deserialization failure.
Scoped as **M11 in [`../android/PLAN.md`](../android/PLAN.md) §9**, two PRs: the visibility rules +
read-model adds ([Android-app #30](https://gitea.whitlocktech.com/RunicGateway/Android-app/pulls/30))
and the four screens ([#31](https://gitea.whitlocktech.com/RunicGateway/Android-app/pulls/31), stacked
on it). Both are **built and in review**; the on-device five-rung walk (§11) against a website on the
cutover branch is the remaining gate. `edge``main` is held until both land, so web and app surface
the same shard on the same day. Neither PR is coupled to the merge order — on a pre-v3 website
every new route and `/public/shard/features` `404`s and the app falls back to today's behavior — so
holding the cutover is a schedule decision, not a technical dependency.
--- ---