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 + if (error) return + if (!geometry) { + return ( + + ) + } + if (leafletError) return + + return ( +
+ {!meta.picture && ( +

+ This server has no picture of its map, so the layers are drawn on a plain background. +

+ )} +
+
+ {!leaflet && ( +
+ +
+ )} +
+ +

{status}

+ + setShown((s) => ({ ...s, [id]: !s[id] }))} /> +
+ ) +} + +function Legend({ meta, live, shown, onToggle }) { + const row = (id, label, swatches, visible, note) => ( +
  • + + + {swatches.map((c) => ( + + {note && {note}} +
  • + ) + + const hiddenNote = (layer) => { + const words = AUDIENCE_WORDS[layer.audience] || AUDIENCE_WORDS.staff + if (layer.audience === 'signed_in') return 'Sign in to see this layer.' + return `Shown to ${words}.` + } + + const swatches = { + world: [COLOURS.monument, COLOURS.cargo, COLOURS.heli, COLOURS.crate], + events: [COLOURS.event], + players: [COLOURS.online, COLOURS.sleeping], + bases: [COLOURS.tc, COLOURS.vending], + } + + return ( +
      + {row('grid', 'Grid', [], true, null)} + {LAYERS.map((l) => { + const layer = meta.layers[l.id] + let note = layer.visible ? null : hiddenNote(layer) + if (l.id === 'players' && layer.cappedByPresence && !layer.visible) { + note = `${note} Limited by who may see who is online.` + } + if (layer.visible && l.id === 'players' && live && live.playersTruncated) note = 'Not every sleeper is shown.' + if (layer.visible && l.id === 'bases' && live && live.basesTruncated) note = 'Not every base is shown.' + return row(l.id, l.label, swatches[l.id], layer.visible, note) + })} + {meta.mates.visible && + row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], true, 'Your own position, and clan mates who are online.')} + {!meta.mates.visible && meta.mates.on && meta.mates.signedIn && !meta.mates.linked && + row('mates', 'You and your clan', [COLOURS.self, COLOURS.mate], false, 'Link your Steam account to see yourself and your clan here.')} +
    + ) +} + +function dot(L, latlng, colour, radius, weight = 1) { + return L.circleMarker(latlng, { radius, color: '#000', weight, fillColor: colour, fillOpacity: 0.95 }) +} + +/** Tooltips are HTML in Leaflet, and a player's name is text they typed. */ +function escape(text) { + return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]) +} + +function liveStatus(live, anyLive, online) { + if (!anyLive) return 'No moving layers are shown to you on this server.' + if (live.loading) return 'Asking the server where things are…' + const data = live.data + if (data && data.live === false) { + return online + ? 'The server did not say where things are just now; it is asked again every ten seconds.' + : 'The server is offline, so nothing is moving on its map.' + } + if (live.error && !data) return 'Positions could not be loaded.' + return live.at ? `Positions as of ${ago(new Date(live.at).toISOString())}, refreshed every ten seconds while this tab is open.` : '' +} diff --git a/client/src/lib/leaflet.js b/client/src/lib/leaflet.js new file mode 100644 index 0000000..2096189 --- /dev/null +++ b/client/src/lib/leaflet.js @@ -0,0 +1,28 @@ +// ── Leaflet, and only when the Map tab asks for it ──────────────────────── +// +// D116 put Leaflet on the map; D120 put it HERE, in a chunk of its own that the +// Map tab imports with `import()`. Two reasons, and both are about `entry.js`: +// +// • `entry.js` is loaded on EVERY page of the site, because core injects every +// started module's chunk into its shell. Leaflet in it would be ~40 KB gz on +// the home page, the forum and the news, for a tab most visitors never open. +// • Leaflet touches `document` and `window` the moment it is evaluated. The +// chunk is evaluated in Node by `test/registration.test.js`, with a +// `window` and nothing else — so Leaflet in the entry chunk fails that test +// at import, before a single registration is checked. +// +// Vite emits this as `dist/map-.js` beside `entry.js`. Core serves the +// directory `client.entry` sits in (MODULE_API.md §3.1), and `release.yml` +// copies every `.js` in it — `test/build.test.js` holds both ends of that. +// +// The ESM build is imported by path: Leaflet 1.9.4's package.json names only its +// UMD file, and the UMD one would go through CommonJS interop for nothing. +// +// The stylesheet comes in as a STRING and is injected once as a `