feat(rust): the live map (phase 14, protocol 11) #17

Merged
whitlocktech merged 1 commits from feat/phase-14-map into edge 2026-09-25 12:00:49 +00:00
34 changed files with 4680 additions and 28 deletions

View File

@@ -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

View File

@@ -38,6 +38,8 @@
"eventWorld.js",
"index.js",
"ingest.js",
"mapImages.js",
"mapLive.js",
"model",
"package.json",
"permSync.js",

View File

@@ -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",

View File

@@ -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",

View File

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

View File

@@ -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) ──────────────────────────────────────────────

View 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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
View 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 }

View 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')}`
}

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

View File

@@ -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 /)
})

View 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')
})

View File

@@ -13,5 +13,5 @@
"player": ["/rust"]
},
"extensions": ["admin.users.detail"],
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity"]
"capabilities": ["rust", "servers", "killfeed", "leaderboard", "presence", "wipes", "identity", "map"]
}

View File

@@ -126,6 +126,21 @@
"path": "/api/v1/public/rust/servers/:id/leaderboard",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/map",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/map/image",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/map/live",
"tier": "public"
},
{
"method": "GET",
"path": "/api/v1/public/rust/servers/:id/online",
@@ -166,6 +181,16 @@
"path": "/api/v1/admin/rust/permissions/sync",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/servers/:id/map/fetch",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/servers/:id/map/render",
"tier": "public"
},
{
"method": "POST",
"path": "/api/v1/admin/rust/servers/:id/test",

View File

@@ -47,6 +47,7 @@ const engagement = require('./engagement/emit')
const eventsDb = require('./model/events/events.db')
const eventWorld = require('./eventWorld')
const ingest = require('./ingest')
const mapImages = require('./mapImages')
const permSync = require('./permSync')
const servers = require('./model/servers/servers.model')
const sidecar = require('./sidecarClient')
@@ -187,6 +188,12 @@ async function refreshOne(server) {
// counts: a board the game left behind says nothing about now.
if (connected) {
eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId, worldReady: frame.worldReady })
// A new boot, wipe or map is a reason to ask what the map is now (D110).
// Started, never awaited: a picture is a megabyte over the game link, and
// the board poll does not wait on one. `mapImages` keeps one fetch per
// server and backs off on its own.
mapImages.observe(server, frame)
}
} catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled`

View File

@@ -19,6 +19,10 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
-- Phase 14.
DROP TABLE IF EXISTS rust_map_overrides;
DROP TABLE IF EXISTS rust_map_images;
-- Phase 13b.
DROP TABLE IF EXISTS rust_perm_run_grants;

View File

@@ -840,3 +840,63 @@ CREATE TABLE IF NOT EXISTS rust_perm_run_grants (
-- post, and without it the day this module updates every post would start
-- appearing in every server's chat.
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS announce_news TINYINT(1) NOT NULL DEFAULT 0;
-- ── The map (phase 14, protocol 11) ───────────────────────────────────────
--
-- One row per server holding the picture of its CURRENT map (PLAN.md §30.2),
-- replaced whole when the map changes. The picture lives here and not in core's
-- `/uploads`, which core serves to anybody by URL and no purge would reach
-- (§30.5). One row per server bounds it at about a megabyte each.
--
-- `bytes` and `sha256` are NULL for a server whose game has no picture yet
-- (`source = 'none'`): the row still carries the geometry and the monuments, so
-- the page can draw the live layers on a plain background, and an admin's
-- render replaces it. `map_key` says WHICH map and `sha256` which picture of it.
--
-- `derivation` is `DERIVATION_VERSION` (R9) — how the geometry columns were
-- worked out from what the plugin said. A row whose number is older is
-- re-derived from a fresh `map.info` without fetching the bytes again.
--
-- `monuments` is JSON as the plugin sent it: kind, label, the `kind#n` token and
-- x/z. It changes only with the map, so it belongs to this row and not to the
-- live answer.
CREATE TABLE IF NOT EXISTS rust_map_images (
server_id VARCHAR(64) NOT NULL PRIMARY KEY,
map_key VARCHAR(160) NOT NULL,
sha256 CHAR(64) NULL,
source VARCHAR(16) NOT NULL,
width INT UNSIGNED NOT NULL,
height INT UNSIGNED NOT NULL,
ocean_margin INT UNSIGNED NOT NULL,
world_size INT UNSIGNED NOT NULL,
grid_cells INT UNSIGNED NOT NULL,
grid_cell_size DECIMAL(10,3) NOT NULL,
background VARCHAR(16) NULL,
derivation INT UNSIGNED NOT NULL,
monuments MEDIUMTEXT NOT NULL,
bytes MEDIUMBLOB NULL,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_rust_map_images_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Per-server overrides of the map's switches (D114): a layer's audience, or the
-- own-and-mates view. The fleet defaults are rows in `rust_settings`
-- (`map.layer.<layer>.audience`, `map.mates`); a server with no row here
-- inherits them. A table of its own rather than five more columns on
-- `rust_servers`, because the set of switches is the part most likely to grow.
--
-- `value` is a word (`staff` · `signed_in` · `public`, or `on` · `off`), and a
-- word this build does not recognise NARROWS (`model/map`).
CREATE TABLE IF NOT EXISTS rust_map_overrides (
server_id VARCHAR(64) NOT NULL,
setting VARCHAR(64) NOT NULL,
value VARCHAR(16) NOT NULL,
updated_by INT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, setting),
CONSTRAINT fk_rust_map_overrides_server
FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE,
CONSTRAINT fk_rust_map_overrides_user
FOREIGN KEY (updated_by) REFERENCES users (id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

277
server/mapImages.js Normal file
View File

@@ -0,0 +1,277 @@
// ── The map's picture: noticing a new map and fetching it ─────────────────
//
// R9's first half, as the rig rewrote it (PLAN.md §30.0). The picture is not
// extracted from anything: the game's Rust+ service already holds a JPEG of the
// map in memory, and the plugin hands it over in 512 KiB slices. That changes
// the one rule R9 carried over from the asset bridge, where a fetch was an
// expensive extraction:
//
// **D110 — the site fetches the picture BY ITSELF the first time it sees a map
// it does not have**, because reading the cache costs the game nothing. It is
// triggered off the board poll (`boot.js`): a new boot, wipe, seed or world
// size, or a server with no row at all, is a reason to ask `map.info`, and a
// key or hash that differs from the row is a reason to fetch.
//
// **D109 — a render is never automatic.** A server with no Rust+ cache has no
// picture until an admin presses Render now, which stalls that game for seconds
// and is said so beside the button. The render answers at once and happens on a
// later frame; this file then polls `map.info` until the picture exists and
// fetches it like any other.
//
// Three rules the fetch keeps:
//
// • **One fetch per server at a time**, held here. A second trigger while one
// runs is dropped, and an admin's Fetch again is told one is running.
// • **A fetch that straddles a map change is abandoned whole.** The plugin
// refuses a slice with `stale` when the key or hash moved, and the bytes are
// checked against the hash before anything is written: two maps are never
// spliced, and a half-fetched picture is never stored.
// • **A failure keeps the old row** and retries on a backoff. The page keeps
// drawing the last picture it had, which is still the right map until the
// key says otherwise.
const crypto = require('crypto')
const core = require('./core')
const db = require('./model/map/map.db')
const model = require('./model/map/map.model')
const sidecar = require('./sidecarClient')
const log = core.logger('map')
/** Backoff after a failed fetch: 30 s doubling to 10 minutes. */
const BACKOFF_MIN_MS = 30 * 1000
const BACKOFF_MAX_MS = 10 * 60 * 1000
/** How soon to ask again when the plugin is still hashing, or the world still loading. */
const SOON_MS = 3000
/** How long a render is watched for before this side gives up on it (a 6000 map stalls ~40 s). */
const RENDER_WATCH_MS = 5 * 60 * 1000
const RENDER_POLL_MS = 3000
/** The first protocol whose plugin knows the map verbs. */
const MAP_PROTOCOL = 11
/** The most slices one picture may have: 16 MiB, MEDIUMBLOB's ceiling. */
const MAX_CHUNKS = 32
/**
* Per server: the observation last acted on, whether a fetch is running, and
* when the next try may happen. In memory, deliberately — a restart of the
* website forgets it and asks once more, which costs one `map.info`.
*/
const state = new Map()
function stateOf(serverId) {
if (!state.has(serverId)) {
state.set(serverId, { sig: null, running: null, nextTryAt: 0, failures: 0, rendering: null, lastError: null })
}
return state.get(serverId)
}
/** What about a server makes its map worth asking about again. */
const signature = ({ bootId, wipeId, seed, worldSize }) => [bootId, wipeId, seed, worldSize].map((v) => (v == null ? '' : String(v))).join('|')
/**
* Called by every board poll for a CONNECTED server whose world is ready. Cheap
* when nothing moved: one map lookup and a string compare. When something did,
* it starts a fetch in the background and returns — the poll never waits on a
* picture.
*/
function observe(server, frame, now = Date.now()) {
if (!server || !frame || frame.worldReady === false) return
// The game link has no version handshake: a plugin older than protocol 11
// never answers `map.info`, and asking would cost a full reply timeout a try.
if (!(Number(frame.protocol) >= MAP_PROTOCOL)) return
const s = stateOf(server.id)
const sig = signature(frame)
if (s.running || now < s.nextTryAt) return
if (s.sig === sig) return
run(server, sig).catch((err) => log.warn('map fetch failed', { server: server.id, error: err.message }))
}
/**
* One fetch cycle. Resolves `{ ok, outcome, detail }`, never rejects in normal
* operation; the outcome words are what the admin card shows.
*
* fetched a new picture is stored
* current the stored picture is already this map's
* none the game has no picture; geometry stored so layers can be drawn
* busy another fetch is running (admin only)
* failed something did not answer; the old row is kept
*/
function run(server, sig = null, { force = false } = {}) {
const s = stateOf(server.id)
if (s.running) return Promise.resolve({ ok: false, outcome: 'busy', detail: 'a fetch is already running for this server' })
s.running = (async () => {
try {
const result = await fetchOnce(server, { force })
if (result.retrySoon) {
s.nextTryAt = Date.now() + SOON_MS
return result
}
if (result.ok) {
s.sig = sig
s.failures = 0
s.nextTryAt = 0
s.lastError = null
} else {
s.failures += 1
s.nextTryAt = Date.now() + Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** (s.failures - 1))
s.lastError = result.detail || result.outcome
}
return result
} finally {
s.running = null
}
})()
return s.running
}
/** The plugin's refusal, as a sentence, or null when the answer was not one. */
function refusal(res) {
if (!res.ok) return `the sidecar did not answer (${res.status})`
if (res.data && res.data.kind === 'map.error') return res.data.message || res.data.reason || 'refused'
return null
}
async function fetchOnce(server, { force }) {
const info = await sidecar.mapInfo(server)
const why = refusal(info)
if (why) {
// A world still loading is not a failure worth backing off for.
if (info.ok && info.data && info.data.reason === 'not-ready') return { ok: false, retrySoon: true, outcome: 'failed', detail: why }
return { ok: false, outcome: 'failed', detail: why }
}
const data = info.data || {}
if (data.hashing) return { ok: false, retrySoon: true, outcome: 'failed', detail: 'the plugin is still hashing the picture' }
const row = model.derive(server.id, data)
if (!row.mapKey) return { ok: false, outcome: 'failed', detail: 'map.info named no map' }
const stored = await db.getMeta(server.id)
if (row.source === 'none') {
// The same map with a picture already stored keeps it: nothing about the map
// changed, only where a picture would come from now. A different map, or no
// row at all, stores the geometry so the page can draw the layers.
if (stored && stored.mapKey === row.mapKey && stored.sha256) {
await db.putGeometry({ ...row, source: stored.source, width: Number(stored.width), height: Number(stored.height), background: stored.background })
return { ok: true, outcome: 'current', detail: 'the game has no picture now; the stored one is the same map' }
}
await db.putImage(row)
return { ok: true, outcome: 'none', detail: 'the game has no picture of its map; an admin can render one' }
}
if (!force && stored && stored.mapKey === row.mapKey && stored.sha256 === row.sha256) {
if (Number(stored.derivation) !== model.DERIVATION_VERSION || stored.source !== row.source) await db.putGeometry(row)
return { ok: true, outcome: 'current', detail: 'the stored picture is this map' }
}
const chunks = Number(data.chunks)
if (!Number.isInteger(chunks) || chunks < 1 || chunks > MAX_CHUNKS) {
return { ok: false, outcome: 'failed', detail: `the plugin described ${data.chunks} slices` }
}
const parts = []
for (let n = 0; n < chunks; n += 1) {
// eslint-disable-next-line no-await-in-loop
const res = await sidecar.mapChunk(server, { mapKey: row.mapKey, sha256: row.sha256, n })
const no = refusal(res)
if (no) {
// `stale` means the map moved under the fetch: start again soon rather
// than back off, because the next `map.info` describes the new one.
if (res.ok && res.data && res.data.reason === 'stale') return { ok: false, retrySoon: true, outcome: 'failed', detail: no }
return { ok: false, outcome: 'failed', detail: `slice ${n}: ${no}` }
}
if (!res.data || res.data.kind !== 'map.chunk' || Number(res.data.chunk) !== n || typeof res.data.data !== 'string') {
return { ok: false, outcome: 'failed', detail: `slice ${n} was not the slice asked for` }
}
parts.push(Buffer.from(res.data.data, 'base64'))
}
const bytes = Buffer.concat(parts)
const hash = crypto.createHash('sha256').update(bytes).digest('hex')
if (hash !== row.sha256 || (Number(data.bytes) > 0 && bytes.length !== Number(data.bytes))) {
return { ok: false, outcome: 'failed', detail: 'the picture did not match its own hash; nothing was stored' }
}
await db.putImage({ ...row, bytes })
log.info('map picture stored', { server: server.id, mapKey: row.mapKey, source: row.source, bytes: bytes.length })
return { ok: true, outcome: 'fetched', detail: `${bytes.length} bytes from ${row.source}` }
}
/**
* An admin's Render now (D109). Refused here when a render is already being
* watched; the plugin refuses the rest (a picture exists, the world is loading).
* On `accepted` the render is watched in the background: `map.info` is polled
* until its source is `rendered` — through a stall that makes the game answer
* nothing at all for its duration — and then fetched like any picture.
*/
async function render(server, actor) {
const s = stateOf(server.id)
if (s.rendering) return { ok: false, status: 409, message: 'A render is already running for this server.' }
const res = await sidecar.mapRender(server, { actor: actor || null })
const no = refusal(res)
if (no) {
const reason = res.ok && res.data ? res.data.reason : null
return { ok: false, status: reason === 'has-picture' || reason === 'busy' ? 409 : 502, message: no, reason }
}
s.rendering = { startedAt: Date.now() }
watchRender(server).catch((err) => log.warn('render watch failed', { server: server.id, error: err.message }))
return { ok: true, worldSize: res.data && res.data.worldSize, stallSeconds: model.renderStallSeconds(res.data && res.data.worldSize) }
}
async function watchRender(server) {
const s = stateOf(server.id)
const until = Date.now() + RENDER_WATCH_MS
try {
while (Date.now() < until) {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
const t = setTimeout(resolve, RENDER_POLL_MS)
if (typeof t.unref === 'function') t.unref()
})
// eslint-disable-next-line no-await-in-loop
const info = await sidecar.mapInfo(server)
const data = info.ok && info.data ? info.data : null
if (!data || data.kind !== 'map.info') continue // the game is mid-stall and answering nothing
if (data.rendering) continue
if (data.source === 'none') {
s.lastError = 'the render finished without a picture — see rg.map on the server console'
return
}
// eslint-disable-next-line no-await-in-loop
await run(server, null, { force: false })
return
}
s.lastError = 'the render did not finish within five minutes'
} finally {
s.rendering = null
}
}
/** For the admin card: what this side is doing about each server's picture. */
function statusOf(serverId) {
const s = state.get(serverId)
if (!s) return { fetching: false, rendering: false, lastError: null }
return { fetching: Boolean(s.running), rendering: Boolean(s.rendering), lastError: s.lastError }
}
/** Test seam: forget everything. */
function _reset() {
state.clear()
}
module.exports = { observe, run, render, statusOf, signature, MAX_CHUNKS, _reset }

58
server/mapLive.js Normal file
View File

@@ -0,0 +1,58 @@
// ── What moves on the map, asked for while somebody is looking ────────────
//
// D111: positions are request/reply, never a board. A board would run with
// nobody looking and keep where everybody is at rest in the sidecar's database;
// this asks `map.live` only when a page does, and keeps the answer IN MEMORY for
// five seconds.
//
// **Any number of viewers cost one ask.** Every request inside the window is
// served from the same answer, and a request that arrives while an ask is in
// flight waits for that ask rather than starting a second — so the sidecar sees
// at most one `map.live` per server per window, whoever and however many are
// watching (§30.4 step 7).
//
// The cache holds EVERY layer, unfiltered; each viewer's answer is projected
// from it by `model/map`. That is why it never leaves this process.
const sidecar = require('./sidecarClient')
const model = require('./model/map/map.model')
/** serverId → { at, answer } for the last good answer, and { pending } while one is in flight. */
const cache = new Map()
/**
* One server's live answer, from the cache when it is fresh. Resolves `{ ok,
* data, status }` like every sidecar call; never rejects.
*/
async function live(server, now = Date.now) {
const hit = cache.get(server.id)
if (hit && hit.answer && now() - hit.at < model.LIVE_CACHE_MS) return hit.answer
if (hit && hit.pending) return hit.pending
const pending = (async () => {
const res = await sidecar.mapLive(server)
const refused = res.ok && res.data && res.data.kind === 'map.error'
const answer = res.ok && !refused
? { ok: true, status: 'ok', data: res.data }
: { ok: false, status: refused ? res.data.reason || 'refused' : res.status, data: null }
// A failure is cached for the window too: a game that is down must not be
// asked once per viewer per poll while it stays down.
cache.set(server.id, { at: now(), answer })
return answer
})()
cache.set(server.id, { ...(hit || {}), pending })
try {
return await pending
} finally {
const entry = cache.get(server.id)
if (entry && entry.pending === pending) delete entry.pending
}
}
/** Test seam. */
function _reset() {
cache.clear()
}
module.exports = { live, _reset }

147
server/model/map/map.db.js Normal file
View File

@@ -0,0 +1,147 @@
// ── SQL for the map ───────────────────────────────────────────────────────
//
// Three things: the picture each server's map is drawn on (`rust_map_images`),
// the per-server overrides of the map's switches (`rust_map_overrides`), and the
// two reads the own-and-mates view needs — which Steam accounts a website user
// holds, and who shares a clan with them on one server.
//
// Nothing here stores a POSITION. Where people are is asked for while somebody
// is looking and kept in memory for seconds (D111, `mapLive.js`).
const core = require('../../core')
const IMAGES = 'rust_map_images'
const OVERRIDES = 'rust_map_overrides'
const LINKS = 'rust_account_links'
const CLANS = 'rust_clans'
const MEMBERS = 'rust_clan_members'
/** The columns every read but the picture's own wants — everything except the bytes. */
const META = `server_id AS serverId, map_key AS mapKey, sha256, source, width, height,
ocean_margin AS oceanMargin, world_size AS worldSize, grid_cells AS gridCells,
grid_cell_size AS gridCellSize, background, derivation, monuments,
OCTET_LENGTH(bytes) AS byteCount, fetched_at AS fetchedAt`
/** One server's picture row without the bytes, or undefined. */
async function getMeta(serverId) {
const rows = await core.query(`SELECT ${META} FROM ${IMAGES} WHERE server_id = ?`, [serverId])
return rows[0]
}
/** Every server's picture row without the bytes, for the admin card. */
async function listMeta() {
return core.query(`SELECT ${META} FROM ${IMAGES}`)
}
/**
* The picture itself, but only if it is still the one named. A URL carries the
* hash it was minted for, and a picture replaced since must not be served under
* it: the response is cached as immutable.
*/
async function getBytes(serverId, sha256) {
const rows = await core.query(
`SELECT bytes FROM ${IMAGES} WHERE server_id = ? AND sha256 = ? AND bytes IS NOT NULL`,
[serverId, sha256],
)
return rows[0] ? rows[0].bytes : null
}
/**
* Replace one server's row whole, in ONE statement. A new map's picture and its
* geometry arrive together or not at all; a reader never sees the new bytes
* under the old monuments.
*/
async function putImage(row) {
await core.query(
`REPLACE INTO ${IMAGES}
(server_id, map_key, sha256, source, width, height, ocean_margin, world_size,
grid_cells, grid_cell_size, background, derivation, monuments, bytes, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`,
[
row.serverId, row.mapKey, row.sha256 || null, row.source, row.width, row.height, row.oceanMargin,
row.worldSize, row.gridCells, row.gridCellSize, row.background || null, row.derivation,
JSON.stringify(row.monuments || []), row.bytes || null,
],
)
}
/**
* Re-derive a row's geometry and monuments without touching its picture — the
* `DERIVATION_VERSION` path, and a map whose picture is unchanged but whose
* source moved (a render replaced by the Rust+ cache of the same bytes).
*/
async function putGeometry(row) {
await core.query(
`UPDATE ${IMAGES}
SET source = ?, width = ?, height = ?, ocean_margin = ?, world_size = ?, grid_cells = ?,
grid_cell_size = ?, background = ?, derivation = ?, monuments = ?
WHERE server_id = ? AND map_key = ?`,
[
row.source, row.width, row.height, row.oceanMargin, row.worldSize, row.gridCells, row.gridCellSize,
row.background || null, row.derivation, JSON.stringify(row.monuments || []), row.serverId, row.mapKey,
],
)
}
/** Every override for one server, as `{ setting: value }` rows. */
async function getOverrides(serverId) {
return core.query(`SELECT setting, value FROM ${OVERRIDES} WHERE server_id = ?`, [serverId])
}
/** Every override in the fleet, for the admin card. */
async function listOverrides() {
return core.query(`SELECT server_id AS serverId, setting, value FROM ${OVERRIDES}`)
}
/** Set (a word) or clear (`null`) one server's override of one switch. */
async function setOverride(serverId, setting, value, userId = null) {
if (value === null) {
await core.query(`DELETE FROM ${OVERRIDES} WHERE server_id = ? AND setting = ?`, [serverId, setting])
return
}
await core.query(
`INSERT INTO ${OVERRIDES} (server_id, setting, value, updated_by, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by),
updated_at = CURRENT_TIMESTAMP`,
[serverId, setting, value, userId],
)
}
/** Every Steam id one website user has linked. A link reaches every server (D28). */
async function steamIdsForUser(userId) {
const rows = await core.query(`SELECT steam_id AS steamId FROM ${LINKS} WHERE user_id = ?`, [userId])
return rows.map((r) => String(r.steamId))
}
/**
* Every Steam id sharing a first-party clan with any of `steamIds` on ONE
* server (D115, D117) — the viewer's own accounts included, since they are
* members too. A clan the board no longer carries (`gone_at`) is nobody's clan.
*/
async function clanMatesOn(serverId, steamIds) {
if (!steamIds.length) return []
const rows = await core.query(
`SELECT DISTINCT m2.steam_id AS steamId
FROM ${MEMBERS} m1
JOIN ${CLANS} c ON c.external_id = m1.external_id
JOIN ${MEMBERS} m2 ON m2.external_id = m1.external_id
WHERE c.server_id = ? AND c.gone_at IS NULL
AND m1.steam_id IN (${steamIds.map(() => '?').join(', ')})`,
[serverId, ...steamIds],
)
return rows.map((r) => String(r.steamId))
}
module.exports = {
getMeta,
listMeta,
getBytes,
putImage,
putGeometry,
getOverrides,
listOverrides,
setOverride,
steamIdsForUser,
clanMatesOn,
}

View File

@@ -0,0 +1,414 @@
// ── The map: who may see which layer, and what a viewer is sent ───────────
//
// R9's security boundary, and the reason this file exists apart from the
// picture machinery in `mapImages.js`: **public player positions in Rust locate
// players, and base positions are where they sleep.** So every layer has its own
// audience (D112), a fleet default with a per-server override (D114), and a
// viewer is SENT only the layers they may see — a hidden layer is absent from the
// answer, never present and hidden by the page (§30.2).
//
// ── The four layers ───────────────────────────────────────────────────────
//
// world monuments, and the world's own events: cargo, the patrol
// helicopter, the Chinook, Bradley, supply drops, locked crates
// events what this site's events placed: zones, crates, NPCs (phase 13a)
// players who is on the server and where, and the sleepers
// bases tool cupboards and player vending machines, as positions only
//
// Defaults: the first two public, the last two staff. The audiences are the
// presence rungs (`staff` · `signed_in` · `public`) and follow its asymmetric
// fallbacks: an unknown stored word narrows to staff, an unknown viewer is
// public (`model/visibility`).
//
// ── Two rules that make the players layer safe to widen ───────────────────
//
// **D113 — it can never show more than presence does.** Its effective audience
// is the NARROWER of the layer's switch and the server's presence audience. An
// operator who opens the map to the public while the roll call stays staff has
// opened nothing: a name on a map says who is online as surely as a list does.
//
// **D115/D117 — own dot and clan mates.** A linked viewer sees their own
// position (their sleeper too, §30.5) and their ONLINE first-party clan mates on
// that server, whatever the players layer says — and only a linked member of the
// same clan sees them. It is gated by its own switch (D118, default on) and by
// nothing else: widening the roster widens who sees the member LIST, never where
// the members are.
const core = require('../../core')
const db = require('./map.db')
const visibility = require('../visibility/visibility.model')
const visibilityDb = require('../visibility/visibility.db')
const log = core.logger('map')
const LAYERS = Object.freeze(['world', 'events', 'players', 'bases'])
const DEFAULTS = Object.freeze({ world: 'public', events: 'public', players: 'staff', bases: 'staff' })
/** D118: on, because it shows a member nothing the game does not already show them. */
const DEFAULT_MATES = true
const MATES_KEY = 'map.mates'
const layerKey = (layer) => `map.layer.${layer}.audience`
/** Every override setting name a server may carry. */
const SETTINGS = Object.freeze([...LAYERS.map(layerKey), MATES_KEY])
/**
* `DERIVATION_VERSION` (R9): how a row's geometry is worked out from what the
* plugin said. A stored row with an older number is re-derived from a fresh
* `map.info`, without fetching the picture again.
*
* 1 — width/height from the picture itself (or, with no picture, the Rust+
* cache's geometry: half scale plus the ocean margin); the grid from the
* game's own `MapHelper` (D119); the margin in PIXELS, unscaled.
*/
const DERIVATION_VERSION = 1
/** The Rust+ cache's scale, used only to size a map that has no picture yet. */
const CACHE_SCALE = 0.5
/** How often the page asks for positions while visible, and how long the module keeps an answer. */
const POLL_MS = 10000
const LIVE_CACHE_MS = 5000
/**
* What a render costs, measured on the rig (§30.0): 8.5 s for a 3000 map at
* half scale, a 2500 × 2500 picture. The work is per pixel, so the estimate for
* another size scales with its area.
*/
const RENDER_MEASURED = Object.freeze({ worldSize: 3000, seconds: 8.5 })
function renderStallSeconds(worldSize, margin = 500) {
const side = (ws) => ws * CACHE_SCALE + 2 * margin
const size = Number(worldSize) > 0 ? Number(worldSize) : RENDER_MEASURED.worldSize
const ratio = (side(size) * side(size)) / (side(RENDER_MEASURED.worldSize) ** 2)
return Math.max(1, Math.round(RENDER_MEASURED.seconds * ratio))
}
/** A stored mates word as a boolean. Anything but `on` is off: an unknown word narrows. */
const matesOn = (value) => value === 'on'
/** The narrower of two audiences: the one fewer people satisfy. */
function narrower(a, b) {
const ra = visibility.AUDIENCES.indexOf(visibility.normalise(a))
const rb = visibility.AUDIENCES.indexOf(visibility.normalise(b))
return visibility.AUDIENCES[Math.max(ra, rb)]
}
/** The fleet defaults, as stored, with the built-in defaults where nothing is. */
async function fleet() {
const stored = await Promise.all([...LAYERS.map((l) => visibilityDb.getSetting(layerKey(l))), visibilityDb.getSetting(MATES_KEY)])
const out = {}
LAYERS.forEach((layer, i) => {
out[layer] = stored[i] == null ? DEFAULTS[layer] : visibility.normalise(stored[i])
})
const mates = stored[LAYERS.length]
out.mates = mates == null ? DEFAULT_MATES : matesOn(mates)
return out
}
/** Overrides rows as `{ world: 'staff', mates: false, … }`, only for what is set. */
function overridesFrom(rows) {
const out = {}
for (const { setting, value } of rows) {
if (setting === MATES_KEY) out.mates = matesOn(value)
else {
const layer = LAYERS.find((l) => layerKey(l) === setting)
if (layer) out[layer] = visibility.normalise(value)
}
}
return out
}
/** What applies to one server: its overrides over the fleet. */
async function forServer(serverId) {
const [base, rows] = await Promise.all([fleet(), db.getOverrides(serverId)])
return { ...base, ...overridesFrom(rows) }
}
/**
* Everything the public routes need to decide what one viewer gets on one
* server's map. Throws nothing: a setting that cannot be read hides every layer
* but the picture, which is the direction a map must fail in.
*/
async function access(req, serverId) {
try {
const [viewer, settings, presence] = await Promise.all([
visibility.viewer(req),
forServer(serverId),
visibility.presenceFor(serverId),
])
const layers = {}
for (const layer of LAYERS) {
const audience = layer === 'players' ? narrower(settings.players, presence) : settings[layer]
layers[layer] = { visible: visibility.meets(viewer.level, audience), audience }
}
// Why the players layer is narrower than its own switch, when it is (D113):
// the page says "limited by who may see who is online" rather than nothing.
if (layers.players.audience !== visibility.normalise(settings.players)) layers.players.cappedByPresence = true
let steamIds = []
if (settings.mates && viewer.userId != null) steamIds = await db.steamIdsForUser(viewer.userId)
const mates = {
on: settings.mates,
linked: steamIds.length > 0,
visible: settings.mates && steamIds.length > 0,
}
return { level: viewer.level, userId: viewer.userId, layers, mates, steamIds }
} catch (err) {
log.warn('could not resolve map visibility; showing the picture only', { server: serverId, error: err.message })
const layers = {}
for (const layer of LAYERS) layers[layer] = { visible: false, audience: 'staff' }
return { level: 'public', userId: null, layers, mates: { on: false, linked: false, visible: false }, steamIds: [] }
}
}
/**
* The positions a viewer who is entitled to own-and-mates may see: their own
* accounts (online or asleep) and their ONLINE clan mates on this server. Empty
* for anybody else. Asked only when there is a live answer to filter.
*/
async function mateIdsFor(serverId, acc) {
if (!acc.mates.visible || !acc.steamIds.length) return { own: new Set(), mates: new Set() }
const own = new Set(acc.steamIds)
const clan = await db.clanMatesOn(serverId, acc.steamIds)
return { own, mates: new Set(clan.filter((id) => !own.has(id))) }
}
/**
* One live answer, cut down to what one viewer may see. **Pure**, and the
* security boundary in one function: a layer the viewer may not see is not in
* the result at all — not an empty array, not a flag — so there is nothing on
* the wire for a page to forget to hide.
*
* `mates` is the viewer's own dots and their online clan mates' (D115). It is
* the ONLY place a player position can appear below the players layer, and it
* never carries anybody outside the viewer's clan.
*/
function project(live, acc, ids = { own: new Set(), mates: new Set() }) {
const out = { mapKey: live.mapKey || null, t: live.t || null }
if (acc.layers.world.visible) out.world = Array.isArray(live.world) ? live.world : []
if (acc.layers.events.visible) out.events = Array.isArray(live.events) ? live.events : []
if (acc.layers.players.visible) {
out.players = Array.isArray(live.players) ? live.players : []
if (live.playersTruncated) out.playersTruncated = true
}
if (acc.layers.bases.visible) {
out.bases = Array.isArray(live.bases) ? live.bases : []
if (live.basesTruncated) out.basesTruncated = true
}
if (acc.mates.visible) {
const players = Array.isArray(live.players) ? live.players : []
out.mates = players
.filter((p) => ids.own.has(String(p.steamId)) || (ids.mates.has(String(p.steamId)) && p.online === true))
.map((p) => ({
steamId: String(p.steamId),
name: p.name,
x: p.x,
z: p.z,
sleeping: Boolean(p.sleeping),
online: p.online === true,
self: ids.own.has(String(p.steamId)),
}))
}
return out
}
/**
* A stored row as the geometry the page draws with. The picture's own size is
* the truth when there is one; without one the Rust+ cache's geometry stands in,
* so a server that has no picture yet draws its layers in the same frame a
* picture would later fill.
*/
function geometryOf(row) {
if (!row) return null
return {
worldSize: Number(row.worldSize),
oceanMargin: Number(row.oceanMargin),
width: Number(row.width),
height: Number(row.height),
gridCells: Number(row.gridCells),
gridCellSize: Number(row.gridCellSize),
background: row.background || null,
}
}
/**
* `map.info` as the row it becomes (without bytes). **Pure** — the one place
* `DERIVATION_VERSION` is applied.
*/
function derive(serverId, info) {
const worldSize = Number(info.worldSize) || 0
const oceanMargin = Number(info.oceanMargin) || 0
const hasPicture = info.source !== 'none' && Number(info.width) > 0 && Number(info.height) > 0
const side = Math.round(worldSize * CACHE_SCALE + 2 * oceanMargin)
return {
serverId,
mapKey: String(info.mapKey || ''),
sha256: hasPicture && info.sha256 ? String(info.sha256).toLowerCase() : null,
source: hasPicture ? String(info.source) : 'none',
width: hasPicture ? Number(info.width) : side,
height: hasPicture ? Number(info.height) : side,
oceanMargin,
worldSize,
gridCells: Number(info.gridCells) || 1,
gridCellSize: Number(info.gridCellSize) || worldSize,
background: typeof info.background === 'string' && /^#[0-9a-f]{6}$/i.test(info.background) ? info.background : null,
derivation: DERIVATION_VERSION,
monuments: Array.isArray(info.monuments)
? info.monuments
.filter((m) => m && Number.isFinite(Number(m.x)) && Number.isFinite(Number(m.z)))
.map((m) => ({
value: String(m.value || ''),
kind: String(m.kind || ''),
label: String(m.label || m.kind || ''),
grid: m.grid ? String(m.grid) : null,
x: Number(m.x),
z: Number(m.z),
}))
: [],
}
}
/** Parse the stored monuments, never throwing: a row that will not parse draws none. */
function monumentsOf(row) {
if (!row || !row.monuments) return []
try {
const parsed = typeof row.monuments === 'string' ? JSON.parse(row.monuments) : row.monuments
return Array.isArray(parsed) ? parsed : []
} catch (err) {
return []
}
}
// ── Admin ────────────────────────────────────────────────────────────────
/** The Map card's switches: the fleet, and each server's overrides and effective values. */
async function describeSwitches(servers) {
const [base, rows] = await Promise.all([fleet(), db.listOverrides()])
const byServer = new Map()
for (const row of rows) {
if (!byServer.has(row.serverId)) byServer.set(row.serverId, [])
byServer.get(row.serverId).push(row)
}
return {
layers: [...LAYERS],
defaults: { ...DEFAULTS, mates: DEFAULT_MATES },
fleet: base,
servers: servers.map((s) => {
const overrides = overridesFrom(byServer.get(s.id) || [])
const full = {}
for (const key of [...LAYERS, 'mates']) full[key] = key in overrides ? overrides[key] : null
return { id: s.id, overrides: full, effective: { ...base, ...overrides } }
}),
}
}
/**
* The Map card's write, validated whole before anything is written, like the
* rest of the visibility page.
*
* { fleet: { world: 'public', …, mates: true },
* servers: { <id>: { players: 'signed_in', mates: null, … } } }
*
* `null` clears an override. Resolves `{ ok, changed }` or `{ ok: false,
* status, message }`. `dryRun` validates and writes nothing, so a caller saving
* several things in one request can refuse the whole request up front.
*/
async function update({ fleet: fleetIn, servers } = {}, actor = null, serverExists = async () => true, { dryRun = false } = {}) {
const fleetChanges = []
const serverChanges = []
const check = (key, value, where, allowNull) => {
if (value === null && allowNull) return null
if (key === 'mates') {
if (typeof value !== 'boolean') return `The own-and-mates view is on or off${where}, not "${value}".`
return null
}
if (!LAYERS.includes(key)) return `"${key}" is not a map layer. The layers are: ${LAYERS.join(', ')}, and mates.`
if (!visibility.isAudience(value)) {
return `"${value}" is not an audience for the ${key} layer${where}. Choose one of: ${visibility.AUDIENCES.join(', ')}.`
}
return null
}
for (const [key, value] of Object.entries(fleetIn || {})) {
const problem = check(key, value, '', false)
if (problem) return { ok: false, status: 400, message: problem }
fleetChanges.push([key, value])
}
for (const [id, settings] of Object.entries(servers || {})) {
if (!settings || typeof settings !== 'object') {
return { ok: false, status: 400, message: `Server ${id}'s map switches must be an object.` }
}
// eslint-disable-next-line no-await-in-loop
if (!(await serverExists(id))) return { ok: false, status: 404, message: `There is no server called ${id}.` }
for (const [key, value] of Object.entries(settings)) {
const problem = check(key, value, ` on server ${id}`, true)
if (problem) return { ok: false, status: 400, message: problem }
serverChanges.push([id, key, value])
}
}
if (dryRun) return { ok: true, changed: {} }
const userId = actor && actor.id != null ? actor.id : null
const settingOf = (key) => (key === 'mates' ? MATES_KEY : layerKey(key))
const wordOf = (key, value) => (key === 'mates' ? (value ? 'on' : 'off') : value)
for (const [key, value] of fleetChanges) {
// eslint-disable-next-line no-await-in-loop
await visibilityDb.setSetting(settingOf(key), wordOf(key, value), userId)
}
for (const [id, key, value] of serverChanges) {
// eslint-disable-next-line no-await-in-loop
await db.setOverride(id, settingOf(key), value === null ? null : wordOf(key, value), userId)
}
const changed = {}
if (fleetChanges.length) changed.fleet = Object.fromEntries(fleetChanges)
if (serverChanges.length) {
changed.servers = {}
for (const [id, key, value] of serverChanges) {
changed.servers[id] = { ...(changed.servers[id] || {}), [key]: value === null ? 'inherit' : value }
}
}
return { ok: true, changed }
}
module.exports = {
LAYERS,
DEFAULTS,
DEFAULT_MATES,
MATES_KEY,
SETTINGS,
DERIVATION_VERSION,
POLL_MS,
LIVE_CACHE_MS,
RENDER_MEASURED,
layerKey,
renderStallSeconds,
narrower,
fleet,
forServer,
access,
mateIdsFor,
project,
geometryOf,
derive,
monumentsOf,
describeSwitches,
update,
}

View File

@@ -78,6 +78,18 @@ async function listForPolling() {
return rows.map(withToken)
}
/**
* One ENABLED server with its token, for a call to its sidecar made on a page's
* behalf (the map's live layers), or `null`. The same rule as `getPublic`: a
* disabled server is not there.
*/
async function getForCalling(id) {
if (!id) return null
const row = await db.getServer(id)
if (!row || !row.enabled) return null
return withToken(row)
}
/**
* The public view: every enabled server and what it last said.
*
@@ -186,6 +198,7 @@ module.exports = {
STALE_AFTER_MS,
withToken,
listForPolling,
getForCalling,
lastEnabledCount,
listPublic,
getPublic,

View File

@@ -93,19 +93,30 @@ const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
* question about somebody's standing must grant nothing.
*/
async function viewerLevel(req) {
return (await viewer(req)).level
}
/**
* The viewer's rung AND who they are, from the same re-read row — `{ level,
* userId }`, with `userId` null for anybody who resolves to `public`. The map's
* own-and-mates view (D115) needs the id, and it must come from the row that
* decided the rung: a banned account is nobody, whatever its token names.
*/
async function viewer(req) {
const nobody = { level: 'public', userId: null }
try {
const claimed = req.user || core.auth.getUserFromRequest(req)
if (!claimed || claimed.id == null) return 'public'
if (!claimed || claimed.id == null) return nobody
const user = await core.users.getById(claimed.id)
if (!user) return 'public'
if (user.status && user.status !== 'active') return 'public'
if (!user) return nobody
if (user.status && user.status !== 'active') return nobody
if (user.role === 'admin' || user.role === 'moderator') return 'staff'
return 'signed_in'
const level = user.role === 'admin' || user.role === 'moderator' ? 'staff' : 'signed_in'
return { level, userId: user.id != null ? user.id : claimed.id }
} catch (err) {
log.warn('could not resolve the viewer; treating them as anonymous', { error: err.message })
return 'public'
return nobody
}
}
@@ -278,6 +289,7 @@ module.exports = {
meets,
normalise,
viewerLevel,
viewer,
fleetPresence,
presenceFor,
canSeePresence,

View File

@@ -13,6 +13,7 @@
const core = require('../../core')
const db = require('../../model/servers/servers.db')
const mapImages = require('../../mapImages')
const servers = require('../../model/servers/servers.model')
const sidecar = require('../../sidecarClient')
@@ -129,4 +130,57 @@ async function testServer(req, res) {
}
}
module.exports = { listServers, putServer, deleteServer, testServer }
/**
* Fetch one server's map picture again (D110's admin button). Runs the same
* fetch the board poll triggers, forced — the stored hash is not trusted to be
* current — and answers what it did, because an operator pressing this is
* asking a question: is the picture this server has the right one?
*/
async function fetchMap(req, res) {
const { id } = req.params
try {
const server = await servers.getForCalling(id)
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
const result = await mapImages.run(server, null, { force: true })
await core.activity.log({ req, action: 'rust.map.fetch', detail: { server: id, outcome: result.outcome } })
if (result.outcome === 'busy') return res.status(409).json({ message: 'A fetch is already running for this server.' })
return res.status(result.ok ? 200 : 502).json({ ok: result.ok, outcome: result.outcome, message: result.detail || null })
} catch (err) {
log.error('failed to fetch a map', { server: id, error: err.message })
return res.status(500).json({ message: 'Failed to fetch the map' })
}
}
/**
* Ask a server with no picture to draw one (D109). **This stalls that game**
* for seconds — the card says how many before the button is pressed — and it is
* refused by the plugin when a picture already exists. Answered as soon as the
* game accepts; the picture is fetched when the render finishes.
*/
async function renderMap(req, res) {
const { id } = req.params
try {
const server = await servers.getForCalling(id)
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
const actor = req.user && (req.user.username || req.user.id)
const result = await mapImages.render(server, actor != null ? String(actor) : null)
await core.activity.log({
req,
action: 'rust.map.render',
detail: { server: id, accepted: result.ok, ...(result.ok ? {} : { reason: result.reason || null }) },
})
if (!result.ok) return res.status(result.status || 502).json({ message: result.message })
return res.status(202).json({ accepted: true, stallSeconds: result.stallSeconds })
} catch (err) {
log.error('failed to ask for a render', { server: id, error: err.message })
return res.status(500).json({ message: 'Failed to ask the server to draw its map' })
}
}
module.exports = { listServers, putServer, deleteServer, testServer, fetchMap, renderMap }

View File

@@ -101,4 +101,37 @@ adminRustRouter.post(
admin.testServer,
)
// ── The map's picture (phase 14) ──────────────────────────────────────────
adminRustRouter.post(
'/servers/:id/map/fetch',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Fetch a server’s map picture again'
// #swagger.description = 'Asks the game what its map is and fetches the picture again, whatever this site already holds. The site does this by itself the first time it sees a new map when the game has a picture to give; this is the button for when that did not happen. `outcome` is `fetched`, `current` (the stored picture is this map’s), `none` (the game has no picture, and Render now is the way to get one) or `failed`, with a sentence in `message`. One fetch per server at a time: a second answers 409.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'What the fetch did' } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
/* #swagger.responses[409] = { description: 'A fetch is already running for this server' } */
/* #swagger.responses[502] = { description: 'The game or its sidecar did not give a picture; the stored one is kept' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.fetchMap,
)
adminRustRouter.post(
'/servers/:id/map/render',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Ask a server to draw its own map'
// #swagger.description = '**This stalls the game server** while it draws — about 8.5 seconds on a 3000 map, longer on a larger one — and nothing on it moves for that time. It exists for a server without Rust+ (`app.port`), whose game keeps no picture of its map, and is refused when a picture already exists. Answers 202 as soon as the game accepts; the picture is fetched when the render finishes, and kept by the game so a restart on the same map does not need another.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[202] = { description: 'The game accepted and will draw on its next frame' } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
/* #swagger.responses[409] = { description: 'A picture already exists, or a render is already running' } */
requireRole('admin'),
param('id').isString().isLength({ min: 1, max: 64 }),
validate,
admin.renderMap,
)
module.exports = adminRustRouter

View File

@@ -3,7 +3,11 @@
const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const map = require('../../model/map/map.model')
const mapDb = require('../../model/map/map.db')
const mapImages = require('../../mapImages')
const visibility = require('../../model/visibility/visibility.model')
const visibilityDb = require('../../model/visibility/visibility.db')
const log = core.logger('visibility')
@@ -14,8 +18,49 @@ const log = core.logger('visibility')
* 100-clan ceiling" (D55) come from.
*/
async function describe() {
const [settings, boards] = await Promise.all([visibility.describe(), clans.boardsForAdmin()])
return { ...settings, clans: { ...settings.clans, servers: boards } }
const [settings, boards, mapCard] = await Promise.all([visibility.describe(), clans.boardsForAdmin(), describeMap()])
return { ...settings, clans: { ...settings.clans, servers: boards }, map: mapCard }
}
/**
* The Map card (phase 14, D114): the four layers' fleet defaults and each
* server's overrides, and beside them what each server's picture is — where it
* came from, which map it is of, when it was fetched — and what a render would
* cost that server (D109). On this page for D106's reason: it is the one page
* with a row per server, and a layer switch answers the same question the
* presence switch does.
*/
async function describeMap() {
const [servers, rows] = await Promise.all([visibilityDb.listServerPresence(), mapDb.listMeta()])
const switches = await map.describeSwitches(servers)
const byId = new Map(rows.map((r) => [r.serverId, r]))
return {
...switches,
servers: switches.servers.map((sw, i) => {
const s = servers[i]
const row = byId.get(s.id)
return {
...sw,
name: s.name,
enabled: Boolean(s.enabled),
picture: row
? {
source: row.source,
mapKey: row.mapKey,
hasPicture: Boolean(row.sha256 && Number(row.byteCount) > 0),
bytes: Number(row.byteCount) || 0,
width: Number(row.width),
height: Number(row.height),
worldSize: Number(row.worldSize),
fetchedAt: row.fetchedAt ? new Date(row.fetchedAt).toISOString() : null,
}
: null,
renderStallSeconds: map.renderStallSeconds(row ? row.worldSize : null),
...mapImages.statusOf(s.id),
}
}),
}
}
async function read(req, res) {
@@ -29,13 +74,31 @@ async function read(req, res) {
async function update(req, res) {
try {
const { fleet, servers, clanRoster, news } = req.body || {}
const { fleet, servers, clanRoster, news, map: mapSwitches } = req.body || {}
// The map's switches are validated whole FIRST, before the rest is written:
// the page saves everything with one PUT, and a refused map switch must not
// leave the presence half already applied.
let mapResult = null
if (mapSwitches !== undefined) {
mapResult = await map.update(mapSwitches, req.user, async (id) => (await visibilityDb.getServerPresence(id)) !== undefined, { dryRun: true })
if (!mapResult.ok) {
res.status(mapResult.status || 400).json({ message: mapResult.message })
return
}
}
const result = await visibility.update({ fleet, servers, clanRoster, news }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
}
if (mapSwitches !== undefined) {
mapResult = await map.update(mapSwitches, req.user, async (id) => (await visibilityDb.getServerPresence(id)) !== undefined)
if (mapResult.ok && Object.keys(mapResult.changed).length) result.changed.map = mapResult.changed
}
// One row per save, naming everything it changed. Widening who may see the
// roll call is exactly the kind of change somebody later needs to trace to a
// person and a time.

View File

@@ -27,7 +27,7 @@ visibilityRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Who may see who is online, and who may see a clan roster'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams.'
// #swagger.description = 'The presence fleet default and every server’s optional override. It governs the Online list, every feed item that names a player who was on the server (connects, respawns, deaths, chat, tallies) and the leaderboard’s `lastSeen`. The default is `staff`: nothing names who is online until an operator widens it. The player count is public at every setting. `clans` carries the clan roster audience (default `members`: the clan’s own linked members, and staff) and each server’s clan board — whether it is current, at the game’s 100-clan ceiling, or running the uMod Clans plugin, whose clans are not Teams. `map` carries the live map’s switches: each layer’s fleet audience (`world` and `events` public, `players` and `bases` staff by default), the own-and-mates switch (on), each server’s overrides, and what each server’s map picture is — its source, the map it is of, when it was fetched, and how long a render would stall that server.'
/* #swagger.responses[200] = { description: 'The fleet default and each server', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
requireRole('admin'),
visibility.read,
@@ -37,7 +37,7 @@ visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it.'
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
@@ -47,6 +47,7 @@ visibilityRouter.put(
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
body('news').optional().isObject().withMessage('news maps a server id to true or false'),
body('map').optional().isObject().withMessage('map carries fleet and servers, each an object of layer switches'),
validate,
visibility.update,
)

View File

@@ -13,6 +13,9 @@ const core = require('../../core')
const clans = require('../../model/clans/clans.model')
const events = require('../../model/events/events.model')
const map = require('../../model/map/map.model')
const mapDb = require('../../model/map/map.db')
const mapLive = require('../../mapLive')
const servers = require('../../model/servers/servers.model')
const visibility = require('../../model/visibility/visibility.model')
@@ -216,4 +219,135 @@ async function getClan(req, res) {
}
}
module.exports = { listServers, getServer, listEvents, listLeaderboard, listWipes, listOnline, listClans, getClan }
// ── The map (phase 14) ────────────────────────────────────────────────────
/**
* One server's map: the picture's address, the geometry to draw it with, the
* monuments when the viewer may see the world layer, and **which layers this
* viewer gets and who gets the others** — the §23.3 shape, where a hidden layer
* says who can see it and never what it holds.
*
* A server that has never described its map answers `picture: null` and
* `geometry: null`, and the page says the map is not available yet. A server
* whose game has no picture answers geometry and no picture, and the page draws
* the layers on a plain background (D109).
*/
async function getMap(req, res) {
try {
const server = await servers.getPublic(req.params.id)
if (!server) {
res.status(404).json({ message: 'No such server' })
return
}
const [row, acc] = await Promise.all([mapDb.getMeta(req.params.id), map.access(req, req.params.id)])
perViewer(res)
const hasPicture = Boolean(row && row.sha256 && Number(row.byteCount) > 0)
res.json({
serverId: req.params.id,
mapKey: row ? row.mapKey : null,
picture: hasPicture
? {
path: `/public/rust/servers/${encodeURIComponent(req.params.id)}/map/image?v=${row.sha256}`,
source: row.source,
fetchedAt: row.fetchedAt ? new Date(row.fetchedAt).toISOString() : null,
}
: null,
geometry: map.geometryOf(row),
...(acc.layers.world.visible ? { monuments: map.monumentsOf(row) } : {}),
layers: acc.layers,
// `signedIn` is what lets the page offer "link your Steam account" to the
// person who can act on it, and not to a visitor who has no account at all.
mates: { visible: acc.mates.visible, on: acc.mates.on, linked: acc.mates.linked, signedIn: acc.level !== 'public' },
pollMs: map.POLL_MS,
})
} catch (err) {
log.error('failed to read a map', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the map' })
}
}
/**
* The picture itself. **Public and immutable**: the hash is in the URL, so a
* browser and any cache in front of the site may keep it for a year — and a
* hash that is no longer the stored picture's is a 404, never the new bytes
* under the old address. The picture is public at every setting (§30.5): it is
* rendered from a seed anybody can render, and it says nothing about who plays.
*/
async function getMapImage(req, res) {
try {
const sha = String(req.query.v || '').toLowerCase()
const server = /^[0-9a-f]{64}$/.test(sha) ? await servers.getPublic(req.params.id) : null
const bytes = server ? await mapDb.getBytes(req.params.id, sha) : null
if (!bytes) {
res.set('Cache-Control', 'no-store')
res.status(404).json({ message: 'No such picture' })
return
}
res.set('Cache-Control', 'public, max-age=31536000, immutable')
res.set('Content-Type', 'image/jpeg')
res.set('X-Content-Type-Options', 'nosniff')
res.send(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes))
} catch (err) {
log.error('failed to read a map picture', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the map picture' })
}
}
/**
* What moves, **projected for this viewer on the server** (§30.2). A layer the
* viewer may not see is absent from the answer — not empty, absent — and
* `mates` carries their own position and their online clan mates when they are
* entitled to it (D115, D117). Positions come from `mapLive`'s five-second
* cache, so any number of viewers cost one ask of the game (D111).
*
* A game that does not answer is `live: false` with a reason, a 200: the page
* keeps the picture and says positions are unavailable.
*/
async function getMapLive(req, res) {
try {
const server = await servers.getForCalling(req.params.id)
if (!server) {
res.status(404).json({ message: 'No such server' })
return
}
const acc = await map.access(req, req.params.id)
perViewer(res)
const anyLayer = map.LAYERS.some((l) => acc.layers[l].visible) || acc.mates.visible
if (!anyLayer) {
res.json({ live: true, layers: acc.layers })
return
}
const answer = await mapLive.live(server)
if (!answer.ok) {
res.json({ live: false, reason: answer.status, layers: acc.layers })
return
}
const ids = await map.mateIdsFor(req.params.id, acc)
res.json({ live: true, layers: acc.layers, ...map.project(answer.data, acc, ids) })
} catch (err) {
log.error('failed to read live map positions', { server: req.params.id, error: err.message })
res.status(500).json({ message: 'Failed to read the map' })
}
}
module.exports = {
listServers,
getServer,
listEvents,
listLeaderboard,
listWipes,
listOnline,
listClans,
getClan,
getMap,
getMapImage,
getMapLive,
}

View File

@@ -142,4 +142,47 @@ rustRouter.get(
servers.getClan,
)
// ── The map (phase 14) ────────────────────────────────────────────────────
//
// The picture is public at every setting; what moves on it is not. Each of the
// four layers has its own audience, and a layer the viewer may not see is
// removed on the server — never sent and hidden by the page (PLAN.md §30.2).
rustRouter.get(
'/servers/:id/map',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'One Rust server’s map'
// #swagger.description = 'Where the picture of the current map is, the geometry to draw it with (world size, the picture’s size and ocean margin in pixels, and the game’s own grid), the monuments when the viewer may see the world layer, and which of the four layers — `world`, `events`, `players`, `bases` — this viewer gets. A hidden layer says which audience can see it and never what it holds. The players layer can never be wider than who may see who is online (`cappedByPresence`). `mates` says whether this viewer gets their own position and their online clan mates’. `picture` is null when the game has no picture of its map; `geometry` is null when the server has never described its map.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The map, as this viewer may see it', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMap" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
siteMode,
servers.getMap,
)
rustRouter.get(
'/servers/:id/map/image',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The picture of one Rust server’s map'
// #swagger.description = 'The JPEG, cached as immutable for a year because the picture’s SHA-256 is in the URL. A hash that is not the stored picture’s answers 404, so a replaced picture is never served under an old address. Public at every setting: the picture is rendered from the map seed and says nothing about who plays.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['v'] = { in: 'query', required: true, description: 'The picture’s SHA-256, as the map route names it', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The picture', content: { "image/jpeg": { schema: { type: "string", format: "binary" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or no picture with that hash' } */
siteMode,
servers.getMapImage,
)
rustRouter.get(
'/servers/:id/map/live',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'What moves on one Rust server’s map'
// #swagger.description = 'The positions this viewer may see, asked of the game while somebody is looking and held for five seconds, so any number of viewers cost one ask. **A layer the viewer may not see is absent from the answer**, not empty: `world` (world events and locked crates), `events` (what the site’s events placed), `players` (online players and sleepers, with names) and `bases` (tool cupboards and vending machines, positions only). `mates` is the viewer’s own position and their online first-party clan mates’, for a linked viewer when the server allows it. `live: false` with a `reason` when the game did not answer. Positions are never stored.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
/* #swagger.responses[200] = { description: 'The positions this viewer may see', content: { "application/json": { schema: { $ref: "#/components/schemas/RustMapLive" } } } } */
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
siteMode,
servers.getMapLive,
)
module.exports = rustRouter

View File

@@ -65,7 +65,9 @@ const TIMEOUT_MS = 12000
* `/world/revert` — what an event places in the world and gives back (PLAN.md
* §28); **10** adds the rewards — `/tally/open`, `/tally/snapshot`,
* `/tally/close`, `/kits` and `/chat`, and a `credits` field on the permission
* sync (PLAN.md §29). The bump lands here in the same change as the emitters,
* sync (PLAN.md §29); **11** adds the map — `/map`, `/map/chunk`,
* `/map/render` and `/map/live`, its picture and what moves on it (PLAN.md
* §30). The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -75,7 +77,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 10
const PROTOCOL_VERSION = 11
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -386,6 +388,36 @@ const kits = (server) => request(server, '/kits')
*/
const chat = (server, body) => request(server, '/chat', { method: 'POST', body })
/**
* What one server's map is and where its picture comes from (protocol 11,
* stage one): `mapKey`, `source` (`companion`, `rendered` or `none`), the
* picture's size, `sha256` and slice count, the grid, and the monuments.
* `hashing: true` in place of `sha256` means ask again in a moment. A refusal
* is `data.kind` `map.error`, like every other write-shaped answer here.
*/
const mapInfo = (server) => request(server, '/map')
/**
* One slice of the picture, base64 in `data.data` (stage two). `map.error`
* `stale` when the map or picture moved since `mapInfo` — the caller starts
* again rather than splicing two maps.
*/
const mapChunk = (server, { mapKey, sha256, n }) =>
request(
server,
`/map/chunk?mapKey=${encodeURIComponent(mapKey)}&sha256=${encodeURIComponent(sha256)}&n=${encodeURIComponent(n)}`,
)
/**
* Ask the game to draw its own map (D109). Answered `accepted` at once; the
* render stalls the game on a LATER frame, and `mapInfo` says `rendered` when
* it is done.
*/
const mapRender = (server, body) => request(server, '/map/render', { method: 'POST', body })
/** Everything that moves on one server's map, every layer, unfiltered. The caller filters (§8.5). */
const mapLive = (server) => request(server, '/map/live')
module.exports = {
TIMEOUT_MS,
LEASE_TIMEOUT_MS,
@@ -417,5 +449,9 @@ module.exports = {
tallyClose,
kits,
chat,
mapInfo,
mapChunk,
mapRender,
mapLive,
joinUrl,
}

View File

@@ -560,6 +560,51 @@ module.exports = {
},
},
},
map: {
type: 'object',
description: 'The live map’s switches and each server’s picture (phase 14).',
properties: {
layers: { type: 'array', items: { type: 'string' }, example: ['world', 'events', 'players', 'bases'] },
fleet: {
type: 'object',
properties: {
world: { $ref: '#/components/schemas/RustAudience' },
events: { $ref: '#/components/schemas/RustAudience' },
players: { $ref: '#/components/schemas/RustAudience' },
bases: { $ref: '#/components/schemas/RustAudience' },
mates: { type: 'boolean', example: true },
},
},
servers: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', example: 'main' },
name: { type: 'string', example: 'Main · Vanilla' },
overrides: { type: 'object', description: 'Each layer and `mates`, or null to follow the fleet.' },
effective: { type: 'object' },
picture: {
type: 'object',
nullable: true,
properties: {
source: { type: 'string', enum: ['companion', 'rendered', 'none'] },
mapKey: { type: 'string' },
hasPicture: { type: 'boolean' },
bytes: { type: 'integer' },
worldSize: { type: 'integer' },
fetchedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
renderStallSeconds: { type: 'integer', description: 'About how long Render now would stall this server.', example: 9 },
fetching: { type: 'boolean' },
rendering: { type: 'boolean' },
lastError: { type: 'string', nullable: true },
},
},
},
},
},
news: {
type: 'object',
description: 'Whether a published news post is also said in each server’s in-game chat. Off by default (D104).',
@@ -580,6 +625,167 @@ module.exports = {
},
},
},
RustMapLayer: {
type: 'object',
description: 'Whether this viewer gets one map layer, and which audience does. A hidden layer never says what it holds.',
properties: {
visible: { type: 'boolean', example: false },
audience: { $ref: '#/components/schemas/RustAudience' },
cappedByPresence: {
type: 'boolean',
description: 'Players layer only: narrower than its own switch because who may see who is online is narrower (D113).',
example: true,
},
},
},
RustMap: {
type: 'object',
description: 'One server’s map, as this viewer may see it (GET /public/rust/servers/{id}/map).',
properties: {
serverId: { type: 'string', example: 'main' },
mapKey: { type: 'string', nullable: true, example: '3000.1234.1' },
picture: {
type: 'object',
nullable: true,
description: 'Null when the game has no picture of its map.',
properties: {
path: { type: 'string', description: 'Relative to /api/v1, with the picture’s hash in it.', example: '/public/rust/servers/main/map/image?v=28da6e8a' },
source: { type: 'string', enum: ['companion', 'rendered'], example: 'companion' },
fetchedAt: { type: 'string', format: 'date-time', nullable: true },
},
},
geometry: {
type: 'object',
nullable: true,
description: 'How world coordinates reach a pixel: s = (width − 2 × oceanMargin) / worldSize, px = (x + worldSize/2) × s + oceanMargin, and z the same way up from the bottom edge.',
properties: {
worldSize: { type: 'integer', example: 3000 },
oceanMargin: { type: 'integer', description: 'In pixels, unscaled.', example: 500 },
width: { type: 'integer', example: 2500 },
height: { type: 'integer', example: 2500 },
gridCells: { type: 'integer', description: 'Cells per side, from the game’s own grid.', example: 20 },
gridCellSize: { type: 'number', description: 'Metres.', example: 150 },
background: { type: 'string', nullable: true, example: '#0B3B4A' },
},
},
monuments: {
type: 'array',
description: 'Present only when the viewer may see the world layer.',
items: {
type: 'object',
properties: {
value: { type: 'string', example: 'harbor_1#2' },
kind: { type: 'string', example: 'harbor_1' },
label: { type: 'string', example: 'Harbor' },
grid: { type: 'string', nullable: true, example: 'O3' },
x: { type: 'number', example: 678.1 },
z: { type: 'number', example: 1005.7 },
},
},
},
layers: {
type: 'object',
properties: {
world: { $ref: '#/components/schemas/RustMapLayer' },
events: { $ref: '#/components/schemas/RustMapLayer' },
players: { $ref: '#/components/schemas/RustMapLayer' },
bases: { $ref: '#/components/schemas/RustMapLayer' },
},
},
mates: {
type: 'object',
description: 'Whether this viewer gets their own position and their online clan mates’ (D115).',
properties: {
visible: { type: 'boolean', example: true },
on: { type: 'boolean', description: 'Is the switch on for this server?', example: true },
linked: { type: 'boolean', description: 'Has this viewer linked a Steam account?', example: true },
signedIn: { type: 'boolean', description: 'Is this viewer signed in at all?', example: true },
},
},
pollMs: { type: 'integer', example: 10000 },
},
},
RustMapLive: {
type: 'object',
description: 'What moves on one server’s map, cut down to this viewer (GET /public/rust/servers/{id}/map/live). A layer the viewer may not see is ABSENT, not empty.',
properties: {
live: { type: 'boolean', description: 'False when the game did not answer; `reason` says why.', example: true },
reason: { type: 'string', nullable: true },
mapKey: { type: 'string', nullable: true, example: '3000.1234.1' },
world: {
type: 'array',
items: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['cargo', 'heli', 'chinook', 'bradley', 'supply', 'crate'], example: 'cargo' },
x: { type: 'number', example: 812.4 },
z: { type: 'number', example: -1320.6 },
hackLeftSec: { type: 'integer', description: 'A locked crate being hacked: seconds left.', example: 540 },
hacked: { type: 'boolean', example: false },
},
},
},
events: {
type: 'array',
items: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['zone', 'crate', 'npc'], example: 'zone' },
runId: { type: 'string', example: '41' },
x: { type: 'number' },
z: { type: 'number' },
radius: { type: 'number', description: 'A zone’s.', example: 60 },
name: { type: 'string', description: 'A zone’s.', example: 'Harbor brawl' },
prefab: { type: 'string', description: 'A crate’s or NPC’s allowlist key.', example: 'crate.elite' },
},
},
},
players: {
type: 'array',
items: {
type: 'object',
properties: {
steamId: { type: 'string', example: '76561198000000000' },
name: { type: 'string', example: 'Wanderer' },
x: { type: 'number' },
z: { type: 'number' },
sleeping: { type: 'boolean', example: false },
online: { type: 'boolean', example: true },
},
},
},
playersTruncated: { type: 'boolean', description: 'More sleepers than the plugin’s MapMaxSleepers.' },
bases: {
type: 'array',
description: 'Positions only: no owner, no authorised list, no shop name.',
items: {
type: 'object',
properties: {
kind: { type: 'string', enum: ['tc', 'vending'], example: 'tc' },
x: { type: 'number' },
z: { type: 'number' },
},
},
},
basesTruncated: { type: 'boolean', description: 'More than the plugin’s MapMaxBases.' },
mates: {
type: 'array',
description: 'The viewer’s own positions (their sleeper too) and their online first-party clan mates on this server.',
items: {
type: 'object',
properties: {
steamId: { type: 'string' },
name: { type: 'string' },
x: { type: 'number' },
z: { type: 'number' },
sleeping: { type: 'boolean' },
online: { type: 'boolean' },
self: { type: 'boolean', description: 'One of the viewer’s own accounts.' },
},
},
},
},
},
RustClanAudience: {
type: 'string',
enum: ['members', 'signed_in', 'public'],
@@ -676,6 +882,11 @@ module.exports = {
additionalProperties: { type: 'boolean' },
example: { main: true },
},
map: {
type: 'object',
description: 'The live map’s switches. `fleet` maps a layer to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet.',
example: { fleet: { players: 'signed_in', mates: true }, servers: { pve: { players: 'public' }, pvp: { players: null } } },
},
},
},
RustSidecarProbe: {

494
server/test/map.test.js Normal file
View File

@@ -0,0 +1,494 @@
// ── The live map (PLAN.md §30, protocol 11) ───────────────────────────────
//
// The map is a security boundary before it is a picture: player positions
// locate people, and base positions are where they sleep. Every test here is one
// of the ways that boundary could look right and be wrong:
//
// a fresh install shows the world and events, and nobody's position
// a word nobody recognises narrows — a layer to staff, the mates switch to off
// the players layer can never be wider than presence (D113)
// a hidden layer is ABSENT from the answer, not an empty array
// own dot and mates: own accounts (asleep too), ONLINE clan mates, nobody else
// the roster's audience has nothing to do with who sees positions (D117)
// a picture is stored only when it matches its own hash
// a fetch that straddles a map change is not spliced
// one fetch per server, and a plugin that predates the map is never asked
// any number of viewers cost one ask of the game (D111)
const test = require('node:test')
const assert = require('node:assert')
const crypto = require('crypto')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
const client = require('../sidecarClient')
const mapDb = require('../model/map/map.db')
const map = require('../model/map/map.model')
const mapImages = require('../mapImages')
const mapLive = require('../mapLive')
const visibility = require('../model/visibility/visibility.model')
const visibilityDb = require('../model/visibility/visibility.db')
/**
* Stub every collaborator the model reads: the stored settings, the overrides,
* the viewer, the presence audience, the viewer's links and the clan query.
*/
function settings(t, { stored = {}, overrides = [], viewer = { level: 'public', userId: null }, presence = 'staff', links = [], mates = [] } = {}) {
const saved = {
getSetting: visibilityDb.getSetting,
getOverrides: mapDb.getOverrides,
viewer: visibility.viewer,
presenceFor: visibility.presenceFor,
steamIdsForUser: mapDb.steamIdsForUser,
clanMatesOn: mapDb.clanMatesOn,
}
const asked = { clans: 0 }
visibilityDb.getSetting = async (key) => (key in stored ? stored[key] : null)
mapDb.getOverrides = async () => overrides
visibility.viewer = async () => viewer
visibility.presenceFor = async () => presence
mapDb.steamIdsForUser = async () => links
mapDb.clanMatesOn = async () => {
asked.clans += 1
return mates
}
t.after(() => {
visibilityDb.getSetting = saved.getSetting
mapDb.getOverrides = saved.getOverrides
visibility.viewer = saved.viewer
visibility.presenceFor = saved.presenceFor
mapDb.steamIdsForUser = saved.steamIdsForUser
mapDb.clanMatesOn = saved.clanMatesOn
})
return asked
}
const LIVE = {
mapKey: '3000.1234.1',
world: [{ kind: 'cargo', x: 10, z: 20 }],
events: [{ kind: 'zone', runId: '4', x: 0, z: 0, radius: 50 }],
players: [
{ steamId: '7001', name: 'Me', x: 1, z: 1, sleeping: false, online: true },
{ steamId: '7002', name: 'Mate', x: 2, z: 2, sleeping: false, online: true },
{ steamId: '7003', name: 'Asleep mate', x: 3, z: 3, sleeping: true, online: false },
{ steamId: '7004', name: 'Stranger', x: 4, z: 4, sleeping: false, online: true },
{ steamId: '7005', name: 'My alt, asleep', x: 5, z: 5, sleeping: true, online: false },
],
bases: [{ kind: 'tc', x: 9, z: 9 }],
}
test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => {
settings(t)
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', mates: true })
const acc = await map.access({}, 'main')
assert.equal(acc.layers.world.visible, true)
assert.equal(acc.layers.events.visible, true)
assert.equal(acc.layers.players.visible, false)
assert.equal(acc.layers.bases.visible, false)
assert.equal(acc.mates.visible, false, 'an anonymous viewer has no accounts to be shown')
})
test('a stored word nobody recognises narrows: a layer to staff, the mates switch to off', async (t) => {
settings(t, { stored: { 'map.layer.world.audience': 'everyone', 'map.mates': 'yes' } })
const fleet = await map.fleet()
assert.equal(fleet.world, 'staff')
assert.equal(fleet.mates, false)
})
test('an override wins over the fleet for its server only', async (t) => {
settings(t, { overrides: [{ setting: 'map.layer.bases.audience', value: 'public' }, { setting: 'map.mates', value: 'off' }] })
const eff = await map.forServer('pve')
assert.equal(eff.bases, 'public')
assert.equal(eff.mates, false)
assert.equal(eff.players, 'staff', 'untouched layers inherit')
})
test('D113: widening the players layer alone shows an anonymous viewer nothing more', async (t) => {
settings(t, { stored: { 'map.layer.players.audience': 'public' }, presence: 'staff' })
const acc = await map.access({}, 'main')
assert.equal(acc.layers.players.visible, false)
assert.equal(acc.layers.players.audience, 'staff')
assert.equal(acc.layers.players.cappedByPresence, true)
assert.equal(map.project(LIVE, acc).players, undefined)
})
test('D113: widening both the layer and presence does show them', async (t) => {
settings(t, { stored: { 'map.layer.players.audience': 'public' }, presence: 'public' })
const acc = await map.access({}, 'main')
assert.equal(acc.layers.players.visible, true)
assert.equal(acc.layers.players.cappedByPresence, undefined)
assert.equal(map.project(LIVE, acc).players.length, LIVE.players.length)
})
test('a hidden layer is absent from the answer, not an empty array', async (t) => {
settings(t)
const out = map.project(LIVE, await map.access({}, 'main'))
assert.ok(Array.isArray(out.world))
assert.ok(Array.isArray(out.events))
for (const hidden of ['players', 'bases', 'mates', 'playersTruncated', 'basesTruncated']) {
assert.equal(Object.prototype.hasOwnProperty.call(out, hidden), false, `${hidden} must not be on the wire`)
}
})
test('staff get every layer', async (t) => {
settings(t, { viewer: { level: 'staff', userId: 1 } })
const out = map.project({ ...LIVE, basesTruncated: true }, await map.access({}, 'main'))
assert.equal(out.players.length, 5)
assert.equal(out.bases.length, 1)
assert.equal(out.basesTruncated, true)
})
test('own dot and mates: own accounts asleep or awake, ONLINE clan mates, and nobody else', async (t) => {
settings(t, {
viewer: { level: 'signed_in', userId: 9 },
links: ['7001', '7005'],
mates: ['7001', '7002', '7003', '7005'],
})
const acc = await map.access({}, 'main')
assert.equal(acc.layers.players.visible, false, 'a player is below the players layer')
assert.equal(acc.mates.visible, true)
const out = map.project(LIVE, acc, await map.mateIdsFor('main', acc))
const ids = out.mates.map((m) => m.steamId).sort()
assert.deepStrictEqual(ids, ['7001', '7002', '7005'])
assert.equal(out.mates.find((m) => m.steamId === '7005').self, true, 'the viewer’s own sleeper is theirs')
assert.equal(out.mates.find((m) => m.steamId === '7002').self, false)
assert.equal(out.players, undefined, 'the players layer itself stays absent')
})
test('the mates switch off removes own dot and mates alike', async (t) => {
const asked = settings(t, {
stored: { 'map.mates': 'off' },
viewer: { level: 'signed_in', userId: 9 },
links: ['7001'],
mates: ['7001', '7002'],
})
const acc = await map.access({}, 'main')
assert.equal(acc.mates.visible, false)
const out = map.project(LIVE, acc, await map.mateIdsFor('main', acc))
assert.equal(out.mates, undefined)
assert.equal(asked.clans, 0, 'nobody’s clan is even looked up')
})
test('D117: a viewer with nothing linked sees no positions, whatever the roster audience is', async (t) => {
settings(t, {
stored: { 'clans.roster.audience': 'public' },
viewer: { level: 'signed_in', userId: 9 },
links: [],
})
const acc = await map.access({}, 'main')
assert.equal(acc.mates.visible, false)
assert.equal(acc.mates.linked, false)
})
test('a setting that cannot be read hides every layer', async (t) => {
settings(t)
visibility.viewer = async () => {
throw new Error('pool exhausted')
}
const acc = await map.access({}, 'main')
for (const layer of map.LAYERS) assert.equal(acc.layers[layer].visible, false)
})
test('the admin write refuses a word it does not know, and writes nothing on a dry run', async (t) => {
settings(t)
const writes = []
const saved = { setSetting: visibilityDb.setSetting, setOverride: mapDb.setOverride }
visibilityDb.setSetting = async (...a) => writes.push(['fleet', ...a])
mapDb.setOverride = async (...a) => writes.push(['server', ...a])
t.after(() => Object.assign(visibilityDb, { setSetting: saved.setSetting }) && Object.assign(mapDb, { setOverride: saved.setOverride }))
assert.equal((await map.update({ fleet: { players: 'everyone' } })).status, 400)
assert.equal((await map.update({ fleet: { mates: 'on' } })).status, 400, 'mates is a boolean')
assert.equal((await map.update({ servers: { nope: { world: 'staff' } } }, null, async () => false)).status, 404)
assert.deepStrictEqual(await map.update({ fleet: { world: 'staff' } }, null, undefined, { dryRun: true }), { ok: true, changed: {} })
assert.equal(writes.length, 0)
const done = await map.update({ fleet: { players: 'signed_in', mates: false }, servers: { pve: { players: 'public', mates: null } } }, { id: 3 })
assert.equal(done.ok, true)
assert.deepStrictEqual(writes, [
['fleet', 'map.layer.players.audience', 'signed_in', 3],
['fleet', 'map.mates', 'off', 3],
['server', 'pve', 'map.layer.players.audience', 'public', 3],
['server', 'pve', 'map.mates', null, 3],
])
})
test('derive: a map with no picture is sized like the Rust+ cache would be', () => {
const row = map.derive('main', { mapKey: '4500.7.1', source: 'none', worldSize: 4500, oceanMargin: 500, gridCells: 30, gridCellSize: 150, monuments: [] })
assert.equal(row.width, 3250)
assert.equal(row.height, 3250)
assert.equal(row.sha256, null)
assert.equal(row.source, 'none')
assert.equal(row.derivation, map.DERIVATION_VERSION)
})
test('derive: the picture’s own size wins, and nothing unsafe reaches a style', () => {
const row = map.derive('main', {
mapKey: '3000.1234.1', source: 'companion', sha256: 'AB'.repeat(32), width: 2500, height: 2500,
worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, background: 'red;x:1',
monuments: [{ value: 'harbor_1#2', kind: 'harbor_1', label: 'Harbor', x: 1, z: 2 }, { kind: 'broken', x: 'nope', z: 1 }],
})
assert.equal(row.sha256, 'ab'.repeat(32), 'hashes are compared lower-case')
assert.equal(row.background, null)
assert.equal(row.monuments.length, 1)
})
test('a render’s stall is estimated from the measured one, scaled by area', () => {
assert.equal(map.renderStallSeconds(3000), 9)
assert.equal(map.renderStallSeconds(4500), 14)
assert.equal(map.renderStallSeconds(null), 9, 'unknown size estimates the measured map')
})
// ── The picture ───────────────────────────────────────────────────────────
const SERVER = { id: 'main', baseUrl: 'http://main:1', token: 't' }
const HELLO = { bootId: 'boot-1', wipeId: 'w-1', seed: 1234, worldSize: 3000, worldReady: true, protocol: 11 }
function picture(bytes, { source = 'companion', mapKey = '3000.1234.1', chunkBytes = 4 } = {}) {
const sha = crypto.createHash('sha256').update(bytes).digest('hex')
const info = {
kind: 'map.info', mapKey, source, sha256: sha, bytes: bytes.length, width: 2500, height: 2500,
worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, background: '#0B3B4A',
chunks: Math.ceil(bytes.length / chunkBytes), monuments: [],
}
const chunk = (n) => ({ kind: 'map.chunk', chunk: n, data: bytes.subarray(n * chunkBytes, (n + 1) * chunkBytes).toString('base64') })
return { info, chunk, sha }
}
function bridge(t, { info, chunk, stored = null } = {}) {
mapImages._reset()
const calls = { info: 0, chunks: [], put: [], geometry: [] }
const saved = { mapInfo: client.mapInfo, mapChunk: client.mapChunk, getMeta: mapDb.getMeta, putImage: mapDb.putImage, putGeometry: mapDb.putGeometry }
client.mapInfo = async () => {
calls.info += 1
return typeof info === 'function' ? info(calls.info) : { ok: true, status: 'ok', data: info }
}
client.mapChunk = async (server, q) => {
calls.chunks.push(q)
return { ok: true, status: 'ok', data: chunk(q.n, q) }
}
mapDb.getMeta = async () => stored
mapDb.putImage = async (row) => calls.put.push(row)
mapDb.putGeometry = async (row) => calls.geometry.push(row)
t.after(() => {
Object.assign(client, { mapInfo: saved.mapInfo, mapChunk: saved.mapChunk })
Object.assign(mapDb, { getMeta: saved.getMeta, putImage: saved.putImage, putGeometry: saved.putGeometry })
mapImages._reset()
})
return calls
}
test('a new map is fetched in slices, checked against its hash, and stored whole', async (t) => {
const p = picture(Buffer.from('a jpeg, honestly'))
const calls = bridge(t, { info: p.info, chunk: p.chunk })
const result = await mapImages.run(SERVER)
assert.equal(result.outcome, 'fetched')
assert.equal(calls.chunks.length, p.info.chunks)
assert.equal(calls.put.length, 1)
assert.equal(calls.put[0].bytes.toString(), 'a jpeg, honestly')
assert.equal(calls.put[0].sha256, p.sha)
})
test('bytes that do not match their hash are never stored', async (t) => {
const p = picture(Buffer.from('the real picture'))
const calls = bridge(t, { info: p.info, chunk: (n) => ({ ...p.chunk(n), data: Buffer.from('xxxx').toString('base64') }) })
const result = await mapImages.run(SERVER)
assert.equal(result.ok, false)
assert.equal(calls.put.length, 0)
})
test('a fetch that straddles a map change is abandoned whole and retried soon, not spliced', async (t) => {
const p = picture(Buffer.from('first map picture'))
const calls = bridge(t, {
info: p.info,
chunk: (n) => (n === 0 ? p.chunk(0) : { kind: 'map.error', reason: 'stale', message: 'moved' }),
})
const result = await mapImages.run(SERVER)
assert.equal(result.ok, false)
assert.equal(result.retrySoon, true)
assert.equal(calls.put.length, 0)
})
test('the stored picture of this map is not fetched again', async (t) => {
const p = picture(Buffer.from('same'))
const calls = bridge(t, {
info: p.info,
chunk: p.chunk,
stored: { mapKey: p.info.mapKey, sha256: p.sha, source: 'companion', derivation: map.DERIVATION_VERSION },
})
assert.equal((await mapImages.run(SERVER)).outcome, 'current')
assert.equal(calls.chunks.length, 0)
assert.equal(calls.geometry.length, 0)
})
test('a game with no picture keeps the one already stored for the same map', async (t) => {
const calls = bridge(t, {
info: { kind: 'map.info', mapKey: '3000.1234.1', source: 'none', worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, monuments: [] },
stored: { mapKey: '3000.1234.1', sha256: 'f'.repeat(64), source: 'rendered', width: 2500, height: 2500 },
})
assert.equal((await mapImages.run(SERVER)).outcome, 'current')
assert.equal(calls.put.length, 0, 'the picture row is not replaced')
assert.equal(calls.geometry[0].source, 'rendered')
})
test('a different map with no picture stores geometry so the layers can be drawn', async (t) => {
const calls = bridge(t, {
info: { kind: 'map.info', mapKey: '3500.9.1', source: 'none', worldSize: 3500, oceanMargin: 500, gridCells: 23, gridCellSize: 152.174, monuments: [] },
stored: { mapKey: '3000.1234.1', sha256: 'f'.repeat(64), source: 'companion' },
})
assert.equal((await mapImages.run(SERVER)).outcome, 'none')
assert.equal(calls.put.length, 1)
assert.equal(calls.put[0].bytes, undefined)
})
test('one fetch per server at a time', async (t) => {
const p = picture(Buffer.from('slow'))
let release
const gate = new Promise((resolve) => {
release = resolve
})
bridge(t, {
info: async () => {
await gate
return { ok: true, status: 'ok', data: p.info }
},
chunk: p.chunk,
})
const first = mapImages.run(SERVER)
assert.equal((await mapImages.run(SERVER)).outcome, 'busy')
release()
assert.equal((await first).outcome, 'fetched')
})
test('observe asks only a connected, ready plugin that knows the map verbs, and only when something moved', async (t) => {
const p = picture(Buffer.from('pic'))
const calls = bridge(t, { info: p.info, chunk: p.chunk })
mapImages.observe(SERVER, { ...HELLO, protocol: 10 })
mapImages.observe(SERVER, { ...HELLO, worldReady: false })
await new Promise((r) => setImmediate(r))
assert.equal(calls.info, 0)
mapImages.observe(SERVER, HELLO)
await new Promise((r) => setTimeout(r, 10))
assert.equal(calls.info, 1)
mapImages.observe(SERVER, HELLO)
await new Promise((r) => setTimeout(r, 10))
assert.equal(calls.info, 1, 'the same boot, wipe and map is not asked about twice')
mapImages.observe(SERVER, { ...HELLO, wipeId: 'w-2' })
await new Promise((r) => setTimeout(r, 10))
assert.equal(calls.info, 2, 'a wipe is a reason to ask')
})
// ── What moves ────────────────────────────────────────────────────────────
test('D111: any number of viewers inside the window cost one ask of the game', async (t) => {
mapLive._reset()
let asks = 0
const saved = client.mapLive
client.mapLive = async () => {
asks += 1
await new Promise((r) => setTimeout(r, 5))
return { ok: true, status: 'ok', data: { kind: 'map.live', ...LIVE } }
}
t.after(() => {
client.mapLive = saved
mapLive._reset()
})
let now = 1000
const clock = () => now
const answers = await Promise.all(Array.from({ length: 12 }, () => mapLive.live(SERVER, clock)))
assert.equal(asks, 1, 'concurrent viewers share the ask in flight')
assert.ok(answers.every((a) => a.ok))
now += map.LIVE_CACHE_MS - 1
await mapLive.live(SERVER, clock)
assert.equal(asks, 1, 'inside the window')
now += 2
await mapLive.live(SERVER, clock)
assert.equal(asks, 2, 'after it')
})
test('a game that does not answer is asked once per window too', async (t) => {
mapLive._reset()
let asks = 0
const saved = client.mapLive
client.mapLive = async () => {
asks += 1
return { ok: false, status: 'http-503' }
}
t.after(() => {
client.mapLive = saved
mapLive._reset()
})
const clock = () => 5000
assert.equal((await mapLive.live(SERVER, clock)).ok, false)
assert.equal((await mapLive.live(SERVER, clock)).ok, false)
assert.equal(asks, 1)
})
// ── The picture's route ───────────────────────────────────────────────────
function fakeRes() {
const res = { headers: {}, statusCode: 200, body: null, vary: () => res }
res.set = (k, v) => {
res.headers[k] = v
return res
}
res.status = (c) => {
res.statusCode = c
return res
}
res.json = (b) => {
res.body = b
return res
}
res.send = (b) => {
res.body = b
return res
}
return res
}
test('the picture is immutable under its hash, and a stale hash is a 404 that nothing caches', async (t) => {
const controller = require('../router/public/rust.controller')
const servers = require('../model/servers/servers.model')
const sha = 'a'.repeat(64)
const saved = { getPublic: servers.getPublic, getBytes: mapDb.getBytes }
servers.getPublic = async (id) => (id === 'main' ? { id } : null)
mapDb.getBytes = async (id, v) => (v === sha ? Buffer.from([0xff, 0xd8, 0xff]) : null)
t.after(() => {
servers.getPublic = saved.getPublic
mapDb.getBytes = saved.getBytes
})
const hit = fakeRes()
await controller.getMapImage({ params: { id: 'main' }, query: { v: sha } }, hit)
assert.equal(hit.statusCode, 200)
assert.equal(hit.headers['Cache-Control'], 'public, max-age=31536000, immutable')
assert.equal(hit.headers['Content-Type'], 'image/jpeg')
const stale = fakeRes()
await controller.getMapImage({ params: { id: 'main' }, query: { v: 'b'.repeat(64) } }, stale)
assert.equal(stale.statusCode, 404)
assert.equal(stale.headers['Cache-Control'], 'no-store')
const junk = fakeRes()
await controller.getMapImage({ params: { id: 'main' }, query: { v: '../../etc' } }, junk)
assert.equal(junk.statusCode, 404)
})

File diff suppressed because it is too large Load Diff