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