Files
Module-Rust/server/model/map/map.model.js
wtclaude 0cb9bdd1f0
All checks were successful
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m9s
feat(rust): the live map (phase 14, protocol 11)
PLAN.md §30 as approved, plus D119/D120 from the build.

Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
  monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
  asks map.info; a new key or hash from the free Rust+ cache (or a render
  kept on disk) is fetched in slices, checked against its SHA-256 and stored
  in one statement. One fetch per server, a backoff on failure, `stale`
  abandons a fetch that straddles a map change. Render now (D109) is
  admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
  are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
  fleet default plus per-server override (D114), the players layer capped by
  presence (D113), own dot and online first-party clan mates for a linked
  viewer (D115, D117, D118). A layer the viewer may not see is absent from
  the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
  /map/live; admin /servers/:id/map/fetch and /render; the Map card on the
  visibility PUT. Swagger fragment and frozen manifest regenerated.

Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
  (labels only when a cell is wide enough to hold one), a legend that lists
  hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
  copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.

Capability `map` declared for the Android app (phase 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 01:06:10 -05:00

415 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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