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,

View File

@@ -42,6 +42,8 @@ async function describeMap() {
const row = byId.get(s.id)
return {
...sw,
// D196: the labels on this server's current map, one switch each.
markerLabels: map.markerLabels(map.monumentsOf(row)),
name: s.name,
enabled: Boolean(s.enabled),
picture: row

View File

@@ -27,7 +27,7 @@ visibilityRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Who may see who is online, and who may see a clan roster'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), the monument-label switches (`minorLabels` start hidden, every other label is drawn; `markerLabels` lists each server’s current map’s labels), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'),
visibility.read,
@@ -37,7 +37,7 @@ visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers`, in either, maps a monument label key (lower case) to true or false, and null clears it (D196). The players layer never shows more than who may see who is online, whatever it is set to.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */

View File

@@ -264,7 +264,8 @@ async function getMap(req, res) {
}
: null,
geometry: map.geometryOf(row),
...(acc.layers.world.visible ? { monuments: map.monumentsOf(row) } : {}),
// D196: only the labels drawn on this server; a hidden label is not sent at all.
...(acc.layers.world.visible ? { monuments: map.filterMonuments(map.monumentsOf(row), acc.markers) } : {}),
layers: acc.layers,
// `signedIn` is what lets the page offer "link your Steam account" to the
// person who can act on it, and not to a visitor who has no account at all.

View File

@@ -152,7 +152,7 @@ rustRouter.get(
'/servers/:id/map',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust server’s map'
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer, and which of the four layers — `world`, `events`, `players`, `bases` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer — only the labels drawn on this server, so a hidden label such as Substation is not sent at all (D196) — and which of the four layers — `world`, `events`, `players`, `bases` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The map, as this viewer may see it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMap" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */

View File

@@ -741,6 +741,12 @@ module.exports = {
description: 'The live map’s switches and each server’s picture (phase 14).',
properties: {
layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases'] },
minorLabels: {
type: 'array',
items: { type: 'string' },
description: 'D196: the monument labels hidden unless an admin turns them on. Every other label is drawn.',
example: ['Substation', 'Underground Cave', 'Train Tunnel'],
},
fleet: {
type: 'object',
properties: {
@@ -749,6 +755,7 @@ module.exports = {
players: { $ref: '#/components/schemas/RustAudience' },
bases: { $ref: '#/components/schemas/RustAudience' },
mates: { type: 'boolean', example: true },
markers: { type: 'object', description: 'D196: a monument label key (lower case) to true (drawn) or false (hidden). Only the labels somebody switched; every other label follows the default.', additionalProperties: { type: 'boolean' }, example: { substation: true } },
},
},
servers: {
@@ -758,8 +765,20 @@ module.exports = {
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
overrides: { type: 'object', description: 'Each layer and `mates`, or null to follow the fleet.' },
overrides: { type: 'object', description: 'Each layer and `mates`, or null to follow the fleet; `markers` holds only the labels this server switched itself.' },
effective: { type: 'object' },
markerLabels: {
type: 'array',
description: 'D196: the monument labels on this server’s current map, each once, with how many markers carry it.',
items: {
type: 'object',
properties: {
key: { type: 'string', example: 'substation' },
label: { type: 'string', example: 'Substation' },
count: { type: 'integer', example: 31 },
},
},
},
picture: {
type: 'object',
nullable: true,
@@ -852,7 +871,7 @@ module.exports = {
},
monuments: {
type: 'array',
description: 'Present only when the viewer may see the world layer.',
description: 'Present only when the viewer may see the world layer, and only the labels drawn on this server (D196): minor labels such as Substation are left out unless an admin turned them on.',
items: {
type: 'object',
properties: {
@@ -1120,8 +1139,8 @@ module.exports = {
},
map: {
type: 'object',
description: 'The live map’s switches. `fleet` maps a layer to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet.',
example: { fleet: { players: 'signed_in', mates: true }, servers: { pve: { players: 'public' }, pvp: { players: null } } },
description: 'The live map’s switches. `fleet` maps a layer to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. `markers` (in either) maps a monument label key — the label in lower case, single-spaced — to true (drawn) or false (hidden); null clears it, so a server follows the fleet and the fleet the built-in list (D196).',
example: { fleet: { players: 'signed_in', mates: true, markers: { substation: true } }, servers: { pve: { players: 'public', markers: { substation: false } }, pvp: { players: null } } },
},
},
},

View File

@@ -14,6 +14,8 @@
// a fetch that straddles a map change is not spliced
// one fetch per server, and a plugin that predates the map is never asked
// any number of viewers cost one ask of the game (D111)
// D196: the minor labels start off, every other label on, and a hidden label
// is not sent; a server's switch wins over the fleet's, label by label
const test = require('node:test')
const assert = require('node:assert')
@@ -39,6 +41,7 @@ const visibilityDb = require('../model/visibility/visibility.db')
function settings(t, { stored = {}, overrides = [], viewer = { level: 'public', userId: null }, presence = 'staff', links = [], mates = [] } = {}) {
const saved = {
getSetting: visibilityDb.getSetting,
listSettings: visibilityDb.listSettings,
getOverrides: mapDb.getOverrides,
viewer: visibility.viewer,
presenceFor: visibility.presenceFor,
@@ -47,6 +50,8 @@ function settings(t, { stored = {}, overrides = [], viewer = { level: 'public',
}
const asked = { clans: 0 }
visibilityDb.getSetting = async (key) => (key in stored ? stored[key] : null)
visibilityDb.listSettings = async (prefix) =>
Object.entries(stored).filter(([k]) => k.startsWith(prefix)).map(([setting, value]) => ({ setting, value }))
mapDb.getOverrides = async () => overrides
visibility.viewer = async () => viewer
visibility.presenceFor = async () => presence
@@ -57,6 +62,7 @@ function settings(t, { stored = {}, overrides = [], viewer = { level: 'public',
}
t.after(() => {
visibilityDb.getSetting = saved.getSetting
visibilityDb.listSettings = saved.listSettings
mapDb.getOverrides = saved.getOverrides
visibility.viewer = saved.viewer
visibility.presenceFor = saved.presenceFor
@@ -82,7 +88,7 @@ const LIVE = {
test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => {
settings(t)
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', mates: true })
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', mates: true, markers: {} })
const acc = await map.access({}, 'main')
assert.equal(acc.layers.world.visible, true)
@@ -218,6 +224,118 @@ test('the admin write refuses a word it does not know, and writes nothing on a d
])
})
// ── Which monuments are drawn (PLAN_REDESIGNS §4, D196) ───────────────────
const MONUMENTS = [
{ kind: 'power_sub_big_1', label: 'Substation', x: 1, z: 1 },
{ kind: 'power_sub_small_2', label: 'Substation', x: 2, z: 2 },
{ kind: 'ue_jungle_swamp_a', label: 'jungle swamp', x: 3, z: 3 },
{ kind: 'fishing_village_b', label: 'Fishing Village', x: 4, z: 4 },
{ kind: 'fishing_village_a', label: 'Large Fishing Village', x: 5, z: 5 },
{ kind: 'powerplant_1', label: 'Power Plant', x: 6, z: 6 },
{ kind: 'brand_new_thing', label: 'Something Facepunch Added', x: 7, z: 7 },
]
const labelsOf = (list) => list.map((m) => m.label)
test('D196: a fresh install hides the minor labels and shows everything else, a new label included', () => {
assert.deepStrictEqual(labelsOf(map.filterMonuments(MONUMENTS, {})), [
'Large Fishing Village',
'Power Plant',
'Something Facepunch Added',
])
assert.equal(map.markerShown({}, 'JUNGLE Swamp'), false, 'labels match case- and space-insensitively')
})
test('D196: the fleet turns a minor label on, and a server turns it off again for itself only', async (t) => {
settings(t, {
stored: { 'map.marker.substation': 'on', 'map.marker.power plant': 'off', 'map.marker.jungle swamp': 'maybe' },
overrides: [{ setting: 'map.marker.substation', value: 'off' }],
})
const fleet = await map.fleet()
assert.deepStrictEqual(fleet.markers, { substation: true, 'power plant': false }, 'a word that is neither on nor off follows the default')
const pve = await map.forServer('pve')
assert.deepStrictEqual(pve.markers, { substation: false, 'power plant': false })
assert.deepStrictEqual(labelsOf(map.filterMonuments(MONUMENTS, pve.markers)), ['Large Fishing Village', 'Something Facepunch Added'])
assert.deepStrictEqual(labelsOf(map.filterMonuments(MONUMENTS, fleet.markers)), [
'Substation',
'Substation',
'Large Fishing Village',
'Something Facepunch Added',
])
})
test('D196: the viewer’s access carries the switches that apply to that server', async (t) => {
settings(t, { overrides: [{ setting: 'map.marker.water well', value: 'on' }] })
const acc = await map.access({}, 'main')
assert.deepStrictEqual(acc.markers, { 'water well': true })
})
test('D196: the admin sees each label once, with its count and a capitalised spelling', () => {
const labels = map.markerLabels([...MONUMENTS, { kind: 'ue_jungle_swamp_b', label: 'Jungle Swamp', x: 8, z: 8 }])
assert.deepStrictEqual(
labels.map((l) => [l.key, l.label, l.count]),
[
['fishing village', 'Fishing Village', 1],
['jungle swamp', 'Jungle Swamp', 2],
['large fishing village', 'Large Fishing Village', 1],
['power plant', 'Power Plant', 1],
['something facepunch added', 'Something Facepunch Added', 1],
['substation', 'Substation', 2],
],
)
assert.deepStrictEqual(map.markerLabels(undefined), [])
})
test('D196: the admin write stores a switch per label, clears with null, and refuses a bad key or word', async (t) => {
settings(t)
const writes = []
const saved = { setSetting: visibilityDb.setSetting, clearSetting: visibilityDb.clearSetting, setOverride: mapDb.setOverride }
visibilityDb.setSetting = async (...a) => writes.push(['fleet', ...a])
visibilityDb.clearSetting = async (...a) => writes.push(['fleet-clear', ...a])
mapDb.setOverride = async (...a) => writes.push(['server', ...a])
t.after(() => Object.assign(visibilityDb, { setSetting: saved.setSetting, clearSetting: saved.clearSetting }) && Object.assign(mapDb, { setOverride: saved.setOverride }))
assert.equal((await map.update({ fleet: { markers: { Substation: true } } })).status, 400, 'keys are lower case')
assert.equal((await map.update({ fleet: { markers: { substation: 'on' } } })).status, 400, 'values are booleans')
assert.equal((await map.update({ fleet: { markers: ['substation'] } })).status, 400)
assert.equal((await map.update({ fleet: { markers: { ['x'.repeat(map.MARKER_KEY_MAX + 1)]: true } } })).status, 400)
assert.equal((await map.update({ servers: { pve: { markers: { substation: 'off' } } } })).status, 400)
assert.equal(writes.length, 0)
const done = await map.update(
{ fleet: { world: 'staff', markers: { substation: true, 'water well': null } }, servers: { pve: { markers: { substation: false, 'power plant': null } } } },
{ id: 3 },
)
assert.equal(done.ok, true)
assert.deepStrictEqual(writes, [
['fleet', 'map.layer.world.audience', 'staff', 3],
['fleet', 'map.marker.substation', 'on', 3],
['fleet-clear', 'map.marker.water well'],
['server', 'pve', 'map.marker.substation', 'off', 3],
['server', 'pve', 'map.marker.power plant', null, 3],
])
assert.deepStrictEqual(done.changed, {
fleet: { world: 'staff', markers: { substation: true, 'water well': 'default' } },
servers: { pve: { markers: { substation: false, 'power plant': 'inherit' } } },
})
})
test('D196: the admin card lists the minor labels, and each server’s marker overrides', async (t) => {
settings(t, { stored: { 'map.marker.ranch': 'on' } })
const saved = { listOverrides: mapDb.listOverrides }
mapDb.listOverrides = async () => [{ serverId: 'pve', setting: 'map.marker.substation', value: 'on' }]
t.after(() => Object.assign(mapDb, saved))
const card = await map.describeSwitches([{ id: 'pve' }, { id: 'pvp' }])
assert.ok(card.minorLabels.includes('Substation'))
assert.deepStrictEqual(card.fleet.markers, { ranch: true })
assert.deepStrictEqual(card.servers[0].overrides.markers, { substation: true })
assert.deepStrictEqual(card.servers[0].effective.markers, { ranch: true, substation: true })
assert.deepStrictEqual(card.servers[1].overrides.markers, {})
})
test('derive: a map with no picture is sized like the Rust+ cache would be', () => {
const row = map.derive('main', { mapKey: '4500.7.1', source: 'none', worldSize: 4500, oceanMargin: 500, gridCells: 30, gridCellSize: 150, monuments: [] })
assert.equal(row.width, 3250)