From 0da8f44b55d065c65515d4cc2f1cdba29a283617 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 12:10:47 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(rust):=20the=20live=20map=20draws=20mo?= =?UTF-8?q?numents=20by=20label,=20minor=20labels=20hidden=20(PLAN=5FREDES?= =?UTF-8?q?IGNS=20=C2=A74,=20D196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. + set('markers', next)} + /> {capped && (

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` : ''} + +
+ {labels.map((l) => { + const mine = markers && Object.prototype.hasOwnProperty.call(markers, l.key) ? (markers[l.key] ? 'on' : 'off') : INHERIT + const fleetOn = fleetShows(minorLabels, fleetMarkers, l.key) + return ( + + ) + })} +
+
+ ) +} + 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..62074b3 --- /dev/null +++ b/client/test/mapMarkers.test.js @@ -0,0 +1,65 @@ +// ── 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('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..e9de55a 100644 --- a/server/model/map/map.model.js +++ b/server/model/map/map.model.js @@ -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 `{