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 && (

No servers are configured yet.

)} @@ -517,6 +531,13 @@ function MapCard({ card, audiences, presenceOf, fleet, setFleet, servers, setSer + 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..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 `{