// ── 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: { : { 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, }