Stage 4 of the §14.3 integration plan, on protocol 14 (docs rust-link/PROTOCOL.md §20.7). - pvp.zone.created is a public kind, pvp.zone.deleted a staff kind. Each row is read per viewer by the new dynamicPvp.js: grid for everyone, the event's name only for Dynamic PVP's own vanilla events, position and zone id for staff only. - The feed's world filter carries the opening: "A PvP zone opened around the downed patrol helicopter · G7 · for 10 min". It names nobody. - The map gains a fifth layer, `pvp` (PvP zones), public by default with its own audience in Admin → Rust visibility, drawn as a red ring with its event's name. - Server settings' plugin line names Dynamic PVP; the swagger schemas and route descriptions carry the layer and the integration (fragment regenerated); ci/bundle.json ships dynamicPvp.js; README lists it. Tests: server 611/611 (dynamicPvp.test.js new, catalogue and map extended) and every check; client 89/89 (feed and map tooltip), build and externals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
671 lines
30 KiB
JavaScript
671 lines
30 KiB
JavaScript
// ── Admin · Rust · Visibility ─────────────────────────────────────────────
|
||
//
|
||
// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
|
||
// is online by default — the narrowest audience, staff, unless an operator
|
||
// deliberately widens it here. A count of players is public at every setting.
|
||
//
|
||
// One fleet default and an optional override per server, because a creative or
|
||
// PvE server may reasonably publish a roll call a PvP server must not — and a
|
||
// server that has not chosen follows the fleet, so narrowing the fleet narrows
|
||
// every server that never said otherwise.
|
||
//
|
||
// The page says what "who is online" covers, because it is wider than the tab
|
||
// of the same name: the killfeed, chat and joins in the feed, and the
|
||
// leaderboard's "last seen" all name a player who was on at a given moment.
|
||
//
|
||
// Phase 9 adds a second setting beside it: who may see a CLAN ROSTER (D48). It
|
||
// defaults to the clan's own members and staff, and widening it widens online
|
||
// status too, because a roster row carries it — the page says so. The same card
|
||
// lists each server's clan board: a server whose clans cannot be read, one at
|
||
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
|
||
// whose clans are a separate system and never Teams (D47).
|
||
//
|
||
// Phase 13b adds a third (D104, D106): whether a published news post is also
|
||
// said in each server's in-game chat. Off by default, because core sends every
|
||
// post to every registered leg — without a switch, the day this module updated,
|
||
// every post would start appearing in every server's chat. It lives here because
|
||
// this is the one page that lists every server with a setting of its own.
|
||
//
|
||
// Phase 14 adds the live map's switches (D114): four layers, each with a fleet
|
||
// audience and an optional per-server override, and the own-and-mates switch
|
||
// (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 = ''
|
||
|
||
const LABEL = {
|
||
staff: 'Staff only',
|
||
signed_in: 'Signed-in members',
|
||
public: 'Everyone',
|
||
}
|
||
|
||
const CLAN_LABEL = {
|
||
members: 'The clan’s members and staff',
|
||
signed_in: 'Signed-in members',
|
||
public: 'Everyone',
|
||
}
|
||
|
||
const CLAN_DESCRIBE = {
|
||
members: 'Players whose linked Rust account is in the clan, plus admins and moderators. The default.',
|
||
signed_in: 'Anybody with an account on this site.',
|
||
public: 'Anybody at all, signed in or not.',
|
||
}
|
||
|
||
const DESCRIBE = {
|
||
staff: 'Admins and moderators. The default.',
|
||
signed_in: 'Anybody with an account on this site.',
|
||
public: 'Anybody at all, signed in or not.',
|
||
}
|
||
|
||
function Card({ title, subtitle, children }) {
|
||
return (
|
||
<section className="panel" style={{ padding: '16px 18px', marginBottom: 18 }}>
|
||
<header style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 12 }}>
|
||
<h2 className="display" style={{ fontSize: '1.05rem', margin: 0, color: 'var(--head)' }}>
|
||
{title}
|
||
</h2>
|
||
{subtitle && (
|
||
<span className="sans dim" style={{ fontSize: '0.76rem' }}>
|
||
{subtitle}
|
||
</span>
|
||
)}
|
||
</header>
|
||
{children}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
|
||
return (
|
||
<select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle} aria-label={label}>
|
||
{inherit && <option value={INHERIT}>{inherit}</option>}
|
||
{audiences.map((a) => (
|
||
<option key={a} value={a}>{LABEL[a] || a}</option>
|
||
))}
|
||
</select>
|
||
)
|
||
}
|
||
|
||
export default function Visibility() {
|
||
const [reloads, setReloads] = useState(0)
|
||
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
|
||
|
||
const [fleet, setFleet] = useState('staff')
|
||
const [clanRoster, setClanRoster] = useState('members')
|
||
const [servers, setServers] = useState({})
|
||
const [news, setNews] = useState({})
|
||
const [delivery, setDelivery] = useState({})
|
||
const [mapFleet, setMapFleet] = useState({})
|
||
const [mapServers, setMapServers] = useState({})
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [saved, setSaved] = useState(false)
|
||
|
||
// The form starts from what the server said and is reset from it after every
|
||
// save — the answer to a PUT is the new state, so what is on screen is always
|
||
// the site's word rather than what this page sent.
|
||
const load = useCallback((state) => {
|
||
setFleet(state.presence.fleet)
|
||
setClanRoster((state.clans && state.clans.roster) || 'members')
|
||
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
|
||
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, markers: { ...(state.map.fleet.markers || {}) } })
|
||
setMapServers(Object.fromEntries(state.map.servers.map((s) => [s.id, mapOverridesToForm(s.overrides)])))
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (data) load(data)
|
||
}, [data, load])
|
||
|
||
if (loadError) return <ErrorState error={loadError} />
|
||
if (!data) return <Loading />
|
||
|
||
const audiences = data.audiences
|
||
const rows = data.presence.servers
|
||
|
||
const dirtyFleet = fleet !== data.presence.fleet
|
||
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
|
||
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
|
||
const dirtyClans = clanRoster !== clans.roster
|
||
const newsRows = (data.news && data.news.servers) || []
|
||
const dirtyNews = newsRows.filter((s) => Boolean(news[s.id]) !== Boolean(s.on))
|
||
const dirtyDelivery = newsRows.filter((s) => (delivery[s.id] || 'chat') !== (s.delivery || 'chat'))
|
||
const mapCard = data.map || null
|
||
const mapChanges = mapCard ? mapDiff(mapCard, mapFleet, mapServers) : null
|
||
const dirtyMap = Boolean(mapChanges)
|
||
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0 || dirtyDelivery.length > 0 || dirtyMap
|
||
|
||
const effective = (id) => servers[id] || fleet
|
||
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
|
||
|
||
const save = async (e) => {
|
||
e.preventDefault()
|
||
setBusy(true)
|
||
setError('')
|
||
setSaved(false)
|
||
try {
|
||
const body = {}
|
||
if (dirtyFleet) body.fleet = fleet
|
||
if (dirtyClans) body.clanRoster = clanRoster
|
||
if (dirtyServers.length) {
|
||
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
|
||
}
|
||
if (dirtyNews.length) body.news = Object.fromEntries(dirtyNews.map((s) => [s.id, Boolean(news[s.id])]))
|
||
if (dirtyDelivery.length) body.newsDelivery = Object.fromEntries(dirtyDelivery.map((s) => [s.id, delivery[s.id] || 'chat']))
|
||
if (dirtyMap) body.map = mapChanges
|
||
load(await api.adminVisibility.save(body))
|
||
setSaved(true)
|
||
setReloads((n) => n + 1)
|
||
} catch (err) {
|
||
setError(err.message || 'That did not save.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={save} style={{ maxWidth: 900 }}>
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 0 }}>
|
||
Nothing on this site names who is online unless you choose to show it. That covers more than
|
||
the Online tab: the joins, deaths and chat in each server’s feed, and the leaderboard’s “last
|
||
seen”, all say that a named player was on at a given moment. How many players are online is
|
||
always shown.
|
||
</p>
|
||
|
||
<Card title="Who is online" subtitle="the default for every server">
|
||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
|
||
<AudienceSelect value={fleet} onChange={setFleet} audiences={audiences} label="Fleet default" />
|
||
<span className="dim" style={{ fontSize: '0.78rem' }}>{DESCRIBE[fleet]}</span>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card title="Per server" subtitle="an override, or the default above">
|
||
{rows.length === 0 && (
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||
)}
|
||
{rows.map((s) => (
|
||
<div
|
||
key={s.id}
|
||
className="sans"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
padding: '8px 0',
|
||
borderTop: '1px solid var(--line-soft)',
|
||
fontSize: '0.86rem',
|
||
}}
|
||
>
|
||
<span style={{ minWidth: 180, color: 'var(--head)' }}>
|
||
{s.name}
|
||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||
</span>
|
||
<AudienceSelect
|
||
value={servers[s.id] ?? INHERIT}
|
||
onChange={(v) => setServers((prev) => ({ ...prev, [s.id]: v }))}
|
||
audiences={audiences}
|
||
inherit={`Default (${LABEL[fleet] || fleet})`}
|
||
label={`Who is online on ${s.name}`}
|
||
/>
|
||
<span className="dim" style={{ fontSize: '0.78rem' }}>
|
||
{servers[s.id] ? 'its own setting' : 'follows the default'}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</Card>
|
||
|
||
{widened && (
|
||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem' }}>
|
||
Wider than staff: on a PvP server, knowing who is on tells a raiding party whose base is
|
||
undefended.
|
||
</p>
|
||
)}
|
||
|
||
<Card title="Clan rosters" subtitle="who is in each clan, on every server">
|
||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12, fontSize: '0.86rem' }}>
|
||
<select
|
||
value={clanRoster}
|
||
onChange={(e) => setClanRoster(e.target.value)}
|
||
style={selectStyle}
|
||
aria-label="Who may see a clan roster"
|
||
>
|
||
{clans.audiences.map((a) => (
|
||
<option key={a} value={a}>{CLAN_LABEL[a] || a}</option>
|
||
))}
|
||
</select>
|
||
<span className="dim" style={{ fontSize: '0.78rem' }}>{CLAN_DESCRIBE[clanRoster]}</span>
|
||
</div>
|
||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '10px 0 0' }}>
|
||
Each clan’s name, colour, score and member count are always public.
|
||
</p>
|
||
{clanRoster !== 'members' && (
|
||
<p className="sans" style={{ color: '#d08a2a', fontSize: '0.8rem', margin: '8px 0 0' }}>
|
||
A roster also shows which members are online right now, so this shows who is on to{' '}
|
||
{clanRoster === 'public' ? 'everyone' : 'every signed-in member'} as well.
|
||
</p>
|
||
)}
|
||
<ClanBoards servers={clans.servers || []} />
|
||
</Card>
|
||
|
||
<Card title="News in game chat" subtitle="a published news post, said in each server’s chat">
|
||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 8px' }}>
|
||
When a news post is published, its title is said in the chat of every server switched on
|
||
here. A server that is down when a post is published is skipped rather than told late. A popup
|
||
needs PopupNotifications on that server; one without it refuses the post and says why.
|
||
</p>
|
||
{newsRows.length === 0 && (
|
||
<p className="sans dim" style={{ fontSize: '0.82rem', margin: 0 }}>No servers are configured yet.</p>
|
||
)}
|
||
{newsRows.map((s) => (
|
||
<label
|
||
key={s.id}
|
||
className="sans"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
padding: '8px 0',
|
||
borderTop: '1px solid var(--line-soft)',
|
||
fontSize: '0.86rem',
|
||
cursor: 'pointer',
|
||
}}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(news[s.id])}
|
||
onChange={(e) => setNews((prev) => ({ ...prev, [s.id]: e.target.checked }))}
|
||
aria-label={`Say news in ${s.name}’s chat`}
|
||
/>
|
||
<span style={{ minWidth: 180, color: 'var(--head)' }}>
|
||
{s.name}
|
||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||
</span>
|
||
<span className="dim" style={{ fontSize: '0.78rem' }}>{news[s.id] ? 'says news' : 'off'}</span>
|
||
{/* D142: where the post goes on this server when it is switched on. */}
|
||
<select
|
||
value={delivery[s.id] || 'chat'}
|
||
onChange={(e) => setDelivery((prev) => ({ ...prev, [s.id]: e.target.value }))}
|
||
disabled={!news[s.id]}
|
||
aria-label={`Where news goes on ${s.name}`}
|
||
style={{ marginLeft: 'auto', background: 'var(--panel-flat, transparent)', color: 'var(--text)', border: '1px solid var(--line)', borderRadius: 'var(--radius-input, 6px)', padding: '3px 6px', fontSize: '0.8rem' }}
|
||
>
|
||
<option value="chat">in chat</option>
|
||
<option value="popup">as a popup</option>
|
||
</select>
|
||
</label>
|
||
))}
|
||
</Card>
|
||
|
||
{mapCard && (
|
||
<MapCard
|
||
card={mapCard}
|
||
audiences={audiences}
|
||
presenceOf={(id) => effective(id)}
|
||
fleet={mapFleet}
|
||
setFleet={setMapFleet}
|
||
servers={mapServers}
|
||
setServers={setMapServers}
|
||
onActed={() => setReloads((n) => n + 1)}
|
||
/>
|
||
)}
|
||
|
||
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<button type="submit" className="btn" disabled={busy || !dirty}>
|
||
{busy ? 'Saving…' : 'Save'}
|
||
</button>
|
||
{saved && !dirty && <span className="dim" style={{ fontSize: '0.8rem' }}>Saved.</span>}
|
||
{error && <span style={{ color: '#d08a2a', fontSize: '0.8rem' }}>{error}</span>}
|
||
</div>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* What each server's clan board says about itself. Only the servers with
|
||
* something to report are listed: a board that is current, complete and read
|
||
* normally is the case that needs no sentence.
|
||
*/
|
||
function ClanBoards({ servers }) {
|
||
const notes = []
|
||
for (const s of servers) {
|
||
if (s.umodClans) {
|
||
notes.push([s, 'is running the uMod Clans plugin. Its clans are a separate system from the game’s own, and only the game’s clans appear on this site.'])
|
||
}
|
||
if (!s.supported) {
|
||
notes.push([s, s.reason ? `cannot report its clans: ${s.reason}.` : 'has not reported its clans yet.'])
|
||
} else if (s.truncated) {
|
||
notes.push([s, 'is at the game’s limit of 100 listed clans, so clans beyond the top 100 by score are not shown, and a disbanded clan is not removed until it drops below.'])
|
||
} else if (!s.fresh) {
|
||
notes.push([s, 'has not reported its clans recently, so they are shown as last reported.'])
|
||
}
|
||
}
|
||
if (!notes.length) return null
|
||
return (
|
||
<ul className="sans" style={{ margin: '12px 0 0', paddingLeft: 18, fontSize: '0.8rem' }}>
|
||
{notes.map(([s, text], i) => (
|
||
// eslint-disable-next-line react/no-array-index-key
|
||
<li key={`${s.id}-${i}`} style={{ margin: '4px 0' }}>
|
||
<strong style={{ color: 'var(--head)' }}>{s.name}</strong> {text}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
|
||
const selectStyle = {
|
||
background: 'var(--panel-flat, transparent)',
|
||
color: 'var(--text)',
|
||
border: '1px solid var(--line)',
|
||
borderRadius: 'var(--radius-input, 6px)',
|
||
padding: '4px 8px',
|
||
fontSize: '0.84rem',
|
||
}
|
||
|
||
// ── The live map (phase 14) ───────────────────────────────────────────────
|
||
|
||
const MAP_LAYERS = [
|
||
{ id: 'world', label: 'Monuments & world events', hint: 'Cargo, helicopters, Bradley, supply drops, locked crates, and RunicNPC bosses placed outside an event.' },
|
||
{ id: 'events', label: 'Site events', hint: 'The zones, crates and NPCs this site’s events placed, while they run.' },
|
||
{ id: 'players', label: 'Players', hint: 'Where everybody is, with names, and the sleepers.' },
|
||
{ id: 'bases', label: 'Bases', hint: 'Tool cupboards and vending machines, as positions only.' },
|
||
{ id: 'pvp', label: 'PvP zones', hint: 'Dynamic PVP’s own PvP zones around a downed helicopter, a Bradley, cargo and the rest. Needs Dynamic PVP on the server.' },
|
||
]
|
||
|
||
const RANK = { public: 0, signed_in: 1, staff: 2 }
|
||
|
||
/** A server's overrides as form values: '' follows the fleet, mates is 'on' / 'off'. */
|
||
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
|
||
}
|
||
|
||
/** What the form changed, in the shape the PUT takes, or null when nothing did. */
|
||
function mapDiff(card, fleet, servers) {
|
||
const out = {}
|
||
const fleetChanges = {}
|
||
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 = {}
|
||
for (const s of card.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 (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]
|
||
}
|
||
if (Object.keys(changed).length) serverChanges[s.id] = changed
|
||
}
|
||
if (Object.keys(serverChanges).length) out.servers = serverChanges
|
||
return Object.keys(out).length ? out : null
|
||
}
|
||
|
||
function MapCard({ card, audiences, presenceOf, fleet, setFleet, servers, setServers, onActed }) {
|
||
const [acting, setActing] = useState(null)
|
||
const [notes, setNotes] = useState({})
|
||
|
||
// Fetch again and Render now act at once rather than on Save: they are
|
||
// questions put to a game server, not settings.
|
||
const act = async (server, kind) => {
|
||
setActing(`${server.id}:${kind}`)
|
||
setNotes((n) => ({ ...n, [server.id]: '' }))
|
||
try {
|
||
if (kind === 'fetch') {
|
||
const r = await api.admin.fetchMap(server.id)
|
||
setNotes((n) => ({ ...n, [server.id]: r.message || `Fetched: ${r.outcome}.` }))
|
||
} else {
|
||
const r = await api.admin.renderMap(server.id)
|
||
setNotes((n) => ({
|
||
...n,
|
||
[server.id]: `The server is drawing its map now — about ${r.stallSeconds} seconds of stall. The picture appears here when it is done.`,
|
||
}))
|
||
}
|
||
onActed()
|
||
} catch (err) {
|
||
setNotes((n) => ({ ...n, [server.id]: err.message || 'That did not work.' }))
|
||
} finally {
|
||
setActing(null)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card title="The live map" subtitle="what each server’s map shows, and to whom">
|
||
<p className="sans dim" style={{ fontSize: '0.8rem', margin: '0 0 10px' }}>
|
||
The picture of the map is always public: it is drawn from the map seed and says nothing about who plays.
|
||
Everything that moves on it is a layer, and each layer has its own audience. The players layer can never
|
||
show more than who is online does, whatever it is set to here.
|
||
</p>
|
||
|
||
<div
|
||
className="sans"
|
||
style={{ display: 'grid', gridTemplateColumns: 'minmax(180px, 1fr) auto', gap: '6px 12px', alignItems: 'center', fontSize: '0.86rem' }}
|
||
>
|
||
{MAP_LAYERS.map((l) => (
|
||
<FleetRow key={l.id} label={l.label} hint={l.hint}>
|
||
<AudienceSelect
|
||
value={fleet[l.id] || 'staff'}
|
||
onChange={(v) => setFleet((f) => ({ ...f, [l.id]: v }))}
|
||
audiences={audiences}
|
||
label={`${l.label}: fleet default`}
|
||
/>
|
||
</FleetRow>
|
||
))}
|
||
<FleetRow label="You and your clan" hint="A linked player sees themselves and their online clan mates, whatever the players layer says.">
|
||
<select
|
||
value={fleet.mates ? 'on' : 'off'}
|
||
onChange={(e) => setFleet((f) => ({ ...f, mates: e.target.value === 'on' }))}
|
||
style={selectStyle}
|
||
aria-label="You and your clan: fleet default"
|
||
>
|
||
<option value="on">On</option>
|
||
<option value="off">Off</option>
|
||
</select>
|
||
</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>
|
||
)}
|
||
|
||
{card.servers.map((s) => {
|
||
const form = servers[s.id] || mapOverridesToForm(s.overrides)
|
||
const set = (key, value) => setServers((prev) => ({ ...prev, [s.id]: { ...form, [key]: value } }))
|
||
const players = form.players || fleet.players
|
||
const presence = presenceOf(s.id)
|
||
const capped = RANK[players] < RANK[presence]
|
||
const noPicture = !s.picture || !s.picture.hasPicture
|
||
|
||
return (
|
||
<div key={s.id} className="sans" style={{ borderTop: '1px solid var(--line-soft)', padding: '10px 0', fontSize: '0.84rem' }}>
|
||
<div style={{ color: 'var(--head)', marginBottom: 6 }}>
|
||
{s.name}
|
||
{!s.enabled && <span className="dim" style={{ fontSize: '0.74rem' }}> · disabled</span>}
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||
{MAP_LAYERS.map((l) => (
|
||
<label key={l.id} className="dim" style={{ display: 'inline-flex', flexDirection: 'column', gap: 2, fontSize: '0.74rem' }}>
|
||
{l.label}
|
||
<AudienceSelect
|
||
value={form[l.id]}
|
||
onChange={(v) => set(l.id, v)}
|
||
audiences={audiences}
|
||
inherit={`Default (${LABEL[fleet[l.id]] || fleet[l.id]})`}
|
||
label={`${l.label} on ${s.name}`}
|
||
/>
|
||
</label>
|
||
))}
|
||
<label className="dim" style={{ display: 'inline-flex', flexDirection: 'column', gap: 2, fontSize: '0.74rem' }}>
|
||
You and your clan
|
||
<select value={form.mates} onChange={(e) => set('mates', e.target.value)} style={selectStyle} aria-label={`You and your clan on ${s.name}`}>
|
||
<option value={INHERIT}>{`Default (${fleet.mates ? 'On' : 'Off'})`}</option>
|
||
<option value="on">On</option>
|
||
<option value="off">Off</option>
|
||
</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.
|
||
</p>
|
||
)}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10, marginTop: 8, fontSize: '0.78rem' }}>
|
||
<span className="dim">{describePicture(s.picture)}</span>
|
||
<button type="button" className="btn" disabled={Boolean(acting) || s.fetching} onClick={() => act(s, 'fetch')}>
|
||
{acting === `${s.id}:fetch` || s.fetching ? 'Fetching…' : 'Fetch again'}
|
||
</button>
|
||
{noPicture && (
|
||
<button type="button" className="btn" disabled={Boolean(acting) || s.rendering} onClick={() => act(s, 'render')}>
|
||
{s.rendering ? 'Drawing…' : 'Render now'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{noPicture && (
|
||
<p style={{ color: '#d08a2a', fontSize: '0.76rem', margin: '6px 0 0' }}>
|
||
Render now makes the game draw its own map, which stops that server for about {s.renderStallSeconds} seconds —
|
||
nobody on it can move while it draws. A server with Rust+ switched on (<code>app.port</code>) never needs it.
|
||
</p>
|
||
)}
|
||
{(notes[s.id] || s.lastError) && (
|
||
<p className="dim" style={{ fontSize: '0.76rem', margin: '6px 0 0' }}>{notes[s.id] || s.lastError}</p>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<>
|
||
<span>
|
||
<span style={{ color: 'var(--head)' }}>{label}</span>
|
||
<span className="dim" style={{ display: 'block', fontSize: '0.74rem' }}>{hint}</span>
|
||
</span>
|
||
{children}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function describePicture(pic) {
|
||
if (!pic) return 'The server has not described its map yet.'
|
||
if (!pic.hasPicture) return `Map ${pic.mapKey}: the game has no picture of it.`
|
||
const from = pic.source === 'companion' ? 'from Rust+' : 'drawn on request'
|
||
const kb = Math.round((pic.bytes || 0) / 1024)
|
||
return `Map ${pic.mapKey}: picture ${from}, ${pic.width} × ${pic.height}, ${kb} KB${pic.fetchedAt ? `, fetched ${new Date(pic.fetchedAt).toLocaleString()}` : ''}.`
|
||
}
|