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

PLAN.md §30 as approved, plus D119/D120 from the build.

Server:
- rust_map_images (one row per server: picture as MEDIUMBLOB, geometry,
  monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair.
- mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and
  asks map.info; a new key or hash from the free Rust+ cache (or a render
  kept on disk) is fetched in slices, checked against its SHA-256 and stored
  in one statement. One fetch per server, a backoff on failure, `stale`
  abandons a fetch that straddles a map change. Render now (D109) is
  admin-only and watched to completion.
- mapLive.js: D111. One map.live per server per 5 s whoever asks; positions
  are held in memory only.
- model/map: four layers (world, events public; players, bases staff), a
  fleet default plus per-server override (D114), the players layer capped by
  presence (D113), own dot and online first-party clan mates for a linked
  viewer (D115, D117, D118). A layer the viewer may not see is absent from
  the answer, never sent and hidden.
- Routes: public /servers/:id/map, /map/image (immutable under its hash),
  /map/live; admin /servers/:id/map/fetch and /render; the Map card on the
  visibility PUT. Swagger fragment and frozen manifest regenerated.

Client:
- A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid
  (labels only when a cell is wide enough to hold one), a legend that lists
  hidden layers with who can see them, polled every 10 s while visible.
- D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml
  copies every dist/*.js; checkExternals and build.test.js hold both ends.
- The Map card on Admin -> Rust visibility, with Fetch again and Render now.

Capability `map` declared for the Android app (phase 15).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 01:06:10 -05:00
parent ac0bcd850a
commit 0cb9bdd1f0
34 changed files with 4680 additions and 28 deletions

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