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

@@ -13,6 +13,7 @@
const core = require('../../core')
const db = require('../../model/servers/servers.db')
const mapImages = require('../../mapImages')
const servers = require('../../model/servers/servers.model')
const sidecar = require('../../sidecarClient')
@@ -129,4 +130,57 @@ async function testServer(req, res) {
}
}
module.exports = { listServers, putServer, deleteServer, testServer }
/**
* Fetch one server's map picture again (D110's admin button). Runs the same
* fetch the board poll triggers, forced — the stored hash is not trusted to be
* current — and answers what it did, because an operator pressing this is
* asking a question: is the picture this server has the right one?
*/
async function fetchMap(req, res) {
const { id } = req.params
try {
const server = await servers.getForCalling(id)
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
const result = await mapImages.run(server, null, { force: true })
await core.activity.log({ req, action: 'rust.map.fetch', detail: { server: id, outcome: result.outcome } })
if (result.outcome === 'busy') return res.status(409).json({ message: 'A fetch is already running for this server.' })
return res.status(result.ok ? 200 : 502).json({ ok: result.ok, outcome: result.outcome, message: result.detail || null })
} catch (err) {
log.error('failed to fetch a map', { server: id, error: err.message })
return res.status(500).json({ message: 'Failed to fetch the map' })
}
}
/**
* Ask a server with no picture to draw one (D109). **This stalls that game**
* for seconds — the card says how many before the button is pressed — and it is
* refused by the plugin when a picture already exists. Answered as soon as the
* game accepts; the picture is fetched when the render finishes.
*/
async function renderMap(req, res) {
const { id } = req.params
try {
const server = await servers.getForCalling(id)
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
const actor = req.user && (req.user.username || req.user.id)
const result = await mapImages.render(server, actor != null ? String(actor) : null)
await core.activity.log({
req,
action: 'rust.map.render',
detail: { server: id, accepted: result.ok, ...(result.ok ? {} : { reason: result.reason || null }) },
})
if (!result.ok) return res.status(result.status || 502).json({ message: result.message })
return res.status(202).json({ accepted: true, stallSeconds: result.stallSeconds })
} catch (err) {
log.error('failed to ask for a render', { server: id, error: err.message })
return res.status(500).json({ message: 'Failed to ask the server to draw its map' })
}
}
module.exports = { listServers, putServer, deleteServer, testServer, fetchMap, renderMap }

View File

@@ -101,4 +101,37 @@ adminRustRouter.post(
admin.testServer,
)
// ── The map's picture (phase 14) ──────────────────────────────────────────
adminRustRouter.post(
'/servers/:id/map/fetch',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Fetch a server’s map picture again'
// #swagger.description = 'Asks the game what its map is and fetches the picture again, whatever this site already holds. The site does this by itself the first time it sees a new map when the game has a picture to give; this is the button for when that did not happen. `outcome` is `fetched`, `current` (the stored picture is this map’s), `none` (the game has no picture, and Render now is the way to get one) or `failed`, with a sentence in `message`. One fetch per server at a time: a second answers 409.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'What the fetch did' } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
/* #swagger.responses[409] = { description: 'A fetch is already running for this server' } */
/* #swagger.responses[502] = { description: 'The game or its sidecar did not give a picture; the stored one is kept' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.fetchMap,
)
adminRustRouter.post(
'/servers/:id/map/render',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Ask a server to draw its own map'
// #swagger.description = '**This stalls the game server** while it draws — about 8.5 seconds on a 3000 map, longer on a larger one — and nothing on it moves for that time. It exists for a server without Rust+ (`app.port`), whose game keeps no picture of its map, and is refused when a picture already exists. Answers 202 as soon as the game accepts; the picture is fetched when the render finishes, and kept by the game so a restart on the same map does not need another.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[202] = { description: 'The game accepted and will draw on its next frame' } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
/* #swagger.responses[409] = { description: 'A picture already exists, or a render is already running' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.renderMap,
)
module.exports = adminRustRouter

View File

@@ -3,7 +3,11 @@
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const map = require('../../model/map/map.model')
const mapDb = require('../../model/map/map.db')
const mapImages = require('../../mapImages')
const visibility = require('../../model/visibility/visibility.model')
const visibilityDb = require('../../model/visibility/visibility.db')
const log = core.logger('visibility')
@@ -14,8 +18,49 @@ const log = core.logger('visibility')
* 100-clan ceiling" (D55) come from.
*/
async function describe() {
const [settings, boards] = await Promise.all([visibility.describe(), clans.boardsForAdmin()])
return { ...settings, clans: { ...settings.clans, servers: boards } }
const [settings, boards, mapCard] = await Promise.all([visibility.describe(), clans.boardsForAdmin(), describeMap()])
return { ...settings, clans: { ...settings.clans, servers: boards }, map: mapCard }
}
/**
* The Map card (phase 14, D114): the four layers' fleet defaults and each
* server's overrides, and beside them what each server's picture is — where it
* came from, which map it is of, when it was fetched — and what a render would
* cost that server (D109). On this page for D106's reason: it is the one page
* with a row per server, and a layer switch answers the same question the
* presence switch does.
*/
async function describeMap() {
const [servers, rows] = await Promise.all([visibilityDb.listServerPresence(), mapDb.listMeta()])
const switches = await map.describeSwitches(servers)
const byId = new Map(rows.map((r) => [r.serverId, r]))
return {
...switches,
servers: switches.servers.map((sw, i) => {
const s = servers[i]
const row = byId.get(s.id)
return {
...sw,
name: s.name,
enabled: Boolean(s.enabled),
picture: row
? {
source: row.source,
mapKey: row.mapKey,
hasPicture: Boolean(row.sha256 && Number(row.byteCount) > 0),
bytes: Number(row.byteCount) || 0,
width: Number(row.width),
height: Number(row.height),
worldSize: Number(row.worldSize),
fetchedAt: row.fetchedAt ? new Date(row.fetchedAt).toISOString() : null,
}
: null,
renderStallSeconds: map.renderStallSeconds(row ? row.worldSize : null),
...mapImages.statusOf(s.id),
}
}),
}
}
async function read(req, res) {
@@ -29,13 +74,31 @@ async function read(req, res) {
async function update(req, res) {
try {
const { fleet, servers, clanRoster, news } = req.body || {}
const { fleet, servers, clanRoster, news, map: mapSwitches } = req.body || {}
// The map's switches are validated whole FIRST, before the rest is written:
// the page saves everything with one PUT, and a refused map switch must not
// leave the presence half already applied.
let mapResult = null
if (mapSwitches !== undefined) {
mapResult = await map.update(mapSwitches, req.user, async (id) => (await visibilityDb.getServerPresence(id)) !== undefined, { dryRun: true })
if (!mapResult.ok) {
res.status(mapResult.status || 400).json({ message: mapResult.message })
return
}
}
const result = await visibility.update({ fleet, servers, clanRoster, news }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
}
if (mapSwitches !== undefined) {
mapResult = await map.update(mapSwitches, req.user, async (id) => (await visibilityDb.getServerPresence(id)) !== undefined)
if (mapResult.ok && Object.keys(mapResult.changed).length) result.changed.map = mapResult.changed
}
// One row per save, naming everything it changed. Widening who may see the
// roll call is exactly the kind of change somebody later needs to trace to a
// person and a time.

View File

@@ -27,7 +27,7 @@ visibilityRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Who may see who is online, and who may see a clan roster'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams.'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'),
visibility.read,
@@ -37,7 +37,7 @@ visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
@@ -47,6 +47,7 @@ visibilityRouter.put(
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
body('news').optional().isObject().withMessage('news maps a server id to true or false'),
body('map').optional().isObject().withMessage('map carries fleet and servers, each an object of layer switches'),
validate,
visibility.update,
)

View File

@@ -13,6 +13,9 @@ const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
const mapDb = require('../../model/map/map.db')
const mapLive = require('../../mapLive')
const servers = require('../../model/servers/servers.model')
const visibility = require('../../model/visibility/visibility.model')
@@ -216,4 +219,135 @@ async function getClan(req, res) {
}
}
module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline, listClans, getClan }
// ── The map (phase 14) ────────────────────────────────────────────────────
/**
* One server's map: the picture's address, the geometry to draw it with, the
* monuments when the viewer may see the world layer, and **which layers this
* viewer gets and who gets the others** — the §23.3 shape, where a hidden layer
* says who can see it and never what it holds.
*
* A server that has never described its map answers `picture: null` and
* `geometry: null`, and the page says the map is not available yet. A server
* whose game has no picture answers geometry and no picture, and the page draws
* the layers on a plain background (D109).
*/
async function getMap(req, res) {
try {
const server = await servers.getPublic(req.params.id)
if (!server) {
res.status(404).json({ message: 'No such server' })
return
}
const [row, acc] = await Promise.all([mapDb.getMeta(req.params.id), map.access(req, req.params.id)])
perViewer(res)
const hasPicture = Boolean(row && row.sha256 && Number(row.byteCount) > 0)
res.json({
serverId: req.params.id,
mapKey: row ? row.mapKey : null,
picture: hasPicture
? {
path: `/public/rust/servers/${encodeURIComponent(req.params.id)}/map/image?v=${row.sha256}`,
source: row.source,
fetchedAt: row.fetchedAt ? new Date(row.fetchedAt).toISOString() : null,
}
: null,
geometry: map.geometryOf(row),
...(acc.layers.world.visible ? { monuments: map.monumentsOf(row) } : {}),
layers: acc.layers,
// `signedIn` is what lets the page offer "link your Steam account" to the
// person who can act on it, and not to a visitor who has no account at all.
mates: { visible: acc.mates.visible, on: acc.mates.on, linked: acc.mates.linked, signedIn: acc.level !== 'public' },
pollMs: map.POLL_MS,
})
} catch (err) {
log.error('failed to read a map', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the map' })
}
}
/**
* The picture itself. **Public and immutable**: the hash is in the URL, so a
* browser and any cache in front of the site may keep it for a year — and a
* hash that is no longer the stored picture's is a 404, never the new bytes
* under the old address. The picture is public at every setting (§30.5): it is
* rendered from a seed anybody can render, and it says nothing about who plays.
*/
async function getMapImage(req, res) {
try {
const sha = String(req.query.v || '').toLowerCase()
const server = /^[0-9a-f]{64}$/.test(sha) ? await servers.getPublic(req.params.id) : null
const bytes = server ? await mapDb.getBytes(req.params.id, sha) : null
if (!bytes) {
res.set('Cache-Control', 'no-store')
res.status(404).json({ message: 'No such picture' })
return
}
res.set('Cache-Control', 'public, max-age=31536000, immutable')
res.set('Content-Type', 'image/jpeg')
res.set('X-Content-Type-Options', 'nosniff')
res.send(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes))
} catch (err) {
log.error('failed to read a map picture', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the map picture' })
}
}
/**
* What moves, **projected for this viewer on the server** (§30.2). A layer the
* viewer may not see is absent from the answer — not empty, absent — and
* `mates` carries their own position and their online clan mates when they are
* entitled to it (D115, D117). Positions come from `mapLive`'s five-second
* cache, so any number of viewers cost one ask of the game (D111).
*
* A game that does not answer is `live: false` with a reason, a 200: the page
* keeps the picture and says positions are unavailable.
*/
async function getMapLive(req, res) {
try {
const server = await servers.getForCalling(req.params.id)
if (!server) {
res.status(404).json({ message: 'No such server' })
return
}
const acc = await map.access(req, req.params.id)
perViewer(res)
const anyLayer = map.LAYERS.some((l) => acc.layers[l].visible) || acc.mates.visible
if (!anyLayer) {
res.json({ live: true, layers: acc.layers })
return
}
const answer = await mapLive.live(server)
if (!answer.ok) {
res.json({ live: false, reason: answer.status, layers: acc.layers })
return
}
const ids = await map.mateIdsFor(req.params.id, acc)
res.json({ live: true, layers: acc.layers, ...map.project(answer.data, acc, ids) })
} catch (err) {
log.error('failed to read live map positions', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the map' })
}
}
module.exports = {
listServers,
getServer,
listEvents,
listLeaderboard,
listWipes,
listOnline,
listClans,
getClan,
getMap,
getMapImage,
getMapLive,
}

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