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:
175
server/model/permissions/chatStyle.js
Normal file
175
server/model/permissions/chatStyle.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// ── A group's BetterChat style, and the voice made from one (phase 17) ─────
|
||||
//
|
||||
// D138 puts all twelve of BetterChat's group fields on a site-authored group,
|
||||
// and D140 lets one styled group be the VOICE the module's own lines are said
|
||||
// in. Both are pure text work, so they live here, without a database or a game:
|
||||
//
|
||||
// validateStyle what an operator typed → the twelve values BetterChat's own
|
||||
// `chat group set` accepts, or one sentence per problem
|
||||
// voiceFormat a style → the format string `chat.say` carries: BetterChat's
|
||||
// markup with exactly one `{message}` in it
|
||||
//
|
||||
// Every value is TEXT, in the form BetterChat's setter parses — `true`/`false`,
|
||||
// a decimal integer, a colour — because text is what the plugin compares the
|
||||
// game's value against when it decides whether somebody edited a field by hand
|
||||
// (§33.2). Two spellings of one value would be drift that never happened.
|
||||
|
||||
/** The longest a format may be (§33.4 reading 7). */
|
||||
const FORMAT_MAX = 128
|
||||
|
||||
/** The longest a group's title may be. BetterChat has no limit; a chat line does. */
|
||||
const TITLE_MAX = 64
|
||||
|
||||
/**
|
||||
* The twelve fields, by the name BetterChat's API takes, each with its type and
|
||||
* BetterChat 5.2.15's default. `Title`'s default is the group's own name in
|
||||
* brackets, so it is worked out in `defaults`.
|
||||
*/
|
||||
const FIELDS = [
|
||||
{ name: 'Priority', type: 'int', min: -9999, max: 9999, default: '0' },
|
||||
{ name: 'Title', type: 'title', default: null },
|
||||
{ name: 'TitleColor', type: 'color', default: '#55aaff' },
|
||||
{ name: 'TitleSize', type: 'size', default: '15' },
|
||||
{ name: 'TitleHidden', type: 'bool', default: 'false' },
|
||||
{ name: 'TitleHiddenIfNotPrimary', type: 'bool', default: 'false' },
|
||||
{ name: 'UsernameColor', type: 'color', default: '#55aaff' },
|
||||
{ name: 'UsernameSize', type: 'size', default: '15' },
|
||||
{ name: 'MessageColor', type: 'color', default: 'white' },
|
||||
{ name: 'MessageSize', type: 'size', default: '15' },
|
||||
{ name: 'ChatFormat', type: 'format', default: '{Title} {Username}: {Message}' },
|
||||
{ name: 'ConsoleFormat', type: 'format', default: '{Title} {Username}: {Message}' },
|
||||
]
|
||||
|
||||
const FIELD_NAMES = FIELDS.map((f) => f.name)
|
||||
|
||||
/** BetterChat's defaults for a new group of this name — what the form starts from. */
|
||||
function defaults(group) {
|
||||
const out = {}
|
||||
for (const f of FIELDS) out[f.name] = f.default
|
||||
out.Title = group === 'default' ? '[Player]' : `[${group}]`
|
||||
return out
|
||||
}
|
||||
|
||||
function occurrences(text, needle) {
|
||||
return text.split(needle).length - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* One field's value as BetterChat's setter takes it, or a sentence.
|
||||
*
|
||||
* Colours are `#rrggbb` or a plain colour word (BetterChat's own default for a
|
||||
* message is `white`). A format holds `{Message}` EXACTLY once: without it every
|
||||
* line a member types is swallowed, and twice cannot be made into a voice,
|
||||
* whose line has one message.
|
||||
*/
|
||||
function checkField(field, raw) {
|
||||
const text = typeof raw === 'boolean' || typeof raw === 'number' ? String(raw) : typeof raw === 'string' ? raw.trim() : null
|
||||
if (text === null || /[\r\n]/.test(text)) return { error: `${field.name} must be text on one line` }
|
||||
|
||||
switch (field.type) {
|
||||
case 'int': {
|
||||
if (!/^-?\d{1,4}$/.test(text)) return { error: `${field.name} must be a whole number from ${field.min} to ${field.max}` }
|
||||
return { value: String(Number(text)) }
|
||||
}
|
||||
case 'size': {
|
||||
if (!/^\d{1,2}$/.test(text) || Number(text) < 6 || Number(text) > 64) return { error: `${field.name} must be a size from 6 to 64` }
|
||||
return { value: String(Number(text)) }
|
||||
}
|
||||
case 'bool': {
|
||||
const lowered = text.toLowerCase()
|
||||
if (lowered !== 'true' && lowered !== 'false') return { error: `${field.name} must be true or false` }
|
||||
return { value: lowered }
|
||||
}
|
||||
case 'color': {
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(text)) return { value: text.toLowerCase() }
|
||||
if (/^[a-z]{3,20}$/.test(text)) return { value: text }
|
||||
return { error: `${field.name} must be a colour like #ffaa55, or a colour word like white` }
|
||||
}
|
||||
case 'title': {
|
||||
if (!text.length || text.length > TITLE_MAX) return { error: `Title must be 1 to ${TITLE_MAX} characters` }
|
||||
// A brace would be read as a placeholder when the title is put into a line.
|
||||
if (/[{}]/.test(text)) return { error: 'Title cannot contain { or }' }
|
||||
return { value: text }
|
||||
}
|
||||
case 'format': {
|
||||
if (text.length > FORMAT_MAX) return { error: `${field.name} must be at most ${FORMAT_MAX} characters` }
|
||||
if (occurrences(text, '{Message}') !== 1) return { error: `${field.name} must contain {Message} exactly once` }
|
||||
return { value: text }
|
||||
}
|
||||
default:
|
||||
return { error: `${field.name} is not a field this site knows` }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An operator's style, checked whole. All twelve fields are required — a style
|
||||
* is the whole of a BetterChat group or nothing (D138), which is what keeps a
|
||||
* half-authored group from being a set of values nobody chose.
|
||||
*
|
||||
* Resolves `{ ok: true, fields }` with every value normalised, or
|
||||
* `{ ok: false, errors }`, one sentence per problem.
|
||||
*/
|
||||
function validateStyle(style) {
|
||||
if (!style || typeof style !== 'object' || Array.isArray(style)) {
|
||||
return { ok: false, errors: ['a chat style is an object of the twelve BetterChat fields'] }
|
||||
}
|
||||
|
||||
const errors = []
|
||||
const fields = {}
|
||||
|
||||
for (const key of Object.keys(style)) {
|
||||
if (!FIELD_NAMES.includes(key)) errors.push(`${key} is not a BetterChat group field`)
|
||||
}
|
||||
|
||||
for (const field of FIELDS) {
|
||||
if (style[field.name] === undefined || style[field.name] === null) {
|
||||
errors.push(`${field.name} is missing`)
|
||||
continue
|
||||
}
|
||||
const checked = checkField(field, style[field.name])
|
||||
if (checked.error) errors.push(checked.error)
|
||||
else fields[field.name] = checked.value
|
||||
}
|
||||
|
||||
return errors.length ? { ok: false, errors } : { ok: true, fields }
|
||||
}
|
||||
|
||||
/** A colour as BetterChat's markup writes it: the hex without its `#`, or the word. */
|
||||
function markupColor(color) {
|
||||
return String(color || 'white').replace(/^#/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The format a styled group's voice says a line in (D140), or null.
|
||||
*
|
||||
* Built from six of the twelve fields (§33.4 reading 5): the title, its colour
|
||||
* and size, the message's colour and size, and `ChatFormat`. The line has no
|
||||
* sender, so `{Username}` renders as nothing — and so does the `:` BetterChat's
|
||||
* own default puts after it, or every announcement would read `[Title] : …`.
|
||||
* `{Group}`, `{ID}`, `{Time}` and `{Date}` render as nothing for the same
|
||||
* reason. The plugin puts the words in for `{message}` and turns the markup into
|
||||
* the game's rich text.
|
||||
*/
|
||||
function voiceFormat(fields) {
|
||||
if (!fields || !fields.ChatFormat || occurrences(fields.ChatFormat, '{Message}') !== 1) return null
|
||||
|
||||
const title =
|
||||
fields.TitleHidden === 'true'
|
||||
? ''
|
||||
: `[#${markupColor(fields.TitleColor)}][+${fields.TitleSize || 15}]${fields.Title || ''}[/+][/#]`
|
||||
const message = `[#${markupColor(fields.MessageColor)}][+${fields.MessageSize || 15}]{message}[/+][/#]`
|
||||
|
||||
// split/join rather than `replace`, whose replacement string treats `$&` and
|
||||
// friends as patterns — and a title is operator text.
|
||||
const format = fields.ChatFormat
|
||||
.replace(/\{Username\}\s*:?/g, '')
|
||||
.replace(/\{(Group|ID|Time|Date)\}/g, '')
|
||||
.split('{Title}').join(title)
|
||||
.split('{Message}').join(message)
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
|
||||
return occurrences(format, '{message}') === 1 ? format : null
|
||||
}
|
||||
|
||||
module.exports = { FIELDS, FIELD_NAMES, FORMAT_MAX, TITLE_MAX, defaults, validateStyle, voiceFormat, checkField }
|
||||
@@ -27,6 +27,7 @@ const core = require('../../core')
|
||||
const GROUPS = 'rust_perm_groups'
|
||||
const GROUP_PERMISSIONS = 'rust_perm_group_permissions'
|
||||
const GROUP_MEMBERS = 'rust_perm_group_members'
|
||||
const GROUP_CHAT = 'rust_perm_group_chat'
|
||||
const GRANTS = 'rust_perm_grants'
|
||||
const RUN_GRANTS = 'rust_perm_run_grants'
|
||||
const PUSHED = 'rust_perm_pushed'
|
||||
@@ -102,6 +103,43 @@ async function setGroupPermissions(name, permissions) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Every group's BetterChat style, one row per field (phase 17, D138). */
|
||||
async function listGroupChat() {
|
||||
return core.query(
|
||||
`SELECT group_name AS groupName, field, value FROM ${GROUP_CHAT} ORDER BY group_name ASC, field ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a group's style whole, or remove it with `null`. A style is all twelve
|
||||
* fields or none, and the form edits it as one thing.
|
||||
*/
|
||||
async function setGroupChat(name, fields) {
|
||||
await core.query(`DELETE FROM ${GROUP_CHAT} WHERE group_name = ?`, [name])
|
||||
|
||||
const entries = fields ? Object.entries(fields) : []
|
||||
if (!entries.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${GROUP_CHAT} (group_name, field, value) VALUES ${placeholders(entries, 3)}`,
|
||||
entries.flatMap(([field, value]) => [name, field, value]),
|
||||
)
|
||||
}
|
||||
|
||||
/** One field of a style, for adopting a hand edit. Returns whether the group has that field. */
|
||||
async function setGroupChatField(name, field, value) {
|
||||
const result = await core.query(
|
||||
`UPDATE ${GROUP_CHAT} SET value = ? WHERE group_name = ? AND field = ?`,
|
||||
[value, name, field],
|
||||
)
|
||||
return Number(result.affectedRows || 0) > 0
|
||||
}
|
||||
|
||||
async function getGroupChat(name) {
|
||||
const rows = await core.query(`SELECT field, value FROM ${GROUP_CHAT} WHERE group_name = ?`, [name])
|
||||
return rows.length ? Object.fromEntries(rows.map((r) => [r.field, r.value])) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every membership, with the member's Steam accounts joined on.
|
||||
*
|
||||
@@ -328,21 +366,38 @@ async function listPushedForSteamIds(steamIds) {
|
||||
|
||||
async function listPushed(serverId) {
|
||||
return core.query(
|
||||
`SELECT kind, subject, object FROM ${PUSHED} WHERE server_id = ?`,
|
||||
`SELECT kind, subject, object, value FROM ${PUSHED} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record rows as landed. A `chat-field` row carries the VALUE that landed, and a
|
||||
* second landing of the same field moves it: the value is what the next sync
|
||||
* tells a hand edit from this site's own write by (§33.2). Every other kind has
|
||||
* no value and is written once.
|
||||
*/
|
||||
async function addPushed(serverId, rows) {
|
||||
if (!rows.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT IGNORE INTO ${PUSHED} (server_id, kind, subject, object)
|
||||
VALUES ${placeholders(rows, 4)}`,
|
||||
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
|
||||
`INSERT INTO ${PUSHED} (server_id, kind, subject, object, value)
|
||||
VALUES ${placeholders(rows, 5)}
|
||||
ON DUPLICATE KEY UPDATE value = VALUES(value)`,
|
||||
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object, row.value === undefined ? null : row.value]),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Say that a style field holds `value` in one game as far as this site is
|
||||
* concerned. It is how a person revokes a hand edit to a style: the next sync
|
||||
* sends the site's value with this as what it expects to find, which is the
|
||||
* game's own value — so the plugin writes over it, on purpose (§33.2).
|
||||
*/
|
||||
async function setPushedValue(serverId, { kind, subject, object, value }) {
|
||||
await addPushed(serverId, [{ kind, subject, object, value }])
|
||||
}
|
||||
|
||||
async function removePushed(serverId, rows) {
|
||||
for (const row of rows) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
@@ -367,10 +422,10 @@ async function replaceDrift(serverId, rows) {
|
||||
}
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${DRIFT} (server_id, kind, subject, object)
|
||||
VALUES ${placeholders(rows, 4)}
|
||||
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP`,
|
||||
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object]),
|
||||
`INSERT INTO ${DRIFT} (server_id, kind, subject, object, detail)
|
||||
VALUES ${placeholders(rows, 5)}
|
||||
ON DUPLICATE KEY UPDATE last_seen = CURRENT_TIMESTAMP, detail = VALUES(detail)`,
|
||||
rows.flatMap((row) => [serverId, row.kind, row.subject, row.object, row.detail === undefined ? null : row.detail]),
|
||||
)
|
||||
|
||||
// Anything this report did NOT name is gone from the game, so it goes from
|
||||
@@ -387,7 +442,7 @@ async function replaceDrift(serverId, rows) {
|
||||
|
||||
async function listDrift() {
|
||||
return core.query(
|
||||
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object,
|
||||
`SELECT d.id, d.server_id AS serverId, d.kind, d.subject, d.object, d.detail,
|
||||
d.first_seen AS firstSeen, d.last_seen AS lastSeen,
|
||||
l.user_id AS userId, u.username, p.name AS playerName
|
||||
FROM ${DRIFT} d
|
||||
@@ -400,7 +455,7 @@ async function listDrift() {
|
||||
|
||||
async function getDrift(id) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, server_id AS serverId, kind, subject, object FROM ${DRIFT} WHERE id = ?`,
|
||||
`SELECT id, server_id AS serverId, kind, subject, object, detail FROM ${DRIFT} WHERE id = ?`,
|
||||
[id],
|
||||
)
|
||||
|
||||
@@ -543,6 +598,10 @@ module.exports = {
|
||||
deleteGroup,
|
||||
listGroupPermissions,
|
||||
setGroupPermissions,
|
||||
listGroupChat,
|
||||
setGroupChat,
|
||||
setGroupChatField,
|
||||
getGroupChat,
|
||||
listGroupMembers,
|
||||
addGroupMember,
|
||||
removeGroupMember,
|
||||
@@ -561,6 +620,7 @@ module.exports = {
|
||||
listPushedForSteamIds,
|
||||
listPushed,
|
||||
addPushed,
|
||||
setPushedValue,
|
||||
removePushed,
|
||||
replaceDrift,
|
||||
listDrift,
|
||||
|
||||
@@ -51,7 +51,7 @@ function inScope(scope, serverId) {
|
||||
* trips per group or one join that repeats every group row once per member.
|
||||
*/
|
||||
async function overview() {
|
||||
const [groups, groupPermissions, members, grants, sync, drift, catalogue] = await Promise.all([
|
||||
const [groups, groupPermissions, members, grants, sync, drift, catalogue, groupChat] = await Promise.all([
|
||||
db.listGroups(),
|
||||
db.listGroupPermissions(),
|
||||
db.listGroupMembers(),
|
||||
@@ -59,9 +59,16 @@ async function overview() {
|
||||
db.listSync(),
|
||||
db.listDrift(),
|
||||
db.listCatalogue(),
|
||||
db.listGroupChat(),
|
||||
])
|
||||
|
||||
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [] }]))
|
||||
const byGroup = new Map(groups.map((group) => [group.name, { ...group, permissions: [], members: [], chat: null }]))
|
||||
|
||||
// Phase 17: a group's BetterChat style, or null for a group without one.
|
||||
for (const [name, fields] of chatByGroup(groupChat)) {
|
||||
const group = byGroup.get(name)
|
||||
if (group) group.chat = fields
|
||||
}
|
||||
|
||||
for (const row of groupPermissions) {
|
||||
const group = byGroup.get(row.groupName)
|
||||
@@ -99,11 +106,23 @@ async function overview() {
|
||||
groups: [...byGroup.values()],
|
||||
grants: collapseGrants(grants),
|
||||
servers: sync.map(shapeSync),
|
||||
drift,
|
||||
drift: drift.map((row) => ({ ...row, detail: row.detail === undefined ? null : row.detail })),
|
||||
catalogue: catalogueByPermission(catalogue),
|
||||
}
|
||||
}
|
||||
|
||||
/** Style rows folded into one object per group: `name → { Field: value }`. */
|
||||
function chatByGroup(rows) {
|
||||
const out = new Map()
|
||||
|
||||
for (const row of rows || []) {
|
||||
if (!out.has(row.groupName)) out.set(row.groupName, {})
|
||||
out.get(row.groupName)[row.field] = row.value
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per grant, not one per linked account.
|
||||
*
|
||||
@@ -278,13 +297,14 @@ async function forPlayer(userId, steamIds, serverRows) {
|
||||
* server is six times the queries for the same rows.
|
||||
*/
|
||||
async function readAuthored() {
|
||||
const [groups, groupPermissions, members, grants, links, runGrants] = await Promise.all([
|
||||
const [groups, groupPermissions, members, grants, links, runGrants, groupChat] = await Promise.all([
|
||||
db.listGroups(),
|
||||
db.listGroupPermissions(),
|
||||
db.listGroupMembers(),
|
||||
db.listGrants(),
|
||||
db.listLinks(),
|
||||
db.listRunGrants(),
|
||||
db.listGroupChat(),
|
||||
])
|
||||
|
||||
const steamIdsByUser = new Map()
|
||||
@@ -294,7 +314,7 @@ async function readAuthored() {
|
||||
steamIdsByUser.get(link.userId).push(link.steamId)
|
||||
}
|
||||
|
||||
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser }
|
||||
return { groups, groupPermissions, members, grants, runGrants, steamIdsByUser, groupChat }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,6 +441,24 @@ function buildDesired(serverId, authored) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── A group's BetterChat style (phase 17, D138) ───────────────────────
|
||||
//
|
||||
// One ledger row per FIELD (`chat-field`, subject the group, object the
|
||||
// field), carrying its value: the diff that retires a style is the same
|
||||
// `pushed − desired` as everything else, and the value is what the next sync
|
||||
// sends as `expect`. The value is not in the row's identity — a changed value
|
||||
// is the same field pushed again, not a retirement.
|
||||
const chat = chatByGroup(authored.groupChat)
|
||||
|
||||
for (const group of scopedGroups) {
|
||||
const fields = chat.get(group.name)
|
||||
if (!fields) continue
|
||||
|
||||
for (const field of Object.keys(fields).sort()) {
|
||||
rows.push({ kind: 'chat-field', subject: group.name, object: field, value: fields[field] })
|
||||
}
|
||||
}
|
||||
|
||||
const creditRows = [...credits.entries()]
|
||||
.map(([key, count]) => {
|
||||
const bar = key.indexOf('|')
|
||||
@@ -435,6 +473,9 @@ function buildDesired(serverId, authored) {
|
||||
rank: group.rank,
|
||||
permissions: permissionsByGroup.get(group.name),
|
||||
members: membersByGroup.get(group.name),
|
||||
// The values only; `permSync` adds what each one expects to find, which
|
||||
// is per server and comes from the ledger.
|
||||
...(chat.has(group.name) ? { chat: chat.get(group.name) } : {}),
|
||||
})),
|
||||
grants: [...permissionsBySteamId.entries()].map(([steamId, permissions]) => ({
|
||||
steamId,
|
||||
@@ -450,8 +491,11 @@ function buildDesired(serverId, authored) {
|
||||
// Credits are in the digest, so a new reward or a revert pushes, but they are
|
||||
// NOT in `rows`: those are the pushed ledger's, and a use of a kit is not
|
||||
// something in the permission store to retire.
|
||||
//
|
||||
// A style field's VALUE goes into the digest the same way, since it is not in
|
||||
// the row's identity: a colour changed on the site must push.
|
||||
const hashed = [
|
||||
...rows,
|
||||
...rows.map((row) => (row.kind === 'chat-field' ? { ...row, object: `${row.object}=${row.value}` } : row)),
|
||||
...creditRows.map((c) => ({ kind: 'credit', subject: c.steamId, object: `${c.kit}#${c.count}` })),
|
||||
]
|
||||
|
||||
@@ -504,4 +548,5 @@ module.exports = {
|
||||
rowKey,
|
||||
collapseGrants,
|
||||
shapeSync,
|
||||
chatByGroup,
|
||||
}
|
||||
|
||||
70
server/model/permissions/voice.js
Normal file
70
server/model/permissions/voice.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// ── The voice the module's own lines are said in (phase 17, D140) ──────────
|
||||
//
|
||||
// One fleet setting (§33.4 reading 5): the name of a permission group that has
|
||||
// a BetterChat style, or nothing for plain chat. News lines and `rust.announce`
|
||||
// lines are then said in that group's title and colours — composed here, said by
|
||||
// our plugin with no player as the sender, so they look right whether or not
|
||||
// BetterChat is loaded.
|
||||
//
|
||||
// The style is read at the moment a line is said, never copied into the
|
||||
// setting: an operator who recolours the group changes the voice with it, and a
|
||||
// group whose style is taken away stops being a voice rather than leaving a
|
||||
// stale one behind.
|
||||
|
||||
const settingsDb = require('../visibility/visibility.db')
|
||||
const chatStyle = require('./chatStyle')
|
||||
const db = require('./permissions.db')
|
||||
const model = require('./permissions.model')
|
||||
|
||||
const VOICE_KEY = 'announce.voice'
|
||||
|
||||
/** The chosen group's name, or '' for plain chat. */
|
||||
async function chosen() {
|
||||
return (await settingsDb.getSetting(VOICE_KEY)) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The format a line is said in right now, or null for plain chat — which is
|
||||
* also the answer when the chosen group has since lost its style or gone.
|
||||
*/
|
||||
async function currentFormat() {
|
||||
const group = await chosen()
|
||||
if (!group) return null
|
||||
|
||||
const fields = await db.getGroupChat(group)
|
||||
return fields ? chatStyle.voiceFormat(fields) : null
|
||||
}
|
||||
|
||||
/** The setting, and every group that could be a voice, for the admin page. */
|
||||
async function describe() {
|
||||
const [voice, rows] = await Promise.all([chosen(), db.listGroupChat()])
|
||||
const options = []
|
||||
|
||||
for (const [group, fields] of model.chatByGroup(rows)) {
|
||||
const format = chatStyle.voiceFormat(fields)
|
||||
if (format) options.push({ group, title: fields.Title || group, format })
|
||||
}
|
||||
|
||||
return { voice, options: options.sort((a, b) => a.group.localeCompare(b.group)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the voice. A group is accepted only when it has a style a voice can be
|
||||
* made from; '' goes back to plain chat. Resolves `{ ok }` or
|
||||
* `{ ok: false, message }`.
|
||||
*/
|
||||
async function choose(group, userId = null) {
|
||||
const name = model.normaliseName(group)
|
||||
|
||||
if (name) {
|
||||
const fields = await db.getGroupChat(name)
|
||||
if (!fields || !chatStyle.voiceFormat(fields)) {
|
||||
return { ok: false, message: `The group "${name}" has no chat style, so it cannot be a voice. Give it one under Permissions first.` }
|
||||
}
|
||||
}
|
||||
|
||||
await settingsDb.setSetting(VOICE_KEY, name, userId)
|
||||
return { ok: true, voice: name }
|
||||
}
|
||||
|
||||
module.exports = { VOICE_KEY, chosen, currentFormat, describe, choose }
|
||||
@@ -24,6 +24,7 @@ async function listServers({ enabledOnly = false } = {}) {
|
||||
return core.query(
|
||||
`SELECT id, name, sidecar_base_url AS sidecarBaseUrl, sidecar_token_enc AS sidecarTokenEnc,
|
||||
protocol, enabled, sort_order AS sortOrder, announce_news AS announceNews,
|
||||
news_delivery AS newsDelivery,
|
||||
wipe_rule AS wipeRule, wipe_day AS wipeDay, wipe_time AS wipeTime, wipe_tz AS wipeTz,
|
||||
DATE_FORMAT(wipe_anchor, '%Y-%m-%d') AS wipeAnchor, wipe_once_at AS wipeOnceAt,
|
||||
created_at AS createdAt, updated_at AS updatedAt
|
||||
|
||||
@@ -57,6 +57,8 @@ function withToken(row) {
|
||||
protocol: row.protocol,
|
||||
// D104: whether a published news post is said in this server's chat.
|
||||
announceNews: Boolean(Number(row.announceNews)),
|
||||
// D142: where that post goes — chat, or a popup.
|
||||
newsDelivery: row.newsDelivery === 'popup' ? 'popup' : 'chat',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
61
server/model/titles/titles.db.js
Normal file
61
server/model/titles/titles.db.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// ── SQL for chat titles (phase 17) ────────────────────────────────────────
|
||||
//
|
||||
// The rules an operator writes, per server, and the two columns on
|
||||
// `rust_servers` that say how many titles a player shows. The titles themselves
|
||||
// are never stored — see `schema.sql` and `titles.js`.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const RULES = 'rust_title_rules'
|
||||
const SERVERS = 'rust_servers'
|
||||
|
||||
/** One server's rules, in precedence order. */
|
||||
async function listRules(serverId) {
|
||||
return core.query(
|
||||
`SELECT id, stat, top_n AS topN, text, color
|
||||
FROM ${RULES}
|
||||
WHERE server_id = ?
|
||||
ORDER BY position ASC, id ASC`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Every server's rules, for the admin list — one read rather than one per server. */
|
||||
async function listAllRules() {
|
||||
return core.query(
|
||||
`SELECT server_id AS serverId, stat, top_n AS topN, text, color
|
||||
FROM ${RULES}
|
||||
ORDER BY server_id ASC, position ASC, id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
async function getMode(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT title_mode AS mode, title_max AS max FROM ${SERVERS} WHERE id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
async function listModes() {
|
||||
return core.query(`SELECT id AS serverId, title_mode AS mode, title_max AS max FROM ${SERVERS}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one server's rules and mode whole. The form edits a list, so the
|
||||
* write is a list — and a rule's position is its place in that list.
|
||||
*/
|
||||
async function saveSettings(serverId, { mode, max, rules }) {
|
||||
await core.query(`UPDATE ${SERVERS} SET title_mode = ?, title_max = ? WHERE id = ?`, [mode, max, serverId])
|
||||
await core.query(`DELETE FROM ${RULES} WHERE server_id = ?`, [serverId])
|
||||
|
||||
if (!rules.length) return
|
||||
|
||||
await core.query(
|
||||
`INSERT INTO ${RULES} (server_id, position, stat, top_n, text, color)
|
||||
VALUES ${rules.map(() => '(?, ?, ?, ?, ?, ?)').join(',')}`,
|
||||
rules.flatMap((r, i) => [serverId, i, r.stat, r.topN, r.text, r.color]),
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { RULES, listRules, listAllRules, getMode, listModes, saveSettings }
|
||||
165
server/model/titles/titles.js
Normal file
165
server/model/titles/titles.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// ── Chat titles, as rules and as a set (phase 17, D135–D137) ───────────────
|
||||
//
|
||||
// An operator writes rules per server — "#1 kills", "top 3 playtime" — and the
|
||||
// site works out who holds each title from the CURRENT wipe's standings. The
|
||||
// same answer goes to three places: BetterChat in the game (`titles.set`), the
|
||||
// web leaderboard and the app's. So it is worked out here, once, as pure
|
||||
// functions of the rules and the standings, and every consumer reads it.
|
||||
//
|
||||
// Four readings shape it (§33.4):
|
||||
//
|
||||
// 1. a rule counts only a stat above zero — a fresh wipe gives no titles,
|
||||
// rather than #1 kills to somebody with none
|
||||
// 2. ties follow the leaderboard's own order, so a rule's top N is the first
|
||||
// N rows the web shows for that stat
|
||||
// 3. a title's text is at most 24 characters, with markup characters taken
|
||||
// out, and its colour is `#rrggbb`
|
||||
// 4. the rule order is precedence: `first` shows the first rule a player meets
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
/** The stats a rule may rank, and the leaderboard sort and column each means. */
|
||||
const STATS = {
|
||||
kills: { sort: 'kills', value: (row) => row.kills, label: 'kills' },
|
||||
npckills: { sort: 'npcKills', value: (row) => row.npcKills, label: 'NPC kills' },
|
||||
playtime: { sort: 'playtime', value: (row) => row.playtimeSec, label: 'playtime' },
|
||||
}
|
||||
|
||||
const MODES = ['first', 'all', 'upto']
|
||||
|
||||
const MAX_RULES = 10
|
||||
const MAX_TOP_N = 10
|
||||
const TEXT_MAX = 24
|
||||
const MAX_UPTO = 5
|
||||
|
||||
/**
|
||||
* An operator's title text, made safe to put in a chat line: the characters
|
||||
* BetterChat's markup and its placeholders are built from are taken out, then
|
||||
* the whitespace is collapsed. `{` and `}` go too, beyond reading 3's four: a
|
||||
* title is substituted into `{Title}` before `{Message}` is, so a title reading
|
||||
* `{Message}` would print the player's words twice.
|
||||
*/
|
||||
function cleanText(raw) {
|
||||
return String(raw === undefined || raw === null ? '' : raw)
|
||||
.replace(/[[\]<>{}]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** A mode word, or `first` — the fewest — for one this build does not know. */
|
||||
function normaliseMode(mode) {
|
||||
return MODES.includes(mode) ? mode : 'first'
|
||||
}
|
||||
|
||||
/**
|
||||
* A server's title settings as the admin form sends them, checked whole.
|
||||
*
|
||||
* Resolves `{ ok: true, value: { mode, max, rules } }` with every rule cleaned,
|
||||
* or `{ ok: false, errors }`, one sentence per problem. A text that is empty
|
||||
* AFTER cleaning is refused rather than saved blank, so a title made entirely of
|
||||
* brackets is a sentence on the form and not an empty chip in the game.
|
||||
*/
|
||||
function validateSettings(body) {
|
||||
const errors = []
|
||||
const input = body || {}
|
||||
const mode = input.mode === undefined ? 'first' : input.mode
|
||||
|
||||
if (!MODES.includes(mode)) errors.push(`mode must be one of ${MODES.join(', ')}`)
|
||||
|
||||
const max = input.max === undefined || input.max === null || input.max === '' ? 2 : Number(input.max)
|
||||
if (!Number.isInteger(max) || max < 1 || max > MAX_UPTO) errors.push(`max must be a whole number from 1 to ${MAX_UPTO}`)
|
||||
|
||||
const rules = Array.isArray(input.rules) ? input.rules : null
|
||||
if (!rules) errors.push('rules must be a list')
|
||||
else if (rules.length > MAX_RULES) errors.push(`a server has at most ${MAX_RULES} title rules`)
|
||||
|
||||
const clean = []
|
||||
|
||||
for (const [i, rule] of (rules || []).entries()) {
|
||||
const n = i + 1
|
||||
const r = rule || {}
|
||||
if (!STATS[r.stat]) errors.push(`rule ${n}: stat must be one of ${Object.keys(STATS).join(', ')}`)
|
||||
|
||||
const topN = Number(r.topN)
|
||||
if (!Number.isInteger(topN) || topN < 1 || topN > MAX_TOP_N) errors.push(`rule ${n}: top must be from 1 to ${MAX_TOP_N}`)
|
||||
|
||||
const text = cleanText(r.text)
|
||||
if (!text) errors.push(`rule ${n}: the title needs some text`)
|
||||
else if (text.length > TEXT_MAX) errors.push(`rule ${n}: a title is at most ${TEXT_MAX} characters`)
|
||||
|
||||
const color = String(r.color || '').trim().toLowerCase()
|
||||
if (!/^#[0-9a-f]{6}$/.test(color)) errors.push(`rule ${n}: colour must look like #ffaa55`)
|
||||
|
||||
clean.push({ stat: r.stat, topN, text, color })
|
||||
}
|
||||
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: { mode, max, rules: clean } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds which title.
|
||||
*
|
||||
* @param {Array} rules in precedence order: `{ stat, topN, text, color }`
|
||||
* @param {object} standings stat → leaderboard rows for the current wipe,
|
||||
* in the leaderboard's order, at least `topN` long
|
||||
* @param {object} options
|
||||
* @param {string} options.mode `first` · `all` · `upto`
|
||||
* @param {number} options.max how many `upto` shows
|
||||
* @returns {Map<string, Array<{text, color}>>} Steam id → titles, in rule order
|
||||
*/
|
||||
function evaluate(rules, standings, { mode = 'first', max = 2 } = {}) {
|
||||
const held = new Map()
|
||||
|
||||
for (const rule of rules || []) {
|
||||
const stat = STATS[rule.stat]
|
||||
if (!stat) continue
|
||||
|
||||
const rows = (standings[rule.stat] || []).filter((row) => Number(stat.value(row)) > 0).slice(0, rule.topN)
|
||||
|
||||
for (const row of rows) {
|
||||
if (!held.has(row.steamId)) held.set(row.steamId, [])
|
||||
held.get(row.steamId).push({ text: rule.text, color: rule.color })
|
||||
}
|
||||
}
|
||||
|
||||
const keep = { first: 1, upto: max, all: Infinity }[normaliseMode(mode)]
|
||||
|
||||
for (const [steamId, list] of held) held.set(steamId, list.slice(0, keep))
|
||||
|
||||
return held
|
||||
}
|
||||
|
||||
/** One player's titles as BetterChat markup: `[#hex]text[/#]`, space-separated. */
|
||||
function markup(list) {
|
||||
return list.map((t) => `[#${t.color.replace(/^#/, '')}]${t.text}[/#]`).join(' ')
|
||||
}
|
||||
|
||||
/** The wire set, sorted so an unchanged set digests the same on every tick. */
|
||||
function wireSet(held) {
|
||||
return [...held.entries()]
|
||||
.map(([steamId, list]) => ({ steamId, text: markup(list) }))
|
||||
.sort((a, b) => a.steamId.localeCompare(b.steamId))
|
||||
}
|
||||
|
||||
function digest(set) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(set.map((t) => `${t.steamId} ${t.text}`).join('\n'))
|
||||
.digest('hex')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STATS,
|
||||
MODES,
|
||||
MAX_RULES,
|
||||
MAX_TOP_N,
|
||||
MAX_UPTO,
|
||||
TEXT_MAX,
|
||||
cleanText,
|
||||
normaliseMode,
|
||||
validateSettings,
|
||||
evaluate,
|
||||
markup,
|
||||
wireSet,
|
||||
digest,
|
||||
}
|
||||
106
server/model/titles/titles.model.js
Normal file
106
server/model/titles/titles.model.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// ── Chat titles, read against the live standings (phase 17) ────────────────
|
||||
//
|
||||
// `titles.js` decides; this file fetches what it decides from. One answer per
|
||||
// server serves three readers — the push to the game, the web leaderboard and
|
||||
// the app's — so it is remembered briefly: the leaderboard is polled by every
|
||||
// open page, and each rule is a leaderboard query of its own.
|
||||
|
||||
const eventsDb = require('../events/events.db')
|
||||
const serversDb = require('../servers/servers.db')
|
||||
const db = require('./titles.db')
|
||||
const titles = require('./titles')
|
||||
|
||||
/** How long one server's answer is reused. The same as the push loop's tick. */
|
||||
const MEMO_MS = 30 * 1000
|
||||
|
||||
const memo = new Map()
|
||||
|
||||
/** A server's settings as the admin form reads them. */
|
||||
async function settingsFor(serverId) {
|
||||
const [mode, rules] = await Promise.all([db.getMode(serverId), db.listRules(serverId)])
|
||||
return shapeSettings(mode, rules)
|
||||
}
|
||||
|
||||
function shapeSettings(mode, rules) {
|
||||
return {
|
||||
mode: titles.normaliseMode(mode && mode.mode),
|
||||
max: mode && Number(mode.max) ? Number(mode.max) : 2,
|
||||
rules: rules.map((r) => ({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })),
|
||||
}
|
||||
}
|
||||
|
||||
/** Every server's settings, keyed by id, for the admin server list. */
|
||||
async function settingsByServer() {
|
||||
const [modes, rules] = await Promise.all([db.listModes(), db.listAllRules()])
|
||||
const out = new Map(modes.map((m) => [m.serverId, shapeSettings(m, [])]))
|
||||
|
||||
for (const r of rules) {
|
||||
const s = out.get(r.serverId)
|
||||
if (s) s.rules.push({ stat: r.stat, topN: Number(r.topN), text: r.text, color: r.color })
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
async function saveSettings(serverId, value) {
|
||||
await db.saveSettings(serverId, value)
|
||||
forget(serverId)
|
||||
}
|
||||
|
||||
function forget(serverId) {
|
||||
for (const key of memo.keys()) if (key.startsWith(`${serverId} `)) memo.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds which title on one server right now: a Map of Steam id to
|
||||
* `[{ text, color }]`, after the server's mode. Empty with no rules, and empty
|
||||
* with no current wipe — a title ranks the current wipe (D135), and a server that
|
||||
* has never said which wipe it is on has none to rank.
|
||||
*/
|
||||
async function heldFor(serverId, { wipeId, now = Date.now() } = {}) {
|
||||
if (!wipeId) return new Map()
|
||||
|
||||
const key = `${serverId} ${wipeId}`
|
||||
const hit = memo.get(key)
|
||||
if (hit && now - hit.at < MEMO_MS) return hit.held
|
||||
|
||||
const settings = await settingsFor(serverId)
|
||||
const standings = {}
|
||||
|
||||
// One query per STAT, at the deepest top N any rule on it asks for, rather
|
||||
// than one per rule: two rules on kills read the same rows.
|
||||
const depth = new Map()
|
||||
for (const r of settings.rules) depth.set(r.stat, Math.max(depth.get(r.stat) || 0, r.topN))
|
||||
|
||||
await Promise.all(
|
||||
[...depth.entries()].map(async ([stat, limit]) => {
|
||||
standings[stat] = await eventsDb.leaderboard({ serverId, wipeId, sort: titles.STATS[stat].sort, limit })
|
||||
}),
|
||||
)
|
||||
|
||||
const held = titles.evaluate(settings.rules, normalise(standings), settings)
|
||||
memo.set(key, { at: now, held })
|
||||
return held
|
||||
}
|
||||
|
||||
/** The leaderboard's rows carry numbers as strings from SUM(); the rules compare numbers. */
|
||||
function normalise(standings) {
|
||||
const out = {}
|
||||
for (const [stat, rows] of Object.entries(standings)) {
|
||||
out[stat] = rows.map((r) => ({
|
||||
steamId: r.steamId,
|
||||
kills: Number(r.kills) || 0,
|
||||
npcKills: Number(r.npcKills) || 0,
|
||||
playtimeSec: Number(r.playtimeSec) || 0,
|
||||
}))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The same, for a server by id, reading its current wipe from its last report. */
|
||||
async function currentFor(serverId) {
|
||||
const state = await serversDb.getState(serverId)
|
||||
return heldFor(serverId, { wipeId: state && state.wipeId ? state.wipeId : null })
|
||||
}
|
||||
|
||||
module.exports = { MEMO_MS, settingsFor, settingsByServer, saveSettings, heldFor, currentFor, forget }
|
||||
@@ -34,7 +34,8 @@ async function getServerPresence(serverId) {
|
||||
/** Every configured server with its override, in the operator's own order. */
|
||||
async function listServerPresence() {
|
||||
return core.query(
|
||||
`SELECT id, name, enabled, presence_audience AS presence, announce_news AS announceNews
|
||||
`SELECT id, name, enabled, presence_audience AS presence, announce_news AS announceNews,
|
||||
news_delivery AS newsDelivery
|
||||
FROM ${SERVERS}
|
||||
ORDER BY sort_order ASC, id ASC`,
|
||||
)
|
||||
@@ -60,4 +61,17 @@ async function setServerNews(serverId, on) {
|
||||
await core.query(`UPDATE ${SERVERS} SET announce_news = ? WHERE id = ?`, [on ? 1 : 0, serverId])
|
||||
}
|
||||
|
||||
module.exports = { getSetting, setSetting, getServerPresence, listServerPresence, setServerPresence, setServerNews }
|
||||
/** Where one server's news goes when its switch is on: `chat` or `popup` (D142). */
|
||||
async function setServerNewsDelivery(serverId, delivery) {
|
||||
await core.query(`UPDATE ${SERVERS} SET news_delivery = ? WHERE id = ?`, [delivery, serverId])
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSetting,
|
||||
setSetting,
|
||||
getServerPresence,
|
||||
listServerPresence,
|
||||
setServerPresence,
|
||||
setServerNews,
|
||||
setServerNewsDelivery,
|
||||
}
|
||||
|
||||
@@ -199,7 +199,15 @@ async function describe() {
|
||||
// On this page because it is the one that lists every server with a setting
|
||||
// of its own, and it answers the same kind of question — what a server shows.
|
||||
news: {
|
||||
servers: servers.map((s) => ({ id: s.id, name: s.name, enabled: Boolean(s.enabled), on: Boolean(Number(s.announceNews)) })),
|
||||
servers: servers.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
enabled: Boolean(s.enabled),
|
||||
on: Boolean(Number(s.announceNews)),
|
||||
// D142: where the post goes when the switch is on. A word this build
|
||||
// does not know reads as chat, which every server can do.
|
||||
delivery: s.newsDelivery === 'popup' ? 'popup' : 'chat',
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -214,7 +222,7 @@ async function describe() {
|
||||
* Resolves `{ ok, changed }`, or `{ ok: false, status, message }` — a refusal is a
|
||||
* sentence the page can show.
|
||||
*/
|
||||
async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
|
||||
async function update({ fleet, servers, clanRoster, news, newsDelivery } = {}, actor = null) {
|
||||
if (fleet !== undefined && !isAudience(fleet)) {
|
||||
return { ok: false, status: 400, message: `"${fleet}" is not an audience. Choose one of: ${AUDIENCES.join(', ')}.` }
|
||||
}
|
||||
@@ -249,6 +257,17 @@ async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
|
||||
}
|
||||
}
|
||||
|
||||
const deliveryChanges = Object.entries(newsDelivery || {})
|
||||
for (const [id, value] of deliveryChanges) {
|
||||
if (value !== 'chat' && value !== 'popup') {
|
||||
return { ok: false, status: 400, message: `News goes to chat or to a popup on server ${id}, not "${value}".` }
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if ((await db.getServerPresence(id)) === undefined) {
|
||||
return { ok: false, status: 404, message: `There is no server called ${id}.` }
|
||||
}
|
||||
}
|
||||
|
||||
const userId = actor && actor.id != null ? actor.id : null
|
||||
|
||||
if (fleet !== undefined) await db.setSetting(PRESENCE_KEY, fleet, userId)
|
||||
@@ -261,6 +280,10 @@ async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setServerNews(id, on)
|
||||
}
|
||||
for (const [id, delivery] of deliveryChanges) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await db.setServerNewsDelivery(id, delivery)
|
||||
}
|
||||
|
||||
// What was written, for the controller's audit row. Recorded there rather than
|
||||
// here because the activity log takes the REQUEST (who, from where), and a
|
||||
@@ -272,6 +295,7 @@ async function update({ fleet, servers, clanRoster, news } = {}, actor = null) {
|
||||
...(clanRoster !== undefined ? { clanRoster } : {}),
|
||||
servers: Object.fromEntries(changes.map(([id, value]) => [id, value === null ? 'inherit' : value])),
|
||||
...(newsChanges.length ? { news: Object.fromEntries(newsChanges) } : {}),
|
||||
...(deliveryChanges.length ? { newsDelivery: Object.fromEntries(deliveryChanges) } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user