// ── 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 }