Files
Module-Rust/server/mapImages.js
wtclaude 0cb9bdd1f0
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
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
2026-09-25 01:06:10 -05:00

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 }