Files
Module-Rust/server/router/admin/visibility.controller.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

115 lines
4.4 KiB
JavaScript

// ── Admin · Rust · Visibility — the handlers ──────────────────────────────
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')
/**
* The page's whole state: both settings, and each server's clan board beside
* the roster setting. The board is where "this server runs the uMod Clans
* plugin, whose clans are not Teams" (D47) and "this server is at the game's
* 100-clan ceiling" (D55) come from.
*/
async function describe() {
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) {
try {
res.json(await describe())
} catch (err) {
log.error('failed to read visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to read the visibility settings' })
}
}
async function update(req, res) {
try {
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.
await core.activity.log({ req, action: 'rust.visibility.save', detail: result.changed })
res.json(await describe())
} catch (err) {
log.error('failed to save visibility settings', { error: err.message })
res.status(500).json({ message: 'Failed to save the visibility settings' })
}
}
module.exports = { read, update }