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:
8
client/package-lock.json
generated
8
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.` : '.'),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) ──────────────────────────────────────────────
|
||||
|
||||
358
client/src/components/MapView.jsx
Normal file
358
client/src/components/MapView.jsx
Normal file
@@ -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: `<span style="font:600 10px/1 system-ui,sans-serif;color:rgba(255,255,255,.55);padding:2px 3px;display:block;white-space:nowrap">${label.text}</span>`,
|
||||
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 <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
if (!geometry) {
|
||||
return (
|
||||
<Empty
|
||||
title="No map yet"
|
||||
message="This server has not described its map to the site yet. It will appear once the server is up and has said which map it is on."
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (leafletError) return <ErrorState error={leafletError} />
|
||||
|
||||
return (
|
||||
<div className="sans">
|
||||
{!meta.picture && (
|
||||
<p style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
This server has no picture of its map, so the layers are drawn on a plain background.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ position: 'relative', zIndex: 0 }}>
|
||||
<div
|
||||
ref={container}
|
||||
role="application"
|
||||
aria-label="Map of the server"
|
||||
style={{
|
||||
height: 'min(70vh, 760px)',
|
||||
minHeight: 320,
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--line)',
|
||||
background: geometry.background || '#0b3b4a',
|
||||
}}
|
||||
/>
|
||||
{!leaflet && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center' }}>
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p style={{ color: 'var(--dim)', fontSize: '0.78rem', margin: '8px 0 16px' }}>{status}</p>
|
||||
|
||||
<Legend meta={meta} live={live.data} shown={shown} onToggle={(id) => setShown((s) => ({ ...s, [id]: !s[id] }))} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Legend({ meta, live, shown, onToggle }) {
|
||||
const row = (id, label, swatches, visible, note) => (
|
||||
<li key={id} style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--line-soft, var(--line))' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: visible ? 'pointer' : 'default', color: visible ? 'var(--ink)' : 'var(--dim)' }}>
|
||||
<input type="checkbox" checked={visible && shown[id]} disabled={!visible} onChange={() => onToggle(id)} />
|
||||
{label}
|
||||
</label>
|
||||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||||
{swatches.map((c) => (
|
||||
<span key={c} aria-hidden="true" style={{ width: 10, height: 10, borderRadius: '50%', background: c, display: 'inline-block', border: '1px solid rgba(0,0,0,.4)' }} />
|
||||
))}
|
||||
</span>
|
||||
{note && <span style={{ color: 'var(--dim)', fontSize: '0.76rem', marginLeft: 'auto', textAlign: 'right' }}>{note}</span>}
|
||||
</li>
|
||||
)
|
||||
|
||||
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 (
|
||||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.86rem' }}>
|
||||
{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.')}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
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.` : ''
|
||||
}
|
||||
28
client/src/lib/leaflet.js
Normal file
28
client/src/lib/leaflet.js
Normal file
@@ -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-<hash>.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 `<style>` by the
|
||||
// page (core's CSP allows `style-src 'unsafe-inline'`). The map uses circle and
|
||||
// div markers only, so the images Leaflet's CSS names are never shown.
|
||||
|
||||
import * as L from 'leaflet/dist/leaflet-src.esm.js'
|
||||
import css from 'leaflet/dist/leaflet.css?inline'
|
||||
|
||||
export { L, css }
|
||||
92
client/src/lib/mapGeometry.js
Normal file
92
client/src/lib/mapGeometry.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// ── How a world position reaches a pixel ──────────────────────────────────
|
||||
//
|
||||
// PLAN.md §30.3, in the one place the page computes it. Rust's world is centred
|
||||
// on the origin, x east and z north; the picture is the world at a scale, with
|
||||
// an ocean margin around it that is measured in PIXELS and is not scaled (the
|
||||
// rig's 3000 map is 3000 × 0.5 + 2 × 500 = 2500 pixels). So:
|
||||
//
|
||||
// s = (width − 2 × margin) / worldSize
|
||||
// px = (x + worldSize / 2) × s + margin
|
||||
// py = (z + worldSize / 2) × s + margin measured UP from the bottom edge
|
||||
//
|
||||
// Up from the bottom because Leaflet's `CRS.Simple` has y growing north, which
|
||||
// is Rust's z — so the picture's bounds are `[[0, 0], [height, width]]` and a
|
||||
// position is `[py, px]` with no flip anywhere.
|
||||
//
|
||||
// The grid is the GAME's (D119): `gridCells` cells of `gridCellSize` metres per
|
||||
// side, lettered from the west and numbered from the north, `A0` at the
|
||||
// north-west corner. Both numbers come from the plugin, which asks the game's own
|
||||
// `MapHelper`; nothing here assumes a cell size.
|
||||
//
|
||||
// Pure, and free of Leaflet, so it is tested in Node.
|
||||
|
||||
/** Pixels per metre, or 0 for a geometry that cannot place anything. */
|
||||
export function scaleOf(g) {
|
||||
if (!g || !(g.worldSize > 0) || !(g.width > 0)) return 0
|
||||
return (g.width - 2 * (g.oceanMargin || 0)) / g.worldSize
|
||||
}
|
||||
|
||||
/** A world position as `[lat, lng]` in the map's pixel space. */
|
||||
export function toLatLng(g, x, z) {
|
||||
const s = scaleOf(g)
|
||||
const half = g.worldSize / 2
|
||||
const m = g.oceanMargin || 0
|
||||
return [(Number(z) + half) * s + m, (Number(x) + half) * s + m]
|
||||
}
|
||||
|
||||
/** The picture's bounds in the same space. */
|
||||
export function boundsOf(g) {
|
||||
return [[0, 0], [g.height, g.width]]
|
||||
}
|
||||
|
||||
/** A column number as Rust spells it: 0 is A, 25 is Z, 26 is AA. */
|
||||
export function column(index) {
|
||||
let name = ''
|
||||
let n = index + 1
|
||||
while (n > 0) {
|
||||
const r = (n - 1) % 26
|
||||
name = String.fromCharCode(65 + r) + name
|
||||
n = Math.floor((n - 1) / 26)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** The grid label for a world position, the way the in-game map writes it. */
|
||||
export function gridLabel(g, x, z) {
|
||||
if (!g || !(g.gridCells > 0) || !(g.gridCellSize > 0)) return null
|
||||
const half = g.worldSize / 2
|
||||
const clamp = (v) => Math.max(0, Math.min(g.gridCells - 1, v))
|
||||
const col = clamp(Math.floor((Number(x) + half) / g.gridCellSize))
|
||||
const row = clamp(Math.floor((half - Number(z)) / g.gridCellSize))
|
||||
return `${column(col)}${row}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The grid as lines and labels in world metres: `lines` are `[[x1, z1], [x2,
|
||||
* z2]]` pairs, `labels` sit at each cell's north-west corner.
|
||||
*/
|
||||
export function grid(g) {
|
||||
if (!g || !(g.gridCells > 0) || !(g.gridCellSize > 0)) return { lines: [], labels: [] }
|
||||
const half = g.worldSize / 2
|
||||
const n = g.gridCells
|
||||
const c = g.gridCellSize
|
||||
const lines = []
|
||||
for (let i = 0; i <= n; i += 1) {
|
||||
const at = -half + i * c
|
||||
lines.push([[at, half], [at, half - n * c]])
|
||||
lines.push([[-half, half - i * c], [-half + n * c, half - i * c]])
|
||||
}
|
||||
const labels = []
|
||||
for (let col = 0; col < n; col += 1) {
|
||||
for (let row = 0; row < n; row += 1) {
|
||||
labels.push({ text: `${column(col)}${row}`, x: -half + col * c, z: half - row * c })
|
||||
}
|
||||
}
|
||||
return { lines, labels }
|
||||
}
|
||||
|
||||
/** Seconds as `m:ss`, for a locked crate's hack. */
|
||||
export function countdown(seconds) {
|
||||
const s = Math.max(0, Math.round(Number(seconds) || 0))
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
@@ -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' && (
|
||||
|
||||
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const CLIENT = path.resolve(HERE, '..')
|
||||
|
||||
const { bareImports, problemsWith } = await import('../scripts/checkExternals.js')
|
||||
const { bareImports, problemsWith, relativeImports } = await import('../scripts/checkExternals.js')
|
||||
const configModule = await import('../vite.config.js')
|
||||
const config = configModule.default
|
||||
const { SHARED, SHARED_PACKAGES: guardedPackages } = configModule
|
||||
@@ -152,3 +152,38 @@ test('an import inside a string is not an import — the check reads code, not t
|
||||
// string must not end it early and leave the tail looking like code.
|
||||
assert.deepStrictEqual(bareImports('const s="he said \\"import\\" loudly";'), [])
|
||||
})
|
||||
|
||||
// ── D120: Leaflet's split chunk ───────────────────────────────────────────
|
||||
|
||||
test('a split chunk is found by what entry.js imports, and a string is still not an import', () => {
|
||||
assert.deepStrictEqual(relativeImports('const m=await import("./leaflet-Ab12.js")'), ['leaflet-Ab12.js'])
|
||||
assert.deepStrictEqual(relativeImports('import"./other.js";import"react"'), ['other.js'])
|
||||
assert.deepStrictEqual(relativeImports('const s="import(\\"./fake.js\\")"'), [])
|
||||
})
|
||||
|
||||
test('every chunk entry.js imports is in dist/, and none of them bundles a shared dependency', () => {
|
||||
const dist = path.join(CLIENT, 'dist')
|
||||
const entry = path.join(dist, 'entry.js')
|
||||
if (!fs.existsSync(entry)) return
|
||||
const split = relativeImports(fs.readFileSync(entry, 'utf8'))
|
||||
assert.ok(split.length >= 1, 'the Map tab imports Leaflet from a chunk of its own (D120)')
|
||||
for (const name of split) {
|
||||
assert.ok(fs.existsSync(path.join(dist, name)), `entry.js imports ./${name}, which the build did not emit`)
|
||||
assert.deepStrictEqual(problemsWith(fs.readFileSync(path.join(dist, name), 'utf8')), [], name)
|
||||
}
|
||||
})
|
||||
|
||||
test('Leaflet is not in the entry chunk — every page of the site loads that one', () => {
|
||||
const entry = path.join(CLIENT, 'dist', 'entry.js')
|
||||
if (!fs.existsSync(entry)) return
|
||||
assert.doesNotMatch(fs.readFileSync(entry, 'utf8'), /Leaflet 1\.9/, 'Leaflet was bundled into entry.js')
|
||||
})
|
||||
|
||||
test('the release ships every chunk, not entry.js by name', () => {
|
||||
// The other half of D120. A release that copied `entry.js` alone would load on
|
||||
// every page and spin for ever on the Map tab, with every check above green.
|
||||
const release = fs.readFileSync(path.join(CLIENT, '..', '.gitea', 'workflows', 'release.yml'), 'utf8')
|
||||
assert.match(release, /cp client\/dist\/\*\.js "\$OUT\/client\/dist\/"/)
|
||||
assert.doesNotMatch(release, /cp client\/dist\/entry\.js /)
|
||||
})
|
||||
|
||||
|
||||
92
client/test/mapGeometry.test.js
Normal file
92
client/test/mapGeometry.test.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// ── How a world position reaches a pixel (PLAN.md §30.3) ─────────────────
|
||||
//
|
||||
// The grid cases are the GAME's answers, not this file's: on 2026-09-25 a probe
|
||||
// on the Oxide rig (a 3000 map, seed 1234) asked `MapHelper.PositionToString`
|
||||
// for these positions and wrote down what it said. A label the page draws that
|
||||
// disagrees with the in-game map is a player walking to the wrong square — and
|
||||
// phase 3's plugin did exactly that for three weeks with a 146.3 m cell (D119).
|
||||
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { boundsOf, column, countdown, grid, gridLabel, scaleOf, toLatLng } from '../src/lib/mapGeometry.js'
|
||||
|
||||
// The rig's map as `GET /map` describes it.
|
||||
const RIG = { worldSize: 3000, oceanMargin: 500, width: 2500, height: 2500, gridCells: 20, gridCellSize: 150 }
|
||||
|
||||
test('the grid label is the game’s, at every probed position', () => {
|
||||
const probed = [
|
||||
['ue_jungle_swamp_a', 764.7, 167.4, 'P8'],
|
||||
['ue_jungle_swamp_a', 733.7, -556.0, 'O13'],
|
||||
['harbor_2', 1122.7, 204.6, 'R8'],
|
||||
['harbor_1', 678.1, 1005.7, 'O3'],
|
||||
['ferry_terminal_1', 645.4, -1004.1, 'O16'],
|
||||
['fishing_village_a', -787.8, 224.1, 'E8'],
|
||||
['fishing_village_c', -203.0, -911.1, 'I16'],
|
||||
['fishing_village_b', 1142.1, -566.5, 'R13'],
|
||||
['desert_military_base_c', 94.0, -729.3, 'K14'],
|
||||
['arctic_research_base_a', -556.9, 840.0, 'G4'],
|
||||
['powerplant_1', -608.6, -346.4, 'F12'],
|
||||
['water_treatment_plant_1', 497.8, 84.9, 'N9'],
|
||||
['nw-corner', -1499, 1499, 'A0'],
|
||||
['se-corner', 1499, -1499, 'T19'],
|
||||
['origin', 0, 0, 'K10'],
|
||||
]
|
||||
for (const [name, x, z, game] of probed) assert.equal(gridLabel(RIG, x, z), game, name)
|
||||
})
|
||||
|
||||
test('a 146.3 m cell — phase 3’s constant — would have disagreed with the game', () => {
|
||||
// Kept as a test so the constant cannot come back in a refactor that "fixes"
|
||||
// the cell size to the number community tools quote.
|
||||
assert.notEqual(gridLabel({ ...RIG, gridCells: 21, gridCellSize: 146.3 }, 645.4, -1004.1), 'O16')
|
||||
})
|
||||
|
||||
test('columns past Z are spelled the way Rust spells them', () => {
|
||||
assert.equal(column(0), 'A')
|
||||
assert.equal(column(25), 'Z')
|
||||
assert.equal(column(26), 'AA')
|
||||
assert.equal(column(27), 'AB')
|
||||
})
|
||||
|
||||
test('the ocean margin is in pixels and is not scaled', () => {
|
||||
assert.equal(scaleOf(RIG), 0.5)
|
||||
// The world's corners sit exactly one margin inside the picture's.
|
||||
assert.deepEqual(toLatLng(RIG, -1500, -1500), [500, 500])
|
||||
assert.deepEqual(toLatLng(RIG, 1500, 1500), [2000, 2000])
|
||||
assert.deepEqual(toLatLng(RIG, 0, 0), [1250, 1250])
|
||||
})
|
||||
|
||||
test('north is up: a larger z is a larger latitude, and x is longitude', () => {
|
||||
const [lat1, lng1] = toLatLng(RIG, 100, 100)
|
||||
const [lat2, lng2] = toLatLng(RIG, 100, 400)
|
||||
assert.ok(lat2 > lat1)
|
||||
assert.equal(lng1, lng2)
|
||||
assert.deepEqual(boundsOf(RIG), [[0, 0], [2500, 2500]])
|
||||
})
|
||||
|
||||
test('something off the edge of the world is still placed, outside the picture', () => {
|
||||
// The rig's cargo ship, as the probe found it: past the world AND the margin.
|
||||
const [lat, lng] = toLatLng(RIG, 2691.6, -1453.1)
|
||||
assert.ok(lng > RIG.width)
|
||||
assert.ok(lat > 0 && lat < RIG.height)
|
||||
})
|
||||
|
||||
test('the grid has a line per edge and a label per cell, A0 at the north-west corner', () => {
|
||||
const g = grid(RIG)
|
||||
assert.equal(g.lines.length, 2 * (RIG.gridCells + 1))
|
||||
assert.equal(g.labels.length, RIG.gridCells * RIG.gridCells)
|
||||
const a0 = g.labels.find((l) => l.text === 'A0')
|
||||
assert.deepEqual([a0.x, a0.z], [-1500, 1500])
|
||||
assert.deepEqual(grid(null), { lines: [], labels: [] })
|
||||
})
|
||||
|
||||
test('a geometry that cannot place anything places nothing rather than NaN everywhere', () => {
|
||||
assert.equal(scaleOf({ worldSize: 0, width: 2500 }), 0)
|
||||
assert.equal(gridLabel({ worldSize: 3000 }, 0, 0), null)
|
||||
})
|
||||
|
||||
test('a hack timer reads as minutes and seconds', () => {
|
||||
assert.equal(countdown(540), '9:00')
|
||||
assert.equal(countdown(61.4), '1:01')
|
||||
assert.equal(countdown(-3), '0:00')
|
||||
})
|
||||
Reference in New Issue
Block a user