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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user