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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user