diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index ae78e71..30884d9 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -336,10 +336,13 @@ jobs:
cp -r "server/$d" "$OUT/server/"
done
- # The client half is the BUILT chunk only. `client/src` is source an
- # operator has no use for and core will never read.
+ # The client half is the BUILT chunks only. `client/src` is source an
+ # operator has no use for and core will never read. Every `.js`, not
+ # `entry.js` by name: since D120 the Map tab imports Leaflet's split
+ # chunk from beside it, and a release that shipped the entry alone
+ # would load everywhere and spin for ever on that tab.
mkdir -p "$OUT/client/dist"
- cp client/dist/entry.js "$OUT/client/dist/"
+ cp client/dist/*.js "$OUT/client/dist/"
# Prove the bundle is loadable before it is published: these are the
# paths core's loader resolves out of module.json, and a release whose
diff --git a/ci/bundle.json b/ci/bundle.json
index 3df9ecf..5b46c58 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -38,6 +38,8 @@
"eventWorld.js",
"index.js",
"ingest.js",
+ "mapImages.js",
+ "mapLive.js",
"model",
"package.json",
"permSync.js",
diff --git a/client/package-lock.json b/client/package-lock.json
index fa61633..0700ab6 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -10,6 +10,7 @@
"license": "GPL-3.0-or-later",
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
+ "leaflet": "1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
@@ -1448,6 +1449,13 @@
"node": ">=6"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "dev": true,
+ "license": "BSD-2-Clause"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
diff --git a/client/package.json b/client/package.json
index f57233f..03212bf 100644
--- a/client/package.json
+++ b/client/package.json
@@ -16,6 +16,7 @@
"//dependencies": "Deliberately none that ship. react, react-dom/client, react/jsx-runtime and react-router-dom are aliased to the shims in src/shim/ and arrive at runtime on window.__rg - there is exactly one React in the page and core owns it (MODULE_API.md 3.2, 3.6). They are devDependencies so that Vite and the JSX transform can resolve them during the build, and for no other reason.",
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
+ "leaflet": "1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
diff --git a/client/scripts/checkExternals.js b/client/scripts/checkExternals.js
index 031b226..b54b5ef 100644
--- a/client/scripts/checkExternals.js
+++ b/client/scripts/checkExternals.js
@@ -82,9 +82,10 @@ export function stringMask(src) {
return inString
}
-// Static and dynamic imports that survived into the output. A relative or
-// absolute specifier is a chunk that was split, which this build does not do —
-// `lib` mode with one entry emits one file — so anything here is a bare name.
+// Static and dynamic imports that survived into the output. A relative
+// specifier is a chunk that was split — since D120 there is one, Leaflet's,
+// which the Map tab imports with `import()` — and is `relativeImports`'s
+// concern below; a bare name is this check's.
//
// **This pattern used to require whitespace after `import`, and so could not see
// the one shape the build actually emits.** Minified Rollup output is
@@ -116,6 +117,28 @@ export function bareImports(chunk) {
return [...bare]
}
+/**
+ * Every relative specifier the chunk imports — its split chunks (D120).
+ *
+ * Each one is a file the browser will ask for beside `entry.js`, and core
+ * serves that directory, so it works in development. Whether it SHIPS is
+ * `release.yml`'s business, which is why the script below also asserts every
+ * one of them exists in `dist/`, and `test/build.test.js` asserts the release
+ * copies every `.js` in `dist/` rather than naming `entry.js`. A split chunk the
+ * release forgot is a Map tab that spins for ever on an operator's site while
+ * every check here passes.
+ */
+export function relativeImports(chunk) {
+ const masked = stringMask(chunk)
+ const found = new Set()
+ for (const match of chunk.matchAll(IMPORTS)) {
+ const keywordAt = match.index + (match[0].startsWith('import') ? 0 : 1)
+ if (masked[keywordAt]) continue
+ if (match[1].startsWith('./')) found.add(match[1].slice(2))
+ }
+ return [...found]
+}
+
// Fingerprints from the shared libraries' own source. Each is a string those
// packages ship and this module has no other reason to contain.
//
@@ -161,12 +184,30 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me
console.error(`No chunk at ${CHUNK} — run \`npm run build\` first.`)
process.exit(1)
}
- const problems = problemsWith(fs.readFileSync(CHUNK, 'utf8'))
+ const dist = path.dirname(CHUNK)
+ const entry = fs.readFileSync(CHUNK, 'utf8')
+ const problems = []
+
+ // Every chunk, not only the entry: a split chunk that bundled a second React
+ // would load on the tab that imports it and fail there, and nowhere else.
+ for (const file of fs.readdirSync(dist).filter((f) => f.endsWith('.js'))) {
+ for (const p of problemsWith(fs.readFileSync(path.join(dist, file), 'utf8'))) problems.push(`${file}: ${p}`)
+ }
+ for (const name of relativeImports(entry)) {
+ if (!fs.existsSync(path.join(dist, name))) {
+ problems.push(`entry.js imports ./${name}, which is not in dist/ — the chunk would load and that import would fail`)
+ }
+ }
+
if (problems.length) {
console.error('\nThe built chunk breaks the shared-dependency rule:\n')
for (const p of problems) console.error(` - ${p}\n`)
process.exit(1)
}
const kb = (fs.statSync(CHUNK).size / 1024).toFixed(1)
- console.log(`OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency.`)
+ const split = relativeImports(entry)
+ console.log(
+ `OK — dist/entry.js (${kb} kB) has no bare imports and bundles no shared dependency` +
+ (split.length ? `; its ${split.length} split chunk(s) (${split.join(', ')}) are present and clean.` : '.'),
+ )
}
diff --git a/client/src/api.js b/client/src/api.js
index 023325a..373d6d1 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -51,6 +51,11 @@ export const servers = {
// Phase 9. The clan list is public (D58): name, colour, score and member count
// name nobody. `board` says whether the list can be trusted right now.
clans: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/clans`),
+
+ // Phase 14. The map's picture address, geometry and which layers this viewer
+ // gets; then what moves on it, already cut down to this viewer on the server.
+ map: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map`),
+ mapLive: (id) => req(`/public/rust/servers/${encodeURIComponent(id)}/map/live`),
}
// One clan. Its roster comes back only for a viewer inside the operator's roster
@@ -123,6 +128,10 @@ export const admin = {
req(`/admin/rust/servers/${encodeURIComponent(id)}`, { method: 'DELETE' }),
testServer: (id) =>
req(`/admin/rust/servers/${encodeURIComponent(id)}/test`, { method: 'POST' }),
+ // Phase 14: fetch a server's map picture again, or ask a server with no
+ // picture to draw one — which stalls that game for seconds (D109).
+ fetchMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/fetch`, { method: 'POST' }),
+ renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }),
}
// ── admin · permissions (R2) ──────────────────────────────────────────────
diff --git a/client/src/components/MapView.jsx b/client/src/components/MapView.jsx
new file mode 100644
index 0000000..75bf79e
--- /dev/null
+++ b/client/src/components/MapView.jsx
@@ -0,0 +1,358 @@
+// ── The live map ──────────────────────────────────────────────────────────
+//
+// R9's page, as PLAN.md §30.2 drew it: the picture of the current map under
+// four layers, each with its own switch, polled every ten seconds while the tab
+// is visible (D14) and not at all while it is hidden.
+//
+// **Nothing here decides who may see what.** The server sends only the layers
+// this viewer may see — a hidden one is absent from the answer, not present and
+// hidden — so the checkboxes below are a reader's convenience and never a
+// boundary. A layer the viewer cannot see is still LISTED, disabled, with who
+// can: "staff only" explains an empty map where silence would imply an empty
+// server (§23.3's shape).
+//
+// Leaflet arrives in a chunk of its own when this tab first mounts (D120, see
+// `lib/leaflet.js`). The page draws with circle and div markers only, so
+// Leaflet's image assets are never needed.
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { ErrorState, Loading, useAsync } from '../core.js'
+import Empty from './Empty.jsx'
+import usePolled from '../hooks/usePolled.js'
+import { ago } from '../lib/format.js'
+import { boundsOf, countdown, grid, gridLabel, toLatLng } from '../lib/mapGeometry.js'
+import api, { BASE } from '../api.js'
+
+const STYLE_ID = 'rust-leaflet-css'
+
+/** The narrowest a grid cell may be on screen, in pixels, and still carry its label. */
+const LABEL_MIN_CELL_PX = 30
+
+/** The layers, in the order the legend lists them, with their marker colours. */
+const LAYERS = [
+ { id: 'world', label: 'Monuments & world events' },
+ { id: 'events', label: 'Site events' },
+ { id: 'players', label: 'Players' },
+ { id: 'bases', label: 'Bases' },
+]
+
+const COLOURS = {
+ monument: '#e8d9a8',
+ cargo: '#4fc3f7',
+ heli: '#ef5350',
+ chinook: '#ffa726',
+ bradley: '#a1887f',
+ supply: '#66bb6a',
+ crate: '#ffee58',
+ event: '#ce93d8',
+ online: '#ffffff',
+ sleeping: '#9e9e9e',
+ tc: '#ff7043',
+ vending: '#26a69a',
+ self: '#00e5ff',
+ mate: '#7cffb2',
+}
+
+const WORLD_NAMES = {
+ cargo: 'Cargo ship',
+ heli: 'Patrol helicopter',
+ chinook: 'Chinook',
+ bradley: 'Bradley APC',
+ supply: 'Supply drop',
+ crate: 'Locked crate',
+}
+
+const AUDIENCE_WORDS = { public: 'everyone', signed_in: 'signed-in players', staff: 'staff only' }
+
+export default function MapView({ serverId, online }) {
+ const { data: meta, loading, error } = useAsync(() => api.servers.map(serverId), [serverId])
+ const geometry = meta ? meta.geometry : null
+
+ const [leaflet, setLeaflet] = useState(null)
+ const [leafletError, setLeafletError] = useState(null)
+ const [shown, setShown] = useState({ grid: true, world: true, events: true, players: true, bases: true, mates: true })
+
+ useEffect(() => {
+ let alive = true
+ import('../lib/leaflet.js')
+ .then((mod) => {
+ if (!alive) return
+ if (typeof document !== 'undefined' && !document.getElementById(STYLE_ID)) {
+ const style = document.createElement('style')
+ style.id = STYLE_ID
+ style.textContent = mod.css
+ document.head.appendChild(style)
+ }
+ setLeaflet(mod)
+ })
+ .catch((err) => alive && setLeafletError(err))
+ return () => {
+ alive = false
+ }
+ }, [])
+
+ const anyLive = Boolean(meta && (LAYERS.some((l) => meta.layers[l.id].visible) || meta.mates.visible))
+ const live = usePolled(() => api.servers.mapLive(serverId), {
+ key: serverId,
+ intervalMs: (meta && meta.pollMs) || 10_000,
+ enabled: Boolean(geometry) && anyLive,
+ })
+
+ const container = useRef(null)
+ const mapRef = useRef(null)
+ const groups = useRef(null)
+
+ // The map itself: made once per server and geometry, torn down with them.
+ useEffect(() => {
+ if (!leaflet || !geometry || !container.current) return undefined
+ const { L } = leaflet
+ const bounds = boundsOf(geometry)
+ const map = L.map(container.current, {
+ crs: L.CRS.Simple,
+ minZoom: -4,
+ maxZoom: 2,
+ zoomSnap: 0.25,
+ attributionControl: false,
+ // Some things sail off the edge: the rig's cargo ship spent the probe
+ // outside the picture entirely. The view may follow them a little way.
+ maxBounds: L.latLngBounds(bounds).pad(0.5),
+ })
+ if (meta.picture) L.imageOverlay(`${BASE}${meta.picture.path}`, bounds).addTo(map)
+ map.fitBounds(bounds)
+
+ const made = {}
+ for (const id of ['grid', 'monuments', 'world', 'events', 'players', 'bases', 'mates']) made[id] = L.layerGroup().addTo(map)
+ // The labels are a layer of their own inside the grid's, shown only when a cell
+ // is wide enough on screen to hold one: at the fitted zoom a 20-cell map's
+ // labels overlap into a wall of text (found on the phase 14 walk).
+ const labels = L.layerGroup()
+ const cellPx = () => geometry.gridCellSize * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize) * 2 ** map.getZoom()
+ const fitLabels = () => {
+ const want = cellPx() >= LABEL_MIN_CELL_PX
+ if (want && !made.grid.hasLayer(labels)) made.grid.addLayer(labels)
+ if (!want && made.grid.hasLayer(labels)) made.grid.removeLayer(labels)
+ }
+ map.on('zoomend', fitLabels)
+
+ const g = grid(geometry)
+ for (const [[x1, z1], [x2, z2]] of g.lines) {
+ L.polyline([toLatLng(geometry, x1, z1), toLatLng(geometry, x2, z2)], {
+ color: '#ffffff',
+ weight: 1,
+ opacity: 0.18,
+ interactive: false,
+ }).addTo(made.grid)
+ }
+ for (const label of g.labels) {
+ L.marker(toLatLng(geometry, label.x, label.z), {
+ interactive: false,
+ keyboard: false,
+ icon: L.divIcon({
+ className: '',
+ html: `${label.text}`,
+ iconSize: null,
+ iconAnchor: [0, 0],
+ }),
+ }).addTo(labels)
+ }
+ fitLabels()
+
+ for (const m of meta.monuments || []) {
+ L.circleMarker(toLatLng(geometry, m.x, m.z), {
+ radius: 4,
+ color: '#000',
+ weight: 1,
+ fillColor: COLOURS.monument,
+ fillOpacity: 0.9,
+ })
+ .bindTooltip(`${escape(m.label)} · ${escape(m.grid || gridLabel(geometry, m.x, m.z) || '')}`)
+ .addTo(made.monuments)
+ }
+
+ mapRef.current = map
+ groups.current = made
+ return () => {
+ map.remove()
+ mapRef.current = null
+ groups.current = null
+ }
+ // `meta` is replaced only when the server changes, with the geometry.
+ }, [leaflet, geometry]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ // What moves: every group cleared and redrawn from the latest answer. The
+ // counts are small — a busy server is a few hundred markers — and a redraw is
+ // simpler to get right than a diff.
+ useEffect(() => {
+ const made = groups.current
+ if (!leaflet || !made || !geometry) return
+ const { L } = leaflet
+ const answer = live.data || {}
+ const at = (p) => toLatLng(geometry, p.x, p.z)
+ for (const id of ['world', 'events', 'players', 'bases', 'mates']) made[id].clearLayers()
+
+ for (const w of answer.world || []) {
+ let tip = WORLD_NAMES[w.kind] || w.kind
+ if (w.kind === 'crate' && w.hackLeftSec != null) tip += ` — ${countdown(w.hackLeftSec)} left on the hack`
+ if (w.kind === 'crate' && w.hacked) tip += ' — hacked'
+ dot(L, at(w), COLOURS[w.kind] || COLOURS.crate, w.kind === 'cargo' ? 7 : 5).bindTooltip(escape(tip)).addTo(made.world)
+ }
+
+ const px = (metres) => metres * ((geometry.width - 2 * geometry.oceanMargin) / geometry.worldSize)
+ for (const e of answer.events || []) {
+ if (e.kind === 'zone') {
+ L.circle(at(e), { radius: px(Number(e.radius) || 0), color: COLOURS.event, weight: 2, fillOpacity: 0.12 })
+ .bindTooltip(escape(e.name ? `Event zone · ${e.name}` : 'Event zone'))
+ .addTo(made.events)
+ } else {
+ dot(L, at(e), COLOURS.event, 5).bindTooltip(e.kind === 'npc' ? 'Event NPC' : 'Event crate').addTo(made.events)
+ }
+ }
+
+ for (const p of answer.players || []) {
+ dot(L, at(p), p.online ? COLOURS.online : COLOURS.sleeping, p.online ? 5 : 4)
+ .bindTooltip(escape(`${p.name || p.steamId}${p.online ? (p.sleeping ? ' · sleeping' : '') : ' · asleep, offline'}`))
+ .addTo(made.players)
+ }
+
+ for (const b of answer.bases || []) {
+ dot(L, at(b), COLOURS[b.kind] || COLOURS.tc, 4).bindTooltip(b.kind === 'tc' ? 'Tool cupboard' : 'Vending machine').addTo(made.bases)
+ }
+
+ for (const m of answer.mates || []) {
+ dot(L, at(m), m.self ? COLOURS.self : COLOURS.mate, m.self ? 8 : 6, 2)
+ .bindTooltip(escape(m.self ? `You${m.online ? '' : ' (asleep, offline)'}` : m.name || 'Clan mate'))
+ .addTo(made.mates)
+ }
+ }, [leaflet, geometry, live.data])
+
+ // The legend's checkboxes: a group is on the map or off it.
+ useEffect(() => {
+ const map = mapRef.current
+ const made = groups.current
+ if (!map || !made) return
+ const want = { grid: shown.grid, monuments: shown.world, world: shown.world, events: shown.events, players: shown.players, bases: shown.bases, mates: shown.mates }
+ for (const [id, on] of Object.entries(want)) {
+ if (on && !map.hasLayer(made[id])) made[id].addTo(map)
+ if (!on && map.hasLayer(made[id])) map.removeLayer(made[id])
+ }
+ }, [shown, leaflet, geometry])
+
+ const status = useMemo(() => liveStatus(live, anyLive, online), [live, anyLive, online])
+
+ if (loading) return
+ This server has no picture of its map, so the layers are drawn on a plain background. +
+ )} +{status}
+ +