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
115 lines
4.4 KiB
JavaScript
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 }
|