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
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const chatStyle = require('../../model/permissions/chatStyle')
|
||||
const db = require('../../model/permissions/permissions.db')
|
||||
const model = require('../../model/permissions/permissions.model')
|
||||
const permSync = require('../../permSync')
|
||||
@@ -27,7 +28,9 @@ const log = core.logger('admin:permissions')
|
||||
/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */
|
||||
async function overview(req, res) {
|
||||
try {
|
||||
res.json(await model.overview())
|
||||
// The twelve BetterChat fields travel with the model, so the form's editor
|
||||
// is built from the same list the server validates against (D138).
|
||||
res.json({ ...(await model.overview()), chatFields: chatStyle.FIELDS })
|
||||
} catch (err) {
|
||||
log.error('failed to read the permission model', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read the permission model' })
|
||||
@@ -50,6 +53,18 @@ async function putGroup(req, res) {
|
||||
return res.status(400).json({ message: 'That scope names no configured server' })
|
||||
}
|
||||
|
||||
// Phase 17: `chat` is the group's BetterChat style — all twelve fields, or
|
||||
// null to take the style away. Absent leaves it as it is, so a client that
|
||||
// predates styles cannot erase one by saving a group.
|
||||
let style
|
||||
if (req.body.chat !== undefined && req.body.chat !== null) {
|
||||
const checked = chatStyle.validateStyle(req.body.chat)
|
||||
if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
|
||||
style = checked.fields
|
||||
} else if (req.body.chat === null) {
|
||||
style = null
|
||||
}
|
||||
|
||||
const previous = await db.getGroup(name)
|
||||
|
||||
await db.upsertGroup({
|
||||
@@ -61,6 +76,7 @@ async function putGroup(req, res) {
|
||||
|
||||
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
|
||||
await db.setGroupPermissions(name, permissions)
|
||||
if (style !== undefined) await db.setGroupChat(name, style)
|
||||
|
||||
// Both scopes: a group that moved from one server to another has to be
|
||||
// retired from where it was as well as applied where it now is, and only the
|
||||
@@ -71,7 +87,12 @@ async function putGroup(req, res) {
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create',
|
||||
detail: { group: name, scope, permissions: permissions.length },
|
||||
detail: {
|
||||
group: name,
|
||||
scope,
|
||||
permissions: permissions.length,
|
||||
...(style !== undefined ? { chatStyle: style ? 'set' : 'removed' } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
return res.status(204).end()
|
||||
@@ -238,6 +259,8 @@ async function adoptDrift(req, res) {
|
||||
const row = await db.getDrift(id)
|
||||
if (!row) return res.status(404).json({ message: 'No such drift' })
|
||||
|
||||
if (row.kind === 'chat-field') return adoptStyleField(req, res, row)
|
||||
|
||||
if (row.kind !== 'grant' && row.kind !== 'member') {
|
||||
return res.status(400).json({
|
||||
message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.',
|
||||
@@ -306,6 +329,8 @@ async function revokeDrift(req, res) {
|
||||
const row = await db.getDrift(id)
|
||||
if (!row) return res.status(404).json({ message: 'No such drift' })
|
||||
|
||||
if (row.kind === 'chat-field') return revokeStyleField(req, res, row)
|
||||
|
||||
await db.queueRevocation({
|
||||
serverId: row.serverId,
|
||||
kind: row.kind,
|
||||
@@ -330,6 +355,65 @@ async function revokeDrift(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a hand edit to a style field: the game's value becomes the site's.
|
||||
*
|
||||
* The style belongs to the GROUP, and a group may reach every server — so the
|
||||
* value adopted from one server is the value every server in its scope is
|
||||
* pushed next. That is what adopting means for a fleet-wide group, and the
|
||||
* activity row names the server it came from.
|
||||
*/
|
||||
async function adoptStyleField(req, res, row) {
|
||||
const style = await db.getGroupChat(row.subject)
|
||||
if (!style || style[row.object] === undefined) {
|
||||
return res.status(409).json({ message: 'That group has no chat style on this site to adopt the change into' })
|
||||
}
|
||||
|
||||
const field = chatStyle.FIELDS.find((f) => f.name === row.object)
|
||||
const checked = field ? chatStyle.checkField(field, row.detail === null ? '' : row.detail) : { error: 'unknown field' }
|
||||
if (checked.error) {
|
||||
return res.status(409).json({
|
||||
message: `The game's value cannot be adopted: ${checked.error}. Revoke it instead, or edit the style.`,
|
||||
})
|
||||
}
|
||||
|
||||
await db.setGroupChatField(row.subject, row.object, checked.value)
|
||||
// Already in that game, so already pushed there — the same reasoning as a grant.
|
||||
await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
|
||||
await db.deleteDrift(row.id)
|
||||
await db.markDirty(model.FLEET)
|
||||
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.perm.drift.adopt',
|
||||
detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object, value: checked.value },
|
||||
})
|
||||
|
||||
return res.status(204).end()
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a hand edit to a style field: put the site's value back.
|
||||
*
|
||||
* Not a queued revocation — there is nothing to remove, only a value to
|
||||
* overwrite. The ledger is told the game's value is this site's own, so the
|
||||
* next sync expects to find it and writes over it. That is a person choosing to
|
||||
* overwrite, which R2 allows (§33.2).
|
||||
*/
|
||||
async function revokeStyleField(req, res, row) {
|
||||
await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
|
||||
await db.deleteDrift(row.id)
|
||||
await db.markDirty(row.serverId)
|
||||
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.perm.drift.revoke',
|
||||
detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object },
|
||||
})
|
||||
|
||||
return res.status(202).json({ queued: true })
|
||||
}
|
||||
|
||||
/** Run the loop's pass now, for one server or for all of them, and report what happened. */
|
||||
async function syncNow(req, res) {
|
||||
const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
|
||||
|
||||
@@ -32,7 +32,7 @@ permissionsRouter.get(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'The whole permission model'
|
||||
// #swagger.description = 'Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.'
|
||||
// #swagger.description = 'Groups with their permissions, members and BetterChat style (`chat`, or null), direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server. A drift row of kind `chat-field` is a style field somebody changed in game: `subject` is the group, `object` the field and `detail` what the game holds. `chatFields` lists the twelve BetterChat fields with their types and defaults, for the style editor.'
|
||||
/* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */
|
||||
requireRole('admin'),
|
||||
permissions.overview,
|
||||
@@ -52,7 +52,7 @@ permissionsRouter.put(
|
||||
'/groups/:name',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Create or update a permission group'
|
||||
// #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.'
|
||||
// #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it. `chat` is the group’s BetterChat style: all twelve fields (`Priority`, `Title`, `TitleColor`, `TitleSize`, `TitleHidden`, `TitleHiddenIfNotPrimary`, `UsernameColor`, `UsernameSize`, `MessageColor`, `MessageSize`, `ChatFormat`, `ConsoleFormat`), each as text; `null` removes the style, which removes the group from BetterChat on the next sync; absent leaves it alone. A format must hold `{Message}` exactly once. A 400 carries one sentence per problem in `errors`.'
|
||||
/* #swagger.responses[204] = { description: 'Saved' } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
|
||||
requireRole('admin'),
|
||||
@@ -62,6 +62,9 @@ permissionsRouter.put(
|
||||
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
|
||||
body('permissions').optional().isArray({ max: 500 }),
|
||||
body('permissions.*').isString().matches(NAME),
|
||||
// Shape only; the twelve fields and their rules are `chatStyle.validateStyle`'s,
|
||||
// in the controller, so the form gets one sentence per problem.
|
||||
body('chat').optional({ values: 'null' }).isObject().withMessage('chat is an object of BetterChat fields, or null'),
|
||||
validate,
|
||||
permissions.putGroup,
|
||||
)
|
||||
@@ -145,7 +148,7 @@ permissionsRouter.post(
|
||||
'/drift/:id/adopt',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Adopt a hand edit'
|
||||
// #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.'
|
||||
// #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link. For a `chat-field` row it copies the game’s value into the group’s style — which every server in the group’s scope is then pushed — and answers 409 when the group has no style or the value is not one the site accepts.'
|
||||
/* #swagger.responses[204] = { description: 'Adopted' } */
|
||||
/* #swagger.responses[400] = { description: 'That kind of drift cannot be adopted' } */
|
||||
/* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */
|
||||
@@ -159,7 +162,7 @@ permissionsRouter.post(
|
||||
'/drift/:id/revoke',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Revoke a hand edit'
|
||||
// #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.'
|
||||
// #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own. For a `chat-field` row it puts the site’s value back over the hand edit on the next sync.'
|
||||
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
|
||||
/* #swagger.responses[404] = { description: 'No such drift' } */
|
||||
requireRole('admin'),
|
||||
|
||||
@@ -17,12 +17,25 @@ const mapImages = require('../../mapImages')
|
||||
const nextWipe = require('../../model/servers/nextWipe')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
const sidecar = require('../../sidecarClient')
|
||||
const titleSync = require('../../titleSync')
|
||||
const titles = require('../../model/titles/titles')
|
||||
const titlesModel = require('../../model/titles/titles.model')
|
||||
const voice = require('../../model/permissions/voice')
|
||||
|
||||
const log = core.logger('admin')
|
||||
|
||||
async function listServers(req, res) {
|
||||
try {
|
||||
res.json({ servers: await servers.listForAdmin() })
|
||||
const [rows, settings] = await Promise.all([servers.listForAdmin(), titlesModel.settingsByServer()])
|
||||
|
||||
// Phase 17: each server's chat titles, and what the last push of them found.
|
||||
res.json({
|
||||
servers: rows.map((row) => ({
|
||||
...row,
|
||||
titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] },
|
||||
titlePush: titleSync.lastPush(row.id),
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read the server list', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read the server list' })
|
||||
@@ -218,4 +231,96 @@ async function renderMap(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { scheduleFrom, listServers, putServer, deleteServer, testServer, fetchMap, renderMap }
|
||||
// ── The optional mods (phase 17) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace one server's chat titles: the rules, the mode and N (D135, D136).
|
||||
* Validated whole, so a bad rule saves nothing. The titles reach the game on
|
||||
* the push loop's next tick, and the web the next time it reads.
|
||||
*/
|
||||
async function putTitles(req, res) {
|
||||
const { id } = req.params
|
||||
|
||||
try {
|
||||
const existing = await db.getServer(id)
|
||||
if (!existing) return res.status(404).json({ message: 'No such server' })
|
||||
|
||||
const checked = titles.validateSettings(req.body)
|
||||
if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
|
||||
|
||||
await titlesModel.saveSettings(id, checked.value)
|
||||
await core.activity.log({
|
||||
req,
|
||||
action: 'rust.titles.save',
|
||||
detail: { server: id, mode: checked.value.mode, max: checked.value.max, rules: checked.value.rules },
|
||||
})
|
||||
|
||||
return res.json({ titles: await titlesModel.settingsFor(id) })
|
||||
} catch (err) {
|
||||
log.error('failed to save chat titles', { server: id, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to save the chat titles' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which optional mods one server has loaded right now, read live from the game
|
||||
* (`integrations` on `server.status`). Live, because an operator installs
|
||||
* BetterChat and then looks here, and the board the site keeps is from the last
|
||||
* time the plugin connected.
|
||||
*/
|
||||
async function integrations(req, res) {
|
||||
const { id } = req.params
|
||||
|
||||
try {
|
||||
const server = await servers.getForCalling(id)
|
||||
if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
|
||||
|
||||
const result = await sidecar.liveStatus(server)
|
||||
const data = result.ok && result.data ? result.data : null
|
||||
|
||||
return res.json({
|
||||
ok: Boolean(data),
|
||||
status: result.status,
|
||||
integrations: data && data.integrations ? data.integrations : null,
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read integrations', { server: id, error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to ask the server what it has loaded' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getVoice(req, res) {
|
||||
try {
|
||||
res.json(await voice.describe())
|
||||
} catch (err) {
|
||||
log.error('failed to read the voice', { error: err.message })
|
||||
res.status(500).json({ message: 'Failed to read the announcement voice' })
|
||||
}
|
||||
}
|
||||
|
||||
async function putVoice(req, res) {
|
||||
try {
|
||||
const result = await voice.choose(req.body.group || '', req.user ? req.user.id : null)
|
||||
if (!result.ok) return res.status(400).json({ message: result.message })
|
||||
|
||||
await core.activity.log({ req, action: 'rust.voice.save', detail: { group: result.voice || null } })
|
||||
return res.json(await voice.describe())
|
||||
} catch (err) {
|
||||
log.error('failed to save the voice', { error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to save the announcement voice' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scheduleFrom,
|
||||
listServers,
|
||||
putServer,
|
||||
deleteServer,
|
||||
testServer,
|
||||
fetchMap,
|
||||
renderMap,
|
||||
putTitles,
|
||||
integrations,
|
||||
getVoice,
|
||||
putVoice,
|
||||
}
|
||||
|
||||
@@ -145,4 +145,62 @@ adminRustRouter.post(
|
||||
admin.renderMap,
|
||||
)
|
||||
|
||||
// ── The optional mods (phase 17) ──────────────────────────────────────────
|
||||
|
||||
adminRustRouter.put(
|
||||
'/servers/:id/titles',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Set a server’s chat titles'
|
||||
// #swagger.description = 'Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by `kills`, `npckills` or `playtime` and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustTitleSettings" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved; answers the server’s settings as stored' } */
|
||||
/* #swagger.responses[400] = { description: 'A rule, the mode or N is not valid' } */
|
||||
/* #swagger.responses[404] = { description: 'No such server' } */
|
||||
requireRole('admin'),
|
||||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||||
body('mode').optional().isIn(['first', 'all', 'upto']),
|
||||
body('rules').isArray({ max: 10 }).withMessage('rules is a list of at most ten'),
|
||||
validate,
|
||||
admin.putTitles,
|
||||
)
|
||||
|
||||
adminRustRouter.get(
|
||||
'/servers/:id/integrations',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Which optional mods a server has loaded'
|
||||
// #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
/* #swagger.responses[200] = { description: 'What the server has loaded', content: { "application/json": { schema: { $ref: "#/components/schemas/RustIntegrations" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
|
||||
requireRole('admin'),
|
||||
param('id').isString().isLength({ min: 1, max: 64 }),
|
||||
validate,
|
||||
admin.integrations,
|
||||
)
|
||||
|
||||
adminRustRouter.get(
|
||||
'/voice',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'The voice announcements are said in'
|
||||
// #swagger.description = 'The permission group whose BetterChat style news and `rust.announce` lines are said in, or empty for plain chat, and every group that could be one. The line is composed on the site and said by the bridge plugin with no player as its sender, so it works whether or not BetterChat is loaded.'
|
||||
/* #swagger.responses[200] = { description: 'The voice and the groups that could be one', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVoice" } } } } */
|
||||
requireRole('admin'),
|
||||
admin.getVoice,
|
||||
)
|
||||
|
||||
adminRustRouter.put(
|
||||
'/voice',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Choose the voice announcements are said in'
|
||||
// #swagger.description = '`group` is a permission group with a chat style, or empty for plain chat. A group without a style is refused.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { group: { type: 'string', example: 'staff' } } } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVoice" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'That group has no chat style' } */
|
||||
requireRole('admin'),
|
||||
body('group').optional({ values: 'null' }).isString().isLength({ max: 64 }),
|
||||
validate,
|
||||
admin.putVoice,
|
||||
)
|
||||
|
||||
module.exports = adminRustRouter
|
||||
|
||||
@@ -74,7 +74,7 @@ async function read(req, res) {
|
||||
|
||||
async function update(req, res) {
|
||||
try {
|
||||
const { fleet, servers, clanRoster, news, map: mapSwitches } = req.body || {}
|
||||
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
|
||||
@@ -88,7 +88,7 @@ async function update(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
const result = await visibility.update({ fleet, servers, clanRoster, news }, req.user)
|
||||
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
|
||||
|
||||
@@ -37,7 +37,7 @@ visibilityRouter.put(
|
||||
'/',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
|
||||
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.'
|
||||
// #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.'
|
||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
|
||||
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
|
||||
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
|
||||
@@ -47,6 +47,7 @@ visibilityRouter.put(
|
||||
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
|
||||
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
|
||||
body('news').optional().isObject().withMessage('news maps a server id to true or false'),
|
||||
body('newsDelivery').optional().isObject().withMessage('newsDelivery maps a server id to chat or popup'),
|
||||
body('map').optional().isObject().withMessage('map carries fleet and servers, each an object of layer switches'),
|
||||
validate,
|
||||
visibility.update,
|
||||
|
||||
@@ -17,6 +17,7 @@ const map = require('../../model/map/map.model')
|
||||
const mapDb = require('../../model/map/map.db')
|
||||
const mapLive = require('../../mapLive')
|
||||
const servers = require('../../model/servers/servers.model')
|
||||
const titles = require('../../model/titles/titles.model')
|
||||
const visibility = require('../../model/visibility/visibility.model')
|
||||
|
||||
const log = core.logger('public')
|
||||
@@ -105,15 +106,23 @@ async function listEvents(req, res) {
|
||||
async function listLeaderboard(req, res) {
|
||||
try {
|
||||
const presence = await visibility.canSeePresence(req, req.params.id)
|
||||
perViewer(res)
|
||||
res.json({
|
||||
leaderboard: await events.leaderboard({
|
||||
const [rows, held] = await Promise.all([
|
||||
events.leaderboard({
|
||||
serverId: req.params.id,
|
||||
wipeId: req.query.wipe || null,
|
||||
sort: req.query.sort,
|
||||
limit: req.query.limit,
|
||||
presence: presence.visible,
|
||||
}),
|
||||
titles.currentFor(req.params.id),
|
||||
])
|
||||
perViewer(res)
|
||||
res.json({
|
||||
// D137: the titles each player holds NOW — the same ones, after the same
|
||||
// mode, that the game shows. They rank the current wipe whichever wipe
|
||||
// this page is showing, because a title is what a player is, not what a
|
||||
// past wipe was.
|
||||
leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
|
||||
|
||||
@@ -82,7 +82,7 @@ rustRouter.get(
|
||||
'/servers/:id/leaderboard',
|
||||
// #swagger.tags = ['Public · Rust']
|
||||
// #swagger.summary = 'The leaderboard for one Rust server'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.'
|
||||
// #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.'
|
||||
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
|
||||
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
|
||||
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
|
||||
|
||||
Reference in New Issue
Block a user