feat(rust): the live map (phase 14, protocol 11)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m9s

PLAN.md §30 as approved, plus D119/D120 from the build.

Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
  monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
  asks map.info; a new key or hash from the free Rust+ cache (or a render
  kept on disk) is fetched in slices, checked against its SHA-256 and stored
  in one statement. One fetch per server, a backoff on failure, `stale`
  abandons a fetch that straddles a map change. Render now (D109) is
  admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
  are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
  fleet default plus per-server override (D114), the players layer capped by
  presence (D113), own dot and online first-party clan mates for a linked
  viewer (D115, D117, D118). A layer the viewer may not see is absent from
  the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
  /map/live; admin /servers/:id/map/fetch and /render; the Map card on the
  visibility PUT. Swagger fragment and frozen manifest regenerated.

Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
  (labels only when a cell is wide enough to hold one), a legend that lists
  hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
  copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.

Capability `map` declared for the Android app (phase 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 01:06:10 -05:00
parent ac0bcd850a
commit 0cb9bdd1f0
34 changed files with 4680 additions and 28 deletions

View File

@@ -142,4 +142,47 @@ rustRouter.get(
servers.getClan,
)
// ── The map (phase 14) ────────────────────────────────────────────────────
//
// The picture is public at every setting; what moves on it is not. Each of the
// four layers has its own audience, and a layer the viewer may not see is
// removed on the server — never sent and hidden by the page (PLAN.md §30.2).
rustRouter.get(
'/servers/:id/map',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust server’s map'
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer, and which of the four layers — `world`, `events`, `players`, `bases` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The map, as this viewer may see it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMap" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
siteMode,
servers.getMap,
)
rustRouter.get(
'/servers/:id/map/image',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The picture of one Rust server’s map'
// #swagger.description = 'The JPEG, cached as immutable for a year because the picture’s SHA-256 is in the URL. A hash that is not the stored picture’s answers 404, so a replaced picture is never served under an old address. Public at every setting: the picture is rendered from the map seed and says nothing about who plays.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['v'] = { in: 'query', required: true, description: 'The picture’s SHA-256, as the map route names it', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The picture', content: { "image/jpeg": { schema: { type: "string", format: "binary" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or no picture with that hash' } */
siteMode,
servers.getMapImage,
)
rustRouter.get(
'/servers/:id/map/live',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'What moves on one Rust server’s map'
// #swagger.description = 'The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The positions this viewer may see', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMapLive" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
siteMode,
servers.getMapLive,
)
module.exports = rustRouter