diff --git a/client/src/lib/mapMarkers.js b/client/src/lib/mapMarkers.js
new file mode 100644
index 0000000..e7b685a
--- /dev/null
+++ b/client/src/lib/mapMarkers.js
@@ -0,0 +1,91 @@
+// ── Which monument labels a map draws (PLAN_REDESIGNS §4, D196) ───────────
+//
+// The admin card's half of the rule `model/map/map.model.js` applies on the
+// server: a switch per monument LABEL, the minor labels off unless somebody
+// turns them on, every other label on. A server's switch wins over the fleet's,
+// the fleet's over the built-in list.
+//
+// The form holds only the EXPLICIT switches, as the server does — `{ key: bool }`
+// — so a label nobody touched keeps following the default above it. Setting the
+// fleet back to what the built-in list says clears the row rather than storing a
+// copy of the default: the next change to the list then reaches that label.
+//
+// Pure, and free of React, so it is tested in Node.
+
+/** A label as its switch's key: trimmed, single-spaced, lower case. The server's rule exactly. */
+export function markerKey(label) {
+ return String(label == null ? '' : label).trim().replace(/\s+/g, ' ').toLowerCase()
+}
+
+/** The built-in answer for one key: hidden for the minor labels, drawn for everything else. */
+export function builtIn(minorLabels, key) {
+ return !(minorLabels || []).some((l) => markerKey(l) === key)
+}
+
+const has = (o, k) => Boolean(o) && Object.prototype.hasOwnProperty.call(o, k)
+
+/** Whether the fleet draws one key: its own switch, or the built-in list. */
+export function fleetShows(minorLabels, fleetMarkers, key) {
+ return has(fleetMarkers, key) ? fleetMarkers[key] : builtIn(minorLabels, key)
+}
+
+/** Whether one server draws one key: its switch, or the fleet's answer. */
+export function serverShows(minorLabels, fleetMarkers, serverMarkers, key) {
+ return has(serverMarkers, key) ? serverMarkers[key] : fleetShows(minorLabels, fleetMarkers, key)
+}
+
+/**
+ * Every label the fleet list offers, sorted by name: each server's current
+ * labels and the minor list, each once. A minor label no map has yet is still
+ * listed, so it can be decided before a wipe brings it.
+ */
+export function fleetLabels(minorLabels, servers) {
+ const byKey = new Map()
+ // A capitalised spelling wins over the game's lower-case one ("jungle swamp").
+ const add = (key, label, count) => {
+ const seen = byKey.get(key)
+ if (!seen) byKey.set(key, { key, label, count })
+ else {
+ seen.count += count
+ if (seen.label === seen.label.toLowerCase() && label !== label.toLowerCase()) seen.label = label
+ }
+ }
+ for (const s of servers || []) {
+ for (const l of s.markerLabels || []) add(l.key, l.label, l.count || 0)
+ }
+ for (const label of minorLabels || []) add(markerKey(label), label, 0)
+ return [...byKey.values()].sort((a, b) => a.label.localeCompare(b.label))
+}
+
+/** The fleet form after ticking one key: an explicit switch, or none where it matches the built-in list. */
+export function setFleet(minorLabels, fleetMarkers, key, shown) {
+ const next = { ...(fleetMarkers || {}) }
+ if (shown === builtIn(minorLabels, key)) delete next[key]
+ else next[key] = shown
+ return next
+}
+
+/** A server form after choosing one key: `null` follows the fleet, otherwise an explicit switch. */
+export function setServer(serverMarkers, key, shown) {
+ const next = { ...(serverMarkers || {}) }
+ if (shown === null) delete next[key]
+ else next[key] = shown
+ return next
+}
+
+/**
+ * What changed between two explicit maps, as the PUT takes it: `{ key: bool }`
+ * for a switch set or changed, `{ key: null }` for one cleared. Null when
+ * nothing changed.
+ */
+export function markerDiff(before, now) {
+ const out = {}
+ const keys = new Set([...Object.keys(before || {}), ...Object.keys(now || {})])
+ for (const key of keys) {
+ const was = has(before, key) ? before[key] : undefined
+ const is = has(now, key) ? now[key] : undefined
+ if (was === is) continue
+ out[key] = is === undefined ? null : is
+ }
+ return Object.keys(out).length ? out : null
+}
diff --git a/client/src/routes/admin/Visibility.jsx b/client/src/routes/admin/Visibility.jsx
index 0682531..0a15edc 100644
--- a/client/src/routes/admin/Visibility.jsx
+++ b/client/src/routes/admin/Visibility.jsx
@@ -31,11 +31,18 @@
// (D118). Beside each server is what its map picture is, and the two buttons
// that act at once rather than on Save: Fetch again, and Render now — shown only
// where there is no picture, with how long it will stall that server (D109).
+//
+// PLAN_REDESIGNS §4 adds which monuments the map draws (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"). The minor labels start
+// hidden and every other label drawn, so a monument Facepunch adds appears. One
+// fleet list, and each server's own labels with an override each.
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
+import { fleetLabels, fleetShows, markerDiff, serverShows, setFleet as setFleetMarker, setServer as setServerMarker } from '../../lib/mapMarkers.js'
const INHERIT = ''
@@ -117,7 +124,7 @@ export default function Visibility() {
setNews(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, Boolean(s.on)])))
setDelivery(Object.fromEntries(((state.news && state.news.servers) || []).map((s) => [s.id, s.delivery || 'chat'])))
if (state.map) {
- setMapFleet({ ...state.map.fleet })
+ setMapFleet({ ...state.map.fleet, markers: { ...(state.map.fleet.markers || {}) } })
setMapServers(Object.fromEntries(state.map.servers.map((s) => [s.id, mapOverridesToForm(s.overrides)])))
}
}, [])
@@ -386,6 +393,7 @@ function mapOverridesToForm(overrides) {
const form = {}
for (const l of MAP_LAYERS) form[l.id] = overrides[l.id] || INHERIT
form.mates = overrides.mates === null || overrides.mates === undefined ? INHERIT : overrides.mates ? 'on' : 'off'
+ form.markers = { ...(overrides.markers || {}) }
return form
}
@@ -396,6 +404,8 @@ function mapDiff(card, fleet, servers) {
for (const key of [...MAP_LAYERS.map((l) => l.id), 'mates']) {
if (key in fleet && fleet[key] !== card.fleet[key]) fleetChanges[key] = fleet[key]
}
+ const fleetMarkers = markerDiff(card.fleet.markers, fleet.markers)
+ if (fleetMarkers) fleetChanges.markers = fleetMarkers
if (Object.keys(fleetChanges).length) out.fleet = fleetChanges
const serverChanges = {}
@@ -403,8 +413,10 @@ function mapDiff(card, fleet, servers) {
const before = mapOverridesToForm(s.overrides)
const now = servers[s.id] || before
const changed = {}
+ const markers = markerDiff(before.markers, now.markers)
+ if (markers) changed.markers = markers
for (const key of Object.keys(before)) {
- if (now[key] === before[key]) continue
+ if (key === 'markers' || now[key] === before[key]) continue
if (key === 'mates') changed.mates = now.mates === INHERIT ? null : now.mates === 'on'
else changed[key] = now[key] === INHERIT ? null : now[key]
}
@@ -477,6 +489,8 @@ function MapCard({ card, audiences, presenceOf, fleet, setFleet, servers, setSer
+
+
{card.servers.length === 0 && (
Players will be shown to {(LABEL[presence] || presence).toLowerCase()} on this server, because that is who may see who is online.
@@ -549,6 +570,84 @@ function MapCard({ card, audiences, presenceOf, fleet, setFleet, servers, setSer
)
}
+/**
+ * The fleet's monument labels (D196): every label on any server's current map,
+ * and the minor list, each a checkbox. Ticked is drawn.
+ */
+function FleetMarkers({ card, fleet, setFleet }) {
+ const minor = card.minorLabels || []
+ const labels = fleetLabels(minor, card.servers)
+ const hidden = labels.filter((l) => !fleetShows(minor, fleet.markers, l.key)).length
+
+ return (
+
+
+ Monuments on the map · {labels.length} labels, {hidden} hidden by default
+
+
+ Ticked labels are drawn on every server that has not chosen for itself. The minor ones — substations, caves, train
+ tunnels, wells and the like — start hidden. Any label not listed here, such as a monument a game update adds, is drawn.
+
+
+ {labels.map((l) => (
+
+ ))}
+
+
+ )
+}
+
+/**
+ * One server's monument labels (D196): the labels on its current map, each
+ * following the fleet or drawn or hidden on this server alone.
+ */
+function ServerMarkers({ server, minorLabels, fleetMarkers, markers, onChange }) {
+ const labels = server.markerLabels || []
+ if (!labels.length) return null
+ const hidden = labels.filter((l) => !serverShows(minorLabels, fleetMarkers, markers, l.key)).length
+ const own = Object.keys(markers || {}).length
+
+ return (
+
+
+ Monuments: {labels.length} labels on this map, {hidden} hidden{own ? `, ${own} set for this server` : ''}
+
+
+
+ )
+}
+
function FleetRow({ label, hint, children }) {
return (
<>
diff --git a/client/test/mapMarkers.test.js b/client/test/mapMarkers.test.js
new file mode 100644
index 0000000..0d6af74
--- /dev/null
+++ b/client/test/mapMarkers.test.js
@@ -0,0 +1,70 @@
+// ── Which monument labels a map draws (PLAN_REDESIGNS §4, D196) ───────────
+//
+// The admin card must agree with the server about what a switch means, or the
+// page says a label is drawn while the map leaves it out. These are the ways it
+// could disagree: case, the built-in list, the fleet under a server, and a
+// switch set back to its default.
+
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { builtIn, fleetLabels, fleetShows, markerDiff, markerKey, serverShows, setFleet, setServer } from '../src/lib/mapMarkers.js'
+
+const MINOR = ['Substation', 'Jungle Swamp', 'Ranch']
+
+test('a label becomes the same key the server stores', () => {
+ assert.equal(markerKey(' jungle Swamp '), 'jungle swamp')
+ assert.equal(markerKey(null), '')
+})
+
+test('the minor labels start hidden and every other label drawn', () => {
+ assert.equal(builtIn(MINOR, 'substation'), false)
+ assert.equal(builtIn(MINOR, 'something facepunch added'), true)
+})
+
+test('a server switch wins over the fleet, and the fleet over the built-in list', () => {
+ assert.equal(fleetShows(MINOR, {}, 'substation'), false)
+ assert.equal(fleetShows(MINOR, { substation: true }, 'substation'), true)
+ assert.equal(serverShows(MINOR, { substation: true }, {}, 'substation'), true)
+ assert.equal(serverShows(MINOR, { substation: true }, { substation: false }, 'substation'), false)
+ assert.equal(serverShows(MINOR, {}, { 'power plant': false }, 'power plant'), false)
+})
+
+test('the fleet list is every server’s labels and the minor list, each once', () => {
+ const labels = fleetLabels(MINOR, [
+ { markerLabels: [{ key: 'substation', label: 'Substation', count: 31 }, { key: 'harbor', label: 'Harbor', count: 2 }] },
+ { markerLabels: [{ key: 'substation', label: 'Substation', count: 12 }] },
+ {},
+ ])
+ assert.deepEqual(
+ labels.map((l) => [l.key, l.count]),
+ [
+ ['harbor', 2],
+ ['jungle swamp', 0],
+ ['ranch', 0],
+ ['substation', 43],
+ ],
+ )
+})
+
+test('the fleet list shows a capitalised spelling where the game only wrote lower case', () => {
+ const labels = fleetLabels(MINOR, [{ markerLabels: [{ key: 'jungle swamp', label: 'jungle swamp', count: 3 }] }])
+ assert.deepEqual(labels.find((l) => l.key === 'jungle swamp'), { key: 'jungle swamp', label: 'Jungle Swamp', count: 3 })
+})
+
+test('setting the fleet back to the built-in answer clears the row instead of copying it', () => {
+ assert.deepEqual(setFleet(MINOR, {}, 'substation', true), { substation: true })
+ assert.deepEqual(setFleet(MINOR, { substation: true }, 'substation', false), {})
+ assert.deepEqual(setFleet(MINOR, {}, 'harbor', false), { harbor: false })
+})
+
+test('a server set to follow the fleet loses its own switch', () => {
+ assert.deepEqual(setServer({ substation: true }, 'substation', null), {})
+ assert.deepEqual(setServer({}, 'substation', false), { substation: false })
+})
+
+test('the diff sends a changed switch as its value and a removed one as null', () => {
+ assert.equal(markerDiff({ a: true }, { a: true }), null)
+ assert.deepEqual(markerDiff({ a: true, b: false }, { a: false, c: true }), { a: false, b: null, c: true })
+ assert.equal(markerDiff(undefined, {}), null)
+})
diff --git a/server/model/map/map.model.js b/server/model/map/map.model.js
index c8f77f9..6217927 100644
--- a/server/model/map/map.model.js
+++ b/server/model/map/map.model.js
@@ -53,9 +53,108 @@ 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, 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
@@ -100,19 +199,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 +238,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 +280,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 +425,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 +452,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 `{