Merge pull request 'docs(rust): phase 15 plan of record — the map and events on a phone (PLAN.md §31)' (#273) from docs/rust-phase-15 into main

Reviewed-on: #273
This commit is contained in:
2026-09-25 12:23:46 +00:00
2 changed files with 202 additions and 1 deletions

View File

@@ -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

View File

@@ -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
---