feat(rust): the live map (phase 14, protocol 11)
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user