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>
This commit is contained in:
111
android/PLAN.md
111
android/PLAN.md
@@ -1,6 +1,6 @@
|
|||||||
# Android App — Plan
|
# 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
|
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,90 @@ push, and Play (M6–M8) 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.
|
||||||
|
|
||||||
|
- **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,
|
||||||
|
|||||||
27
link/v3.md
27
link/v3.md
@@ -23,6 +23,11 @@ Order 5 split in two once §8.6's cliloc dependency turned out to be a client-fo
|
|||||||
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
|
||||||
@@ -921,7 +926,7 @@ not a blocker here.)
|
|||||||
| 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 | ✅ Done |
|
| 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 |
|
| 6 | **Cutover** — `PROTOCOL_VERSION` 2→3, `edge` → `main` | all four | the bump | 🟨 In review — `edge` → `main` held for Android parity (§10) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -943,8 +948,24 @@ 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, then the four screens). `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