Files
Module-Rust/server/router/admin/visibility.controller.js
wtclaude 1b70cef5be feat(rust): chat titles, BetterChat group styles, the voice and popups (phase 17)
PLAN.md §33, D134-D143. Protocol 12.

- Chat titles (D135-D137): per-server rules (stat, top N, text, colour)
  that rank the current wipe, and a mode (first | all | up to N). Worked
  out once in model/titles and read three ways: pushed whole to the game by
  a new titleSync loop (on change, restart or wipe), and on every
  leaderboard row as `titles`. Admin: PUT /servers/:id/titles.
- Group styles (D138, D139): a site group may carry all twelve BetterChat
  fields (rust_perm_group_chat). They ride perm.sync with `expect` from the
  pushed ledger, which gains a value column; a field changed in game is a
  `chat-field` drift row with the game's value, adopted into the style or
  put back. A withdrawn style is one `chat-group` retirement, never for
  `default`, cleared from the ledger only once BetterChat removed it.
- The voice (D140): one fleet setting naming a styled group; news and
  rust.announce chat lines carry its format and the plugin says them with
  no sender. Admin: GET/PUT /voice.
- Popups (D141, D142): rust.announce gains `delivery` (still version 1,
  from rust.options.delivery); each server gains news_delivery beside the
  news switch; `popup-unavailable` is not retried.
- GET /servers/:id/integrations reads, live, which optional mods a server
  has loaded. README lists BetterChat and PopupNotifications as optional.

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

115 lines
4.5 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, newsDelivery, 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, newsDelivery }, 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 }