Files
Module-Rust/server/model/map/map.model.js
wtclaude b4f71c05cc feat(rust): eight more minor map labels start hidden (D213)
Read from the Carbon rig regenerated at world 6000, seed 981448696, whose map
carries every built-in label: the three that were unverified (Oxum's Gas
Station, Mining Outpost, Ranch) are spelled as written. Adds Canyon B/C,
Lake A, Oasis A/C, Mountain, Train Tunnel Link and Abandoned Cabins, and a
test over that map's 51 real labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-29 18:12:08 -05:00

599 lines
23 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, 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, spelled as the game labels
* them: all were read from the Carbon rig's 6000 map, seed 981448696, which has
* every one (2026-09-29). The game writes "Jungle ruin" there and "Jungle Ruin"
* on a 3000 map; the key is lower case, so both match.
*/
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',
// D213: found on the 6000 map, hidden at the org lead's word.
'Canyon B',
'Canyon C',
'Lake A',
'Oasis A',
'Oasis C',
'Mountain',
'Train Tunnel Link',
'Abandoned Cabins',
])
/** 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,
}