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

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