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 }
|
||||
Reference in New Issue
Block a user