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
415 lines
16 KiB
JavaScript
415 lines
16 KiB
JavaScript
// ── 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,
|
||
}
|