feat(shard): follow the visibility framework and read the Protocol 3.0 profile #30
Reference in New Issue
Block a user
No description provided.
Delete Branch "feat/protocol-3-visibility"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/403into a generic "something went wrong, retry" where the web client hides them.The visibility rules
GET /public/shard/featuresbehind a singletonShardFeaturesRepository, 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.featurebesideaccess. 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.ShardBoardtags each hub tile the same way, so the boards card filters with the same call.lib/useShardFeatures.js. The server gates every call regardless, so a link that briefly403s beats a drawer that flickers its entries in on every cold start. A pre-3.0 website404s this route, which reads as "unknown" — that site has no visibility framework, so the menu behaves exactly as it does today.toShardUiState()maps404and403to a newErrorKind.FEATURE_UNAVAILABLE.requireFeatureanswers404for a disabled feature (deliberately not disclosing that it exists) and403for a viewer below its rung. Kept as a separate mapper rather than folded intotoUiState()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: 0means uncapped and is the common case, so nothing divides by it and only a capped system gets a meter;nameStringis usuallynullbecause most systems name themselves with a cliloc, making the humanise-the-key path primary rather than defensive;rankis absent unless the shard opts in, and absent is not "unranked".equipment[].clilocNameandtitles.rewardResolved, so equipment stops rendering as a bare layer.rewardResolvedis positional: an entry the table couldn't resolve is null and is skipped without shifting theselectedindex onto its neighbour, which would otherwise show the wrong title confidently.ActorDtokeepsacct/webIdbut 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 publicleaderboardsfeature, 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, the404/403/503split,rewardResolvedindex 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.
Co-Authored-Bytrailer.🤖 Generated with Claude Code
https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
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>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>placesobjects and render them 4f85021be2`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_01U7CBg11prhLimL9iHSX1bPplacesobjects and render them' (#32) from fix/atlas-places-decode into feat/protocol-3-visibility