A switch per monument label, a fleet default and a per-server override, on Admin -> Rust visibility's live map card. Substations, caves, train tunnels, wells and the other minor labels start hidden; every other label is drawn, so a monument a game update adds appears. GET /map leaves hidden labels out of the answer, so the website and the app both lose them. No schema change: rows are map.marker.<label key> in rust_settings and rust_map_overrides. No wire change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
589 lines
23 KiB
JavaScript
589 lines
23 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, besides the marker switches below. */
|
||
const SETTINGS = Object.freeze([...LAYERS.map(layerKey), MATES_KEY])
|
||
|
||
// ── Which monuments are drawn (PLAN_REDESIGNS §4, D196) ───────────────────
|
||
//
|
||
// A switch per monument LABEL, because that is what staff read and it groups the
|
||
// variants: 31 substations of four prefabs are one "Substation". It is clutter
|
||
// control, not a security boundary — the world layer's audience still decides
|
||
// whether a viewer gets monuments at all — but a hidden label is still left out
|
||
// of the answer rather than sent and hidden, like every other map rule.
|
||
//
|
||
// The minor labels below start OFF. **Every other label is on**, so a monument
|
||
// Facepunch adds next month appears rather than disappears. One row per label in
|
||
// `rust_settings` (the fleet) and `rust_map_overrides` (a server), `on` or `off`.
|
||
// Labels match case-insensitively: the game says "jungle swamp" for one prefab.
|
||
|
||
const MARKER_PREFIX = 'map.marker.'
|
||
|
||
/** Both setting columns are VARCHAR(64); a longer label cannot carry a switch. */
|
||
const MARKER_KEY_MAX = 64 - MARKER_PREFIX.length
|
||
|
||
/**
|
||
* The labels off unless an admin turns them on. All but the last three are as
|
||
* the rigs' map (world 3000, seed 1234) labels them; the gas station, mining
|
||
* outpost and ranch are not on that map, and are Rust's display names for them.
|
||
*/
|
||
const MINOR_LABELS = Object.freeze([
|
||
'Substation',
|
||
'Underground Cave',
|
||
'Train Tunnel',
|
||
'Water Well',
|
||
'Wild Swamp',
|
||
'Jungle Swamp',
|
||
'Ice Lake',
|
||
'Jungle Ruin',
|
||
'Fishing Village',
|
||
'Large Barn',
|
||
'Abandoned Supermarket',
|
||
"Oxum's Gas Station",
|
||
'Mining Outpost',
|
||
'Ranch',
|
||
])
|
||
|
||
/** A label as its switch's key: trimmed, single-spaced, lower case. */
|
||
const markerKey = (label) => String(label == null ? '' : label).trim().replace(/\s+/g, ' ').toLowerCase()
|
||
|
||
const MINOR = new Set(MINOR_LABELS.map(markerKey))
|
||
|
||
/** Whether a label is drawn when nobody has said: off for the minor list, on for everything else. */
|
||
const markerDefault = (key) => !MINOR.has(key)
|
||
|
||
/** A stored word as a boolean, or undefined for a word that is neither — which then follows the default. */
|
||
function markerWord(value) {
|
||
if (value === 'on') return true
|
||
if (value === 'off') return false
|
||
return undefined
|
||
}
|
||
|
||
/**
|
||
* Whether one label is drawn, given the explicit switches that apply (a
|
||
* server's over the fleet's, already merged). **Pure.**
|
||
*/
|
||
function markerShown(markers, label) {
|
||
const key = markerKey(label)
|
||
return markers && Object.prototype.hasOwnProperty.call(markers, key) ? markers[key] : markerDefault(key)
|
||
}
|
||
|
||
/** The monuments a viewer is sent: those whose label is drawn on this server. **Pure.** */
|
||
function filterMonuments(monuments, markers) {
|
||
return (monuments || []).filter((m) => markerShown(markers, m.label || m.kind))
|
||
}
|
||
|
||
/**
|
||
* The labels on one stored map, each once, with how many markers carry it and
|
||
* the spelling to show (a capitalised one where the game uses both).
|
||
*/
|
||
function markerLabels(monuments) {
|
||
const byKey = new Map()
|
||
for (const m of monuments || []) {
|
||
const label = String(m.label || m.kind || '').trim().replace(/\s+/g, ' ')
|
||
const key = markerKey(label)
|
||
if (!key) continue
|
||
const seen = byKey.get(key)
|
||
if (!seen) byKey.set(key, { key, label, count: 1 })
|
||
else {
|
||
seen.count += 1
|
||
if (seen.label === seen.label.toLowerCase() && label !== label.toLowerCase()) seen.label = label
|
||
}
|
||
}
|
||
return [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label))
|
||
}
|
||
|
||
/**
|
||
* `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),
|
||
visibilityDb.listSettings(MARKER_PREFIX),
|
||
])
|
||
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)
|
||
out.markers = markersFrom(stored[LAYERS.length + 1] || [])
|
||
return out
|
||
}
|
||
|
||
/** Marker rows as `{ substation: true, … }`, only for the labels somebody switched. */
|
||
function markersFrom(rows) {
|
||
const out = {}
|
||
for (const { setting, value } of rows) {
|
||
if (!String(setting).startsWith(MARKER_PREFIX)) continue
|
||
const shown = markerWord(value)
|
||
if (shown !== undefined) out[String(setting).slice(MARKER_PREFIX.length)] = shown
|
||
}
|
||
return out
|
||
}
|
||
|
||
/** Overrides rows as `{ world: 'staff', mates: false, markers: { … }, … }`, only for what is set. */
|
||
function overridesFrom(rows) {
|
||
const out = { markers: markersFrom(rows) }
|
||
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
|
||
}
|
||
|
||
/** A server's overrides over the fleet, the marker switches merged label by label. */
|
||
function over(base, overrides) {
|
||
return { ...base, ...overrides, markers: { ...base.markers, ...overrides.markers } }
|
||
}
|
||
|
||
/** 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 over(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, markers: settings.markers }
|
||
} 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: [], markers: {} }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 },
|
||
// D196: the labels that start off. Every label not listed here starts on.
|
||
minorLabels: [...MINOR_LABELS],
|
||
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
|
||
full.markers = overrides.markers
|
||
return { id: s.id, overrides: full, effective: over(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 fleetMarkers = []
|
||
const serverMarkers = []
|
||
|
||
// `markers` is `{ <label key>: true | false | null }` (D196). `null` clears:
|
||
// on a server it follows the fleet again, on the fleet the built-in list.
|
||
const checkMarkers = (markers, where) => {
|
||
if (!markers || typeof markers !== 'object' || Array.isArray(markers)) {
|
||
return { problem: `The marker switches${where} must be an object of label: on or off.` }
|
||
}
|
||
const out = []
|
||
for (const [key, value] of Object.entries(markers)) {
|
||
if (!key || markerKey(key) !== key) {
|
||
return { problem: `"${key}" is not a marker label key${where}: keys are the label in lower case, single-spaced.` }
|
||
}
|
||
if (key.length > MARKER_KEY_MAX) {
|
||
return { problem: `The label "${key}"${where} is longer than ${MARKER_KEY_MAX} characters, so it cannot carry a switch.` }
|
||
}
|
||
if (value !== null && typeof value !== 'boolean') {
|
||
return { problem: `The "${key}" markers are shown or hidden${where}, not "${value}".` }
|
||
}
|
||
out.push([key, value])
|
||
}
|
||
return { out }
|
||
}
|
||
|
||
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 || {})) {
|
||
if (key === 'markers') {
|
||
const { problem, out } = checkMarkers(value, '')
|
||
if (problem) return { ok: false, status: 400, message: problem }
|
||
fleetMarkers.push(...out)
|
||
continue
|
||
}
|
||
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)) {
|
||
if (key === 'markers') {
|
||
const { problem, out } = checkMarkers(value, ` on server ${id}`)
|
||
if (problem) return { ok: false, status: 400, message: problem }
|
||
for (const [label, shown] of out) serverMarkers.push([id, label, shown])
|
||
continue
|
||
}
|
||
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 markerWordOf = (shown) => (shown ? 'on' : 'off')
|
||
for (const [key, shown] of fleetMarkers) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
if (shown === null) await visibilityDb.clearSetting(MARKER_PREFIX + key)
|
||
// eslint-disable-next-line no-await-in-loop
|
||
else await visibilityDb.setSetting(MARKER_PREFIX + key, markerWordOf(shown), userId)
|
||
}
|
||
for (const [id, key, shown] of serverMarkers) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
await db.setOverride(id, MARKER_PREFIX + key, shown === null ? null : markerWordOf(shown), userId)
|
||
}
|
||
|
||
const changed = {}
|
||
if (fleetChanges.length) changed.fleet = Object.fromEntries(fleetChanges)
|
||
if (fleetMarkers.length) {
|
||
changed.fleet = { ...(changed.fleet || {}), markers: Object.fromEntries(fleetMarkers.map(([k, v]) => [k, v === null ? 'default' : v])) }
|
||
}
|
||
if (serverChanges.length || serverMarkers.length) {
|
||
changed.servers = {}
|
||
for (const [id, key, value] of serverChanges) {
|
||
changed.servers[id] = { ...(changed.servers[id] || {}), [key]: value === null ? 'inherit' : value }
|
||
}
|
||
for (const [id, key, shown] of serverMarkers) {
|
||
const entry = changed.servers[id] || {}
|
||
changed.servers[id] = { ...entry, markers: { ...(entry.markers || {}), [key]: shown === null ? 'inherit' : shown } }
|
||
}
|
||
}
|
||
return { ok: true, changed }
|
||
}
|
||
|
||
module.exports = {
|
||
LAYERS,
|
||
DEFAULTS,
|
||
DEFAULT_MATES,
|
||
MATES_KEY,
|
||
SETTINGS,
|
||
MARKER_PREFIX,
|
||
MARKER_KEY_MAX,
|
||
MINOR_LABELS,
|
||
DERIVATION_VERSION,
|
||
POLL_MS,
|
||
LIVE_CACHE_MS,
|
||
RENDER_MEASURED,
|
||
layerKey,
|
||
markerKey,
|
||
markerShown,
|
||
filterMonuments,
|
||
markerLabels,
|
||
renderStallSeconds,
|
||
narrower,
|
||
fleet,
|
||
forServer,
|
||
access,
|
||
mateIdsFor,
|
||
project,
|
||
geometryOf,
|
||
derive,
|
||
monumentsOf,
|
||
describeSwitches,
|
||
update,
|
||
}
|