feat(rust): the live map (phase 14, protocol 11)
PLAN.md §30 as approved, plus D119/D120 from the build. Server: - rust_map_images (one row per server: picture as MEDIUMBLOB, geometry, monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair. - mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and asks map.info; a new key or hash from the free Rust+ cache (or a render kept on disk) is fetched in slices, checked against its SHA-256 and stored in one statement. One fetch per server, a backoff on failure, `stale` abandons a fetch that straddles a map change. Render now (D109) is admin-only and watched to completion. - mapLive.js: D111. One map.live per server per 5 s whoever asks; positions are held in memory only. - model/map: four layers (world, events public; players, bases staff), a fleet default plus per-server override (D114), the players layer capped by presence (D113), own dot and online first-party clan mates for a linked viewer (D115, D117, D118). A layer the viewer may not see is absent from the answer, never sent and hidden. - Routes: public /servers/:id/map, /map/image (immutable under its hash), /map/live; admin /servers/:id/map/fetch and /render; the Map card on the visibility PUT. Swagger fragment and frozen manifest regenerated. Client: - A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid (labels only when a cell is wide enough to hold one), a legend that lists hidden layers with who can see them, polled every 10 s while visible. - D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml copies every dist/*.js; checkExternals and build.test.js hold both ends. - The Map card on Admin -> Rust visibility, with Fetch again and Render now. Capability `map` declared for the Android app (phase 15). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -25,6 +25,12 @@
|
||||
// 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).
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
@@ -94,6 +100,8 @@ export default function Visibility() {
|
||||
const [clanRoster, setClanRoster] = useState('members')
|
||||
const [servers, setServers] = useState({})
|
||||
const [news, setNews] = useState({})
|
||||
const [mapFleet, setMapFleet] = useState({})
|
||||
const [mapServers, setMapServers] = useState({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saved, setSaved] = useState(false)
|
||||
@@ -106,6 +114,10 @@ export default function Visibility() {
|
||||
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)])))
|
||||
if (state.map) {
|
||||
setMapFleet({ ...state.map.fleet })
|
||||
setMapServers(Object.fromEntries(state.map.servers.map((s) => [s.id, mapOverridesToForm(s.overrides)])))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -124,7 +136,10 @@ export default function Visibility() {
|
||||
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 dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans || dirtyNews.length > 0
|
||||
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 || dirtyMap
|
||||
|
||||
const effective = (id) => servers[id] || fleet
|
||||
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
|
||||
@@ -142,6 +157,7 @@ export default function Visibility() {
|
||||
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 (dirtyMap) body.map = mapChanges
|
||||
load(await api.adminVisibility.save(body))
|
||||
setSaved(true)
|
||||
setReloads((n) => n + 1)
|
||||
@@ -273,6 +289,19 @@ export default function Visibility() {
|
||||
))}
|
||||
</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'}
|
||||
@@ -324,3 +353,202 @@ const selectStyle = {
|
||||
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.' },
|
||||
{ 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.' },
|
||||
]
|
||||
|
||||
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'
|
||||
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]
|
||||
}
|
||||
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 = {}
|
||||
for (const key of Object.keys(before)) {
|
||||
if (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>
|
||||
|
||||
{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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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()}` : ''}.`
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../c
|
||||
import Clans from '../../components/Clans.jsx'
|
||||
import Feed from '../../components/Feed.jsx'
|
||||
import Leaderboard from '../../components/Leaderboard.jsx'
|
||||
import MapView from '../../components/MapView.jsx'
|
||||
import Online from '../../components/Online.jsx'
|
||||
import Tabs from '../../components/Tabs.jsx'
|
||||
import WipeSelect, { ALL_TIME } from '../../components/WipeSelect.jsx'
|
||||
@@ -37,6 +38,8 @@ const TABS = [
|
||||
{ id: 'feed', label: 'Feed' },
|
||||
{ id: 'leaderboard', label: 'Leaderboard' },
|
||||
{ id: 'online', label: 'Online' },
|
||||
// Phase 14. The picture is public; what moves on it has an audience per layer.
|
||||
{ id: 'map', label: 'Map' },
|
||||
{ id: 'wipes', label: 'Wipes' },
|
||||
// Phase 9. The list is public (D58); each clan's roster is on its own page.
|
||||
{ id: 'clans', label: 'Clans' },
|
||||
@@ -155,6 +158,8 @@ export default function ServerDetail() {
|
||||
|
||||
{tab === 'online' && <Online serverId={server.id} online={server.online} />}
|
||||
|
||||
{tab === 'map' && <MapView serverId={server.id} online={server.online} />}
|
||||
|
||||
{tab === 'clans' && <Clans serverId={server.id} />}
|
||||
|
||||
{tab === 'wipes' && (
|
||||
|
||||
Reference in New Issue
Block a user