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:
89
client/src/lib/mapMarkers.js
Normal file
89
client/src/lib/mapMarkers.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// ── 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()
|
||||
for (const s of servers || []) {
|
||||
for (const l of s.markerLabels || []) {
|
||||
const seen = byKey.get(l.key)
|
||||
if (seen) seen.count += l.count || 0
|
||||
else byKey.set(l.key, { key: l.key, label: l.label, count: l.count || 0 })
|
||||
}
|
||||
}
|
||||
for (const label of minorLabels || []) {
|
||||
const key = markerKey(label)
|
||||
if (!byKey.has(key)) byKey.set(key, { key, label, count: 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
|
||||
}
|
||||
@@ -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
|
||||
</FleetRow>
|
||||
</div>
|
||||
|
||||
<FleetMarkers card={card} fleet={fleet} setFleet={setFleet} />
|
||||
|
||||
{card.servers.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: '12px 0 0' }}>No servers are configured yet.</p>
|
||||
)}
|
||||
@@ -517,6 +531,13 @@ function MapCard({ card, audiences, presenceOf, fleet, setFleet, servers, setSer
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<ServerMarkers
|
||||
server={s}
|
||||
minorLabels={card.minorLabels || []}
|
||||
fleetMarkers={fleet.markers}
|
||||
markers={form.markers}
|
||||
onChange={(next) => set('markers', next)}
|
||||
/>
|
||||
{capped && (
|
||||
<p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 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 (
|
||||
<details className="sans" style={{ marginTop: 12, fontSize: '0.84rem' }}>
|
||||
<summary style={{ cursor: 'pointer', color: 'var(--head)' }}>
|
||||
Monuments on the map <span className="dim" style={{ fontSize: '0.76rem' }}>· {labels.length} labels, {hidden} hidden by default</span>
|
||||
</summary>
|
||||
<p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 8px' }}>
|
||||
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.
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px, 1fr))', gap: '4px 12px' }}>
|
||||
{labels.map((l) => (
|
||||
<label key={l.key} style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fleetShows(minor, fleet.markers, l.key)}
|
||||
onChange={(e) => setFleet((f) => ({ ...f, markers: setFleetMarker(minor, f.markers, l.key, e.target.checked) }))}
|
||||
aria-label={`Draw ${l.label} by default`}
|
||||
/>
|
||||
<span>{l.label}</span>
|
||||
{l.count > 0 && <span className="dim" style={{ fontSize: '0.72rem' }}>{l.count}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<details style={{ marginTop: 8, fontSize: '0.8rem' }}>
|
||||
<summary style={{ cursor: 'pointer' }} className="dim">
|
||||
Monuments: {labels.length} labels on this map, {hidden} hidden{own ? `, ${own} set for this server` : ''}
|
||||
</summary>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '4px 12px', marginTop: 6 }}>
|
||||
{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 (
|
||||
<label key={l.key} style={{ display: 'flex', alignItems: 'center', gap: 6, justifyContent: 'space-between' }}>
|
||||
<span>
|
||||
{l.label} <span className="dim" style={{ fontSize: '0.72rem' }}>{l.count}</span>
|
||||
</span>
|
||||
<select
|
||||
value={mine}
|
||||
onChange={(e) => onChange(setServerMarker(markers, l.key, e.target.value === INHERIT ? null : e.target.value === 'on'))}
|
||||
style={{ ...selectStyle, fontSize: '0.78rem', padding: '2px 6px' }}
|
||||
aria-label={`${l.label} on ${server.name}`}
|
||||
>
|
||||
<option value={INHERIT}>{`Default (${fleetOn ? 'drawn' : 'hidden'})`}</option>
|
||||
<option value="on">Drawn</option>
|
||||
<option value="off">Hidden</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
function FleetRow({ label, hint, children }) {
|
||||
return (
|
||||
<>
|
||||
|
||||
65
client/test/mapMarkers.test.js
Normal file
65
client/test/mapMarkers.test.js
Normal file
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user