// ── 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 }