From ed4d9f52d327db88e0b8ef96a2a585a307375c87 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 25 Sep 2026 07:22:19 -0500 Subject: [PATCH] =?UTF-8?q?docs(rust):=20phase=2015=20plan=20of=20record?= =?UTF-8?q?=20=E2=80=94=20the=20map=20and=20events=20on=20a=20phone=20(PLA?= =?UTF-8?q?N.md=20=C2=A731)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android leg D. D121-D125: the map drawn natively in Compose as a fifth tab gated on `map`, polled every 10 s; a site-event marker opening the app's own event page, which needs runId on core's public calendar entries; and M13 walked against a Rust run. Android plan gains M17. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- android/PLAN.md | 20 +++++ modules/rust/PLAN.md | 183 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 1 deletion(-) diff --git a/android/PLAN.md b/android/PLAN.md index 95e1f64..91dda12 100644 --- a/android/PLAN.md +++ b/android/PLAN.md @@ -1579,6 +1579,26 @@ push, and Play (M6–M8) follow the designed app. pins it), and the lock-screen rendering (the emulator has no screen lock; the public version was read back from `dumpsys notification`). +18. **M17 — the Rust map and events on a phone** (post-v1; planned 2026-09-25). `module-rust` + phase 15, R10's leg D. **Design of record: + [`../modules/rust/PLAN.md`](../modules/rust/PLAN.md) §31**, with its five decisions + (D121–D125). + + - **The map is a fifth tab on the Rust server screen**, between Online and Wipes, shown when the + module declares `map`. It is drawn natively: Coil loads the picture and a Compose `Canvas` + draws the layers, with pinch and pan. The app gains no dependency. + - **The layer gates are the server's.** `map/live` is projected per viewer on the server, and a + layer the viewer may not see is absent from the answer. The app draws what it is sent, and it + lists each missing layer with who can see it. + - **It polls every 10 seconds** while resumed and on the tab. The other tabs keep 20. + - **A site-event marker opens the app's own event page on its run.** That needs `runId` on + core's public calendar entries (D125, a website change). A rehearsal's or an unlisted event's + marker stays unlinked. + - **M13 is walked against a Rust run** for the first time: participant names from the module's + meta, fractional scores, *My events*. + + Excluded, as M14 excluded it: the admin Map card, which is operator configuration. + ### 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 0d1fe0b..adc7c4d 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -1007,7 +1007,7 @@ Each phase ends with its findings written down, as every workstream here does. | 13a | **Events: the world verbs** (§9, R17, **split by D80**). ✅ **Built and walked 2026-09-24 on both rigs — plan in §28 (D80–D95), as built and findings in §28.5–28.8 (D96–D97).** A restart mid-run found that the reconcile asked a world that had not loaded, and the plugin pruned live crates on the empty answer; fixed with `worldReady` (§28.7). The owner fix was walked before and after. The placing verb became two (D97), because core infers cap boxes from examples. The phase-7 owner fix (D85); `rust.zone.open` and `rust.prefab.place` (crates and NPCs, D88) at a monument or raw coordinates (split into `rust.crate.place` and `rust.npc.place` by D97); the monument and prefab option sources; the plugin's ownership registry, keyed by the idempotency key; and **`reconcile()` with the boot-id and wipe watch calling `ctx.events.reconcile()`** (§11.1). Protocol 9 | all 3 + docs | A wipe reconciles the ledger instead of stranding it, and each world verb's teardown is observed in the game | | 13b | **Events: the rewards** (§9, R3, R16). ✅ **Built and walked 2026-09-24 on both rigs, without a player — plan in §29 (D98–D105), as built and findings in §29.6–29.9 (D106–D108).** Real core refused the module over one camelCase option-source id, which 310 green tests had not caught (§29.8). The steps that need a person are the rewards walk in `PLAYER_WALK.md`. The participation tally kept by the game (D81–D83), `rust.kit.entitle` through the site mirror's per-run rows (D84) with the kit option source flagging kits with no permission gate, and `rust.announce` with the announce leg (D90). Plan of record in §29, written before its code. Protocol 10 | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it | | 14 | **The live map** (R9). ✅ **Built and walked 2026-09-25 (§30.7), protocol 11, D119–D120 (§30.6); the person-parts are the map walk in `PLAYER_WALK.md`.** 📐 **Plan of record in §30 (D109–D118), 2026-09-24.** A rig probe found that the `.map` file is terrain, not a picture, and that Rust+ already caches the rendered JPEG; the game's own render stalls the main thread 8.5 s (§30.0). Protocol 11. *As first written:* the map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it | -| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates | +| 15 | **Android leg D** (R10). Map and events. 📐 **Plan of record in §31 (D121–D125), 2026-09-25.** The map is a fifth tab drawn natively in Compose over the module's projected answer, so the app has no gate of its own to get wrong. "Events" is a site-event marker opening the app's own event page, plus M13 walked against a Rust run. That needs one field on core's public calendar (D125) | Android-app + website + docs | The map renders on a phone with the same layer gates | | 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel | | 17 | **Optional mod integrations** (R15). **BetterChat** first — leaderboard titles through `API_RegisterThirdPartyTitle`, a pull with no drift — then the uMod **Clans** adapter (alliances and clan chat, beside the provider rather than under it, R5), then others as they prove useful | Rust-Plugins + Module-Rust + docs | A server missing every optional mod still runs the module, Teams included | | 18 | **The installer** (R4) **and the Pterodactyl egg** (R20) — the two halves of "how the shard side reaches an operator", built together so one story is not told twice. `--game servuo\|rust`, the bundle payload as a variant, a **framework** prerequisite check in `doctor` (which one, not whether Oxide — R19), the protocol pairing refusal carried over; the egg derived from "Rust Autowipe" with the sidecar inside the game container, a second allocation for `[web].bind`, **the sidecar configured from egg variables** (R22), the sidecar store held out of `REMOVE_FILES`, and its install script fetching the same pinned pair the installer resolves | installer + Rust-Link + docs | An operator sets a Rust server up with the released binary and nothing hand-copied; **and** a second operator imports the egg, deploys, and reaches the same place — on either framework | @@ -5400,9 +5400,190 @@ first-party clan *Northwatch* already on the Oxide rig (phase 9) holds two of th picture's state, Fetch again. The page itself was built and its tests pass, but signing in as an admin in the walk browser means typing a password, which was left to the org lead's review. +## 31. Phase 15 — Android leg D: the map and events on a phone (plan of record, 2026-09-25) + +R10's fourth leg. The row names two things, and its criterion covers only the first: *the map renders +on a phone with the same layer gates*. The second, "events", was left undefined, and D123 defines it. +The event screens are core's, and M13 already shows them on any site that runs events. Two things +join them to Rust. A site-event marker on the map opens the event it belongs to, and M13 has never +been walked against a Rust run. + +Three repositories: [Android-app][aa] into `edge`; [website][web] into `main`, for one field (D125); +and this document into `main`. **Module-Rust is not expected to change**, and `MODULE_API` does not +move: the field D125 adds is on core's own public REST, not on the module contract. + +### 31.0 The decisions this phase needed + +All from the org lead, 2026-09-25. + +| # | Decision | +|---|---| +| **D121** | **The phone draws the map itself, in Compose.** Coil loads the picture into its disk cache. The URL carries the hash, so a picture is fetched once per map. A `Canvas` draws the markers and the grid over it, and pinch and pan come from `detectTransformGestures`. §30.3's transform is ported as one pure, tested function. **The app gains no dependency.** Rejected: a `WebView` of the web's Map tab, because the app authenticates with a bearer token and not a cookie, so the layer gates would need a second way in, and it would be the app's one non-native screen. Also rejected: a map library (osmdroid, MapLibre). Both are built for geographic tiles, and one flat picture with a simple frame works against them. | +| **D122** | **The map is a fifth tab on the server screen, between Online and Wipes, where the web has it.** That is D13's one page with tabs, translated as phase 5 translated it. It is shown only when the site's module declares **`map`**. `?tab=map` opens it, by D71's rule. Rejected: a full-screen destination behind a button, and a drawer row with a server picker (D12 and D13 rejected that shape on the web). | +| **D123** | **"Events" is two things.** (1) A site-event marker on the map (a zone, crate or NPC that 13a placed) **opens the app's own event page on that run** when core publishes the run. A run it does not publish keeps an unlinked marker. (2) **An emulator walk of M13's event screens against a real Rust run**: participant names from the module's meta, a fractional score, and *My events*. **The web map is not changed in this phase.** Rejected: walking M13 alone with the markers unlinked, and linking on the web too, which would bring a Module-Rust client change into an Android leg. | +| **D124** | **The Map tab polls `map/live` every 10 seconds while the screen is RESUMED and the tab is selected**, and not at all otherwise. That is the web's cadence (§30.5), and the module's 5-second cache (D111) already bounds what the game is asked per server, whatever the number of viewers. The other tabs stay on D17's 20 seconds. | +| **D125** | **Core's public calendar publishes each run entry's `runId`.** This departs from what D123 was asked on: the question said the public calendar already maps a run to its event, and it does not (§31.1). The id is not a secret. The public event page already publishes it on every occurrence (`PublicEventOccurrence.runId`), and `?run=` takes it. Adding it is one additive field in `publicRunEntry` and the OpenAPI schema. Rehearsals and unlisted events stay absent from the calendar, so their markers stay unlinked. Rejected: the app learning run ids by fetching every live event's page (N+1 requests to answer one question), and dropping the link. | + +### 31.1 Facts the phase rests on + +**From the module** (`Module-Rust`, `edge`, as phase 14 shipped it): + +- **`GET /public/rust/servers/:id/map`** answers: + - `mapKey` + - `picture`: `{ path, source, fetchedAt }`, or null + - `geometry`: `worldSize`, `oceanMargin`, `width`, `height`, `gridCells`, `gridCellSize`, `background`; or null + - `monuments`: only when the viewer may see the world layer + - `layers`: `{ visible, audience }` for each of the four, plus `cappedByPresence` on the players layer + - `mates`: `{ visible, on, linked, signedIn }` + - `pollMs`: 10 000 + + A null `geometry` means the server has never described its map. A null `picture` with a + `geometry` means the game has none, and the layers are drawn on `background`. +- **The picture's `path` is public and immutable.** The hash is in its query, a hash that is no + longer current is a 404 and never the new bytes, and it is served + `Cache-Control: public, max-age=31536000, immutable`. It is relative to the API base like every + other path the app is handed. +- **`GET …/map/live` is projected on the server.** A layer the viewer may not see is **absent**: + not empty, not flagged. `mates` is the only place a position appears below the players layer. A + game that does not answer is a 200 with `live: false` and a `reason`, and the picture stays. + Both reads are `private, no-store` with `Vary: Cookie, Authorization`, and the viewer comes from + `core.auth.getUserFromRequest`, which reads a bearer token. The app's requests are gated exactly + as the browser's are, and **the app has no gate of its own to get wrong**. It draws what it is + sent. +- **A site-event item is `kind` (`zone`, `crate` or `npc`), `runId`, `x` and `z`**, plus a zone's + `radius` and `name` or a thing's `prefab` (`rust-link/PROTOCOL.md` §17). `runId` is core's run + id, the one 13a keyed its registry by. +- **The picture is `worldSize × 0.5 + 1 000` pixels square.** That is 2 500 on the rig's 3 000 m + map and 3 250 on a 4 500 m one. The app cannot assume the rig's size. + +**From core** (`website`, `main`): + +- **The public calendar's run entry has no `runId`.** `publicRunEntry` + (`server/src/model/events/eventPublic.model.js`) sends the title, slug, series, time and status. + The admin calendar's `runEntry` carries the id, and so does the public event page's + `publicOccurrence`. The public calendar is the odd one out, and D125 closes it. +- **A live run is in the calendar whatever the window.** `listInWindow`'s overlap test answers the + live half, and `publicOnly` is *listed AND ready AND not a rehearsal*, in SQL. So the default + window already holds every run a marker could name that the public may see. +- **`/events/:slug?run=` ignores a run that is not that event's** rather than refusing it. A stale + link opens the event's current state. + +**From the app** (`Android-app`, `edge`): + +- **`RustTab` holds `feed`, `leaderboard`, `online` and `wipes`**, each with the web's name for + `?tab=` (`fromWire`). The web's order is feed, leaderboard, online, **map**, wipes, clans. +- **`PollWhileResumed` calls one `refresh()` for the whole screen every 20 s**, and `refresh()` + asks only the panel on screen. The Map tab needs its own cadence (D124) inside the same + lifecycle. +- **Coil 2.7 is already a dependency**, used through `AsyncImage` with its default loader and disk + cache. **Nothing in the app pans or zooms today.** +- **`SiteCapabilitiesRepository` has the three-state rule** from events phase 14b: an answer that + omits a string hides, a failed read keeps the last answer, and a host that has never answered + leaves the gate open. +- **M13's event screens are gated on core's `events` capability.** They name a participant from + the module's meta (13b sends `{ name, seconds, kills }`), print scores through `scoreText`, which + handles a `DECIMAL`, and have **never been shown a Rust run**. `Routes.event(slug, runId)` + already builds the `?run=` link. + +### 31.2 What the plan builds + +**website** (`feat/public-calendar-run-id`, from `main`): `runId` on a public calendar entry whose +`kind` is `run`. A projected entry has none, because nothing is committed to it. `PublicEventEntry` +gains the field, the spec is regenerated, and a test pins that a rehearsal is still absent. The web +calendar ignores the field. + +**Android-app** (`feat/rust-phase-15-map`, from `edge`): + +- **The tab (D122).** `RustTab.MAP` (`map`) between Online and Wipes. It is shown when the site's + capabilities include `map` under the app's one three-state rule. A `?tab=map` on a site without + it opens the feed. +- **The reads.** `map` and `map/live` in the Rust repository, with every layer field nullable, + because absent means *not yours*. `map` is read when the tab opens, and again when a live + answer's `mapKey` differs from the one being drawn. That is a wipe or a new seed, and the new + picture follows without leaving the tab. +- **The frame (D121).** `MapFrame`, pure Kotlin: §30.3's `toPixel(x, z)`, and the grid from + `gridCells` × `gridCellSize` with the game's labels, ported from the web's `mapGeometry.js` with + the same test cases. +- **The canvas (D121).** The picture is decoded once at its own size, as `RGB_565` with hardware + bitmaps off, which is half the memory of ARGB. That is 12.5 MB at the rig's 2 500 px. + - Without a picture, the geometry is filled with `background`. Without a geometry, the empty + state says the map is not available yet. + - Zoom runs from fit-to-screen to 4× the picture's own pixels. + - Markers and labels are drawn at a **fixed screen size**, whatever the zoom. A zone's circle is + the exception: it is the ground the zone covers, in world metres. + - Grid labels appear only once a cell is 30 dp across, as on the web. + - The colours are the web's. +- **The legend.** It has one switch for each layer the viewer was sent. Each layer they were not + sent is listed disabled with who can see it (§23.3's shape), with the D113 line when + `cappedByPresence` is set. The mates line offers *link your Steam account* only to a signed-in, + unlinked viewer with the switch on, as the web does. A truncated layer says so. `live: false` + keeps the picture and says positions are unavailable. +- **The tap.** The nearest marker within 24 dp opens a small card with what it is, its grid square + and what the layer carried: a crate's hack time, a zone's name, a player's name. Nothing is + looked up beyond what was sent. +- **The event link (D123, D125).** When the events layer names a `runId` the screen has not seen, + it reads the public calendar's default window once and keeps `runId → slug` for the kind `run` + entries. It re-reads at most once a minute, and only for an unknown id. A resolved marker's card + has **Open event**, which goes to `Routes.event(slug, runId)`. An unresolved one says *site event* + and nothing more. +- **The poll (D124).** Every `pollMs` (10 s when absent) while RESUMED and on the Map tab. On a + failure the last positions are kept, in the same shape as `Polling.kt`. +- **Per account.** The map's state is keyed on the signed-in account. Mates and the players layer + are per viewer, and events phase 14b found that a view model that outlives a sign-out shows the + next account the previous one's answer without asking. +- **Tests** for the frame, the capability gate on the tab, the resolver, the account keying, and + one that decodes a live answer with every layer absent. + +**docs:** this section as built; the phase row; the Android plan's M17; `website/BACKEND_DESIGN.md` +for the calendar field. + +### 31.3 Done when + +The row's criterion: **the map renders on a phone with the same layer gates.** It is walked on an +emulator against real core on `main` with this module installed, D125 applied, and the Oxide rig's +protocol-11 sidecar behind it. The rig has no player, so positions come from phase 14's probe +stand-ins as they did there. Carbon is not walked again: the app reads the module, and phase 14 +proved the module's answer on both frameworks. + +1. **The picture.** The Map tab renders the rig's picture, pans and zooms, and draws the monuments + on the monuments. Three grid labels match the web's for the same points. +2. **The gates.** Signed out: world and site events are drawn, and players and bases are listed with + who can see them. Signed in and not staff: the same. Staff: all four. Players widened past + presence: the D113 line and nothing more. A per-server override opens one server alone. +3. **Own dot and a mate** for a linked player who is not staff. Signing out and in as another account + shows none of the first account's dots before its own answer arrives. +4. **Live.** A cargo ship or helicopter spawned by console moves within one poll. Leaving the tab, or + backgrounding the app, stops the asks, which the module's log shows. +5. **Events.** A listed event's run places a zone. Its marker's **Open event** opens the app's event + page on that run. A rehearsal's zone has no link. After the run completes with results published, + M13 shows participant names from the meta, their scores, and the linked player's *My events* row. +6. **A new map.** A new seed replaces the picture without leaving the tab. +7. **Nothing else moved.** A site without `map` shows no Map tab (a test, since every running core + here has it), and a UO site is unchanged. + +### 31.4 Readings the org lead may overrule + +None of these was asked. They are written down so that review can overrule them before code. + +- **Markers keep their size on screen as the map zooms**, and a zone's circle keeps its size on the + ground. A dot that grew with the zoom would hide a monument at 4×. A zone that did not grow would + stop saying how far it reaches. +- **The Map tab follows the app's one capability rule**, so a host that has never answered shows it. + A module older than phase 14 then answers `map` with a 404, and the tab shows the existing error + state. A second rule for one tab would be the thing to get wrong. +- **The card shows only what the layer sent.** A player's name on the players layer is already on + the wire for a viewer entitled to it. The app asks nothing further. +- **The resolver reads the calendar's default window.** A live run is in it whatever the window, + and a narrower window saves nothing worth a second rule. +- **The picture is decoded whole, not tiled.** It is one bounded JPEG. Tiling would matter only + near the largest worlds, and none is in sight. +- **The admin Map card is not in the app.** It is operator configuration, the same exclusion as + M14 made for the rest of the Rust admin surface. + --- [aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app +[web]: https://gitea.whitlocktech.com/RunicGateway/website ---