Compare commits
5 Commits
cdea1aa7cd
...
docs/andro
| Author | SHA1 | Date | |
|---|---|---|---|
| f6a22734d3 | |||
| 5a091157d6 | |||
| 3a1bbdd165 | |||
| 32def88c4e | |||
| 71207cef16 |
115
android/PLAN.md
115
android/PLAN.md
@@ -1,6 +1,6 @@
|
||||
# Android App — Plan
|
||||
|
||||
Status: **M0–M7 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: **M0–M7 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
|
||||
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
|
||||
@@ -631,6 +631,8 @@ not rank).
|
||||
| News & content | everyone | `/public/posts/:category`, `/public/pages/:slug` |
|
||||
| Wiki | everyone | `/public/wiki`, `/public/wiki/categories`, `/public/wiki/tags`, `/public/wiki/:slug` |
|
||||
| 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` |
|
||||
| **My Account** | signed-in | `/player/account/*` (or `/admin/account/*` for staff — see §6.4) |
|
||||
| **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` |
|
||||
@@ -639,6 +641,12 @@ not rank).
|
||||
Guidelines:
|
||||
- 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.
|
||||
- **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
|
||||
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
|
||||
@@ -663,9 +671,15 @@ Guidelines:
|
||||
### 6.2 Public shard (live)
|
||||
- Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the
|
||||
`/public/shard/*` GETs.
|
||||
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE, safe kinds only) and patch the
|
||||
in-memory boards in place (champ/guild/city/house/presence update+remove frames). Reconnect with
|
||||
backoff; fall back to poll if SSE drops.
|
||||
- **Live updates** — subscribe to `GET /public/shard/stream` (SSE) and patch the in-memory boards in
|
||||
place (champ/guild/city/house/presence update+remove frames). Reconnect with backoff; fall back to
|
||||
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)
|
||||
- **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`,
|
||||
`/sales`, `/houses`. All ownership-checked server-side; a `503` means shard/sidecar down → show an
|
||||
"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
|
||||
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.
|
||||
@@ -883,10 +902,94 @@ push, and Play (M6–M8) follow the designed app.
|
||||
shard-write actions degrade gracefully when the sidecar is offline. Excluded: hero/CMS block
|
||||
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)
|
||||
|
||||
- **`/api/mobile` facade migration + app-version floor** — briefly planned as M11 (2026-07-22), now
|
||||
**deferred with no app work scheduled**. The website's router refactor is being done in place with
|
||||
- **`/api/mobile` facade migration + app-version floor** — briefly planned as its own milestone
|
||||
(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/…`
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
- Every response carries an **`X-UOLink-Version: 2`** header.
|
||||
- `GET /health` and the WebSocket `ws.hello` frame include `"protocol": 2`.
|
||||
- **Optionally**, send `X-UOLink-Version: 2` on your requests. If it disagrees with the sidecar, the request is rejected **409 Conflict**:
|
||||
- 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": 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.
|
||||
|
||||
**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
|
||||
`world.ruleset`, `points.board`, `vendor.listing` / `vendor.listing.remove`*, and the bump to
|
||||
`X-UOLink-Version: 3` happens **exactly once**, at the end, when [`v3.md`](v3.md) §4's `edge` → `main`
|
||||
cutover lands — because a bump is an operator-visible hard break (409 on every protected route, and
|
||||
the website's WS closes on the `ws.hello` mismatch), so doing it per phase would break the site
|
||||
repeatedly.
|
||||
**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.
|
||||
|
||||
Until then, sidecars on `edge` still report `2` while already carrying some v3 kinds and endpoints.
|
||||
That is safe in the direction that matters: event kinds are additive, and a client that ignores
|
||||
unknown kinds and tolerates a `404` on a not-yet-present endpoint keeps working. What you must **not**
|
||||
do is infer feature availability from the version number during this window — probe the endpoint, or
|
||||
treat a missing `world.ruleset` as "this shard hasn't published one". There is deliberately **no
|
||||
feature-negotiation array**: v3 implies all three kinds.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -68,7 +68,7 @@ GET /health (no auth)
|
||||
```json
|
||||
{
|
||||
"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?
|
||||
"database": "ok", // "ok" | "error"
|
||||
"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:
|
||||
|
||||
```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`.
|
||||
@@ -955,7 +955,7 @@ sidecar defines no audiences. Deciding who may see what is the consuming site's
|
||||
A typical character page:
|
||||
|
||||
```js
|
||||
const H = { "Authorization": `Bearer ${TOKEN}`, "X-UOLink-Version": "2" };
|
||||
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());
|
||||
|
||||
@@ -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
|
||||
`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`)
|
||||
|
||||
```ini
|
||||
|
||||
71
link/v3.md
71
link/v3.md
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
**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).
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3 (§4) | ⬜ Not started | — |
|
||||
| 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) | 🟨 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
|
||||
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.
|
||||
|
||||
**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
|
||||
@@ -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.
|
||||
- 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
|
||||
@@ -887,8 +925,8 @@ not a blocker here.)
|
||||
| 3 | **C** — spawn atlas (§6) | website, docs | none | ✅ 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 |
|
||||
| 5b | **B/3** — `vendor.listing` (§8) | all four | new kinds | 🟨 In review |
|
||||
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | ⬜ |
|
||||
| 5b | **B/3** — `vendor.listing` (§8) | all four | new kinds | ✅ Done |
|
||||
| 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
|
||||
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
|
||||
need `/public/shard/features` to hide its own nav. Track separately against `android-app/`.
|
||||
**Android parity — now scoped, and it gates the cutover (decided 2026-07-30).** This was written as a
|
||||
"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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user