Merge pull request 'docs(rust): phase 14 as built — the live map (PLAN.md §30.6–§30.9, PROTOCOL.md §17)' (#272) from docs/rust-phase-14-built into main

Reviewed-on: #272
This commit is contained in:
2026-09-25 11:59:51 +00:00
3 changed files with 261 additions and 1 deletions

View File

@@ -1006,7 +1006,7 @@ Each phase ends with its findings written down, as every workstream here does.
| 12 | **Events: option sources and the leases** (§9, **as corrected by §27**). ✅ **Built and walked 2026-09-24 on both rigs — as built and findings in §27.5–27.7.** 21 keys walked live, two `spawn.max_*` left out; two defects of its own fixed (the Oxide grant owner, `EventsEnabled` not applying); four found outside it and raised. [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised. **Plan of record in §27 (D73–D79)**: the target names the server, game convars only (vanilla Rust has no gather/craft/smelt rate), the weekend-VIP lease is a group-wide permission, an `EventsEnabled` switch off by default, seven-day holds, and no budget dimension until phase 13; protocol 8 | Module-Rust + 2 + docs | A leased value is observed changing in the running game and restored, per key; `rust.group.permission` expires without core asking |
| 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). 📐 **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 |
| 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 |
| 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 |
@@ -5284,6 +5284,122 @@ them before code.
- **Locked crates, supply drops and the helicopters are in "world events"**, not their own layers.
D112 grouped them, and one switch per group is what an operator reasons about.
Review merged this section (docs#271) with no comment, so all six readings stand.
### 30.6 Two more decisions the build needed
Both came from the org lead on 2026-09-25, asked mid-build.
| # | Decision |
|---|---|
| **D119** | **The plugin's grid label is the game's own.** A second probe on the rig asked `MapHelper.PositionToString` for fifteen positions. The game fits a whole number of cells to the world: a 3000 map is **20 × 20 cells of exactly 150 m** (A0 to T19, the origin K10). Phase 3's `Grid()` used a fixed 146.3 m cell, the number community tools quote, and put a position up to a row too far south (the ferry terminal read O17 where the game says O16). It was fixed in this phase: `Grid()` now calls `MapHelper.PositionToString`, and the map's grid comes from `MapHelper` as well. Stored history keeps its old labels. §30.3's "150 m cells" was right for this map by luck of arithmetic. The page never assumes a cell size; it draws `gridCells` cells of `gridCellSize` metres, both from `map.info`. |
| **D120** | **Leaflet is a lazy second chunk, not part of `entry.js`.** D116 said "bundled into the chunk". But `entry.js` loads on every page of the site, and Leaflet touches `document` the moment it is evaluated, which breaks the registration test that evaluates the chunk in Node. The Map tab does `import('../lib/leaflet.js')`, and Vite emits `dist/leaflet-<hash>.js` beside `entry.js`. Core serves that directory (MODULE_API §3.1). `release.yml` now copies every `dist/*.js`, and `checkExternals.js` and `build.test.js` hold both ends: every chunk `entry.js` imports exists and is clean, Leaflet is not in `entry.js`, and the release copies them all. |
### 30.7 As built, 2026-09-25
[Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr] on `feat/phase-14-map`, into `edge`, as protocol 11
([`PROTOCOL.md`](../../rust-link/PROTOCOL.md) §17). **Core is unchanged and `MODULE_API` did not move.**
| Repo | What |
|---|---|
| Rust-Plugins | `map.info`, `map.fetch`, `map.render` and `map.live`; the Rust+ cache read by name; hashing and slice encoding on the thread pool; the render kept in `map.jpg` and `map.json`; world and base sets kept by `OnEntitySpawned` / `OnEntityKill` and seeded at `OnServerInitialized`; `MapMaxSleepers` and `MapMaxBases`; `rg.map`; `Grid()` from `MapHelper` (D119) |
| Rust-Link | Four forwards and `PROTOCOL_VERSION` 11, with a test that a base64 slice fits the line cap |
| Module-Rust | `rust_map_images` and `rust_map_overrides`; `mapImages.js` (the D110 fetch, Render now); `mapLive.js` (the D111 cache); `model/map` (layers, D113, D115, D117, D118, the per-viewer projection, `DERIVATION_VERSION` 1); the three public routes and two admin routes; the Map tab and the Map card; capability `map`; Leaflet as a lazy chunk (D120) |
**Where the build departed from §30.2, and why:**
- **`map.info` also carries `gridCells` and `gridCellSize`**, from the game's `MapHelper` (D119), and
`rust_map_images` stores them. §30.2 had the page derive the grid from a constant.
- **A server whose game has no picture still gets a row.** `bytes` and `sha256` are NULL and
`source` is `none`, and the row keeps the geometry and monuments, so the page draws the layers on a
plain background. That background is sized as the Rust+ cache would be (half scale plus the margin),
so a picture that arrives later fills the same frame. The same map with a picture already stored
keeps it when the game later has none.
- **"One fetch in flight" is the module's rule, not the plugin's.** A slice is answered within one
frame on the main thread, so two can never overlap in the plugin. The plugin refuses slices with
`busy` only while a render runs. The module holds one fetch per server.
- **The live answer names who is asking.** `GET …/map` returns `mates.signedIn`, so the page offers
"link your Steam account" only to someone who has an account (a walk finding, §30.8).
- **Grid labels are shown only when a cell is at least 30 px wide on screen.** At the fitted zoom a
20-cell map's labels overlapped into a wall of text.
- **The Leaflet chunk is 76 KB gzipped**, not the ~40 KB D116 was weighed on. Vite's library build
mangles names but never strips whitespace, for the entry chunk too. It loads only on the Map tab
(D120), so it was left alone rather than changing how every chunk is built.
- **Positions are read from `userID`**, never `UserIDString` (§30.8).
**Walked against real core on `main`** (the `rustp12` database, this module staged from the branch)
and the two rigs, 2026-09-25. There is no player on either rig, so a rig-only probe plugin made
stand-ins: sleepers under Steam ids linked to five throwaway website users, one of them moved into
the game's online list for a minute; a vending machine; a patrol helicopter and a cargo ship. The
first-party clan *Northwatch* already on the Oxide rig (phase 9) holds two of those Steam ids.
1. **The cache, and its absence (step 1).** At boot the site fetched the Rust+ picture by itself on
first sight (D110): `companion`, 360 341 bytes, 2500 × 2500, stored at the first board poll.
**The rig could not be booted without a picture** (§30.8), so the probe emptied the cache field
by name and `map.info` answered `none`. Fetch again answered `current` and kept the stored picture,
because it is the same map. Render now answered 202 with an estimate of 9 s. The plugin drew the
map in **8 141 ms of stall**, byte-identical to the cache (the same SHA-256), and the site moved the
row to `rendered`. A second Render now while the first ran was refused 409. After a restart on the same map, with the cache emptied again, `map.info` answered **`rendered` from the file on disk**, and the boot drew nothing.
2. **The geometry (step 2).** In the browser, monument markers sit on the monuments in the picture.
The page's grid label equals the game's at all fifteen positions the probe asked
`MapHelper.PositionToString` for, and the client suite holds those fifteen answers.
3. **A wipe (step 3).** The rig restarted on seed 1235. `map.info` gave a new key, `3000.1235.1`, and
at the next board poll the site fetched the new picture (398 896 bytes) and replaced the old row.
A slice asked for with the old hash is refused `stale`, on the plugin and in the module's suite.
4. **The switches (step 4).** At the defaults, an anonymous viewer's `map/live` carries `world` and
`events` and **no `players` or `bases` key at all**, and a signed-in viewer gets the same. Staff
get all four. Widening the players layer to `public` with presence at `staff` showed an anonymous
viewer nothing more, and `/map` said `cappedByPresence`. Widening both showed the three sleepers.
A per-server override opened the Oxide rig alone; the Carbon rig stayed at staff.
5. **Own dot and mates (step 5).** walkp1, who is linked but not staff, with the players layer at
`staff`: with everybody asleep they saw only their own sleeper, marked `self`. With the clan mate
WalkTwo and the stranger WalkFour moved online, walkp1 saw WalkTwo and not WalkFour, while staff
saw all three. **With the clan roster widened to `public`** nothing about positions changed:
walkp4, outside the clan, still saw only their own dot, and an anonymous viewer saw none (D117).
**With the Oxide rig's mates switch off**, walkp1's own dot and their mate's both went.
6. **Live things (step 6).** A patrol helicopter and a cargo ship spawned on the rig were on the next
anonymous poll and moved across three polls ten seconds apart. A 13a zone and an elite crate for a
run appeared under `events`, and after the run's revert `events` was empty.
7. **Cost (step 7).** Twenty viewers polling once a second for thirty seconds made 600 requests. The
plugin's own counter moved from 13 to 19: **one `map.live` per five seconds**. The slowest ask took
1 ms.
8. **Carbon (step 8).** On the Carbon rig at protocol 11, the site fetched the picture by itself.
It is byte-identical to the Oxide rig's for the same seed (SHA-256 `28da6e8a…`). `map.live`
answered for anonymous and staff viewers, and `rg.hooks` showed `OnEntitySpawned` and
`OnEntityKill` firing on Carbon.
### 30.8 What the walk found that the plan did not say
- **`UserIDString` is empty for a player the game did not see connect.** Every stand-in sleeper came
back with `steamId: null`, which on a real server would be an own dot and a clan mate the site can
never match. `map.live` reads the id from `userID`. The players board (phase 3) still uses
`UserIDString` for connected players, where it has always been set.
- **The legend told an anonymous visitor to link a Steam account.** `/map` now says `signedIn`.
- **The game now draws its map at every boot, whatever Rust+ is doing.** The boot log reads
`[Rust.MapCache-Images] Image uploaded to backend: …/86b01198b36d…jpg`, and that is exactly the
SHA-256 `map.info` reported for the cache. Facepunch renders the map for its own map-image service,
and the Rust+ cache holds the same bytes. So **the render fallback (D109) is for a server that
somehow has no picture, and the walk could not make one by configuration**: `+app.port -1` on the
command line reached the game as `1`, and so did `app.port -1` and `app.port "-1"` in `server.cfg`.
The value parser drops the minus sign; at the console the convar takes `-1`. §30.0's open question
("is the cache empty when `app.port` is off?") is therefore unanswered, and on a current server it
probably does not arise. Render now stays, as the admin's way out of a server that answers `none`,
and phase 18's egg should not promise that `app.port -1` switches Rust+ off.
- **A tool cupboard placed by code is destroyed by the game within two seconds**, on terrain and on a
foundation, so the rig could not show a real TC on the Bases layer. The layer's tracking was walked
with a vending machine. A TC is in the map walk in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md).
- **The cargo ship and its crates were outside the picture**, past the world and the ocean margin. The
page places them anyway, and the view may pan half a map past the picture's edge.
### 30.9 What is not proven here
- **A real player.** A moving own dot, a clan mate dropping off the map when they leave the clan or
log off, a real base on the Bases layer and a crate being hacked all need people in the game. They
are in [`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md) as the map walk.
- **The admin Map card in a browser.** Its data was walked through the API: switches saved, the
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.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app

View File

@@ -327,3 +327,35 @@ landing a kill, and opening the Kits menu.
of it; the kill lands on the tally; a reward granted while you were offline is in the menu when you
come back; its extra use works exactly once; and a revert takes away the right to redeem but never
what you already redeemed.
## The map walk (phase 14, protocol 11)
The parts of [`PLAN.md`](../modules/rust/PLAN.md) §30.4 that need somebody in the game. Everything
else was walked with stand-ins: sleepers made by a rig-only probe plugin, one of them moved into the
game's online list for a minute (§30.8). What a stand-in cannot be is a person who moves, joins a
clan and builds a base. This walk needs two accounts.
**Before you start:**
1. **Two Steam accounts, A and B, both linked on the site**, each signed in to its own browser
session. Neither is staff.
2. **A and B in the same first-party clan** on the server (the in-game clan menu; D115 reads the
game's own clans, not the uMod Clans plugin).
3. The map switches at their defaults (Admin → Rust visibility → *The live map*): players and bases
staff only, *You and your clan* on.
| # | Do this | You should see |
|---|---|---|
| 1 | Both A and B in game. A opens the server's **Map** tab | A's own dot (cyan) where A stands, and B's dot (green) where B stands. **No other player**, and no Players layer. Walk A somewhere; within ten seconds the dot follows |
| 2 | B leaves the clan | Within one clan board (a minute) B's dot is gone from A's map. A's own dot stays |
| 3 | B rejoins the clan and logs off | A sees only A's own dot: a mate appears only while online (D115). B, signed in to the site while offline, sees B's own **sleeper** |
| 4 | Staff widen the clan roster to *Everyone* | Nothing about positions changes for anybody (D117) |
| 5 | Staff turn *You and your clan* off for this server | A's own dot and B's both disappear from A's map |
| 6 | A places a tool cupboard on a foundation, and a vending machine | A staff viewer sees both on the Bases layer within ten seconds. A does not |
| 7 | A player hacks a locked crate | The crate's tooltip on the map counts the hack down |
**Run steps 1 and 6 on Carbon as well.**
**What counts as a pass:** a player sees themselves and their online clan mates and nobody else; a mate
drops off the map when they leave the clan or the server; switching the view off removes it; and a
real base shows to staff only.

View File

@@ -473,6 +473,12 @@ Every kind protocol 2 defines, and the hook behind it. **`class` is not a field
and every community site shows it; a **structure's** grid is where somebody lives, which is why
`entity.destroyed` is staff-class here and why R9 makes the same distinction for map layers.
**From protocol 11 the label is the game's own** (`MapHelper.PositionToString`, PLAN.md D119).
Protocols 2 to 10 computed it in the plugin from a 146.3 m cell, the number community tools quote.
That was wrong: the game fits a whole number of cells to the world, so a 3000 map has 20 cells of
exactly 150 m, and the constant put a position up to a row too far south (the rig's ferry terminal read
O17 where the in-game map says O16). Rows already stored keep the label they arrived with.
**Three hooks are deliberately not in this wave, and none of them is an oversight:** `OnEntityTakeDamage`
and `OnFrame`/`OnTick` fire at a rate that makes a bridge a performance regression, and nothing in
phases 3–19 needs per-hit or per-frame fidelity. R17's warning about chatty zone transitions is the
@@ -1721,3 +1727,109 @@ the game is down:
| `POST /chat` | `chat.say` | Opaque object |
`perm.sync`'s `credits` field passes through untouched, like the rest of that body.
## 17. Protocol 11 — the map
Added in phase 14 ([`PLAN.md`](../modules/rust/PLAN.md) §30). The picture of this wipe's map, and
everything that moves on it. **Four commands, two hooks, two plugin config keys and one console
command.** The plugin reads; nothing here changes the world, so **no map command is behind
`EventsEnabled`**. The website decides which layer a viewer may see. The plugin answers every layer
to anyone who asks, because the sidecar and the website are the only things that ask (§8.5).
### 17.1 The commands
| Command | Answers | |
|---|---|---|
| `map.info` | `map.info` or `map.error` | What this map is and where its picture comes from. See below |
| `map.fetch` | `map.chunk` or `map.error` | One slice of the picture: `mapKey`, `sha256` and `chunk` (from 0) in; `data` (base64), `chunk` and `chunks` out |
| `map.render` | `map.render` or `map.error` | The admin fallback: `accepted: true` at once, and the render on a later frame |
| `map.live` | `map.live` or `map.error` | What moves, every layer at once. See §17.3 |
**`map.info`** carries:
- `mapKey`: which map. `<worldSize>.<seed>.<salt>` for a procedural map, `custom.<sha256 of the level URL>` for a custom one.
- `source`: `companion` (the Rust+ cache), `rendered` (a render the plugin kept, §17.2) or `none`.
- For a picture: `bytes`, `width`, `height`, `chunks`, `chunkBytes` (524 288), `background` and `sha256`.
- `worldSize`, and `oceanMargin` (500, in **pixels** and never scaled).
- `gridCells` and `gridCellSize`: the game's own grid, from `MapHelper`. On the rig's 3000 map that is 20 cells of 150 m.
- `monuments`: those with a display phrase, which is what the in-game map shows. Each has `value` (the same `kind#n` token as `world.monuments`), `kind`, `label`, `x`, `z` and `grid`.
- `rendering`: whether a render is under way.
**The hash is computed on the thread pool** the first time a picture is seen. Until it is ready the
answer carries `hashing: true` and no `sha256`, and the caller asks again in a few seconds.
**`map.fetch` is refused `stale`** when `mapKey` or `sha256` no longer names the picture being served,
so a fetch that straddles a map change cannot splice two maps. The slice is base64-encoded and
serialized on the thread pool. A base64 slice of 512 KiB plus its envelope fits the game link's 1 MiB
line cap, and the sidecar has a test that holds that sum.
**`map.error` reasons**:
| `reason` | Means | Worth retrying |
|---|---|---|
| `not-ready` | the world has not finished loading (§15.5) | yes |
| `stale` | the map or picture moved since `map.info` | yes, from `map.info` |
| `no-picture` | this server has no picture to slice | no |
| `malformed` | a slice number outside the picture | no |
| `busy` | a render is under way, or a second render was asked for | yes, later |
| `has-picture` | `map.render` on a server that already has a picture | no |
### 17.2 Where the picture comes from
**Rust+ already keeps the picture.** When `app.port` is set, the game renders the map at boot and
holds the JPEG in `CompanionServer.Handlers.Map.ImageData`. The plugin reads that property and the
`_background` field **by name, by reflection, and nothing else of that type**: `CompanionServer`
types hold the server's Rust+ secret, and a probe that enumerated one printed it into the log (PLAN.md
§30.0). The picture's size is read from the JPEG's own header.
**Without Rust+ there is no picture until an admin asks for one** (D109). `map.render` calls
`MapImageRenderer.Render` at half scale with a 500-pixel margin, which is the cache's geometry. **It
holds the main thread for about 8.5 seconds on a 3000 map.** The answer goes out first, and the render
runs half a second later. The picture is written to `map.jpg` beside `map.json` (its key) in the
framework's data directory, so a restart on the same map serves it again as `rendered` without
drawing a second time. A new map key makes the file stale, and it is ignored.
### 17.3 `map.live`
| Field | Rows |
|---|---|
| `world` | `kind` (`cargo`, `heli`, `chinook`, `bradley`, `supply`, `crate`), `x`, `z`; a locked crate being hacked adds `hackLeftSec`, a hacked one `hacked: true` |
| `events` | what this site's events placed (§15.2): `kind` (`zone`, `crate`, `npc`), `runId`, `x`, `z`, and a zone's `radius` and `name` or a thing's `prefab` |
| `players` | `steamId`, `name`, `x`, `z`, `sleeping`, `online`. Connected players, then offline sleepers |
| `bases` | `kind` (`tc`, `vending`), `x`, `z`. **Positions only**: no owner, no authorised list, no shop name |
It also carries `mapKey` and `tookMs`, the plugin's own time for the ask. `playersTruncated` and
`basesTruncated` are set when a bound cut a layer short.
**There is no sweep per ask.** The world and base sets are kept by `OnEntitySpawned` and
`OnEntityKill`, which do two type tests per spawn, and are seeded by one sweep at
`OnServerInitialized`, because a hotload hears no spawns. A player's vending machine is followed, a
monument's (`NPCVendingMachine`) is not. On the rig the sweep of about 42 000 entities took 5 ms, and
an ask took under 1 ms.
The Steam id comes from `userID`, not `UserIDString`. The phase 14 walk read the latter back empty from
a sleeper made on the server.
### 17.4 Two bounds, and the console
Two config keys are written into an existing config the first time protocol 11 loads: **`MapMaxSleepers`**
and **`MapMaxBases`** (2 000 each). Over a bound, the layer answers the first that many and sets its
`…Truncated` flag. **This is not D95's refuse-never-clamp rule**, which is about a value an author
typed. A read has nobody to refuse.
**`rg.map`** prints the key, the source, the picture's size and hash, whether a render is running, the
game's grid, the size of each set, and how many `map.live` asks there have been, with the last and
slowest.
### 17.5 The sidecar
`PROTOCOL_VERSION` becomes 11. There are four routes, each a correlated round trip that fails when the
game is down, and **nothing is stored**: the picture passes through, and positions never touch the
database (D111).
| Route | Command | |
|---|---|---|
| `GET /map` | `map.info` | |
| `GET /map/chunk?mapKey=&sha256=&n=` | `map.fetch` | All three required |
| `POST /map/render` | `map.render` | Opaque object; `cmd` and `reqId` written over the caller's |
| `GET /map/live` | `map.live` | |