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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user