diff --git a/android/PLAN.md b/android/PLAN.md index 0d7102c..a8a1c30 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -661,6 +661,8 @@ not rank). | Contact | everyone | `/public/contact` | | **My Account** | signed-in | `/auth/me/account/*` — one surface for every role (see §6.4) | | **My Characters / Vendors / Houses** | `player` (linked) | `/player/shard/*` | +| **Events** | everyone, *if core serves events* | `/public/events`, `/public/events/:slug`, `/public/events/series/:slug` (M13) | +| **My Events** | signed-in, any role | `/player/events/history` (M13) | | Sign in / Sign out | toggles on session | `/auth/mobile/*` | Guidelines: @@ -672,6 +674,15 @@ Guidelines: 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. +- **Nor is it the only gate on a MODULE's surfaces (M13).** A feature flag says whether a shard + publishes a surface to this viewer; it cannot say whether the module that owns it is installed at + all. `GET /public/modules` and core's own `capabilities` in the `version` block answer that, per + host rather than per viewer, and an entry may declare a capability alongside its feature. The two + compose and both must pass. **The two failure directions differ, and deliberately:** an unknown + *feature* answer shows everything, because the server gates every call regardless; an unknown + *capability* answer shows everything too, but a **known** one that does not name the capability + hides the rows — absence of an answer is not an answer of absence. Without that distinction a site + with no `uo` module renders five shard rows that each `404`. - 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 @@ -692,6 +703,11 @@ Guidelines: - **CMS pages** — `GET /public/pages/:slug` (block-based; render the block types the site uses). - **Wiki** — list/categories/tags/detail as above. - **Contact** — `POST /public/contact` (rate-limited; handle 429/502). +- **Events (M13)** — `GET /public/events` (the calendar; entries are a materialised `run` or a + `projected` forecast and say which), `GET /public/events/:slug` (one event, `?run=` selecting + which occurrence a results table is about), `GET /public/events/series/:slug` (an arc). Gated by + core's `events` capability, not by a module's. A draft, an archived and an *unlisted* definition + all answer `404`, indistinguishable from a slug that never existed. ### 6.2 Public shard (live) - Status/online/feed/economy/champs/guilds/governors(+history)/presence/houses/idoc — the @@ -1128,6 +1144,169 @@ push, and Play (M6–M8) follow the designed app. rather than absorb it. Phase 8 also narrowed a stale `ui/theme/**` Sonar coverage glob that was discarding three 98–100%-covered resolvers. **477 tests green.** +14. **M13 — Events in the app** (post-v1; scoped 2026-09-08). The website's Event System reached its + public surface in events Phase 14a (website#196 / docs#224): a public calendar, event pages, arcs + and participation history, plus an `events` capability string in the `version` block. This + milestone is **events Phase 14b** — the app's half — and the phase was split on the repo line for + the reason 12a/12b was: the app cannot be walked against a page that does not exist yet. + + **Design of record: [`../website/EVENTS.md`](../website/EVENTS.md)** §I and its API surface table, + and [`../website/EVENTS_PLAN.md`](../website/EVENTS_PLAN.md) Phase 14b. The contract is normative + there; this entry records what the app does about it. + + **No backend work.** All four routes are live on the website's `edge`: + `GET /public/events`, `/public/events/:slug` (with `?run=`), `/public/events/series/:slug`, and + `GET /player/events/history`. `MODULE_API_VERSION` is untouched — nothing about the module + contract changes here. + + #### The prerequisite, which is in this milestone and not after it + + **The app cannot tell a module that is not installed from a lookup that failed, and it renders a + broken drawer because of it.** `ShardFeaturesRepository.refresh()` maps *every* failure to `null` + and `canSee(null, …)` answers `true`, so on a site with no `uo` module — which is any site running + a different game — `GET /public/shard/features` 404s, all five shard rows render, and every one of + them 404s when tapped. Failing open is right *within* a module that is installed (an admin may + switch a surface off between two refreshes, and the server gates every call regardless) and wrong + for one that is not there at all. + + Three decisions, taken by the org lead on 2026-09-08: + + - **Absence of an answer is not an answer of absence.** A *successful* read of + `GET /public/modules` that does not name a capability **hides** the rows that need it — that is + an answer. A read that *failed* keeps the last answer this host gave, and when the host has + never answered the gate stays open and the drawer renders as it does today. The bug is that the + current code collapses those three into one `null`. + - **The capability gate composes with the feature gate; it does not replace it.** They answer + different questions and both must pass. `capability` asks *is this module installed at all*, and + is per-host — it changes when an operator installs or removes a module. `feature` asks *does this + shard publish this surface to this viewer*, and is per-viewer — it changes on sign-in. That is + also why the two refresh on different clocks: capabilities with the appearance (per host), + features with the session (per viewer). + - **One capability gates all five shard rows: `shard`.** module-uo also declares `atlas`, `market`, + `champs`, `guilds`, `governors`, `houses` and `cliloc`, and gating each row on its own string + would be a second, worse copy of what `feature` already decides. The two new events rows gate on + core's own `events`. + + Core's list and a module's are **separate lists**, deliberately, and the app reads both: + `capabilities` in the `version` block of `GET /public/status` for core, and `GET /public/modules` + for the installed modules. See [`../website/MODULE_API.md`](../website/MODULE_API.md) §2.9 — an + unknown string is treated as absent, and **no route may be inferred from one**. + + #### The website path → route table was stale, and that is a second live defect + + Found while scoping this milestone, and **fixed here** rather than logged (org lead, 2026-09-08). + [`NavPaths.kt`](#) quotes the website's `export const NAV` verbatim as sixteen rows. Since the + module-system cutover on 2026-08-12 core's `NAV` is **eight** rows and the nine shard rows belong + to module-uo, which registers them at `/uo/shard`, `/uo/champs`, `/uo/guilds`, `/uo/governors`, + `/uo/houses`, `/uo/rules`, `/uo/atlas`, `/uo/leaderboards` and `/uo/market` — core mounts a + module's public pages at `//`. Three things follow, all of them true of the shipped app: + + - a nav override an admin sets on any shard row is **ignored**, because the merge intersects with + the app's own menu through a table that no longer resolves those paths; + - an added link to a shard page **hands off to a Custom Tab** instead of opening natively, and + `/uo` alone resolves to a CMS page that does not exist; + - `WEB_ROUTE_ORDER` is the number line a stored `order` is compared against, and a sixteen-row + line against a nav that now numbers core 0–7 and appends the module's rows after it puts a + partly-overridden drawer in the wrong order. `/site/events` is a new core row at index 2, so + every row after News shifts. + + **The nine `/uo/*` paths are hardcoded, and documented as coupling to one module** (org lead). The + alternative — deriving `//…` from the module list — infers a route from a capability, which + §2.9 forbids, and would hardcode the same path shape less visibly. A site running a different game + module maps none of the nine and its links hand off to the browser, which is the correct answer. + + #### What ships + + Four screens, one per 14a surface, and the arc is included because the event page links to it — + omitting it would make that link a dead end or a hand-off out of a native screen: + + | Screen | Route | Reads | + | --- | --- | --- | + | **Events** (the calendar) | `events` | `GET /public/events` | + | **Event** | `events/{slug}?run={run}` | `GET /public/events/:slug` | + | **Arc** | `events/series/{slug}` | `GET /public/events/series/:slug` | + | **My Events** | `account/events` | `GET /player/events/history` | + + - **The calendar is a list, and a projection is drawn differently from a run.** Both are §I's + rules, one tier along: an entry past the materialisation horizon is arithmetic with nothing + committed to it, so it is labelled rather than drawn as a booking. + - **The day heading is the reader's, the time beside it is the event's.** The split is §I's and it + is the one thing about event times that is easy to get wrong. A player in Berlin told an American + shard's event is at 02:00 has been told something true and useless. + - **The status word follows the clock, not the status.** The server publishes `failed` and `missed` + as `cancelled`; *did not happen* is right for a past occurrence and false for a future one, and a + run four days out that an operator called off is the common case. This is 14a's own defect, and + the app must not re-introduce it in Kotlin. + - **`?run=` is carried, not dropped.** The page lives at the definition's slug so a weekly event + has one address, and the occurrence is in the query. + + **My Events is one drawer row at `SIGNED_IN`, not two** (org lead). The website mounts + participation history twice — `/account/events` and `/admin/events/mine` — only because + `RequirePlayer` guards `/account` and the route behind it is role-agnostic. The app's drawer has no + such wall: `GET /player/events/history` is `requireAuth` alone and self-scoped on the caller's own + id, so one row serves players and staff alike. It is deliberately **not** at `PLAYER` access, which + would imply event history is game-linked data; it is not, and a shard with no game module still has + events. + + #### An inbox link to an event opens the app, not the browser + + **Departing from the recommendation** (org lead, 2026-09-08). Engagement Phase 8's contract is that + an inbox item's `url` is site-relative and hands off to a Custom Tab. Phase 14a gave the six public + `event.` triggers an `eventUrl` of exactly the form `/site/events/?run=`, so an inbox row + about an event now has a native destination and should use it. + + The mechanism is the one that already exists — `resolveWebPath` — rather than a second one. That + forces one rule to change, and it is worth stating because it is the exact trap the rule was + written to avoid: **a path carrying a query hands off**, because no app route took one and a + native match would silently drop what the admin wrote. The event page is the first app route that + takes a query, so the rule becomes *a query hands off, except `run` on an event page*. Anything + else in the query string, and any fragment, still hands off untouched. + + - **Excluded**, in the same class as M10's, M11's and M12's exclusions: the events **authoring** + surface. The app does not gain an event editor, a run console or the cap meter; Phase 13 built + those for the web and they are admin *configuration*, which the app is a consumer of and not an + editor for. + + **`account/events`, not `events/mine`**, and the reason is a bug one tier along: `events/mine` and + `events/{slug}` are both two segments, and a static-versus-argument race between two NavHost + patterns is exactly what events Phase 13 found in React Router, where a static `events/new` + outranked `events/:id` and made creating an event impossible for seven phases. Under `account/` + there is no dynamic sibling and no race to lose. + + **Verified:** the app suite (**570 tests, 0 failures**), and an emulator walk against a local + website running the 14a code, at three rungs — anonymous, player and admin. + + #### The walk found three defects, and 563 green tests found none of them + + The rig's own site had **no `uo` module installed**, which made it the exact case the prerequisite + fix is for, and is why two of the three were visible at all. + + - **The three player game-data rows were not gated.** `My characters`, `My vendors` and `My houses` + read `/player/shard/*` — the same module's *player* mount — and rendered on a backend with no + module, answering *"This content couldn't be found"* with a Retry button. The gate had been + applied to the five public shard rows only. The test that was meant to catch this asked whether + every row **with a `feature`** declared the capability, which is true and insufficient: these + three carry no feature, because they are gated by ownership rather than by the visibility + framework. It now asks by **route**. + - **`score` is `DECIMAL(18,4)`, and declaring it an integer breaks the whole response.** The first + history row the walk read scored `318.5`. kotlinx does not round a fraction into a `Long` — it + refuses the entire body, so a `200` reached the screen as *"Something went wrong on the server."* + The same field is on the public results table, so **any** event whose module scores fractionally + would have broken the event page for every anonymous visitor. + - **A drawer route's view model outlives a sign-out, and this one is per-account.** + `navigateTopLevel` saves and restores back-stack state, so the entry keeps its `ViewModelStore` + and a view model that loads only in `init` never runs again. Signing out of the admin and back in + as a player showed the **player the admin's participation history**, with no request made at all. + It now keys on the signed-in account id, and the shell sends a signed-out caller home rather than + letting the screen ask and render a 401. + + What the walk **confirmed working**: every shard row absent on a module-less backend at all three + rungs while Events remained; a cancelled occurrence three days out reading *Cancelled* rather than + *Did not happen*, on both the calendar and the event page; projections labelled; the event's own + zone beside the reader's own day; an arc; `?run=` surviving the NavHost pattern to reach the server; + an inbox event link opening the app natively while a forum link still opened a Custom Tab; and + participation history self-scoped, proved by two accounts rather than asserted. + ### Deferred (not a milestone) - **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is diff --git a/website/EVENTS.md b/website/EVENTS.md index 3d31b18..ae84234 100644 --- a/website/EVENTS.md +++ b/website/EVENTS.md @@ -1475,7 +1475,7 @@ with no `roles` — every account has a participation history and the server sco | **Leaderboards** | A run publishes a result table; it does not create a leaderboard entity. module-uo's `/points` boards are read-only mirrors of ServUO's and must not be written to. **`core.results.publish` is that publication** (Phase 10): an ordinary `inspect` step an author places, which ranks the run's participants over a total order and stamps `event_runs.results_published_at`. Idempotent, so a retry re-writes the same numbers. | by publication | | **Rewards** | **An ordinary module action — optional, and shaped by whatever the game can actually give.** module-uo grants an item from a plugin-declared allowlist whose labels and icons come from `shard_clilocs` and `item_id`, already imported for the market pages, so the authoring catalog costs nothing new. A module that grants nothing declares no such action and core has no empty reward feature. | optional, per module | | **Points** | **Still out.** No core points system exists and no write path to any loyalty board exists — both halves are missing, not one. | out | -| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, and an `events` capability string — **all three built in Phase 14a**, the capability in the `version` block that `/public/version` returns and `/public/status` embeds, separate from a module's because core is not a module. **The app half is Phase 14b**, with its prerequisite: the app hardcodes `api/v1/public/shard/stream` — a module path — reads `/public/modules` nowhere, and `ShardFeaturesRepository` maps a failed `/public/shard/features` lookup to "unknown", which `canSee` treats as visible. On a site with no `uo` module that route 404s, so **every** shard entry renders and every one of them 404s. Failing open is right *within* a module that is installed and wrong for one that is not; the fix gates the group on a capability string rather than on a module id. | server built, app is 14b | +| **Mobile** | `GET /api/v1/public/events`, `/events/:slug`, and an `events` capability string — **all three built in Phase 14a**, the capability in the `version` block that `/public/version` returns and `/public/status` embeds, separate from a module's because core is not a module. **The app half was built in Phase 14b** (`android-app` M13), with its prerequisite: the app read `/public/modules` nowhere, and `ShardFeaturesRepository` mapped a failed `/public/shard/features` lookup to "unknown", which `canSee` treats as visible. On a site with no `uo` module that route 404s, so **every** shard entry rendered and every one of them 404d. Failing open is right *within* a module that is installed and wrong for one that is not; the fix gates on a capability string rather than on a module id, and its governing rule is that **absence of an answer is not an answer of absence** — a successful module list that omits the string hides the rows, a failed read keeps the last answer, and a host that never answered leaves the gate open. Core's `events` gates the two event rows. The app's four screens are the calendar, an event page (carrying `?run=`), an arc and participation history — the last as **one** row for any signed-in account, because the route is `requireAuth` alone and self-scoped and only the web's own `/account` guard needed a second mount. | built | | **Admin audit** | `ctx.activity.log` for every edit, publish and manual transition. The runtime record is `event_run_log`, because `activity_log.detail` is `TEXT` and cannot be queried by phase or step. | exists | --- diff --git a/website/EVENTS_PLAN.md b/website/EVENTS_PLAN.md index 25cacda..4954d95 100644 --- a/website/EVENTS_PLAN.md +++ b/website/EVENTS_PLAN.md @@ -1736,21 +1736,51 @@ a clock — and the third was a screen the reviewing admin could not open at all `RequirePlayer` guards `/account` and the route behind it is role-agnostic. Engagement Phase 7 had already solved that one; the fix is its `notificationPaths.js` mapping gaining a third entry. -#### Phase 14b — the app (`android-app` + `docs`) +#### Phase 14b — the app (`android-app` + `docs`) — **built** -The app's events screens, and **one prerequisite fix that is in this phase and not after it.** The -Android app hardcodes `api/v1/public/shard/stream` — a *module* path — reads `/public/modules` -nowhere, and `ShardFeaturesRepository` maps a failed `/public/shard/features` lookup to "unknown", -which `canSee` treats as visible. On a site with no `uo` module installed that route 404s, so every -shard entry in the drawer renders and every one of them 404s. Failing open is right *within* a module -that exists and wrong for one that is not installed; the fix gates the group on a **capability -string** — `shard`, which module-uo declares — rather than on a module id, and gates the new events -entry on core's `events`. +`android-app#44`, `docs#225`. **TWO repos**, and `MODULE_API_VERSION` is untouched. Recorded as +**M13** in `docs/android/PLAN.md`, which gained the milestone before the code was written. -`docs/android/PLAN.md` gains the milestone before the code is written. +The app's events screens — calendar, event page, arc, participation history — and **the prerequisite +fix, which is in this phase and not after it.** The app read `/public/modules` nowhere, and +`ShardFeaturesRepository` mapped a failed `/public/shard/features` lookup to "unknown", which `canSee` +treats as visible. On a site with no `uo` module that route 404s, so every shard entry rendered and +every one of them 404d. Failing open is right *within* a module that exists and wrong for one that is +not installed. + +Six org-lead decisions. Four up front — the failure direction, the screen scope, where history lives, +and native deep links; and two on the scope of the second defect below. **One departed from the +recommendation:** an inbox link to `/site/events/?run=` **opens the app** rather than a +Custom Tab, reusing `resolveWebPath` rather than adding a second link-routing mechanism. That forces +the one rule that function was written to enforce — *a query hands off* — to gain an exception, and it +is exactly one key on exactly one path. + +**Absence of an answer is not an answer of absence**, which is the distinction the whole fix rests on: +a *successful* `/public/modules` read that does not name `shard` **hides** the rows; a *failed* read +keeps the last answer the host gave; a host that has never answered leaves the gate open. The old code +collapsed all three into one `null`. Capability and feature are a second and third gate that compose: +capability asks *is the module installed* (per host), feature asks *does this shard publish it to this +viewer* (per viewer). + +**A second, older defect was found while scoping and fixed here** (org lead): the app's website +path → route table had been wrong since the module-system cutover on 2026-08-12. It quoted a +sixteen-row `NAV`; core's is now **eight** and the nine shard rows moved to `/uo/*`, because core +mounts a module's pages at `//`. Three live consequences: a nav override on any shard row +was ignored, an added link to a shard page handed off to a browser instead of opening natively, and +the sort-key number line was wrong. Two tests in the existing suite had been passing **vacuously** +since that day — they asserted a section came out empty, and it did, because the item never joined it. **Ships:** the calendar, event pages and participation history in the app. -**Verify:** the app suite; an emulator walk. +**Verify:** the app suite (**570 tests, 0 failures**); an emulator walk at three rungs. + +**The walk found three defects and the suite found none of them.** The rig's own site had no `uo` +module, which is what made two of them visible. The three player game-data rows read `/player/shard/*` +and were not gated, so they rendered and 404d — and the test meant to catch that asked whether every +row *with a `feature`* declared the capability, which those three do not have. `score` is +`DECIMAL(18,4)` and was declared an integer, so one `318.5` made kotlinx refuse the **entire** body and +a 200 rendered as a server error — latent on the public results table for every visitor. And a drawer +route's view model outlives a sign-out, so signing in as a second account showed it the first +account's participation history with no request made at all. See `docs/android/PLAN.md` M13. ---