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
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
// ── The map's picture: noticing a new map and fetching it ─────────────────
|
|
//
|
|
// R9's first half, as the rig rewrote it (PLAN.md §30.0). The picture is not
|
|
// extracted from anything: the game's Rust+ service already holds a JPEG of the
|
|
// map in memory, and the plugin hands it over in 512 KiB slices. That changes
|
|
// the one rule R9 carried over from the asset bridge, where a fetch was an
|
|
// expensive extraction:
|
|
//
|
|
// **D110 — the site fetches the picture BY ITSELF the first time it sees a map
|
|
// it does not have**, because reading the cache costs the game nothing. It is
|
|
// triggered off the board poll (`boot.js`): a new boot, wipe, seed or world
|
|
// size, or a server with no row at all, is a reason to ask `map.info`, and a
|
|
// key or hash that differs from the row is a reason to fetch.
|
|
//
|
|
// **D109 — a render is never automatic.** A server with no Rust+ cache has no
|
|
// picture until an admin presses Render now, which stalls that game for seconds
|
|
// and is said so beside the button. The render answers at once and happens on a
|
|
// later frame; this file then polls `map.info` until the picture exists and
|
|
// fetches it like any other.
|
|
//
|
|
// Three rules the fetch keeps:
|
|
//
|
|
// • **One fetch per server at a time**, held here. A second trigger while one
|
|
// runs is dropped, and an admin's Fetch again is told one is running.
|
|
// • **A fetch that straddles a map change is abandoned whole.** The plugin
|
|
// refuses a slice with `stale` when the key or hash moved, and the bytes are
|
|
// checked against the hash before anything is written: two maps are never
|
|
// spliced, and a half-fetched picture is never stored.
|
|
// • **A failure keeps the old row** and retries on a backoff. The page keeps
|
|
// drawing the last picture it had, which is still the right map until the
|
|
// key says otherwise.
|
|
|
|
const crypto = require('crypto')
|
|
|
|
const core = require('./core')
|
|
|
|
const db = require('./model/map/map.db')
|
|
const model = require('./model/map/map.model')
|
|
const sidecar = require('./sidecarClient')
|
|
|
|
const log = core.logger('map')
|
|
|
|
/** Backoff after a failed fetch: 30 s doubling to 10 minutes. */
|
|
const BACKOFF_MIN_MS = 30 * 1000
|
|
const BACKOFF_MAX_MS = 10 * 60 * 1000
|
|
|
|
/** How soon to ask again when the plugin is still hashing, or the world still loading. */
|
|
const SOON_MS = 3000
|
|
|
|
/** How long a render is watched for before this side gives up on it (a 6000 map stalls ~40 s). */
|
|
const RENDER_WATCH_MS = 5 * 60 * 1000
|
|
const RENDER_POLL_MS = 3000
|
|
|
|
/** The first protocol whose plugin knows the map verbs. */
|
|
const MAP_PROTOCOL = 11
|
|
|
|
/** The most slices one picture may have: 16 MiB, MEDIUMBLOB's ceiling. */
|
|
const MAX_CHUNKS = 32
|
|
|
|
/**
|
|
* Per server: the observation last acted on, whether a fetch is running, and
|
|
* when the next try may happen. In memory, deliberately — a restart of the
|
|
* website forgets it and asks once more, which costs one `map.info`.
|
|
*/
|
|
const state = new Map()
|
|
|
|
function stateOf(serverId) {
|
|
if (!state.has(serverId)) {
|
|
state.set(serverId, { sig: null, running: null, nextTryAt: 0, failures: 0, rendering: null, lastError: null })
|
|
}
|
|
return state.get(serverId)
|
|
}
|
|
|
|
/** What about a server makes its map worth asking about again. */
|
|
const signature = ({ bootId, wipeId, seed, worldSize }) => [bootId, wipeId, seed, worldSize].map((v) => (v == null ? '' : String(v))).join('|')
|
|
|
|
/**
|
|
* Called by every board poll for a CONNECTED server whose world is ready. Cheap
|
|
* when nothing moved: one map lookup and a string compare. When something did,
|
|
* it starts a fetch in the background and returns — the poll never waits on a
|
|
* picture.
|
|
*/
|
|
function observe(server, frame, now = Date.now()) {
|
|
if (!server || !frame || frame.worldReady === false) return
|
|
// The game link has no version handshake: a plugin older than protocol 11
|
|
// never answers `map.info`, and asking would cost a full reply timeout a try.
|
|
if (!(Number(frame.protocol) >= MAP_PROTOCOL)) return
|
|
const s = stateOf(server.id)
|
|
const sig = signature(frame)
|
|
|
|
if (s.running || now < s.nextTryAt) return
|
|
if (s.sig === sig) return
|
|
|
|
run(server, sig).catch((err) => log.warn('map fetch failed', { server: server.id, error: err.message }))
|
|
}
|
|
|
|
/**
|
|
* One fetch cycle. Resolves `{ ok, outcome, detail }`, never rejects in normal
|
|
* operation; the outcome words are what the admin card shows.
|
|
*
|
|
* fetched a new picture is stored
|
|
* current the stored picture is already this map's
|
|
* none the game has no picture; geometry stored so layers can be drawn
|
|
* busy another fetch is running (admin only)
|
|
* failed something did not answer; the old row is kept
|
|
*/
|
|
function run(server, sig = null, { force = false } = {}) {
|
|
const s = stateOf(server.id)
|
|
if (s.running) return Promise.resolve({ ok: false, outcome: 'busy', detail: 'a fetch is already running for this server' })
|
|
|
|
s.running = (async () => {
|
|
try {
|
|
const result = await fetchOnce(server, { force })
|
|
if (result.retrySoon) {
|
|
s.nextTryAt = Date.now() + SOON_MS
|
|
return result
|
|
}
|
|
if (result.ok) {
|
|
s.sig = sig
|
|
s.failures = 0
|
|
s.nextTryAt = 0
|
|
s.lastError = null
|
|
} else {
|
|
s.failures += 1
|
|
s.nextTryAt = Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** (s.failures - 1))
|
|
s.lastError = result.detail || result.outcome
|
|
}
|
|
return result
|
|
} finally {
|
|
s.running = null
|
|
}
|
|
})()
|
|
|
|
return s.running
|
|
}
|
|
|
|
/** The plugin's refusal, as a sentence, or null when the answer was not one. */
|
|
function refusal(res) {
|
|
if (!res.ok) return `the sidecar did not answer (${res.status})`
|
|
if (res.data && res.data.kind === 'map.error') return res.data.message || res.data.reason || 'refused'
|
|
return null
|
|
}
|
|
|
|
async function fetchOnce(server, { force }) {
|
|
const info = await sidecar.mapInfo(server)
|
|
const why = refusal(info)
|
|
|
|
if (why) {
|
|
// A world still loading is not a failure worth backing off for.
|
|
if (info.ok && info.data && info.data.reason === 'not-ready') return { ok: false, retrySoon: true, outcome: 'failed', detail: why }
|
|
return { ok: false, outcome: 'failed', detail: why }
|
|
}
|
|
|
|
const data = info.data || {}
|
|
if (data.hashing) return { ok: false, retrySoon: true, outcome: 'failed', detail: 'the plugin is still hashing the picture' }
|
|
|
|
const row = model.derive(server.id, data)
|
|
if (!row.mapKey) return { ok: false, outcome: 'failed', detail: 'map.info named no map' }
|
|
|
|
const stored = await db.getMeta(server.id)
|
|
|
|
if (row.source === 'none') {
|
|
// The same map with a picture already stored keeps it: nothing about the map
|
|
// changed, only where a picture would come from now. A different map, or no
|
|
// row at all, stores the geometry so the page can draw the layers.
|
|
if (stored && stored.mapKey === row.mapKey && stored.sha256) {
|
|
await db.putGeometry({ ...row, source: stored.source, width: Number(stored.width), height: Number(stored.height), background: stored.background })
|
|
return { ok: true, outcome: 'current', detail: 'the game has no picture now; the stored one is the same map' }
|
|
}
|
|
await db.putImage(row)
|
|
return { ok: true, outcome: 'none', detail: 'the game has no picture of its map; an admin can render one' }
|
|
}
|
|
|
|
if (!force && stored && stored.mapKey === row.mapKey && stored.sha256 === row.sha256) {
|
|
if (Number(stored.derivation) !== model.DERIVATION_VERSION || stored.source !== row.source) await db.putGeometry(row)
|
|
return { ok: true, outcome: 'current', detail: 'the stored picture is this map' }
|
|
}
|
|
|
|
const chunks = Number(data.chunks)
|
|
if (!Number.isInteger(chunks) || chunks < 1 || chunks > MAX_CHUNKS) {
|
|
return { ok: false, outcome: 'failed', detail: `the plugin described ${data.chunks} slices` }
|
|
}
|
|
|
|
const parts = []
|
|
for (let n = 0; n < chunks; n += 1) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const res = await sidecar.mapChunk(server, { mapKey: row.mapKey, sha256: row.sha256, n })
|
|
const no = refusal(res)
|
|
if (no) {
|
|
// `stale` means the map moved under the fetch: start again soon rather
|
|
// than back off, because the next `map.info` describes the new one.
|
|
if (res.ok && res.data && res.data.reason === 'stale') return { ok: false, retrySoon: true, outcome: 'failed', detail: no }
|
|
return { ok: false, outcome: 'failed', detail: `slice ${n}: ${no}` }
|
|
}
|
|
if (!res.data || res.data.kind !== 'map.chunk' || Number(res.data.chunk) !== n || typeof res.data.data !== 'string') {
|
|
return { ok: false, outcome: 'failed', detail: `slice ${n} was not the slice asked for` }
|
|
}
|
|
parts.push(Buffer.from(res.data.data, 'base64'))
|
|
}
|
|
|
|
const bytes = Buffer.concat(parts)
|
|
const hash = crypto.createHash('sha256').update(bytes).digest('hex')
|
|
|
|
if (hash !== row.sha256 || (Number(data.bytes) > 0 && bytes.length !== Number(data.bytes))) {
|
|
return { ok: false, outcome: 'failed', detail: 'the picture did not match its own hash; nothing was stored' }
|
|
}
|
|
|
|
await db.putImage({ ...row, bytes })
|
|
log.info('map picture stored', { server: server.id, mapKey: row.mapKey, source: row.source, bytes: bytes.length })
|
|
return { ok: true, outcome: 'fetched', detail: `${bytes.length} bytes from ${row.source}` }
|
|
}
|
|
|
|
/**
|
|
* An admin's Render now (D109). Refused here when a render is already being
|
|
* watched; the plugin refuses the rest (a picture exists, the world is loading).
|
|
* On `accepted` the render is watched in the background: `map.info` is polled
|
|
* until its source is `rendered` — through a stall that makes the game answer
|
|
* nothing at all for its duration — and then fetched like any picture.
|
|
*/
|
|
async function render(server, actor) {
|
|
const s = stateOf(server.id)
|
|
if (s.rendering) return { ok: false, status: 409, message: 'A render is already running for this server.' }
|
|
|
|
const res = await sidecar.mapRender(server, { actor: actor || null })
|
|
const no = refusal(res)
|
|
if (no) {
|
|
const reason = res.ok && res.data ? res.data.reason : null
|
|
return { ok: false, status: reason === 'has-picture' || reason === 'busy' ? 409 : 502, message: no, reason }
|
|
}
|
|
|
|
s.rendering = { startedAt: Date.now() }
|
|
watchRender(server).catch((err) => log.warn('render watch failed', { server: server.id, error: err.message }))
|
|
return { ok: true, worldSize: res.data && res.data.worldSize, stallSeconds: model.renderStallSeconds(res.data && res.data.worldSize) }
|
|
}
|
|
|
|
async function watchRender(server) {
|
|
const s = stateOf(server.id)
|
|
const until = Date.now() + RENDER_WATCH_MS
|
|
try {
|
|
while (Date.now() < until) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await new Promise((resolve) => {
|
|
const t = setTimeout(resolve, RENDER_POLL_MS)
|
|
if (typeof t.unref === 'function') t.unref()
|
|
})
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const info = await sidecar.mapInfo(server)
|
|
const data = info.ok && info.data ? info.data : null
|
|
if (!data || data.kind !== 'map.info') continue // the game is mid-stall and answering nothing
|
|
if (data.rendering) continue
|
|
if (data.source === 'none') {
|
|
s.lastError = 'the render finished without a picture — see rg.map on the server console'
|
|
return
|
|
}
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await run(server, null, { force: false })
|
|
return
|
|
}
|
|
s.lastError = 'the render did not finish within five minutes'
|
|
} finally {
|
|
s.rendering = null
|
|
}
|
|
}
|
|
|
|
/** For the admin card: what this side is doing about each server's picture. */
|
|
function statusOf(serverId) {
|
|
const s = state.get(serverId)
|
|
if (!s) return { fetching: false, rendering: false, lastError: null }
|
|
return { fetching: Boolean(s.running), rendering: Boolean(s.rendering), lastError: s.lastError }
|
|
}
|
|
|
|
/** Test seam: forget everything. */
|
|
function _reset() {
|
|
state.clear()
|
|
}
|
|
|
|
module.exports = { observe, run, render, statusOf, signature, MAX_CHUNKS, _reset }
|