Files
Module-Rust/server/test/map.test.js
wtclaude 0cb9bdd1f0
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
feat(rust): the live map (phase 14, protocol 11)
PLAN.md §30 as approved, plus D119/D120 from the build.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 01:06:10 -05:00

495 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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