diff --git a/android/PLAN.md b/android/PLAN.md index 8891a0d..47782a1 100644 --- a/android/PLAN.md +++ b/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,90 @@ 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. + + - **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, diff --git a/link/v3.md b/link/v3.md index cb393e8..284f886 100644 --- a/link/v3.md +++ b/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 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 @@ -921,7 +926,7 @@ not a blocker here.) | 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 | ✅ 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 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, 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. ---