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:
@@ -47,6 +47,7 @@ const engagement = require('./engagement/emit')
|
||||
const eventsDb = require('./model/events/events.db')
|
||||
const eventWorld = require('./eventWorld')
|
||||
const ingest = require('./ingest')
|
||||
const mapImages = require('./mapImages')
|
||||
const permSync = require('./permSync')
|
||||
const servers = require('./model/servers/servers.model')
|
||||
const sidecar = require('./sidecarClient')
|
||||
@@ -187,6 +188,12 @@ async function refreshOne(server) {
|
||||
// counts: a board the game left behind says nothing about now.
|
||||
if (connected) {
|
||||
eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId, worldReady: frame.worldReady })
|
||||
|
||||
// A new boot, wipe or map is a reason to ask what the map is now (D110).
|
||||
// Started, never awaited: a picture is a megabyte over the game link, and
|
||||
// the board poll does not wait on one. `mapImages` keeps one fetch per
|
||||
// server and backs off on its own.
|
||||
mapImages.observe(server, frame)
|
||||
}
|
||||
} catch (err) {
|
||||
// A failure here is one server's, and it must not reach `Promise.allSettled`
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
-- it knows this module registered, because it is the side that knows which
|
||||
-- registrant owned what.
|
||||
|
||||
-- Phase 14.
|
||||
DROP TABLE IF EXISTS rust_map_overrides;
|
||||
DROP TABLE IF EXISTS rust_map_images;
|
||||
|
||||
-- Phase 13b.
|
||||
DROP TABLE IF EXISTS rust_perm_run_grants;
|
||||
|
||||
|
||||
@@ -840,3 +840,63 @@ CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
|
||||
-- post, and without it the day this module updates every post would start
|
||||
-- appearing in every server's chat.
|
||||
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS announce_news TINYINT(1) NOT NULL DEFAULT 0;
|
||||
|
||||
-- ── The map (phase 14, protocol 11) ───────────────────────────────────────
|
||||
--
|
||||
-- One row per server holding the picture of its CURRENT map (PLAN.md §30.2),
|
||||
-- replaced whole when the map changes. The picture lives here and not in core's
|
||||
-- `/uploads`, which core serves to anybody by URL and no purge would reach
|
||||
-- (§30.5). One row per server bounds it at about a megabyte each.
|
||||
--
|
||||
-- `bytes` and `sha256` are NULL for a server whose game has no picture yet
|
||||
-- (`source = 'none'`): the row still carries the geometry and the monuments, so
|
||||
-- the page can draw the live layers on a plain background, and an admin's
|
||||
-- render replaces it. `map_key` says WHICH map and `sha256` which picture of it.
|
||||
--
|
||||
-- `derivation` is `DERIVATION_VERSION` (R9) — how the geometry columns were
|
||||
-- worked out from what the plugin said. A row whose number is older is
|
||||
-- re-derived from a fresh `map.info` without fetching the bytes again.
|
||||
--
|
||||
-- `monuments` is JSON as the plugin sent it: kind, label, the `kind#n` token and
|
||||
-- x/z. It changes only with the map, so it belongs to this row and not to the
|
||||
-- live answer.
|
||||
CREATE TABLE IF NOT EXISTS rust_map_images (
|
||||
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
map_key VARCHAR(160) NOT NULL,
|
||||
sha256 CHAR(64) NULL,
|
||||
source VARCHAR(16) NOT NULL,
|
||||
width INT UNSIGNED NOT NULL,
|
||||
height INT UNSIGNED NOT NULL,
|
||||
ocean_margin INT UNSIGNED NOT NULL,
|
||||
world_size INT UNSIGNED NOT NULL,
|
||||
grid_cells INT UNSIGNED NOT NULL,
|
||||
grid_cell_size DECIMAL(10,3) NOT NULL,
|
||||
background VARCHAR(16) NULL,
|
||||
derivation INT UNSIGNED NOT NULL,
|
||||
monuments MEDIUMTEXT NOT NULL,
|
||||
bytes MEDIUMBLOB NULL,
|
||||
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_rust_map_images_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Per-server overrides of the map's switches (D114): a layer's audience, or the
|
||||
-- own-and-mates view. The fleet defaults are rows in `rust_settings`
|
||||
-- (`map.layer.<layer>.audience`, `map.mates`); a server with no row here
|
||||
-- inherits them. A table of its own rather than five more columns on
|
||||
-- `rust_servers`, because the set of switches is the part most likely to grow.
|
||||
--
|
||||
-- `value` is a word (`staff` · `signed_in` · `public`, or `on` · `off`), and a
|
||||
-- word this build does not recognise NARROWS (`model/map`).
|
||||
CREATE TABLE IF NOT EXISTS rust_map_overrides (
|
||||
server_id VARCHAR(64) NOT NULL,
|
||||
setting VARCHAR(64) NOT NULL,
|
||||
value VARCHAR(16) NOT NULL,
|
||||
updated_by INT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (server_id, setting),
|
||||
CONSTRAINT fk_rust_map_overrides_server
|
||||
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_rust_map_overrides_user
|
||||
FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
277
server/mapImages.js
Normal file
277
server/mapImages.js
Normal file
@@ -0,0 +1,277 @@
|
||||
// ── 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 }
|
||||
58
server/mapLive.js
Normal file
58
server/mapLive.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// ── What moves on the map, asked for while somebody is looking ────────────
|
||||
//
|
||||
// D111: positions are request/reply, never a board. A board would run with
|
||||
// nobody looking and keep where everybody is at rest in the sidecar's database;
|
||||
// this asks `map.live` only when a page does, and keeps the answer IN MEMORY for
|
||||
// five seconds.
|
||||
//
|
||||
// **Any number of viewers cost one ask.** Every request inside the window is
|
||||
// served from the same answer, and a request that arrives while an ask is in
|
||||
// flight waits for that ask rather than starting a second — so the sidecar sees
|
||||
// at most one `map.live` per server per window, whoever and however many are
|
||||
// watching (§30.4 step 7).
|
||||
//
|
||||
// The cache holds EVERY layer, unfiltered; each viewer's answer is projected
|
||||
// from it by `model/map`. That is why it never leaves this process.
|
||||
|
||||
const sidecar = require('./sidecarClient')
|
||||
const model = require('./model/map/map.model')
|
||||
|
||||
/** serverId → { at, answer } for the last good answer, and { pending } while one is in flight. */
|
||||
const cache = new Map()
|
||||
|
||||
/**
|
||||
* One server's live answer, from the cache when it is fresh. Resolves `{ ok,
|
||||
* data, status }` like every sidecar call; never rejects.
|
||||
*/
|
||||
async function live(server, now = Date.now) {
|
||||
const hit = cache.get(server.id)
|
||||
if (hit && hit.answer && now() - hit.at < model.LIVE_CACHE_MS) return hit.answer
|
||||
if (hit && hit.pending) return hit.pending
|
||||
|
||||
const pending = (async () => {
|
||||
const res = await sidecar.mapLive(server)
|
||||
const refused = res.ok && res.data && res.data.kind === 'map.error'
|
||||
const answer = res.ok && !refused
|
||||
? { ok: true, status: 'ok', data: res.data }
|
||||
: { ok: false, status: refused ? res.data.reason || 'refused' : res.status, data: null }
|
||||
// A failure is cached for the window too: a game that is down must not be
|
||||
// asked once per viewer per poll while it stays down.
|
||||
cache.set(server.id, { at: now(), answer })
|
||||
return answer
|
||||
})()
|
||||
|
||||
cache.set(server.id, { ...(hit || {}), pending })
|
||||
try {
|
||||
return await pending
|
||||
} finally {
|
||||
const entry = cache.get(server.id)
|
||||
if (entry && entry.pending === pending) delete entry.pending
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
function _reset() {
|
||||
cache.clear()
|
||||
}
|
||||
|
||||
module.exports = { live, _reset }
|
||||
147
server/model/map/map.db.js
Normal file
147
server/model/map/map.db.js
Normal file
@@ -0,0 +1,147 @@
|
||||
// ── SQL for the map ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Three things: the picture each server's map is drawn on (`rust_map_images`),
|
||||
// the per-server overrides of the map's switches (`rust_map_overrides`), and the
|
||||
// two reads the own-and-mates view needs — which Steam accounts a website user
|
||||
// holds, and who shares a clan with them on one server.
|
||||
//
|
||||
// Nothing here stores a POSITION. Where people are is asked for while somebody
|
||||
// is looking and kept in memory for seconds (D111, `mapLive.js`).
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const IMAGES = 'rust_map_images'
|
||||
const OVERRIDES = 'rust_map_overrides'
|
||||
const LINKS = 'rust_account_links'
|
||||
const CLANS = 'rust_clans'
|
||||
const MEMBERS = 'rust_clan_members'
|
||||
|
||||
/** The columns every read but the picture's own wants — everything except the bytes. */
|
||||
const META = `server_id AS serverId, map_key AS mapKey, sha256, source, width, height,
|
||||
ocean_margin AS oceanMargin, world_size AS worldSize, grid_cells AS gridCells,
|
||||
grid_cell_size AS gridCellSize, background, derivation, monuments,
|
||||
OCTET_LENGTH(bytes) AS byteCount, fetched_at AS fetchedAt`
|
||||
|
||||
/** One server's picture row without the bytes, or undefined. */
|
||||
async function getMeta(serverId) {
|
||||
const rows = await core.query(`SELECT ${META} FROM ${IMAGES} WHERE server_id = ?`, [serverId])
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
/** Every server's picture row without the bytes, for the admin card. */
|
||||
async function listMeta() {
|
||||
return core.query(`SELECT ${META} FROM ${IMAGES}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture itself, but only if it is still the one named. A URL carries the
|
||||
* hash it was minted for, and a picture replaced since must not be served under
|
||||
* it: the response is cached as immutable.
|
||||
*/
|
||||
async function getBytes(serverId, sha256) {
|
||||
const rows = await core.query(
|
||||
`SELECT bytes FROM ${IMAGES} WHERE server_id = ? AND sha256 = ? AND bytes IS NOT NULL`,
|
||||
[serverId, sha256],
|
||||
)
|
||||
return rows[0] ? rows[0].bytes : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one server's row whole, in ONE statement. A new map's picture and its
|
||||
* geometry arrive together or not at all; a reader never sees the new bytes
|
||||
* under the old monuments.
|
||||
*/
|
||||
async function putImage(row) {
|
||||
await core.query(
|
||||
`REPLACE INTO ${IMAGES}
|
||||
(server_id, map_key, sha256, source, width, height, ocean_margin, world_size,
|
||||
grid_cells, grid_cell_size, background, derivation, monuments, bytes, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
[
|
||||
row.serverId, row.mapKey, row.sha256 || null, row.source, row.width, row.height, row.oceanMargin,
|
||||
row.worldSize, row.gridCells, row.gridCellSize, row.background || null, row.derivation,
|
||||
JSON.stringify(row.monuments || []), row.bytes || null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive a row's geometry and monuments without touching its picture — the
|
||||
* `DERIVATION_VERSION` path, and a map whose picture is unchanged but whose
|
||||
* source moved (a render replaced by the Rust+ cache of the same bytes).
|
||||
*/
|
||||
async function putGeometry(row) {
|
||||
await core.query(
|
||||
`UPDATE ${IMAGES}
|
||||
SET source = ?, width = ?, height = ?, ocean_margin = ?, world_size = ?, grid_cells = ?,
|
||||
grid_cell_size = ?, background = ?, derivation = ?, monuments = ?
|
||||
WHERE server_id = ? AND map_key = ?`,
|
||||
[
|
||||
row.source, row.width, row.height, row.oceanMargin, row.worldSize, row.gridCells, row.gridCellSize,
|
||||
row.background || null, row.derivation, JSON.stringify(row.monuments || []), row.serverId, row.mapKey,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/** Every override for one server, as `{ setting: value }` rows. */
|
||||
async function getOverrides(serverId) {
|
||||
return core.query(`SELECT setting, value FROM ${OVERRIDES} WHERE server_id = ?`, [serverId])
|
||||
}
|
||||
|
||||
/** Every override in the fleet, for the admin card. */
|
||||
async function listOverrides() {
|
||||
return core.query(`SELECT server_id AS serverId, setting, value FROM ${OVERRIDES}`)
|
||||
}
|
||||
|
||||
/** Set (a word) or clear (`null`) one server's override of one switch. */
|
||||
async function setOverride(serverId, setting, value, userId = null) {
|
||||
if (value === null) {
|
||||
await core.query(`DELETE FROM ${OVERRIDES} WHERE server_id = ? AND setting = ?`, [serverId, setting])
|
||||
return
|
||||
}
|
||||
await core.query(
|
||||
`INSERT INTO ${OVERRIDES} (server_id, setting, value, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by),
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
[serverId, setting, value, userId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Every Steam id one website user has linked. A link reaches every server (D28). */
|
||||
async function steamIdsForUser(userId) {
|
||||
const rows = await core.query(`SELECT steam_id AS steamId FROM ${LINKS} WHERE user_id = ?`, [userId])
|
||||
return rows.map((r) => String(r.steamId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Steam id sharing a first-party clan with any of `steamIds` on ONE
|
||||
* server (D115, D117) — the viewer's own accounts included, since they are
|
||||
* members too. A clan the board no longer carries (`gone_at`) is nobody's clan.
|
||||
*/
|
||||
async function clanMatesOn(serverId, steamIds) {
|
||||
if (!steamIds.length) return []
|
||||
const rows = await core.query(
|
||||
`SELECT DISTINCT m2.steam_id AS steamId
|
||||
FROM ${MEMBERS} m1
|
||||
JOIN ${CLANS} c ON c.external_id = m1.external_id
|
||||
JOIN ${MEMBERS} m2 ON m2.external_id = m1.external_id
|
||||
WHERE c.server_id = ? AND c.gone_at IS NULL
|
||||
AND m1.steam_id IN (${steamIds.map(() => '?').join(', ')})`,
|
||||
[serverId, ...steamIds],
|
||||
)
|
||||
return rows.map((r) => String(r.steamId))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMeta,
|
||||
listMeta,
|
||||
getBytes,
|
||||
putImage,
|
||||
putGeometry,
|
||||
getOverrides,
|
||||
listOverrides,
|
||||
setOverride,
|
||||
steamIdsForUser,
|
||||
clanMatesOn,
|
||||
}
|
||||
414
server/model/map/map.model.js
Normal file
414
server/model/map/map.model.js
Normal file
@@ -0,0 +1,414 @@
|
||||
// ── The map: who may see which layer, and what a viewer is sent ───────────
|
||||
//
|
||||
// R9's security boundary, and the reason this file exists apart from the
|
||||
// picture machinery in `mapImages.js`: **public player positions in Rust locate
|
||||
// players, and base positions are where they sleep.** So every layer has its own
|
||||
// audience (D112), a fleet default with a per-server override (D114), and a
|
||||
// viewer is SENT only the layers they may see — a hidden layer is absent from the
|
||||
// answer, never present and hidden by the page (§30.2).
|
||||
//
|
||||
// ── The four layers ───────────────────────────────────────────────────────
|
||||
//
|
||||
// world monuments, and the world's own events: cargo, the patrol
|
||||
// helicopter, the Chinook, Bradley, supply drops, locked crates
|
||||
// events what this site's events placed: zones, crates, NPCs (phase 13a)
|
||||
// players who is on the server and where, and the sleepers
|
||||
// bases tool cupboards and player vending machines, as positions only
|
||||
//
|
||||
// Defaults: the first two public, the last two staff. The audiences are the
|
||||
// presence rungs (`staff` · `signed_in` · `public`) and follow its asymmetric
|
||||
// fallbacks: an unknown stored word narrows to staff, an unknown viewer is
|
||||
// public (`model/visibility`).
|
||||
//
|
||||
// ── Two rules that make the players layer safe to widen ───────────────────
|
||||
//
|
||||
// **D113 — it can never show more than presence does.** Its effective audience
|
||||
// is the NARROWER of the layer's switch and the server's presence audience. An
|
||||
// operator who opens the map to the public while the roll call stays staff has
|
||||
// opened nothing: a name on a map says who is online as surely as a list does.
|
||||
//
|
||||
// **D115/D117 — own dot and clan mates.** A linked viewer sees their own
|
||||
// position (their sleeper too, §30.5) and their ONLINE first-party clan mates on
|
||||
// that server, whatever the players layer says — and only a linked member of the
|
||||
// same clan sees them. It is gated by its own switch (D118, default on) and by
|
||||
// nothing else: widening the roster widens who sees the member LIST, never where
|
||||
// the members are.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('./map.db')
|
||||
const visibility = require('../visibility/visibility.model')
|
||||
const visibilityDb = require('../visibility/visibility.db')
|
||||
|
||||
const log = core.logger('map')
|
||||
|
||||
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases'])
|
||||
|
||||
const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff' })
|
||||
|
||||
/** D118: on, because it shows a member nothing the game does not already show them. */
|
||||
const DEFAULT_MATES = true
|
||||
|
||||
const MATES_KEY = 'map.mates'
|
||||
|
||||
const layerKey = (layer) => `map.layer.${layer}.audience`
|
||||
|
||||
/** Every override setting name a server may carry. */
|
||||
const SETTINGS = Object.freeze([...LAYERS.map(layerKey), MATES_KEY])
|
||||
|
||||
/**
|
||||
* `DERIVATION_VERSION` (R9): how a row's geometry is worked out from what the
|
||||
* plugin said. A stored row with an older number is re-derived from a fresh
|
||||
* `map.info`, without fetching the picture again.
|
||||
*
|
||||
* 1 — width/height from the picture itself (or, with no picture, the Rust+
|
||||
* cache's geometry: half scale plus the ocean margin); the grid from the
|
||||
* game's own `MapHelper` (D119); the margin in PIXELS, unscaled.
|
||||
*/
|
||||
const DERIVATION_VERSION = 1
|
||||
|
||||
/** The Rust+ cache's scale, used only to size a map that has no picture yet. */
|
||||
const CACHE_SCALE = 0.5
|
||||
|
||||
/** How often the page asks for positions while visible, and how long the module keeps an answer. */
|
||||
const POLL_MS = 10000
|
||||
const LIVE_CACHE_MS = 5000
|
||||
|
||||
/**
|
||||
* What a render costs, measured on the rig (§30.0): 8.5 s for a 3000 map at
|
||||
* half scale, a 2500 × 2500 picture. The work is per pixel, so the estimate for
|
||||
* another size scales with its area.
|
||||
*/
|
||||
const RENDER_MEASURED = Object.freeze({ worldSize: 3000, seconds: 8.5 })
|
||||
|
||||
function renderStallSeconds(worldSize, margin = 500) {
|
||||
const side = (ws) => ws * CACHE_SCALE + 2 * margin
|
||||
const size = Number(worldSize) > 0 ? Number(worldSize) : RENDER_MEASURED.worldSize
|
||||
const ratio = (side(size) * side(size)) / (side(RENDER_MEASURED.worldSize) ** 2)
|
||||
return Math.max(1, Math.round(RENDER_MEASURED.seconds * ratio))
|
||||
}
|
||||
|
||||
/** A stored mates word as a boolean. Anything but `on` is off: an unknown word narrows. */
|
||||
const matesOn = (value) => value === 'on'
|
||||
|
||||
/** The narrower of two audiences: the one fewer people satisfy. */
|
||||
function narrower(a, b) {
|
||||
const ra = visibility.AUDIENCES.indexOf(visibility.normalise(a))
|
||||
const rb = visibility.AUDIENCES.indexOf(visibility.normalise(b))
|
||||
return visibility.AUDIENCES[Math.max(ra, rb)]
|
||||
}
|
||||
|
||||
/** The fleet defaults, as stored, with the built-in defaults where nothing is. */
|
||||
async function fleet() {
|
||||
const stored = await Promise.all([...LAYERS.map((l) => visibilityDb.getSetting(layerKey(l))), visibilityDb.getSetting(MATES_KEY)])
|
||||
const out = {}
|
||||
LAYERS.forEach((layer, i) => {
|
||||
out[layer] = stored[i] == null ? DEFAULTS[layer] : visibility.normalise(stored[i])
|
||||
})
|
||||
const mates = stored[LAYERS.length]
|
||||
out.mates = mates == null ? DEFAULT_MATES : matesOn(mates)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Overrides rows as `{ world: 'staff', mates: false, … }`, only for what is set. */
|
||||
function overridesFrom(rows) {
|
||||
const out = {}
|
||||
for (const { setting, value } of rows) {
|
||||
if (setting === MATES_KEY) out.mates = matesOn(value)
|
||||
else {
|
||||
const layer = LAYERS.find((l) => layerKey(l) === setting)
|
||||
if (layer) out[layer] = visibility.normalise(value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** What applies to one server: its overrides over the fleet. */
|
||||
async function forServer(serverId) {
|
||||
const [base, rows] = await Promise.all([fleet(), db.getOverrides(serverId)])
|
||||
return { ...base, ...overridesFrom(rows) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the public routes need to decide what one viewer gets on one
|
||||
* server's map. Throws nothing: a setting that cannot be read hides every layer
|
||||
* but the picture, which is the direction a map must fail in.
|
||||
*/
|
||||
async function access(req, serverId) {
|
||||
try {
|
||||
const [viewer, settings, presence] = await Promise.all([
|
||||
visibility.viewer(req),
|
||||
forServer(serverId),
|
||||
visibility.presenceFor(serverId),
|
||||
])
|
||||
|
||||
const layers = {}
|
||||
for (const layer of LAYERS) {
|
||||
const audience = layer === 'players' ? narrower(settings.players, presence) : settings[layer]
|
||||
layers[layer] = { visible: visibility.meets(viewer.level, audience), audience }
|
||||
}
|
||||
// Why the players layer is narrower than its own switch, when it is (D113):
|
||||
// the page says "limited by who may see who is online" rather than nothing.
|
||||
if (layers.players.audience !== visibility.normalise(settings.players)) layers.players.cappedByPresence = true
|
||||
|
||||
let steamIds = []
|
||||
if (settings.mates && viewer.userId != null) steamIds = await db.steamIdsForUser(viewer.userId)
|
||||
|
||||
const mates = {
|
||||
on: settings.mates,
|
||||
linked: steamIds.length > 0,
|
||||
visible: settings.mates && steamIds.length > 0,
|
||||
}
|
||||
|
||||
return { level: viewer.level, userId: viewer.userId, layers, mates, steamIds }
|
||||
} catch (err) {
|
||||
log.warn('could not resolve map visibility; showing the picture only', { server: serverId, error: err.message })
|
||||
const layers = {}
|
||||
for (const layer of LAYERS) layers[layer] = { visible: false, audience: 'staff' }
|
||||
return { level: 'public', userId: null, layers, mates: { on: false, linked: false, visible: false }, steamIds: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The positions a viewer who is entitled to own-and-mates may see: their own
|
||||
* accounts (online or asleep) and their ONLINE clan mates on this server. Empty
|
||||
* for anybody else. Asked only when there is a live answer to filter.
|
||||
*/
|
||||
async function mateIdsFor(serverId, acc) {
|
||||
if (!acc.mates.visible || !acc.steamIds.length) return { own: new Set(), mates: new Set() }
|
||||
const own = new Set(acc.steamIds)
|
||||
const clan = await db.clanMatesOn(serverId, acc.steamIds)
|
||||
return { own, mates: new Set(clan.filter((id) => !own.has(id))) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One live answer, cut down to what one viewer may see. **Pure**, and the
|
||||
* security boundary in one function: a layer the viewer may not see is not in
|
||||
* the result at all — not an empty array, not a flag — so there is nothing on
|
||||
* the wire for a page to forget to hide.
|
||||
*
|
||||
* `mates` is the viewer's own dots and their online clan mates' (D115). It is
|
||||
* the ONLY place a player position can appear below the players layer, and it
|
||||
* never carries anybody outside the viewer's clan.
|
||||
*/
|
||||
function project(live, acc, ids = { own: new Set(), mates: new Set() }) {
|
||||
const out = { mapKey: live.mapKey || null, t: live.t || null }
|
||||
|
||||
if (acc.layers.world.visible) out.world = Array.isArray(live.world) ? live.world : []
|
||||
if (acc.layers.events.visible) out.events = Array.isArray(live.events) ? live.events : []
|
||||
if (acc.layers.players.visible) {
|
||||
out.players = Array.isArray(live.players) ? live.players : []
|
||||
if (live.playersTruncated) out.playersTruncated = true
|
||||
}
|
||||
if (acc.layers.bases.visible) {
|
||||
out.bases = Array.isArray(live.bases) ? live.bases : []
|
||||
if (live.basesTruncated) out.basesTruncated = true
|
||||
}
|
||||
|
||||
if (acc.mates.visible) {
|
||||
const players = Array.isArray(live.players) ? live.players : []
|
||||
out.mates = players
|
||||
.filter((p) => ids.own.has(String(p.steamId)) || (ids.mates.has(String(p.steamId)) && p.online === true))
|
||||
.map((p) => ({
|
||||
steamId: String(p.steamId),
|
||||
name: p.name,
|
||||
x: p.x,
|
||||
z: p.z,
|
||||
sleeping: Boolean(p.sleeping),
|
||||
online: p.online === true,
|
||||
self: ids.own.has(String(p.steamId)),
|
||||
}))
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored row as the geometry the page draws with. The picture's own size is
|
||||
* the truth when there is one; without one the Rust+ cache's geometry stands in,
|
||||
* so a server that has no picture yet draws its layers in the same frame a
|
||||
* picture would later fill.
|
||||
*/
|
||||
function geometryOf(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
worldSize: Number(row.worldSize),
|
||||
oceanMargin: Number(row.oceanMargin),
|
||||
width: Number(row.width),
|
||||
height: Number(row.height),
|
||||
gridCells: Number(row.gridCells),
|
||||
gridCellSize: Number(row.gridCellSize),
|
||||
background: row.background || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `map.info` as the row it becomes (without bytes). **Pure** — the one place
|
||||
* `DERIVATION_VERSION` is applied.
|
||||
*/
|
||||
function derive(serverId, info) {
|
||||
const worldSize = Number(info.worldSize) || 0
|
||||
const oceanMargin = Number(info.oceanMargin) || 0
|
||||
const hasPicture = info.source !== 'none' && Number(info.width) > 0 && Number(info.height) > 0
|
||||
const side = Math.round(worldSize * CACHE_SCALE + 2 * oceanMargin)
|
||||
|
||||
return {
|
||||
serverId,
|
||||
mapKey: String(info.mapKey || ''),
|
||||
sha256: hasPicture && info.sha256 ? String(info.sha256).toLowerCase() : null,
|
||||
source: hasPicture ? String(info.source) : 'none',
|
||||
width: hasPicture ? Number(info.width) : side,
|
||||
height: hasPicture ? Number(info.height) : side,
|
||||
oceanMargin,
|
||||
worldSize,
|
||||
gridCells: Number(info.gridCells) || 1,
|
||||
gridCellSize: Number(info.gridCellSize) || worldSize,
|
||||
background: typeof info.background === 'string' && /^#[0-9a-f]{6}$/i.test(info.background) ? info.background : null,
|
||||
derivation: DERIVATION_VERSION,
|
||||
monuments: Array.isArray(info.monuments)
|
||||
? info.monuments
|
||||
.filter((m) => m && Number.isFinite(Number(m.x)) && Number.isFinite(Number(m.z)))
|
||||
.map((m) => ({
|
||||
value: String(m.value || ''),
|
||||
kind: String(m.kind || ''),
|
||||
label: String(m.label || m.kind || ''),
|
||||
grid: m.grid ? String(m.grid) : null,
|
||||
x: Number(m.x),
|
||||
z: Number(m.z),
|
||||
}))
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the stored monuments, never throwing: a row that will not parse draws none. */
|
||||
function monumentsOf(row) {
|
||||
if (!row || !row.monuments) return []
|
||||
try {
|
||||
const parsed = typeof row.monuments === 'string' ? JSON.parse(row.monuments) : row.monuments
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch (err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ── Admin ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The Map card's switches: the fleet, and each server's overrides and effective values. */
|
||||
async function describeSwitches(servers) {
|
||||
const [base, rows] = await Promise.all([fleet(), db.listOverrides()])
|
||||
const byServer = new Map()
|
||||
for (const row of rows) {
|
||||
if (!byServer.has(row.serverId)) byServer.set(row.serverId, [])
|
||||
byServer.get(row.serverId).push(row)
|
||||
}
|
||||
|
||||
return {
|
||||
layers: [...LAYERS],
|
||||
defaults: { ...DEFAULTS, mates: DEFAULT_MATES },
|
||||
fleet: base,
|
||||
servers: servers.map((s) => {
|
||||
const overrides = overridesFrom(byServer.get(s.id) || [])
|
||||
const full = {}
|
||||
for (const key of [...LAYERS, 'mates']) full[key] = key in overrides ? overrides[key] : null
|
||||
return { id: s.id, overrides: full, effective: { ...base, ...overrides } }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Map card's write, validated whole before anything is written, like the
|
||||
* rest of the visibility page.
|
||||
*
|
||||
* { fleet: { world: 'public', …, mates: true },
|
||||
* servers: { <id>: { players: 'signed_in', mates: null, … } } }
|
||||
*
|
||||
* `null` clears an override. Resolves `{ ok, changed }` or `{ ok: false,
|
||||
* status, message }`. `dryRun` validates and writes nothing, so a caller saving
|
||||
* several things in one request can refuse the whole request up front.
|
||||
*/
|
||||
async function update({ fleet: fleetIn, servers } = {}, actor = null, serverExists = async () => true, { dryRun = false } = {}) {
|
||||
const fleetChanges = []
|
||||
const serverChanges = []
|
||||
|
||||
const check = (key, value, where, allowNull) => {
|
||||
if (value === null && allowNull) return null
|
||||
if (key === 'mates') {
|
||||
if (typeof value !== 'boolean') return `The own-and-mates view is on or off${where}, not "${value}".`
|
||||
return null
|
||||
}
|
||||
if (!LAYERS.includes(key)) return `"${key}" is not a map layer. The layers are: ${LAYERS.join(', ')}, and mates.`
|
||||
if (!visibility.isAudience(value)) {
|
||||
return `"${value}" is not an audience for the ${key} layer${where}. Choose one of: ${visibility.AUDIENCES.join(', ')}.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(fleetIn || {})) {
|
||||
const problem = check(key, value, '', false)
|
||||
if (problem) return { ok: false, status: 400, message: problem }
|
||||
fleetChanges.push([key, value])
|
||||
}
|
||||
|
||||
for (const [id, settings] of Object.entries(servers || {})) {
|
||||
if (!settings || typeof settings !== 'object') {
|
||||
return { ok: false, status: 400, message: `Server ${id}'s map switches must be an object.` }
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await serverExists(id))) return { ok: false, status: 404, message: `There is no server called ${id}.` }
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
const problem = check(key, value, ` on server ${id}`, true)
|
||||
if (problem) return { ok: false, status: 400, message: problem }
|
||||
serverChanges.push([id, key, value])
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) return { ok: true, changed: {} }
|
||||
|
||||
const userId = actor && actor.id != null ? actor.id : null
|
||||
const settingOf = (key) => (key === 'mates' ? MATES_KEY : layerKey(key))
|
||||
const wordOf = (key, value) => (key === 'mates' ? (value ? 'on' : 'off') : value)
|
||||
|
||||
for (const [key, value] of fleetChanges) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await visibilityDb.setSetting(settingOf(key), wordOf(key, value), userId)
|
||||
}
|
||||
for (const [id, key, value] of serverChanges) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setOverride(id, settingOf(key), value === null ? null : wordOf(key, value), userId)
|
||||
}
|
||||
|
||||
const changed = {}
|
||||
if (fleetChanges.length) changed.fleet = Object.fromEntries(fleetChanges)
|
||||
if (serverChanges.length) {
|
||||
changed.servers = {}
|
||||
for (const [id, key, value] of serverChanges) {
|
||||
changed.servers[id] = { ...(changed.servers[id] || {}), [key]: value === null ? 'inherit' : value }
|
||||
}
|
||||
}
|
||||
return { ok: true, changed }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LAYERS,
|
||||
DEFAULTS,
|
||||
DEFAULT_MATES,
|
||||
MATES_KEY,
|
||||
SETTINGS,
|
||||
DERIVATION_VERSION,
|
||||
POLL_MS,
|
||||
LIVE_CACHE_MS,
|
||||
RENDER_MEASURED,
|
||||
layerKey,
|
||||
renderStallSeconds,
|
||||
narrower,
|
||||
fleet,
|
||||
forServer,
|
||||
access,
|
||||
mateIdsFor,
|
||||
project,
|
||||
geometryOf,
|
||||
derive,
|
||||
monumentsOf,
|
||||
describeSwitches,
|
||||
update,
|
||||
}
|
||||
@@ -78,6 +78,18 @@ async function listForPolling() {
|
||||
return rows.map(withToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* One ENABLED server with its token, for a call to its sidecar made on a page's
|
||||
* behalf (the map's live layers), or `null`. The same rule as `getPublic`: a
|
||||
* disabled server is not there.
|
||||
*/
|
||||
async function getForCalling(id) {
|
||||
if (!id) return null
|
||||
const row = await db.getServer(id)
|
||||
if (!row || !row.enabled) return null
|
||||
return withToken(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* The public view: every enabled server and what it last said.
|
||||
*
|
||||
@@ -186,6 +198,7 @@ module.exports = {
|
||||
STALE_AFTER_MS,
|
||||
withToken,
|
||||
listForPolling,
|
||||
getForCalling,
|
||||
lastEnabledCount,
|
||||
listPublic,
|
||||
getPublic,
|
||||
|
||||
@@ -93,19 +93,30 @@ const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
|
||||
* question about somebody's standing must grant nothing.
|
||||
*/
|
||||
async function viewerLevel(req) {
|
||||
return (await viewer(req)).level
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewer's rung AND who they are, from the same re-read row — `{ level,
|
||||
* userId }`, with `userId` null for anybody who resolves to `public`. The map's
|
||||
* own-and-mates view (D115) needs the id, and it must come from the row that
|
||||
* decided the rung: a banned account is nobody, whatever its token names.
|
||||
*/
|
||||
async function viewer(req) {
|
||||
const nobody = { level: 'public', userId: null }
|
||||
try {
|
||||
const claimed = req.user || core.auth.getUserFromRequest(req)
|
||||
if (!claimed || claimed.id == null) return 'public'
|
||||
if (!claimed || claimed.id == null) return nobody
|
||||
|
||||
const user = await core.users.getById(claimed.id)
|
||||
if (!user) return 'public'
|
||||
if (user.status && user.status !== 'active') return 'public'
|
||||
if (!user) return nobody
|
||||
if (user.status && user.status !== 'active') return nobody
|
||||
|
||||
if (user.role === 'admin' || user.role === 'moderator') return 'staff'
|
||||
return 'signed_in'
|
||||
const level = user.role === 'admin' || user.role === 'moderator' ? 'staff' : 'signed_in'
|
||||
return { level, userId: user.id != null ? user.id : claimed.id }
|
||||
} catch (err) {
|
||||
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
|
||||
return 'public'
|
||||
return nobody
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +289,7 @@ module.exports = {
|
||||
meets,
|
||||
normalise,
|
||||
viewerLevel,
|
||||
viewer,
|
||||
fleetPresence,
|
||||
presenceFor,
|
||||
canSeePresence,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('../../model/servers/servers.db')
|
||||
const mapImages = require('../../mapImages')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
const sidecar = require('../../sidecarClient')
|
||||
|
||||
@@ -129,4 +130,57 @@ async function testServer(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, putServer, deleteServer, testServer }
|
||||
/**
|
||||
* Fetch one server's map picture again (D110's admin button). Runs the same
|
||||
* fetch the board poll triggers, forced — the stored hash is not trusted to be
|
||||
* current — and answers what it did, because an operator pressing this is
|
||||
* asking a question: is the picture this server has the right one?
|
||||
*/
|
||||
async function fetchMap(req, res) {
|
||||
const { id } = req.params
|
||||
|
||||
try {
|
||||
const server = await servers.getForCalling(id)
|
||||
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
|
||||
|
||||
const result = await mapImages.run(server, null, { force: true })
|
||||
await core.activity.log({ req, action: 'rust.map.fetch', detail: { server: id, outcome: result.outcome } })
|
||||
|
||||
if (result.outcome === 'busy') return res.status(409).json({ message: 'A fetch is already running for this server.' })
|
||||
return res.status(result.ok ? 200 : 502).json({ ok: result.ok, outcome: result.outcome, message: result.detail || null })
|
||||
} catch (err) {
|
||||
log.error('failed to fetch a map', { server: id, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to fetch the map' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a server with no picture to draw one (D109). **This stalls that game**
|
||||
* for seconds — the card says how many before the button is pressed — and it is
|
||||
* refused by the plugin when a picture already exists. Answered as soon as the
|
||||
* game accepts; the picture is fetched when the render finishes.
|
||||
*/
|
||||
async function renderMap(req, res) {
|
||||
const { id } = req.params
|
||||
|
||||
try {
|
||||
const server = await servers.getForCalling(id)
|
||||
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
|
||||
|
||||
const actor = req.user && (req.user.username || req.user.id)
|
||||
const result = await mapImages.render(server, actor != null ? String(actor) : null)
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.map.render',
|
||||
detail: { server: id, accepted: result.ok, ...(result.ok ? {} : { reason: result.reason || null }) },
|
||||
})
|
||||
|
||||
if (!result.ok) return res.status(result.status || 502).json({ message: result.message })
|
||||
return res.status(202).json({ accepted: true, stallSeconds: result.stallSeconds })
|
||||
} catch (err) {
|
||||
log.error('failed to ask for a render', { server: id, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to ask the server to draw its map' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { listServers, putServer, deleteServer, testServer, fetchMap, renderMap }
|
||||
|
||||
@@ -101,4 +101,37 @@ adminRustRouter.post(
|
||||
admin.testServer,
|
||||
)
|
||||
|
||||
// ── The map's picture (phase 14) ──────────────────────────────────────────
|
||||
|
||||
adminRustRouter.post(
|
||||
'/servers/:id/map/fetch',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Fetch a server’s map picture again'
|
||||
// #swagger.description = 'Asks the game what its map is and fetches the picture again, whatever this site already holds. The site does this by itself the first time it sees a new map when the game has a picture to give; this is the button for when that did not happen. `outcome` is `fetched`, `current` (the stored picture is this map’s), `none` (the game has no picture, and Render now is the way to get one) or `failed`, with a sentence in `message`. One fetch per server at a time: a second answers 409.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'What the fetch did' } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||||
/* #swagger.responses[409] = { description: 'A fetch is already running for this server' } */
|
||||
/* #swagger.responses[502] = { description: 'The game or its sidecar did not give a picture; the stored one is kept' } */
|
||||
requireRole('admin'),
|
||||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||||
validate,
|
||||
admin.fetchMap,
|
||||
)
|
||||
|
||||
adminRustRouter.post(
|
||||
'/servers/:id/map/render',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Ask a server to draw its own map'
|
||||
// #swagger.description = '**This stalls the game server** while it draws — about 8.5 seconds on a 3000 map, longer on a larger one — and nothing on it moves for that time. It exists for a server without Rust+ (`app.port`), whose game keeps no picture of its map, and is refused when a picture already exists. Answers 202 as soon as the game accepts; the picture is fetched when the render finishes, and kept by the game so a restart on the same map does not need another.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[202] = { description: 'The game accepted and will draw on its next frame' } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||||
/* #swagger.responses[409] = { description: 'A picture already exists, or a render is already running' } */
|
||||
requireRole('admin'),
|
||||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||||
validate,
|
||||
admin.renderMap,
|
||||
)
|
||||
|
||||
module.exports = adminRustRouter
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const clans = require('../../model/clans/clans.model')
|
||||
const map = require('../../model/map/map.model')
|
||||
const mapDb = require('../../model/map/map.db')
|
||||
const mapImages = require('../../mapImages')
|
||||
const visibility = require('../../model/visibility/visibility.model')
|
||||
const visibilityDb = require('../../model/visibility/visibility.db')
|
||||
|
||||
const log = core.logger('visibility')
|
||||
|
||||
@@ -14,8 +18,49 @@ const log = core.logger('visibility')
|
||||
* 100-clan ceiling" (D55) come from.
|
||||
*/
|
||||
async function describe() {
|
||||
const [settings, boards] = await Promise.all([visibility.describe(), clans.boardsForAdmin()])
|
||||
return { ...settings, clans: { ...settings.clans, servers: boards } }
|
||||
const [settings, boards, mapCard] = await Promise.all([visibility.describe(), clans.boardsForAdmin(), describeMap()])
|
||||
return { ...settings, clans: { ...settings.clans, servers: boards }, map: mapCard }
|
||||
}
|
||||
|
||||
/**
|
||||
* The Map card (phase 14, D114): the four layers' fleet defaults and each
|
||||
* server's overrides, and beside them what each server's picture is — where it
|
||||
* came from, which map it is of, when it was fetched — and what a render would
|
||||
* cost that server (D109). On this page for D106's reason: it is the one page
|
||||
* with a row per server, and a layer switch answers the same question the
|
||||
* presence switch does.
|
||||
*/
|
||||
async function describeMap() {
|
||||
const [servers, rows] = await Promise.all([visibilityDb.listServerPresence(), mapDb.listMeta()])
|
||||
const switches = await map.describeSwitches(servers)
|
||||
const byId = new Map(rows.map((r) => [r.serverId, r]))
|
||||
|
||||
return {
|
||||
...switches,
|
||||
servers: switches.servers.map((sw, i) => {
|
||||
const s = servers[i]
|
||||
const row = byId.get(s.id)
|
||||
return {
|
||||
...sw,
|
||||
name: s.name,
|
||||
enabled: Boolean(s.enabled),
|
||||
picture: row
|
||||
? {
|
||||
source: row.source,
|
||||
mapKey: row.mapKey,
|
||||
hasPicture: Boolean(row.sha256 && Number(row.byteCount) > 0),
|
||||
bytes: Number(row.byteCount) || 0,
|
||||
width: Number(row.width),
|
||||
height: Number(row.height),
|
||||
worldSize: Number(row.worldSize),
|
||||
fetchedAt: row.fetchedAt ? new Date(row.fetchedAt).toISOString() : null,
|
||||
}
|
||||
: null,
|
||||
renderStallSeconds: map.renderStallSeconds(row ? row.worldSize : null),
|
||||
...mapImages.statusOf(s.id),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function read(req, res) {
|
||||
@@ -29,13 +74,31 @@ async function read(req, res) {
|
||||
|
||||
async function update(req, res) {
|
||||
try {
|
||||
const { fleet, servers, clanRoster, news } = req.body || {}
|
||||
const { fleet, servers, clanRoster, news, map: mapSwitches } = req.body || {}
|
||||
|
||||
// The map's switches are validated whole FIRST, before the rest is written:
|
||||
// the page saves everything with one PUT, and a refused map switch must not
|
||||
// leave the presence half already applied.
|
||||
let mapResult = null
|
||||
if (mapSwitches !== undefined) {
|
||||
mapResult = await map.update(mapSwitches, req.user, async (id) => (await visibilityDb.getServerPresence(id)) !== undefined, { dryRun: true })
|
||||
if (!mapResult.ok) {
|
||||
res.status(mapResult.status || 400).json({ message: mapResult.message })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const result = await visibility.update({ fleet, servers, clanRoster, news }, req.user)
|
||||
if (!result.ok) {
|
||||
res.status(result.status || 400).json({ message: result.message })
|
||||
return
|
||||
}
|
||||
|
||||
if (mapSwitches !== undefined) {
|
||||
mapResult = await map.update(mapSwitches, req.user, async (id) => (await visibilityDb.getServerPresence(id)) !== undefined)
|
||||
if (mapResult.ok && Object.keys(mapResult.changed).length) result.changed.map = mapResult.changed
|
||||
}
|
||||
|
||||
// One row per save, naming everything it changed. Widening who may see the
|
||||
// roll call is exactly the kind of change somebody later needs to trace to a
|
||||
// person and a time.
|
||||
|
||||
@@ -27,7 +27,7 @@ visibilityRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Who may see who is online, and who may see a clan roster'
|
||||
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams.'
|
||||
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
|
||||
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
|
||||
requireRole('admin'),
|
||||
visibility.read,
|
||||
@@ -37,7 +37,7 @@ visibilityRouter.put(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
|
||||
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
|
||||
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
|
||||
@@ -47,6 +47,7 @@ visibilityRouter.put(
|
||||
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
|
||||
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
|
||||
body('news').optional().isObject().withMessage('news maps a server id to true or false'),
|
||||
body('map').optional().isObject().withMessage('map carries fleet and servers, each an object of layer switches'),
|
||||
validate,
|
||||
visibility.update,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,7 +65,9 @@ const TIMEOUT_MS = 12000
|
||||
* `/world/revert` — what an event places in the world and gives back (PLAN.md
|
||||
* §28); **10** adds the rewards — `/tally/open`, `/tally/snapshot`,
|
||||
* `/tally/close`, `/kits` and `/chat`, and a `credits` field on the permission
|
||||
* sync (PLAN.md §29). The bump lands here in the same change as the emitters,
|
||||
* sync (PLAN.md §29); **11** adds the map — `/map`, `/map/chunk`,
|
||||
* `/map/render` and `/map/live`, its picture and what moves on it (PLAN.md
|
||||
* §30). The bump lands here in the same change as the emitters,
|
||||
* because the sidecar refuses a client declaring a different version with a
|
||||
* `409`: a module left on 2 would stop being able to read the server board it
|
||||
* has been reading all along. A constant that lags the deployment is not a safe
|
||||
@@ -75,7 +77,7 @@ const TIMEOUT_MS = 12000
|
||||
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||
* layers further in.
|
||||
*/
|
||||
const PROTOCOL_VERSION = 10
|
||||
const PROTOCOL_VERSION = 11
|
||||
|
||||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||
function reply(ok, status, data = null) {
|
||||
@@ -386,6 +388,36 @@ const kits = (server) => request(server, '/kits')
|
||||
*/
|
||||
const chat = (server, body) => request(server, '/chat', { method: 'POST', body })
|
||||
|
||||
/**
|
||||
* What one server's map is and where its picture comes from (protocol 11,
|
||||
* stage one): `mapKey`, `source` (`companion`, `rendered` or `none`), the
|
||||
* picture's size, `sha256` and slice count, the grid, and the monuments.
|
||||
* `hashing: true` in place of `sha256` means ask again in a moment. A refusal
|
||||
* is `data.kind` `map.error`, like every other write-shaped answer here.
|
||||
*/
|
||||
const mapInfo = (server) => request(server, '/map')
|
||||
|
||||
/**
|
||||
* One slice of the picture, base64 in `data.data` (stage two). `map.error`
|
||||
* `stale` when the map or picture moved since `mapInfo` — the caller starts
|
||||
* again rather than splicing two maps.
|
||||
*/
|
||||
const mapChunk = (server, { mapKey, sha256, n }) =>
|
||||
request(
|
||||
server,
|
||||
`/map/chunk?mapKey=${encodeURIComponent(mapKey)}&sha256=${encodeURIComponent(sha256)}&n=${encodeURIComponent(n)}`,
|
||||
)
|
||||
|
||||
/**
|
||||
* Ask the game to draw its own map (D109). Answered `accepted` at once; the
|
||||
* render stalls the game on a LATER frame, and `mapInfo` says `rendered` when
|
||||
* it is done.
|
||||
*/
|
||||
const mapRender = (server, body) => request(server, '/map/render', { method: 'POST', body })
|
||||
|
||||
/** Everything that moves on one server's map, every layer, unfiltered. The caller filters (§8.5). */
|
||||
const mapLive = (server) => request(server, '/map/live')
|
||||
|
||||
module.exports = {
|
||||
TIMEOUT_MS,
|
||||
LEASE_TIMEOUT_MS,
|
||||
@@ -417,5 +449,9 @@ module.exports = {
|
||||
tallyClose,
|
||||
kits,
|
||||
chat,
|
||||
mapInfo,
|
||||
mapChunk,
|
||||
mapRender,
|
||||
mapLive,
|
||||
joinUrl,
|
||||
}
|
||||
|
||||
@@ -560,6 +560,51 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
map: {
|
||||
type: 'object',
|
||||
description: 'The live map’s switches and each server’s picture (phase 14).',
|
||||
properties: {
|
||||
layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases'] },
|
||||
fleet: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
world: { $ref: '#/components/schemas/RustAudience' },
|
||||
events: { $ref: '#/components/schemas/RustAudience' },
|
||||
players: { $ref: '#/components/schemas/RustAudience' },
|
||||
bases: { $ref: '#/components/schemas/RustAudience' },
|
||||
mates: { type: 'boolean', example: true },
|
||||
},
|
||||
},
|
||||
servers: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', example: 'main' },
|
||||
name: { type: 'string', example: 'Main · Vanilla' },
|
||||
overrides: { type: 'object', description: 'Each layer and `mates`, or null to follow the fleet.' },
|
||||
effective: { type: 'object' },
|
||||
picture: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
source: { type: 'string', enum: ['companion', 'rendered', 'none'] },
|
||||
mapKey: { type: 'string' },
|
||||
hasPicture: { type: 'boolean' },
|
||||
bytes: { type: 'integer' },
|
||||
worldSize: { type: 'integer' },
|
||||
fetchedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
renderStallSeconds: { type: 'integer', description: 'About how long Render now would stall this server.', example: 9 },
|
||||
fetching: { type: 'boolean' },
|
||||
rendering: { type: 'boolean' },
|
||||
lastError: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
news: {
|
||||
type: 'object',
|
||||
description: 'Whether a published news post is also said in each server’s in-game chat. Off by default (D104).',
|
||||
@@ -580,6 +625,167 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
RustMapLayer: {
|
||||
type: 'object',
|
||||
description: 'Whether this viewer gets one map layer, and which audience does. A hidden layer never says what it holds.',
|
||||
properties: {
|
||||
visible: { type: 'boolean', example: false },
|
||||
audience: { $ref: '#/components/schemas/RustAudience' },
|
||||
cappedByPresence: {
|
||||
type: 'boolean',
|
||||
description: 'Players layer only: narrower than its own switch because who may see who is online is narrower (D113).',
|
||||
example: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
RustMap: {
|
||||
type: 'object',
|
||||
description: 'One server’s map, as this viewer may see it (GET /public/rust/servers/{id}/map).',
|
||||
properties: {
|
||||
serverId: { type: 'string', example: 'main' },
|
||||
mapKey: { type: 'string', nullable: true, example: '3000.1234.1' },
|
||||
picture: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Null when the game has no picture of its map.',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Relative to /api/v1, with the picture’s hash in it.', example: '/public/rust/servers/main/map/image?v=28da6e8a' },
|
||||
source: { type: 'string', enum: ['companion', 'rendered'], example: 'companion' },
|
||||
fetchedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
geometry: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'How world coordinates reach a pixel: s = (width − 2 × oceanMargin) / worldSize, px = (x + worldSize/2) × s + oceanMargin, and z the same way up from the bottom edge.',
|
||||
properties: {
|
||||
worldSize: { type: 'integer', example: 3000 },
|
||||
oceanMargin: { type: 'integer', description: 'In pixels, unscaled.', example: 500 },
|
||||
width: { type: 'integer', example: 2500 },
|
||||
height: { type: 'integer', example: 2500 },
|
||||
gridCells: { type: 'integer', description: 'Cells per side, from the game’s own grid.', example: 20 },
|
||||
gridCellSize: { type: 'number', description: 'Metres.', example: 150 },
|
||||
background: { type: 'string', nullable: true, example: '#0B3B4A' },
|
||||
},
|
||||
},
|
||||
monuments: {
|
||||
type: 'array',
|
||||
description: 'Present only when the viewer may see the world layer.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: { type: 'string', example: 'harbor_1#2' },
|
||||
kind: { type: 'string', example: 'harbor_1' },
|
||||
label: { type: 'string', example: 'Harbor' },
|
||||
grid: { type: 'string', nullable: true, example: 'O3' },
|
||||
x: { type: 'number', example: 678.1 },
|
||||
z: { type: 'number', example: 1005.7 },
|
||||
},
|
||||
},
|
||||
},
|
||||
layers: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
world: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
events: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
players: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
bases: { $ref: '#/components/schemas/RustMapLayer' },
|
||||
},
|
||||
},
|
||||
mates: {
|
||||
type: 'object',
|
||||
description: 'Whether this viewer gets their own position and their online clan mates’ (D115).',
|
||||
properties: {
|
||||
visible: { type: 'boolean', example: true },
|
||||
on: { type: 'boolean', description: 'Is the switch on for this server?', example: true },
|
||||
linked: { type: 'boolean', description: 'Has this viewer linked a Steam account?', example: true },
|
||||
signedIn: { type: 'boolean', description: 'Is this viewer signed in at all?', example: true },
|
||||
},
|
||||
},
|
||||
pollMs: { type: 'integer', example: 10000 },
|
||||
},
|
||||
},
|
||||
RustMapLive: {
|
||||
type: 'object',
|
||||
description: 'What moves on one server’s map, cut down to this viewer (GET /public/rust/servers/{id}/map/live). A layer the viewer may not see is ABSENT, not empty.',
|
||||
properties: {
|
||||
live: { type: 'boolean', description: 'False when the game did not answer; `reason` says why.', example: true },
|
||||
reason: { type: 'string', nullable: true },
|
||||
mapKey: { type: 'string', nullable: true, example: '3000.1234.1' },
|
||||
world: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['cargo', 'heli', 'chinook', 'bradley', 'supply', 'crate'], example: 'cargo' },
|
||||
x: { type: 'number', example: 812.4 },
|
||||
z: { type: 'number', example: -1320.6 },
|
||||
hackLeftSec: { type: 'integer', description: 'A locked crate being hacked: seconds left.', example: 540 },
|
||||
hacked: { type: 'boolean', example: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
events: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['zone', 'crate', 'npc'], example: 'zone' },
|
||||
runId: { type: 'string', example: '41' },
|
||||
x: { type: 'number' },
|
||||
z: { type: 'number' },
|
||||
radius: { type: 'number', description: 'A zone’s.', example: 60 },
|
||||
name: { type: 'string', description: 'A zone’s.', example: 'Harbor brawl' },
|
||||
prefab: { type: 'string', description: 'A crate’s or NPC’s allowlist key.', example: 'crate.elite' },
|
||||
},
|
||||
},
|
||||
},
|
||||
players: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
steamId: { type: 'string', example: '76561198000000000' },
|
||||
name: { type: 'string', example: 'Wanderer' },
|
||||
x: { type: 'number' },
|
||||
z: { type: 'number' },
|
||||
sleeping: { type: 'boolean', example: false },
|
||||
online: { type: 'boolean', example: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
playersTruncated: { type: 'boolean', description: 'More sleepers than the plugin’s MapMaxSleepers.' },
|
||||
bases: {
|
||||
type: 'array',
|
||||
description: 'Positions only: no owner, no authorised list, no shop name.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: ['tc', 'vending'], example: 'tc' },
|
||||
x: { type: 'number' },
|
||||
z: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
basesTruncated: { type: 'boolean', description: 'More than the plugin’s MapMaxBases.' },
|
||||
mates: {
|
||||
type: 'array',
|
||||
description: 'The viewer’s own positions (their sleeper too) and their online first-party clan mates on this server.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
steamId: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
x: { type: 'number' },
|
||||
z: { type: 'number' },
|
||||
sleeping: { type: 'boolean' },
|
||||
online: { type: 'boolean' },
|
||||
self: { type: 'boolean', description: 'One of the viewer’s own accounts.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
RustClanAudience: {
|
||||
type: 'string',
|
||||
enum: ['members', 'signed_in', 'public'],
|
||||
@@ -676,6 +882,11 @@ module.exports = {
|
||||
additionalProperties: { type: 'boolean' },
|
||||
example: { main: true },
|
||||
},
|
||||
map: {
|
||||
type: 'object',
|
||||
description: 'The live map’s switches. `fleet` maps a layer to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet.',
|
||||
example: { fleet: { players: 'signed_in', mates: true }, servers: { pve: { players: 'public' }, pvp: { players: null } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
RustSidecarProbe: {
|
||||
|
||||
494
server/test/map.test.js
Normal file
494
server/test/map.test.js
Normal file
@@ -0,0 +1,494 @@
|
||||
// ── The live map (PLAN.md §30, protocol 11) ───────────────────────────────
|
||||
//
|
||||
// The map is a security boundary before it is a picture: player positions
|
||||
// locate people, and base positions are where they sleep. Every test here is one
|
||||
// of the ways that boundary could look right and be wrong:
|
||||
//
|
||||
// a fresh install shows the world and events, and nobody's position
|
||||
// a word nobody recognises narrows — a layer to staff, the mates switch to off
|
||||
// the players layer can never be wider than presence (D113)
|
||||
// a hidden layer is ABSENT from the answer, not an empty array
|
||||
// own dot and mates: own accounts (asleep too), ONLINE clan mates, nobody else
|
||||
// the roster's audience has nothing to do with who sees positions (D117)
|
||||
// a picture is stored only when it matches its own hash
|
||||
// a fetch that straddles a map change is not spliced
|
||||
// one fetch per server, and a plugin that predates the map is never asked
|
||||
// any number of viewers cost one ask of the game (D111)
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert')
|
||||
const crypto = require('crypto')
|
||||
|
||||
const { fakeCtx } = require('./_fakes')
|
||||
|
||||
require('../core')._reset()
|
||||
require('../core').init(fakeCtx())
|
||||
|
||||
const client = require('../sidecarClient')
|
||||
const mapDb = require('../model/map/map.db')
|
||||
const map = require('../model/map/map.model')
|
||||
const mapImages = require('../mapImages')
|
||||
const mapLive = require('../mapLive')
|
||||
const visibility = require('../model/visibility/visibility.model')
|
||||
const visibilityDb = require('../model/visibility/visibility.db')
|
||||
|
||||
/**
|
||||
* Stub every collaborator the model reads: the stored settings, the overrides,
|
||||
* the viewer, the presence audience, the viewer's links and the clan query.
|
||||
*/
|
||||
function settings(t, { stored = {}, overrides = [], viewer = { level: 'public', userId: null }, presence = 'staff', links = [], mates = [] } = {}) {
|
||||
const saved = {
|
||||
getSetting: visibilityDb.getSetting,
|
||||
getOverrides: mapDb.getOverrides,
|
||||
viewer: visibility.viewer,
|
||||
presenceFor: visibility.presenceFor,
|
||||
steamIdsForUser: mapDb.steamIdsForUser,
|
||||
clanMatesOn: mapDb.clanMatesOn,
|
||||
}
|
||||
const asked = { clans: 0 }
|
||||
visibilityDb.getSetting = async (key) => (key in stored ? stored[key] : null)
|
||||
mapDb.getOverrides = async () => overrides
|
||||
visibility.viewer = async () => viewer
|
||||
visibility.presenceFor = async () => presence
|
||||
mapDb.steamIdsForUser = async () => links
|
||||
mapDb.clanMatesOn = async () => {
|
||||
asked.clans += 1
|
||||
return mates
|
||||
}
|
||||
t.after(() => {
|
||||
visibilityDb.getSetting = saved.getSetting
|
||||
mapDb.getOverrides = saved.getOverrides
|
||||
visibility.viewer = saved.viewer
|
||||
visibility.presenceFor = saved.presenceFor
|
||||
mapDb.steamIdsForUser = saved.steamIdsForUser
|
||||
mapDb.clanMatesOn = saved.clanMatesOn
|
||||
})
|
||||
return asked
|
||||
}
|
||||
|
||||
const LIVE = {
|
||||
mapKey: '3000.1234.1',
|
||||
world: [{ kind: 'cargo', x: 10, z: 20 }],
|
||||
events: [{ kind: 'zone', runId: '4', x: 0, z: 0, radius: 50 }],
|
||||
players: [
|
||||
{ steamId: '7001', name: 'Me', x: 1, z: 1, sleeping: false, online: true },
|
||||
{ steamId: '7002', name: 'Mate', x: 2, z: 2, sleeping: false, online: true },
|
||||
{ steamId: '7003', name: 'Asleep mate', x: 3, z: 3, sleeping: true, online: false },
|
||||
{ steamId: '7004', name: 'Stranger', x: 4, z: 4, sleeping: false, online: true },
|
||||
{ steamId: '7005', name: 'My alt, asleep', x: 5, z: 5, sleeping: true, online: false },
|
||||
],
|
||||
bases: [{ kind: 'tc', x: 9, z: 9 }],
|
||||
}
|
||||
|
||||
test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => {
|
||||
settings(t)
|
||||
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', mates: true })
|
||||
|
||||
const acc = await map.access({}, 'main')
|
||||
assert.equal(acc.layers.world.visible, true)
|
||||
assert.equal(acc.layers.events.visible, true)
|
||||
assert.equal(acc.layers.players.visible, false)
|
||||
assert.equal(acc.layers.bases.visible, false)
|
||||
assert.equal(acc.mates.visible, false, 'an anonymous viewer has no accounts to be shown')
|
||||
})
|
||||
|
||||
test('a stored word nobody recognises narrows: a layer to staff, the mates switch to off', async (t) => {
|
||||
settings(t, { stored: { 'map.layer.world.audience': 'everyone', 'map.mates': 'yes' } })
|
||||
const fleet = await map.fleet()
|
||||
assert.equal(fleet.world, 'staff')
|
||||
assert.equal(fleet.mates, false)
|
||||
})
|
||||
|
||||
test('an override wins over the fleet for its server only', async (t) => {
|
||||
settings(t, { overrides: [{ setting: 'map.layer.bases.audience', value: 'public' }, { setting: 'map.mates', value: 'off' }] })
|
||||
const eff = await map.forServer('pve')
|
||||
assert.equal(eff.bases, 'public')
|
||||
assert.equal(eff.mates, false)
|
||||
assert.equal(eff.players, 'staff', 'untouched layers inherit')
|
||||
})
|
||||
|
||||
test('D113: widening the players layer alone shows an anonymous viewer nothing more', async (t) => {
|
||||
settings(t, { stored: { 'map.layer.players.audience': 'public' }, presence: 'staff' })
|
||||
const acc = await map.access({}, 'main')
|
||||
assert.equal(acc.layers.players.visible, false)
|
||||
assert.equal(acc.layers.players.audience, 'staff')
|
||||
assert.equal(acc.layers.players.cappedByPresence, true)
|
||||
assert.equal(map.project(LIVE, acc).players, undefined)
|
||||
})
|
||||
|
||||
test('D113: widening both the layer and presence does show them', async (t) => {
|
||||
settings(t, { stored: { 'map.layer.players.audience': 'public' }, presence: 'public' })
|
||||
const acc = await map.access({}, 'main')
|
||||
assert.equal(acc.layers.players.visible, true)
|
||||
assert.equal(acc.layers.players.cappedByPresence, undefined)
|
||||
assert.equal(map.project(LIVE, acc).players.length, LIVE.players.length)
|
||||
})
|
||||
|
||||
test('a hidden layer is absent from the answer, not an empty array', async (t) => {
|
||||
settings(t)
|
||||
const out = map.project(LIVE, await map.access({}, 'main'))
|
||||
assert.ok(Array.isArray(out.world))
|
||||
assert.ok(Array.isArray(out.events))
|
||||
for (const hidden of ['players', 'bases', 'mates', 'playersTruncated', 'basesTruncated']) {
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(out, hidden), false, `${hidden} must not be on the wire`)
|
||||
}
|
||||
})
|
||||
|
||||
test('staff get every layer', async (t) => {
|
||||
settings(t, { viewer: { level: 'staff', userId: 1 } })
|
||||
const out = map.project({ ...LIVE, basesTruncated: true }, await map.access({}, 'main'))
|
||||
assert.equal(out.players.length, 5)
|
||||
assert.equal(out.bases.length, 1)
|
||||
assert.equal(out.basesTruncated, true)
|
||||
})
|
||||
|
||||
test('own dot and mates: own accounts asleep or awake, ONLINE clan mates, and nobody else', async (t) => {
|
||||
settings(t, {
|
||||
viewer: { level: 'signed_in', userId: 9 },
|
||||
links: ['7001', '7005'],
|
||||
mates: ['7001', '7002', '7003', '7005'],
|
||||
})
|
||||
const acc = await map.access({}, 'main')
|
||||
assert.equal(acc.layers.players.visible, false, 'a player is below the players layer')
|
||||
assert.equal(acc.mates.visible, true)
|
||||
|
||||
const out = map.project(LIVE, acc, await map.mateIdsFor('main', acc))
|
||||
const ids = out.mates.map((m) => m.steamId).sort()
|
||||
assert.deepStrictEqual(ids, ['7001', '7002', '7005'])
|
||||
assert.equal(out.mates.find((m) => m.steamId === '7005').self, true, 'the viewer’s own sleeper is theirs')
|
||||
assert.equal(out.mates.find((m) => m.steamId === '7002').self, false)
|
||||
assert.equal(out.players, undefined, 'the players layer itself stays absent')
|
||||
})
|
||||
|
||||
test('the mates switch off removes own dot and mates alike', async (t) => {
|
||||
const asked = settings(t, {
|
||||
stored: { 'map.mates': 'off' },
|
||||
viewer: { level: 'signed_in', userId: 9 },
|
||||
links: ['7001'],
|
||||
mates: ['7001', '7002'],
|
||||
})
|
||||
const acc = await map.access({}, 'main')
|
||||
assert.equal(acc.mates.visible, false)
|
||||
const out = map.project(LIVE, acc, await map.mateIdsFor('main', acc))
|
||||
assert.equal(out.mates, undefined)
|
||||
assert.equal(asked.clans, 0, 'nobody’s clan is even looked up')
|
||||
})
|
||||
|
||||
test('D117: a viewer with nothing linked sees no positions, whatever the roster audience is', async (t) => {
|
||||
settings(t, {
|
||||
stored: { 'clans.roster.audience': 'public' },
|
||||
viewer: { level: 'signed_in', userId: 9 },
|
||||
links: [],
|
||||
})
|
||||
const acc = await map.access({}, 'main')
|
||||
assert.equal(acc.mates.visible, false)
|
||||
assert.equal(acc.mates.linked, false)
|
||||
})
|
||||
|
||||
test('a setting that cannot be read hides every layer', async (t) => {
|
||||
settings(t)
|
||||
visibility.viewer = async () => {
|
||||
throw new Error('pool exhausted')
|
||||
}
|
||||
const acc = await map.access({}, 'main')
|
||||
for (const layer of map.LAYERS) assert.equal(acc.layers[layer].visible, false)
|
||||
})
|
||||
|
||||
test('the admin write refuses a word it does not know, and writes nothing on a dry run', async (t) => {
|
||||
settings(t)
|
||||
const writes = []
|
||||
const saved = { setSetting: visibilityDb.setSetting, setOverride: mapDb.setOverride }
|
||||
visibilityDb.setSetting = async (...a) => writes.push(['fleet', ...a])
|
||||
mapDb.setOverride = async (...a) => writes.push(['server', ...a])
|
||||
t.after(() => Object.assign(visibilityDb, { setSetting: saved.setSetting }) && Object.assign(mapDb, { setOverride: saved.setOverride }))
|
||||
|
||||
assert.equal((await map.update({ fleet: { players: 'everyone' } })).status, 400)
|
||||
assert.equal((await map.update({ fleet: { mates: 'on' } })).status, 400, 'mates is a boolean')
|
||||
assert.equal((await map.update({ servers: { nope: { world: 'staff' } } }, null, async () => false)).status, 404)
|
||||
assert.deepStrictEqual(await map.update({ fleet: { world: 'staff' } }, null, undefined, { dryRun: true }), { ok: true, changed: {} })
|
||||
assert.equal(writes.length, 0)
|
||||
|
||||
const done = await map.update({ fleet: { players: 'signed_in', mates: false }, servers: { pve: { players: 'public', mates: null } } }, { id: 3 })
|
||||
assert.equal(done.ok, true)
|
||||
assert.deepStrictEqual(writes, [
|
||||
['fleet', 'map.layer.players.audience', 'signed_in', 3],
|
||||
['fleet', 'map.mates', 'off', 3],
|
||||
['server', 'pve', 'map.layer.players.audience', 'public', 3],
|
||||
['server', 'pve', 'map.mates', null, 3],
|
||||
])
|
||||
})
|
||||
|
||||
test('derive: a map with no picture is sized like the Rust+ cache would be', () => {
|
||||
const row = map.derive('main', { mapKey: '4500.7.1', source: 'none', worldSize: 4500, oceanMargin: 500, gridCells: 30, gridCellSize: 150, monuments: [] })
|
||||
assert.equal(row.width, 3250)
|
||||
assert.equal(row.height, 3250)
|
||||
assert.equal(row.sha256, null)
|
||||
assert.equal(row.source, 'none')
|
||||
assert.equal(row.derivation, map.DERIVATION_VERSION)
|
||||
})
|
||||
|
||||
test('derive: the picture’s own size wins, and nothing unsafe reaches a style', () => {
|
||||
const row = map.derive('main', {
|
||||
mapKey: '3000.1234.1', source: 'companion', sha256: 'AB'.repeat(32), width: 2500, height: 2500,
|
||||
worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, background: 'red;x:1',
|
||||
monuments: [{ value: 'harbor_1#2', kind: 'harbor_1', label: 'Harbor', x: 1, z: 2 }, { kind: 'broken', x: 'nope', z: 1 }],
|
||||
})
|
||||
assert.equal(row.sha256, 'ab'.repeat(32), 'hashes are compared lower-case')
|
||||
assert.equal(row.background, null)
|
||||
assert.equal(row.monuments.length, 1)
|
||||
})
|
||||
|
||||
test('a render’s stall is estimated from the measured one, scaled by area', () => {
|
||||
assert.equal(map.renderStallSeconds(3000), 9)
|
||||
assert.equal(map.renderStallSeconds(4500), 14)
|
||||
assert.equal(map.renderStallSeconds(null), 9, 'unknown size estimates the measured map')
|
||||
})
|
||||
|
||||
// ── The picture ───────────────────────────────────────────────────────────
|
||||
|
||||
const SERVER = { id: 'main', baseUrl: 'http://main:1', token: 't' }
|
||||
const HELLO = { bootId: 'boot-1', wipeId: 'w-1', seed: 1234, worldSize: 3000, worldReady: true, protocol: 11 }
|
||||
|
||||
function picture(bytes, { source = 'companion', mapKey = '3000.1234.1', chunkBytes = 4 } = {}) {
|
||||
const sha = crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
const info = {
|
||||
kind: 'map.info', mapKey, source, sha256: sha, bytes: bytes.length, width: 2500, height: 2500,
|
||||
worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, background: '#0B3B4A',
|
||||
chunks: Math.ceil(bytes.length / chunkBytes), monuments: [],
|
||||
}
|
||||
const chunk = (n) => ({ kind: 'map.chunk', chunk: n, data: bytes.subarray(n * chunkBytes, (n + 1) * chunkBytes).toString('base64') })
|
||||
return { info, chunk, sha }
|
||||
}
|
||||
|
||||
function bridge(t, { info, chunk, stored = null } = {}) {
|
||||
mapImages._reset()
|
||||
const calls = { info: 0, chunks: [], put: [], geometry: [] }
|
||||
const saved = { mapInfo: client.mapInfo, mapChunk: client.mapChunk, getMeta: mapDb.getMeta, putImage: mapDb.putImage, putGeometry: mapDb.putGeometry }
|
||||
client.mapInfo = async () => {
|
||||
calls.info += 1
|
||||
return typeof info === 'function' ? info(calls.info) : { ok: true, status: 'ok', data: info }
|
||||
}
|
||||
client.mapChunk = async (server, q) => {
|
||||
calls.chunks.push(q)
|
||||
return { ok: true, status: 'ok', data: chunk(q.n, q) }
|
||||
}
|
||||
mapDb.getMeta = async () => stored
|
||||
mapDb.putImage = async (row) => calls.put.push(row)
|
||||
mapDb.putGeometry = async (row) => calls.geometry.push(row)
|
||||
t.after(() => {
|
||||
Object.assign(client, { mapInfo: saved.mapInfo, mapChunk: saved.mapChunk })
|
||||
Object.assign(mapDb, { getMeta: saved.getMeta, putImage: saved.putImage, putGeometry: saved.putGeometry })
|
||||
mapImages._reset()
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
test('a new map is fetched in slices, checked against its hash, and stored whole', async (t) => {
|
||||
const p = picture(Buffer.from('a jpeg, honestly'))
|
||||
const calls = bridge(t, { info: p.info, chunk: p.chunk })
|
||||
|
||||
const result = await mapImages.run(SERVER)
|
||||
assert.equal(result.outcome, 'fetched')
|
||||
assert.equal(calls.chunks.length, p.info.chunks)
|
||||
assert.equal(calls.put.length, 1)
|
||||
assert.equal(calls.put[0].bytes.toString(), 'a jpeg, honestly')
|
||||
assert.equal(calls.put[0].sha256, p.sha)
|
||||
})
|
||||
|
||||
test('bytes that do not match their hash are never stored', async (t) => {
|
||||
const p = picture(Buffer.from('the real picture'))
|
||||
const calls = bridge(t, { info: p.info, chunk: (n) => ({ ...p.chunk(n), data: Buffer.from('xxxx').toString('base64') }) })
|
||||
|
||||
const result = await mapImages.run(SERVER)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(calls.put.length, 0)
|
||||
})
|
||||
|
||||
test('a fetch that straddles a map change is abandoned whole and retried soon, not spliced', async (t) => {
|
||||
const p = picture(Buffer.from('first map picture'))
|
||||
const calls = bridge(t, {
|
||||
info: p.info,
|
||||
chunk: (n) => (n === 0 ? p.chunk(0) : { kind: 'map.error', reason: 'stale', message: 'moved' }),
|
||||
})
|
||||
|
||||
const result = await mapImages.run(SERVER)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retrySoon, true)
|
||||
assert.equal(calls.put.length, 0)
|
||||
})
|
||||
|
||||
test('the stored picture of this map is not fetched again', async (t) => {
|
||||
const p = picture(Buffer.from('same'))
|
||||
const calls = bridge(t, {
|
||||
info: p.info,
|
||||
chunk: p.chunk,
|
||||
stored: { mapKey: p.info.mapKey, sha256: p.sha, source: 'companion', derivation: map.DERIVATION_VERSION },
|
||||
})
|
||||
|
||||
assert.equal((await mapImages.run(SERVER)).outcome, 'current')
|
||||
assert.equal(calls.chunks.length, 0)
|
||||
assert.equal(calls.geometry.length, 0)
|
||||
})
|
||||
|
||||
test('a game with no picture keeps the one already stored for the same map', async (t) => {
|
||||
const calls = bridge(t, {
|
||||
info: { kind: 'map.info', mapKey: '3000.1234.1', source: 'none', worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, monuments: [] },
|
||||
stored: { mapKey: '3000.1234.1', sha256: 'f'.repeat(64), source: 'rendered', width: 2500, height: 2500 },
|
||||
})
|
||||
assert.equal((await mapImages.run(SERVER)).outcome, 'current')
|
||||
assert.equal(calls.put.length, 0, 'the picture row is not replaced')
|
||||
assert.equal(calls.geometry[0].source, 'rendered')
|
||||
})
|
||||
|
||||
test('a different map with no picture stores geometry so the layers can be drawn', async (t) => {
|
||||
const calls = bridge(t, {
|
||||
info: { kind: 'map.info', mapKey: '3500.9.1', source: 'none', worldSize: 3500, oceanMargin: 500, gridCells: 23, gridCellSize: 152.174, monuments: [] },
|
||||
stored: { mapKey: '3000.1234.1', sha256: 'f'.repeat(64), source: 'companion' },
|
||||
})
|
||||
assert.equal((await mapImages.run(SERVER)).outcome, 'none')
|
||||
assert.equal(calls.put.length, 1)
|
||||
assert.equal(calls.put[0].bytes, undefined)
|
||||
})
|
||||
|
||||
test('one fetch per server at a time', async (t) => {
|
||||
const p = picture(Buffer.from('slow'))
|
||||
let release
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
bridge(t, {
|
||||
info: async () => {
|
||||
await gate
|
||||
return { ok: true, status: 'ok', data: p.info }
|
||||
},
|
||||
chunk: p.chunk,
|
||||
})
|
||||
|
||||
const first = mapImages.run(SERVER)
|
||||
assert.equal((await mapImages.run(SERVER)).outcome, 'busy')
|
||||
release()
|
||||
assert.equal((await first).outcome, 'fetched')
|
||||
})
|
||||
|
||||
test('observe asks only a connected, ready plugin that knows the map verbs, and only when something moved', async (t) => {
|
||||
const p = picture(Buffer.from('pic'))
|
||||
const calls = bridge(t, { info: p.info, chunk: p.chunk })
|
||||
|
||||
mapImages.observe(SERVER, { ...HELLO, protocol: 10 })
|
||||
mapImages.observe(SERVER, { ...HELLO, worldReady: false })
|
||||
await new Promise((r) => setImmediate(r))
|
||||
assert.equal(calls.info, 0)
|
||||
|
||||
mapImages.observe(SERVER, HELLO)
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
assert.equal(calls.info, 1)
|
||||
|
||||
mapImages.observe(SERVER, HELLO)
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
assert.equal(calls.info, 1, 'the same boot, wipe and map is not asked about twice')
|
||||
|
||||
mapImages.observe(SERVER, { ...HELLO, wipeId: 'w-2' })
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
assert.equal(calls.info, 2, 'a wipe is a reason to ask')
|
||||
})
|
||||
|
||||
// ── What moves ────────────────────────────────────────────────────────────
|
||||
|
||||
test('D111: any number of viewers inside the window cost one ask of the game', async (t) => {
|
||||
mapLive._reset()
|
||||
let asks = 0
|
||||
const saved = client.mapLive
|
||||
client.mapLive = async () => {
|
||||
asks += 1
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
return { ok: true, status: 'ok', data: { kind: 'map.live', ...LIVE } }
|
||||
}
|
||||
t.after(() => {
|
||||
client.mapLive = saved
|
||||
mapLive._reset()
|
||||
})
|
||||
|
||||
let now = 1000
|
||||
const clock = () => now
|
||||
const answers = await Promise.all(Array.from({ length: 12 }, () => mapLive.live(SERVER, clock)))
|
||||
assert.equal(asks, 1, 'concurrent viewers share the ask in flight')
|
||||
assert.ok(answers.every((a) => a.ok))
|
||||
|
||||
now += map.LIVE_CACHE_MS - 1
|
||||
await mapLive.live(SERVER, clock)
|
||||
assert.equal(asks, 1, 'inside the window')
|
||||
|
||||
now += 2
|
||||
await mapLive.live(SERVER, clock)
|
||||
assert.equal(asks, 2, 'after it')
|
||||
})
|
||||
|
||||
test('a game that does not answer is asked once per window too', async (t) => {
|
||||
mapLive._reset()
|
||||
let asks = 0
|
||||
const saved = client.mapLive
|
||||
client.mapLive = async () => {
|
||||
asks += 1
|
||||
return { ok: false, status: 'http-503' }
|
||||
}
|
||||
t.after(() => {
|
||||
client.mapLive = saved
|
||||
mapLive._reset()
|
||||
})
|
||||
|
||||
const clock = () => 5000
|
||||
assert.equal((await mapLive.live(SERVER, clock)).ok, false)
|
||||
assert.equal((await mapLive.live(SERVER, clock)).ok, false)
|
||||
assert.equal(asks, 1)
|
||||
})
|
||||
|
||||
// ── The picture's route ───────────────────────────────────────────────────
|
||||
|
||||
function fakeRes() {
|
||||
const res = { headers: {}, statusCode: 200, body: null, vary: () => res }
|
||||
res.set = (k, v) => {
|
||||
res.headers[k] = v
|
||||
return res
|
||||
}
|
||||
res.status = (c) => {
|
||||
res.statusCode = c
|
||||
return res
|
||||
}
|
||||
res.json = (b) => {
|
||||
res.body = b
|
||||
return res
|
||||
}
|
||||
res.send = (b) => {
|
||||
res.body = b
|
||||
return res
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
test('the picture is immutable under its hash, and a stale hash is a 404 that nothing caches', async (t) => {
|
||||
const controller = require('../router/public/rust.controller')
|
||||
const servers = require('../model/servers/servers.model')
|
||||
const sha = 'a'.repeat(64)
|
||||
const saved = { getPublic: servers.getPublic, getBytes: mapDb.getBytes }
|
||||
servers.getPublic = async (id) => (id === 'main' ? { id } : null)
|
||||
mapDb.getBytes = async (id, v) => (v === sha ? Buffer.from([0xff, 0xd8, 0xff]) : null)
|
||||
t.after(() => {
|
||||
servers.getPublic = saved.getPublic
|
||||
mapDb.getBytes = saved.getBytes
|
||||
})
|
||||
|
||||
const hit = fakeRes()
|
||||
await controller.getMapImage({ params: { id: 'main' }, query: { v: sha } }, hit)
|
||||
assert.equal(hit.statusCode, 200)
|
||||
assert.equal(hit.headers['Cache-Control'], 'public, max-age=31536000, immutable')
|
||||
assert.equal(hit.headers['Content-Type'], 'image/jpeg')
|
||||
|
||||
const stale = fakeRes()
|
||||
await controller.getMapImage({ params: { id: 'main' }, query: { v: 'b'.repeat(64) } }, stale)
|
||||
assert.equal(stale.statusCode, 404)
|
||||
assert.equal(stale.headers['Cache-Control'], 'no-store')
|
||||
|
||||
const junk = fakeRes()
|
||||
await controller.getMapImage({ params: { id: 'main' }, query: { v: '../../etc' } }, junk)
|
||||
assert.equal(junk.statusCode, 404)
|
||||
})
|
||||
Reference in New Issue
Block a user