feat(rust): the live map draws monuments by label, minor labels hidden (PLAN_REDESIGNS §4, D196)

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
This commit is contained in:
2026-09-29 12:10:47 -05:00
parent eb184aaac8
commit 0da8f44b55
12 changed files with 759 additions and 26 deletions

View File

@@ -53,9 +53,98 @@ const MATES_KEY = 'map.mates'
const layerKey = (layer) => `map.layer.${layer}.audience`
/** Every override setting name a server may carry. */
/** 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
@@ -100,19 +189,35 @@ function narrower(a, b) {
/** 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 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
}
/** Overrides rows as `{ world: 'staff', mates: false, … }`, only for what is set. */
function overridesFrom(rows) {
/** 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 {
@@ -123,10 +228,15 @@ function overridesFrom(rows) {
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 { ...base, ...overridesFrom(rows) }
return over(base, overridesFrom(rows))
}
/**
@@ -160,12 +270,12 @@ async function access(req, serverId) {
visible: settings.mates && steamIds.length > 0,
}
return { level: viewer.level, userId: viewer.userId, layers, mates, steamIds }
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: [] }
return { level: 'public', userId: null, layers, mates: { on: false, linked: false, visible: false }, steamIds: [], markers: {} }
}
}
@@ -305,12 +415,15 @@ async function describeSwitches(servers) {
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
return { id: s.id, overrides: full, effective: { ...base, ...overrides } }
full.markers = overrides.markers
return { id: s.id, overrides: full, effective: over(base, overrides) }
}),
}
}
@@ -329,6 +442,30 @@ async function describeSwitches(servers) {
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
@@ -344,6 +481,12 @@ async function update({ fleet: fleetIn, servers } = {}, actor = null, serverExis
}
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])
@@ -356,6 +499,12 @@ async function update({ fleet: fleetIn, servers } = {}, actor = null, serverExis
// 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])
@@ -376,14 +525,32 @@ async function update({ fleet: fleetIn, servers } = {}, actor = null, serverExis
// 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 (serverChanges.length) {
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 }
}
@@ -394,11 +561,18 @@ module.exports = {
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,

View File

@@ -25,6 +25,22 @@ async function setSetting(key, value, userId = null) {
)
}
/**
* Every stored setting whose key starts with `prefix`, as `{ setting, value }`
* rows: the map's per-label marker switches are one row each (D196).
*/
async function listSettings(prefix) {
return core.query(
`SELECT setting_key AS setting, value FROM ${SETTINGS} WHERE setting_key LIKE CONCAT(?, '%')`,
[prefix.replace(/[\\%_]/g, '\\$&')],
)
}
/** Forget one setting, so it follows the built-in default again. */
async function clearSetting(key) {
await core.query(`DELETE FROM ${SETTINGS} WHERE setting_key = ?`, [key])
}
/** One server's override, `null` for "inherit", or `undefined` when there is no such server. */
async function getServerPresence(serverId) {
const rows = await core.query(`SELECT presence_audience AS presence FROM ${SERVERS} WHERE id = ?`, [serverId])
@@ -69,6 +85,8 @@ async function setServerNewsDelivery(serverId, delivery) {
module.exports = {
getSetting,
setSetting,
listSettings,
clearSetting,
getServerPresence,
listServerPresence,
setServerPresence,