feat(rust): the live map (phase 14, protocol 11)
All checks were successful
PR Checks / server-tests (pull_request) Successful in 28s
PR Checks / client-build (pull_request) Successful in 27s
PR Checks / frozen-manifest (pull_request) Successful in -1m9s

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:
2026-09-25 01:06:10 -05:00
parent ac0bcd850a
commit 0cb9bdd1f0
34 changed files with 4680 additions and 28 deletions

View File

@@ -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()}` : ''}.`
}

View File

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