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:
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,
|
||||
|
||||
Reference in New Issue
Block a user