feat(shard): follow the visibility framework and read the Protocol 3.0 profile #30

Merged
whitlocktech merged 5 commits from feat/protocol-3-visibility into main 2026-08-01 07:22:09 +00:00
Member

M11 Part 1 of 2 — the rules half. Plan: docs/android/PLAN.md §9 M11 (docs#74), design: docs/link/v3.md §3.

The problem

Protocol 3.0 made every shard-derived surface admin-configurable: a feature can be switched off, or its audience raised above the caller's rung. The app knew nothing about it and gated shard navigation on the session role alone — so an admin change left the drawer and the hub offering entries that 404/403 into a generic "something went wrong, retry" where the web client hides them.

The visibility rules

  • GET /public/shard/features behind a singleton ShardFeaturesRepository, re-resolved on every session change (the answer is per-viewer) and dropped on a Settings → Server switch — the one case no session change covers, since switching between two signed-out hosts changes no session and the new shard would otherwise inherit the old one's menu.
  • MenuEntry.feature beside access. Two independent gates, both must pass: right role + feature switched off is still hidden, and a staff role is not a licence to render a link to a disabled feature. ShardBoard tags each hub tile the same way, so the boards card filters with the same call.
  • An unknown answer fails open, matching lib/useShardFeatures.js. The server gates every call regardless, so a link that briefly 403s beats a drawer that flickers its entries in on every cold start. A pre-3.0 website 404s this route, which reads as "unknown" — that site has no visibility framework, so the menu behaves exactly as it does today.
  • toShardUiState() maps 404 and 403 to a new ErrorKind.FEATURE_UNAVAILABLE. requireFeature answers 404 for a disabled feature (deliberately not disclosing that it exists) and 403 for a viewer below its rung. Kept as a separate mapper rather than folded into toUiState() because both statuses mean something else off the shard surface — a deleted post, an ownership refusal on a player route. That state renders without a retry button: an admin controls it, so retrying cannot change the answer and offering it would read as a transient failure the user could wait out.

The read-model adds

Same v3 series, both already live on the website:

  • char.profile.points — the Loyalty & Points block (§7.3). Three shapes here are what a real shard sends and a fake one doesn't (§7.5): maxPoints: 0 means uncapped and is the common case, so nothing divides by it and only a capped system gets a meter; nameString is usually null because most systems name themselves with a cliloc, making the humanise-the-key path primary rather than defensive; rank is absent unless the shard opts in, and absent is not "unranked".
  • Cliloc-resolved names (§8.6) — equipment[].clilocName and titles.rewardResolved, so equipment stops rendering as a bare layer. rewardResolved is positional: an entry the table couldn't resolve is null and is skipped without shifting the selected index onto its neighbour, which would otherwise show the wrong title confidently.

ActorDto keeps acct/webId but now documents them as admin-locked rather than available — they're stripped from every response below that rung and no setting can loosen it.

Points ride ungated on /player/shard/char/:serial: a character's own standings are self-service and don't depend on the public leaderboards feature, so the app mirrors that rather than re-gating them.

Verified

  • ./gradlew testDebugUnitTest304 tests, 0 failures. New coverage: the fail-open direction (empty and unknown are opposite answers — a failed lookup must never read as "this shard publishes nothing"), the two gates being independent, the 404/403/503 split, rewardResolved index alignment, and uncapped-vs-zero-cap.
  • ./gradlew lint — clean.

Not yet on-device: the v3 routes only exist on the website's edge/cutover branch, so the five-rung walk (§11) runs against a local website on that branch and is scheduled with Part 2.

Scope

Part 2 adds the four screens (Rules, Leaderboards, Market + vendor detail, Atlas + creature detail), each hidden by its feature name — the plumbing they hang on is all here. Nothing in this PR depends on the cutover merge order.

  • AI-assisted: authored with Claude Code (Claude Opus 5); commits carry a Co-Authored-By trailer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP

M11 Part 1 of 2 — the rules half. Plan: `docs/android/PLAN.md` §9 M11 (docs#74), design: `docs/link/v3.md` §3. ## The problem Protocol 3.0 made every shard-derived surface **admin-configurable**: a feature can be switched off, or its audience raised above the caller's rung. The app knew nothing about it and gated shard navigation on the **session role alone** — so an admin change left the drawer and the hub offering entries that `404`/`403` into a generic "something went wrong, retry" where the web client hides them. ## The visibility rules - **`GET /public/shard/features`** behind a singleton `ShardFeaturesRepository`, re-resolved on every session change (the answer is per-viewer) and dropped on a Settings → Server switch — the one case no session change covers, since switching between two signed-out hosts changes no session and the new shard would otherwise inherit the old one's menu. - **`MenuEntry.feature` beside `access`.** Two independent gates, both must pass: right role + feature switched off is still hidden, and a staff role is not a licence to render a link to a disabled feature. `ShardBoard` tags each hub tile the same way, so the boards card filters with the same call. - **An unknown answer fails open**, matching `lib/useShardFeatures.js`. The server gates every call regardless, so a link that briefly `403`s beats a drawer that flickers its entries in on every cold start. A pre-3.0 website `404`s this route, which reads as "unknown" — that site has no visibility framework, so the menu behaves exactly as it does today. - **`toShardUiState()` maps `404` *and* `403` to a new `ErrorKind.FEATURE_UNAVAILABLE`.** `requireFeature` answers `404` for a *disabled* feature (deliberately not disclosing that it exists) and `403` for a viewer below its rung. Kept as a separate mapper rather than folded into `toUiState()` because both statuses mean something else off the shard surface — a deleted post, an ownership refusal on a player route. That state renders **without a retry button**: an admin controls it, so retrying cannot change the answer and offering it would read as a transient failure the user could wait out. ## The read-model adds Same v3 series, both already live on the website: - **`char.profile.points`** — the Loyalty & Points block (§7.3). Three shapes here are what a *real* shard sends and a fake one doesn't (§7.5): `maxPoints: 0` means **uncapped** and is the common case, so nothing divides by it and only a capped system gets a meter; `nameString` is usually `null` because most systems name themselves with a cliloc, making the humanise-the-key path **primary** rather than defensive; `rank` is absent unless the shard opts in, and absent is not "unranked". - **Cliloc-resolved names** (§8.6) — `equipment[].clilocName` and `titles.rewardResolved`, so equipment stops rendering as a bare layer. `rewardResolved` is **positional**: an entry the table couldn't resolve is null and is skipped *without* shifting the `selected` index onto its neighbour, which would otherwise show the wrong title confidently. `ActorDto` keeps `acct`/`webId` but now documents them as **admin-locked** rather than available — they're stripped from every response below that rung and no setting can loosen it. Points ride **ungated** on `/player/shard/char/:serial`: a character's own standings are self-service and don't depend on the public `leaderboards` feature, so the app mirrors that rather than re-gating them. ## Verified - `./gradlew testDebugUnitTest` — **304 tests, 0 failures**. New coverage: the fail-open direction (empty and unknown are opposite answers — a failed lookup must never read as "this shard publishes nothing"), the two gates being independent, the `404`/`403`/`503` split, `rewardResolved` index alignment, and uncapped-vs-zero-cap. - `./gradlew lint` — clean. Not yet on-device: the v3 routes only exist on the website's `edge`/cutover branch, so the five-rung walk (§11) runs against a local website on that branch and is scheduled with Part 2. ## Scope Part 2 adds the four screens (Rules, Leaderboards, Market + vendor detail, Atlas + creature detail), each hidden by its feature name — the plumbing they hang on is all here. Nothing in this PR depends on the cutover merge order. - [x] AI-assisted: authored with **Claude Code** (Claude Opus 5); commits carry a `Co-Authored-By` trailer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
wtclaude added 1 commit 2026-07-30 07:35:25 +00:00
feat(shard): follow the visibility framework and read the Protocol 3.0 profile
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m20s
833e51de69
M11 Part 1 (docs/android/PLAN.md §9). The website's Protocol 3.0 work made every
shard-derived surface admin-configurable — a feature can be switched off, or its
audience raised above the caller's rung — and the app knew nothing about it: it
gated shard navigation on the session role alone, so an admin change left the
drawer and the hub offering entries that 404/403 into a generic error where the
web client hides them.

The visibility rules:

  - GET /public/shard/features behind a singleton ShardFeaturesRepository,
    re-resolved on every session change (the answer is per-viewer) and dropped on
    a Settings → Server switch, which is the one case no session change covers.
  - MenuEntry gains `feature` beside `access`; the two gates are independent and
    both must pass. ShardBoard tags each hub tile the same way.
  - An unknown answer FAILS OPEN, matching lib/useShardFeatures.js: the server
    gates every call regardless, so a link that briefly 403s beats a drawer that
    flickers its entries in on every cold start. A pre-3.0 website 404s this
    route, which reads as "unknown" and behaves exactly as before.
  - toShardUiState() maps 404 AND 403 to a new ErrorKind.FEATURE_UNAVAILABLE:
    requireFeature answers 404 for a disabled feature (deliberately not
    disclosing it exists) and 403 for a viewer below its rung. Kept separate from
    toUiState() because both statuses mean something else off the shard surface —
    a deleted post, an ownership refusal. That state renders without a retry
    button; an admin controls it, so retrying cannot change the answer.

The read-model adds, from the same v3 series:

  - char.profile `points` — the Loyalty & Points block. maxPoints 0 means
    UNCAPPED and is the common case, so nothing divides by it and only a capped
    system gets a meter; nameString is usually null (systems name themselves with
    a cliloc) so humanising the PointsType key is the primary display path; rank
    is absent unless the shard opts in, and absent is not "unranked".
  - Cliloc-resolved names — equipment `clilocName` and titles `rewardResolved`,
    so items stop rendering as a layer. rewardResolved is positional: an entry
    the table could not resolve is null and is skipped WITHOUT shifting the
    `selected` index onto its neighbour.

ActorDto keeps acct/webId but documents them as admin-locked rather than
available. Points ride ungated on /player/shard/char/:serial — a character's own
standings are self-service and do not depend on the public leaderboards feature,
so the app mirrors that rather than re-gating it.

304 unit tests pass; lint clean.

Co-Authored-By: Claude <noreply@anthropic.com>
whitlocktech added 2 commits 2026-07-30 07:54:33 +00:00
M11 Part 2 (docs/android/PLAN.md §9), on the visibility plumbing Part 1 added.
Each screen hides from the menu when the shard doesn't publish its feature, and
self-reports "not available here" from its own 404/403 so a deep link still
lands on an honest answer.

  - Rules (/public/shard/ruleset). A null body means the shard has never
    published a ruleset, which is a SUCCESS state, not the feature being off —
    the screen tells the two apart. Blocks render only when published, since an
    omitted block means the system is off rather than unknown. Skill caps are
    converted out of tenths; the raw 1000 reads as ten times the real limit.
    Live via world.ruleset, which the shard re-emits on every reconnect.
  - Leaderboards (/public/shard/points). Boards order most-contested first, live
    via points.board. maxPoints 0 is uncapped so no cap line is drawn, and a
    cliloc-named board (nameString null, the usual case) falls back to the
    humanised PointsType key. A nameless rank is a valid row: the character name
    is the feature's one admin-configurable field.
  - Market (/public/shard/market + /meta + /vendors/:serial). NOT live: the
    market feature ships with its SSE fan-out disabled, so this is a plain
    paginated read, searched on submit rather than per keystroke because it is
    the site's first rate-limited public endpoint. The staleness line is
    required, not decoration — the round-robin sweep means a price can be a full
    cycle old. The vendor screen is the only surface that can render a truncated
    shop and a gated location, the latter as a real answer rather than a blank
    coordinate.
  - Atlas (/public/atlas/creatures[/:slug]). Static shard content, so it stays
    readable while the shard is down — but site-mode gated, unlike /shard/*.
    Rows lead with the server's placement label ("Despise, Felucca"), which is
    the transform the whole feature exists for. Respawn delays are read as
    SECONDS, the unit the parser normalises XmlSpawner's mixed minutes/seconds
    into. Facet filter options are discovered from the shard's own data — nothing
    here names a facet, since a shard may add, replace or rename them.

336 unit tests pass (32 new); lint clean. The five-rung on-device walk runs
against a local website on the cutover branch before the cutover merges.

Co-Authored-By: Claude <noreply@anthropic.com>
Reviewed-on: #31
whitlocktech approved these changes 2026-07-30 07:56:30 +00:00
Dismissed
whitlocktech added 2 commits 2026-08-01 06:03:58 +00:00
`AtlasCreatureDto.places` was typed `List<String>` while the server sends
`{facet, label, spawners, maxAlive}` objects. The detail route answers 200 with
~49 KB, kotlinx throws on decode, and the screen renders "Something went wrong
on the server" — so the whole Atlas creature page was dead, and the error
blamed a server that was fine. Nullable-with-defaults protects against a
missing field, never a wrong element type.

Adds AtlasPlaceDto, plus the `art` field the server also sends, so a decode
cannot depend on that staying absent (neither client renders art yet).

`places` was never rendered either, so the aggregate the atlas exists to give —
"Shrines, Isamu-Jima, Yew", resolved server-side by point-in-rect — was missing
from the app while the web page led with it. Adds a "Where it spawns" section
above the individual spawners, matching web's ordering, and a plural for the
spawner count now that single-spawner places are on screen in bulk.

Adds ShardContentDtoTest — the first decode test any of the four Protocol 3.0
DTOs has had, fed payloads captured from a live server. That absence is the
root cause: the fakes in data/api/fake/ construct DTOs in Kotlin, so no test in
the suite could see a wire mismatch, even though PLAN.md §9 already required
"DTO decode for each new shape".

Also renders a placeholder row on an unscored leaderboard (the instance name,
em dash where a score goes) rather than a blank card — deliberately not shaped
like a real entry, since a placeholder that looked like a standing would be a
fabricated one.

Found by the on-device five-rung walk against a live shard; all four screens
re-verified on the emulator afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
Reviewed-on: #32
whitlocktech approved these changes 2026-08-01 07:22:00 +00:00
whitlocktech merged commit 5eaf5d22c6 into main 2026-08-01 07:22:09 +00:00
whitlocktech deleted branch feat/protocol-3-visibility 2026-08-01 07:22:10 +00:00
Sign in to join this conversation.
No description provided.