diff --git a/android/PLAN.md b/android/PLAN.md index a8a1c30..cf3f9de 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1307,6 +1307,128 @@ push, and Play (M6–M8) follow the designed app. 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. +15. **M14 — the Rust module in the app** (post-v1; built 2026-09-17). The platform's **second game + module** reached its first public pages in `module-rust` phase 4 + ([`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §17), and this is phase 5 — the app's leg. + R10 has each Android leg trail the website surface it consumes by exactly one phase, so every + route here existed and answered before a line of Kotlin was written. + + **Design of record: [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md)**, §17 for the surface + this mirrors and §18 for this phase as built. The contract is normative there; this entry records + what the app does about it. + + **No backend work beyond one word.** The five public routes were live. The one change is + Module-Rust#5, which adds `rust` to the module's `capabilities` — see the gate below. + + #### Why this is not the shard screens with a different name + + The two games have genuinely different shapes, and collapsing them would have cost the app the + thing that makes each legible. **UO is one shard: a place**, five drawer rows, a live SSE stream. + **Rust is a fleet**: a list, and one page beneath it with four tabs. The app grows a second route + tree rather than a second meaning for `shard/`, and both can be installed on one backend — in + which case both trees exist at once and neither row appears on a site without its module. + + | Screen | Route | Reads | + | --- | --- | --- | + | **Rust servers** (the list) | `rust` | `GET /public/rust/servers` | + | **One server** (four tabs) | `rust/servers/{serverId}` | `…/:id`, `…/:id/events`, `…/leaderboard`, `…/online`, `…/wipes` | + + #### Four decisions, taken by the org lead on 2026-09-16 + + - **D16 — the gate is a new capability, `rust`.** `module-uo`'s five shard rows all hang on one + string, `shard`, because that is the only question a capability can answer: *is the module + there*. `module-rust` declared five and every one named a **surface** — `servers`, `killfeed`, + `leaderboard`, `presence`, `wipes`. Core flattens every started module's capabilities into a + single list, so gating on `servers` would let another module declaring that word silently reveal + these screens on a site that does not run Rust. Gating on the module **id** was considered and + rejected: `id` is a mount prefix (§2.1 requires it to equal the directory core loads from), and + `MODULE_API.md` §2.9 forbids a client inferring a route from a capability — making the two the + same thing would quietly end that separation. So the module declares its own name as a sixth + string, asserted in its suite against `module.json`'s own `id` so the two cannot drift. + - **D17 — poll every 20s while the screen is RESUMED**, the phone's version of D14's Page + Visibility gate. Immediate refresh on return to the foreground; nothing at all while away. + - **D18 — the Rust repositories move to `edge`** for the rest of the workstream, with releases at + the cutover rather than per phase. `pr-checks.yml` in all four repositories already triggers on + `[main, edge]`, so this costs no CI — the trap that made all nine M12 phase PRs land unchecked + was closed in engagement Phase 8. + - **D19 — the drawer row carries a live player count**, and NavPaths learns `/rust`. + + #### A refresh is not a load, and the app had only ever done loads + + The app has had exactly one shape for a read since M1: set `Loading`, ask, replace. That is right + for opening a screen and wrong for a poll — a twenty-second refresh built on it clears the + killfeed, renders a spinner in its place and re-fills it, three times a minute, for ever. **The + website hit the same wall one tier along**, which is why `module-rust` bundles its own `usePolled` + instead of using core's `useAsync` (§17.3). `ui/Polling.kt` is that hook's other half: + + - `refreshInto` — **a refresh is invisible when it succeeds and keeps the rows when it fails.** A + failure with rows on screen keeps them and reports the failure beside them; a failure with + nothing on screen is an ordinary error with a retry, because there is nothing to protect. + - `PollWhileResumed` — `repeatOnLifecycle(RESUMED)`, which buys three behaviours from one line: no + requests at all while backgrounded, an immediate refresh on return, and a pause behind a dialog + or the recents switcher. `STARTED` would keep polling for a reader who is not reading. + + Only the **visible** live panel is polled. The leaderboard and the wipe list never are: a + leaderboard that re-sorted itself under a finger every twenty seconds would be worse than a stale + one. Changing the filter, the sort or the wipe **is** a different question, so that panel blanks + and loads — leaving the old rows up would show last wipe's killfeed under this wipe's heading. + + #### The drawer badge is D15 translated, not D15 copied + + D15 put a live count in core's `site.footer.status` slot, which works because every page of the + website renders the same footer. The app has no footer and no slot. What it has is a drawer row + per surface and, since engagement Phase 8, a precedent for a number beside one — the inbox's + unread badge, in the `NavigationDrawerItem` badge slot, with a `contentDescription` so a screen + reader says "42 players online" rather than "42". The count rides there, and keeps the website + version's three rules: **zero renders nothing** (an empty fleet is not a notification), a failed + read keeps the last number, and it never polls. It is asked for only where the module is + installed, so a UO site makes no request at all. + + #### Verified + + The app suite (**644 tests, 0 failures**), `lintDebug`, `assembleDebug`, and an emulator walk + against the phase-4 rig — a core with the module installed, one live server and one seeded fixture + that has never reported. + + **Both halves of the phase criterion, directly.** With its server unreachable and reading + *Offline*, the page still rendered its map, size, seed, wipe date, killfeed, per-wipe and all-time + leaderboards, its last known presence board and its wipe history. The same app pointed at the UO + core showed Shard / Rules / Atlas / Leaderboards / Market and **no Rust row**. + + Also proven rather than asserted: `refreshInto` against a genuinely dead backend (the core was + stopped with the list on screen; a poll tick later the rows were unchanged under one quiet line); + R12's arithmetic on a phone (all-time 59 = 41 + 18, and a player who appears only in the older + wipe **drops out** of it rather than reading zero); every `describe` branch from real rows, + including the fall that must not read as a kill by nobody; the calendar-day rule, filtering to the + August wipe and getting three rows six weeks old, each unmistakably dated; and the badge. + + #### The walk found three defects, and 644 green tests found none of them + + - **The drawer's live count resolved once per process.** It was keyed on the capability answer + alone, so it was read at connect and never again — which is not what *live* means on a row + somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge. + - **Every card's text sat flush against its edge.** `ShardCard` is the themed `Card` and carries + no padding of its own; each caller pads its own content, and these four did not. On a phone the + first glyph of each line read as clipped. + - **A name touched its own kill count.** Five numeric columns beside an equal-weight name column + left *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes, + and the **active sort is marked on the header** rather than by tinting a column of numbers — the + header is the control, and tinting the values says *these are special* instead of *this is what + the table is ordered by*. + + #### The rig note worth keeping + + The debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` only** + — not `10.0.2.2`. An emulator walk against a local core therefore needs + `adb reverse tcp: tcp:` and the loopback address; typed as `10.0.2.2` every request + fails with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, which the + connect screen reports — correctly, and indistinguishably from a core that is not running. + + - **Excluded**, in the same class as every earlier milestone's exclusions: the Rust **admin** + surface. Server configuration, the sidecar token and the connection test are admin + *configuration*, which the app consumes and does not edit. Identity and permissions are legs B + and C (phases 8 and 11), and the map is leg D. + ### Deferred (not a milestone) - **Platform Teams in the app** — **deferred 2026-08-17, no app work scheduled.** The website is diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index 56875e6..71084f0 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -982,7 +982,7 @@ Each phase ends with its findings written down, as every workstream here does. | 2 | **Packaging and release.** ✅ **Done 2026-09-16 — as built and findings in §15.** `release.yml` *and* the gate that was missing entirely (`pr-checks.yml`, including the frozen-manifest job); the include list with two readers; `v0.1.0` published and installed into a running core from its manifest URL. Three org-lead decisions (§15.0), and the first proof by a core that `/rust` collides with nothing (§15.2). **Criterion met** | Module-Rust + docs | An operator installs the empty module from Admin -> Modules and it reaches `started` | | 3 | **The read path, on both frameworks.** ✅ **Built and largely proven 2026-09-16 — as built and findings in §16.** Protocol 2: fifteen hooks, an envelope every frame carries, boards, a cursor feed and bounded history; four org-lead decisions (§16.0), two defects only a booted server could find (§16.2), and CI for the two bridge repositories that had none. **The player half of the catalogue is written down as a walk to run rather than measured** — see §16.7. First hook wave from [`HOOKS.md`](HOOKS.md); events and snapshots distinct at the wire; `wipe_id` **and server id** on every row (R8); all-time rollups (R12); every board re-emitted on connect. **First phase to run against the Carbon rig (R19/R21)** — it turns [`CARBON.md`](CARBON.md) from a source-read hypothesis into tested fact, including whether the 13 unlisted hook names are renames or holes | all 3 + docs | A restarted sidecar is fully populated within one connection, a wipe does not erase a player's history, and **the same plugin file does all of that on Oxide and on Carbon** | | 4 | **The first pages.** ✅ **Done 2026-09-16 — as built and findings in §17.** `/rust` is the list (D12), `/rust/servers/:id` is one server with four tabs (D13), everything selectable in the URL; visibility-gated polling (D14); the `site.footer.status` slot filled with a live count (D15). Four decisions (§17.0) and **four defects a browser walk found, two of them already shipped in phase 3** (§17.2) — an unreachable refresh that erased the server's description, and a "last reported" line reading the wrong timestamp. **Criterion met**, walked against a live rig | Module-Rust | The site renders the last thing each server said while every server is off | -| 5 | **Android leg A** (R10). Capability-driven shell from `GET /api/v1/public/modules`, plus the phase-4 screens | Android-app | The app renders a Rust site it has never seen, and a UO site unchanged | +| 5 | **Android leg A** (R10). ✅ **Done 2026-09-17 — as built and findings in §18.** The server list and one server with four tabs, gated on a NEW capability the module had to declare (D16 — its five named surfaces, and a client needs one that names the module); a poll that keeps its rows when it fails, which the app had no shape for (D17); the drawer badge as D15 translated (D19). Four decisions (§18.0) and **three defects an emulator walk found that 644 green tests did not** (§18.4). **Both halves of the criterion walked on one device against two cores** | Android-app + Module-Rust + docs | The app renders a Rust site it has never seen, and a UO site unchanged | | 6 | **Identity** (R1), and the `admin.users.detail` slot (R13) | 3 + docs | A player links an account in-game; an operator sees the Steam id inside core's own user page | | 7 | **Site-owned permissions** (R2). Groups and grants authored on the site; full set pushed on connect, deltas after; drift reported. The `PermissionExists` pre-check stays the mechanism on **both** frameworks (R19); Carbon's 14 permission hooks are tested here as a possible live drift signal, and suppressed against our own pushes if they fire | all 3 + docs | A grant made on the website gates a third-party plugin in-game, survives a wipe, and behaves the same against Oxide's JSON store and Carbon's Protobuf/SQLite one | | 7b | **Mod configuration from the site** (R18). **Recursive** walk of `Interface.Oxide.ConfigDirectory` — never `DataDirectory`, and never either as a literal path (R19) — generated form from the live values, raw-JSON advanced tier, explicit reload target, versioned read/write, auto-reload watched on `OnPluginLoaded`, **automatic rollback** over the whole file set, path-traversal guards, secret redaction, its own permission and an audit trail | all 3 + docs | An admin flips a ZoneManager setting from the website and it takes effect; a deliberately broken config rolls itself back and says why; a nested `/x.json` is found and reloads the right plugin | @@ -2583,6 +2583,154 @@ file and nothing else on the site changes. What is **not** proven here and is deliberately left: the pages have not been read on a phone-width viewport, and the Android leg (phase 5) is where the same surface gets a second client anyway. +## 18. Phase 5 as built — Android leg A, 2026-09-17 + +The first leg of R10, and the first time this module's surface has had a second client. The app's +own record of it is [`../../android/PLAN.md`](../../android/PLAN.md) **M14**; what follows is what +the phase decided and what the walk found. + +**Criterion met, and walked in an emulator rather than asserted:** *the app renders a Rust site it +has never seen, and a UO site unchanged.* Both halves were shown on one device against two running +cores. + +### 18.0 Four org-lead decisions + +- **D16 — the app gates on a new capability, `rust`.** This module declared five strings and every + one names a **surface**: `servers`, `killfeed`, `leaderboard`, `presence`, `wipes`. Core flattens + every started module's capabilities into one list, so a client gating a whole navigation group on + `servers` would have those screens revealed by any future module that declared the same generic + word. `module-uo` has exactly one string for this job — `shard` — and this module had none. + + **Gating on the module `id` was considered and rejected.** It is the strongest fact available and + it is already on the wire, but `id` is a **mount prefix** (§2.1 requires it to equal the directory + core loads the module from) and `MODULE_API.md` §2.9 forbids a client inferring a route from a + capability. Letting a client gate on `id` makes the two the same value in practice, and the day one + builds `//servers` from it the separation that lets this module move its own pages is gone. + + So `module.json` declares its own name as a sixth capability, and `server/test/entry.test.js` + asserts it **against `manifest.id`** rather than against the literal `"rust"` — the day the id + changes, the string a client gates on has to change with it. Module-Rust#5. + +- **D17 — the app polls every 20 seconds while its screen is RESUMED.** D14's Page Visibility gate, + translated. `repeatOnLifecycle(RESUMED)` gives the same three behaviours from one line: nothing at + all while the app is away, an immediate refresh on return, and a pause behind a dialog. `STARTED` + was rejected — it keeps polling behind a partially obscured screen, which is precisely the reader + who is not reading. + +- **D18 — the three Rust repositories move to `edge`** for the rest of the workstream, with releases + cut at the cutover rather than per phase. `edge` branches were created from `main` in Module-Rust, + Rust-Link and Rust-Plugins; `pr-checks.yml` in all three (and in Android-app) already triggers on + `[main, edge]`, so this costs no CI. `release.yml` still fires only on a push to `main`, which is + what makes the cutover the release. + +- **D19 — the drawer row carries a live player count, and `NavPaths` learns `/rust`.** The second is + small and load-bearing: without it an admin's nav override on the module's own `Servers` row, or an + added link to `/rust`, hands off to a browser rather than opening the native screen. + +### 18.1 D15 has no analogue on a phone, so it was translated + +The footer slot works on the web because every public page renders the same footer (§17.4). The app +has no footer and no slot. What it has is one drawer row per surface and, since engagement Phase 8, a +precedent for a number beside one — the inbox's unread badge, in `NavigationDrawerItem`'s badge slot, +with a `contentDescription` so a screen reader says *"42 players online"* and not *"42"*. + +The count rides there and keeps all three of the website version's rules: **zero renders nothing** (an +empty fleet is not a notification, and a badge reading `0` on a quiet evening is worse than none), a +failed read keeps the last number rather than dropping to zero, and it **never polls**. It is asked +for only where the module is installed, so a UO site makes no request at all. + +**The number is players, not servers.** A badge is one integer, and of D15's two halves the live one +is how many people are on — a server count changes when an operator edits configuration, which is not +news, and is on the page the row opens anyway. + +### 18.2 What the app had to grow: a refresh that is not a load + +The app has had exactly one shape for a read since its first milestone — set `Loading`, ask, replace. +That is right for opening a screen and wrong for a poll, and it is the *same* wall this module hit one +tier along with core's `useAsync` (§17.3). A twenty-second refresh built on it would clear the +killfeed, render a spinner in its place and re-fill it, three times a minute, for ever. + +`ui/Polling.kt` is `usePolled`'s other half, and it keeps the same rule: **a refresh is invisible when +it succeeds and keeps the rows when it fails.** Three cases, and the middle one is the whole point: + +| Result | What the reader sees | +| --- | --- | +| It answered | New rows. Nothing else. | +| It failed, and there are rows | The same rows, and one quiet line saying the refresh failed. | +| It failed, and there is nothing yet | An ordinary error with a retry — a first load that failed. | + +Only the **visible** live panel is polled. The website can afford to mount the one tab it is +rendering; the app's four tabs are one screen, so the refresh asks what the reader is actually looking +at. The leaderboard and the wipe list are never polled at all. + +**Changing the question is not a poll.** Filter, sort and wipe blank their panel and load, because +what is on screen is an answer to something the reader has stopped asking — leaving it up would show +last wipe's killfeed under this wipe's heading. + +### 18.3 What the walk proved, and how + +The rig is phase 4's, unchanged: a core on `:3200` with this module installed, one live server +(`main`, a real sidecar and a real game host) and the seeded `demo` fixture that has never reported. +A second core on `:3100` serves `module-uo` and no Rust. + +- **The criterion, first half.** With `demo` unreachable and reading *Offline*, the phone rendered its + map, size, seed, wipe date, killfeed, per-wipe and all-time leaderboards, its last known presence + board and its wipe history. Nothing on the screen is a live call to a game host. +- **The criterion, second half.** The same app, switched to the UO core, showed Shard / Rules / Atlas + / Leaderboards / Market and **no Rust row**. +- **`refreshInto` against a genuinely dead backend.** The core was stopped with the list on screen. A + poll tick later the rows were unchanged, under one line reading *"Could not refresh just now. This + is the last thing the site heard."* — no spinner, no error page, nothing blanked. +- **R12's arithmetic, on a phone.** All-time 59 = 41 + 18 across two wipes, and Drift — who appears + only in the current wipe — **drops out** of the August board rather than reading zero. +- **Every `describe` branch, from real rows**: a player kill with weapon, distance and grid; an NPC + kill with the prefab read as words; a suicide; an environment death (the fall that must not read as + a kill by nobody); chat with its colon in the join and a non-Global channel beside it; a disconnect + with reason and session length; and *while sleeping*. +- **The calendar-day rule.** Filtering to the August wipe produced three rows six weeks old, each + carrying its date — the §17.2 defect, not re-introduced in Kotlin. +- **The presence panel saying which it is.** The offline server's board rendered under *"The last + board this server sent. It is offline, so this is who was on then — not who is on now."* +- **The badge**, showing a live count on the drawer row. + +### 18.4 The walk found three defects, and a green suite found none of them + +1. **The drawer's live count resolved once per process.** It was keyed on the capability answer alone, + so it was read when the app connected and never again — which is not what *live* means on a row + somebody opens the drawer to look at. It now refreshes on resume, beside the unread badge and for + the same reason: coming back to the app is exactly when a stale number would be noticed. *Visible + only by backgrounding the app and returning to it.* +2. **Every card's text sat flush against its edge.** The app's themed `ShardCard` is a `Card` and + nothing more — it carries no padding, and each caller pads its own content. Four new call sites did + not, and on a phone the first glyph of each line read as clipped. +3. **A name touched its own kill count.** Five numeric columns beside an equal-weight name column left + *Brannock* and *50* reading as one field. The name now takes a wider share and ellipsizes — and the + **active sort moved to the header**, because the header is the control: tinting a column of numbers + says *these are special* where tinting the header says *this is what the table is ordered by*. + +### 18.5 The rig note worth keeping + +The app's debug `network_security_config.xml` permits cleartext to **`127.0.0.1` and `localhost` +only** — not `10.0.2.2`. An emulator walk against a local core therefore needs +`adb reverse tcp: tcp:` and the loopback address. Typed as `10.0.2.2`, every request fails +with `UnknownServiceException: CLEARTEXT communication to 10.0.2.2 not permitted`, and the connect +screen reports *"Couldn't reach that site"* — correct, and indistinguishable from a core that is not +running. + +Two smaller things: the first AVD tried had 95% of `/data` used and refused a 44 MB install with +*"Requested internal only, but not enough space"* — `pm trim-caches` freed nothing, and the second AVD +was the answer. And the app's own `pm clear` is the way to reach the first-run connect screen, because +an `install -r` over an earlier install keeps the stored base URL. + +### 18.6 What is not proven here + +- **A phone-width read of the website's own pages**, which §17.6 deliberately left open. This phase + gave the surface a second client rather than re-reading the first, and the pages have still not been + looked at in a narrow browser. +- **The badge's non-zero case on real traffic.** Nobody was playing on the rig, so the count was shown + by seeding a player count on the unreachable server and reading the badge before the 30-second + refresh zeroed it. The arithmetic and the rendering are proven; a fleet with people on it is not. + --- [rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link