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:
@@ -176,8 +176,12 @@ async function syncOne(server, { authored, sync, state, force }) {
|
||||
])
|
||||
|
||||
const retirements = model.retirements(pushed, desired.rows)
|
||||
const { styleRetired, sent: styleRetire } = styleRetirements(retirements)
|
||||
const retire = [
|
||||
...retirements.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
|
||||
...retirements
|
||||
.filter((row) => row.kind !== 'chat-field')
|
||||
.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
|
||||
...styleRetire,
|
||||
...revocations.map((row) => ({ kind: row.kind, subject: row.subject, object: row.object })),
|
||||
]
|
||||
|
||||
@@ -212,7 +216,7 @@ async function syncOne(server, { authored, sync, state, force }) {
|
||||
|
||||
const result = await sidecar.permSync(server, {
|
||||
setId: desired.hash,
|
||||
groups: desired.payload.groups,
|
||||
groups: withExpect(desired.payload.groups, pushed),
|
||||
grants: desired.payload.grants,
|
||||
managed: desired.payload.managed,
|
||||
credits: desired.payload.credits,
|
||||
@@ -253,11 +257,52 @@ async function syncOne(server, { authored, sync, state, force }) {
|
||||
return report.reason || 'refused'
|
||||
}
|
||||
|
||||
await applyReport(server, { desired, retire, report, bootId, wipeId })
|
||||
await applyReport(server, { desired, retire, styleRetired, report, bootId, wipeId })
|
||||
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/**
|
||||
* The groups as the wire carries them: each style field with the value this
|
||||
* site last pushed to THIS server (`expect`), or null for one it never has.
|
||||
* The plugin writes a field only when the game still holds that, so a field
|
||||
* somebody changed by hand is reported instead of overwritten (D138).
|
||||
*/
|
||||
function withExpect(groups, pushed) {
|
||||
const expect = new Map(
|
||||
pushed.filter((row) => row.kind === 'chat-field').map((row) => [`${row.subject} ${row.object}`, row.value]),
|
||||
)
|
||||
|
||||
return groups.map((group) => {
|
||||
if (!group.chat) return group
|
||||
|
||||
const chat = {}
|
||||
for (const [field, value] of Object.entries(group.chat)) {
|
||||
const last = expect.get(`${group.name} ${field}`)
|
||||
chat[field] = { value, expect: last === undefined ? null : last }
|
||||
}
|
||||
|
||||
return { ...group, chat }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Style fields the site pushed and no longer wants, as the wire says it: ONE
|
||||
* `chat-group` retirement per group, because a style is all twelve fields or
|
||||
* none, and the only way to take one out of BetterChat is to remove its group
|
||||
* (D139).
|
||||
*
|
||||
* **`default` is never removed.** It is BetterChat's fallback, which it warns
|
||||
* about on every line when it is missing; a style withdrawn from the site's
|
||||
* `default` group stops being pushed and is left as it stands (§33.5).
|
||||
*/
|
||||
function styleRetirements(retirements) {
|
||||
const styleRetired = retirements.filter((row) => row.kind === 'chat-field')
|
||||
const groups = [...new Set(styleRetired.map((row) => row.subject))].filter((name) => name !== 'default')
|
||||
|
||||
return { styleRetired, sent: groups.map((name) => ({ kind: 'chat-group', subject: name, object: '' })) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what the game said it did.
|
||||
*
|
||||
@@ -265,7 +310,7 @@ async function syncOne(server, { authored, sync, state, force }) {
|
||||
* a sync that crashes here is re-run next tick and reaches the same place, which
|
||||
* is the property that lets this loop be the only writer.
|
||||
*/
|
||||
async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
|
||||
async function applyReport(server, { desired, retire, styleRetired = [], report, bootId, wipeId }) {
|
||||
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
|
||||
const pending = new Set(report.pending || [])
|
||||
// Grants the plugin made and then did not find in the store when it read it
|
||||
@@ -281,7 +326,22 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
|
||||
//
|
||||
// The same for a member the store could not place: the membership is waiting
|
||||
// on their first connection, and it is not in the game yet.
|
||||
// Protocol 12. A style field landed when BetterChat was there to take it and
|
||||
// the report names it neither drift nor failed. With BetterChat absent none
|
||||
// did, and each is sent again with the same `expect` next time (§33.2).
|
||||
const chat = report.chat && typeof report.chat === 'object' ? report.chat : null
|
||||
const chatLoaded = Boolean(chat && chat.loaded === true)
|
||||
const fieldKey = (group, field) => `${group} ${String(field || '').toLowerCase()}`
|
||||
const chatHeld = new Set([
|
||||
...((chatLoaded && chat.drift) || []).map((row) => fieldKey(row.group, row.field)),
|
||||
...((chatLoaded && chat.failed) || []).filter((row) => row.field).map((row) => fieldKey(row.group, row.field)),
|
||||
])
|
||||
const chatGroupFailed = new Set(((chatLoaded && chat.failed) || []).filter((row) => !row.field).map((row) => row.group))
|
||||
|
||||
const landed = desired.rows.filter((row) => {
|
||||
if (row.kind === 'chat-field') {
|
||||
return chatLoaded && !chatGroupFailed.has(row.subject) && !chatHeld.has(fieldKey(row.subject, row.object))
|
||||
}
|
||||
if (row.kind === 'grant' || row.kind === 'group-permission') {
|
||||
return !unresolved.has(row.object) && !notLanded.has(`${row.subject}:${row.object}`.toLowerCase())
|
||||
}
|
||||
@@ -293,16 +353,35 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
|
||||
|
||||
// Everything retired is gone from the game whether the plugin removed it or
|
||||
// found it already absent, so it stops being something this site put there.
|
||||
await db.removePushed(server.id, retire)
|
||||
// A `chat-group` is not a ledger row; its fields are, below.
|
||||
await db.removePushed(server.id, retire.filter((row) => row.kind !== 'chat-group'))
|
||||
|
||||
// A style's fields leave the ledger only once BetterChat has removed the group
|
||||
// — or for `default`, which is never removed — so a style withdrawn while
|
||||
// BetterChat was absent is retired by the first sync that can (D139).
|
||||
const removed = new Set((chatLoaded && chat.removed) || [])
|
||||
await db.removePushed(
|
||||
server.id,
|
||||
styleRetired.filter((row) => row.subject === 'default' || removed.has(row.subject)),
|
||||
)
|
||||
|
||||
const revocations = await db.listRevocations(server.id)
|
||||
await db.deleteRevocations(revocations.map((row) => row.id))
|
||||
|
||||
await db.replaceDrift(server.id, (report.foreign || []).map((row) => ({
|
||||
kind: String(row.kind || ''),
|
||||
subject: String(row.subject || ''),
|
||||
object: String(row.object || ''),
|
||||
})))
|
||||
await db.replaceDrift(server.id, [
|
||||
...(report.foreign || []).map((row) => ({
|
||||
kind: String(row.kind || ''),
|
||||
subject: String(row.subject || ''),
|
||||
object: String(row.object || ''),
|
||||
})),
|
||||
// A style field somebody changed by hand, with what it holds now (D138).
|
||||
...((chatLoaded && chat.drift) || []).map((row) => ({
|
||||
kind: 'chat-field',
|
||||
subject: String(row.group || ''),
|
||||
object: String(row.field || ''),
|
||||
detail: row.game === undefined || row.game === null ? null : String(row.game).slice(0, 255),
|
||||
})),
|
||||
])
|
||||
|
||||
await db.putSyncResult(server.id, {
|
||||
state: 'ok',
|
||||
@@ -334,6 +413,14 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
|
||||
foreign: (report.foreign || []).length,
|
||||
pending: (report.pending || []).length,
|
||||
notLanded: (report.notLanded || []).length,
|
||||
...(chat
|
||||
? {
|
||||
betterChat: chatLoaded,
|
||||
...(chatLoaded
|
||||
? { styleApplied: chat.applied, styleSaved: chat.saved, styleDrift: (chat.drift || []).length, styleFailed: (chat.failed || []).length }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(report.creditsApplied !== undefined
|
||||
? { creditsApplied: report.creditsApplied, creditsWithdrawn: report.creditsWithdrawn }
|
||||
: {}),
|
||||
@@ -351,4 +438,6 @@ module.exports = {
|
||||
syncOne,
|
||||
reasonToSync,
|
||||
applyReport,
|
||||
withExpect,
|
||||
styleRetirements,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user