diff --git a/README.md b/README.md
index e3789c5..98f1c02 100644
--- a/README.md
+++ b/README.md
@@ -38,17 +38,18 @@ rows here; the website core never learns there is more than one.
| Public | `GET /api/v1/public/rust/servers` — every server and what it last reported |
| Public | `GET …/servers/:id` — one server, or a `404`; the only route under `:id` that can say a server does not exist |
| Public | `GET …/servers/:id/events` — the feed, served from a default-deny allowlist (`server/catalogue.js`) |
-| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed |
+| Public | `GET …/servers/:id/leaderboard` — per wipe, or all-time as those rows summed; each row carries the player's chat titles |
| Public | `GET …/servers/:id/wipes` and `…/online` |
| Public | `GET …/servers/:id/clans` — the server's clans, best score first (public: names nobody) |
| Public | `GET /api/v1/public/rust/clans/:externalId` — one clan, and its roster inside the roster audience |
| Player | `GET /api/v1/player/rust/servers` — the server list, on the authenticated tier |
| Admin | `GET/PUT/DELETE /api/v1/admin/rust/servers` and `POST …/:id/test` — the `PUT` carries the wipe schedule |
| Admin | `GET/PUT /api/v1/admin/rust/visibility` — who may see who is online, fleet-wide and per server |
+| Admin | `PUT …/servers/:id/titles`, `GET …/servers/:id/integrations`, `GET/PUT /api/v1/admin/rust/voice` — chat titles, the optional mods a server has, and the announcement voice |
| Pages | `/rust` — the server list, and the module's landing page |
| Pages | `/rust/servers/:id` — one server: feed, leaderboard, who is on, wipes, clans |
| Pages | `/rust/clans/:externalId` — one clan, with core's Team notify, activity and forum in three module slots |
-| Pages | `/admin/rust/servers` — add, edit, test and remove servers, and set each one's wipe schedule |
+| Pages | `/admin/rust/servers` — add, edit, test and remove servers, set each one's wipe schedule and chat titles, and choose the announcement voice |
| Discord | `/status`, `/wipe`, `/top`, `/online`, `/clan` — read-only, answered from this module's tables |
| Teams | The deployment's Team provider: a first-party Rust clan is a Team |
| Slot | `site.footer.status` — a live server/player count in core's footer |
@@ -81,6 +82,23 @@ forced wipe (first Thursday, 19:00 UK time) — plus an optional one-off date th
computed wipe. It is computed on every read and never stored, so it cannot go stale after a wipe.
The server list, the server page, the Android app and `/wipe` all show it.
+**Two uMod plugins are optional, and the module works without either** (`docs/modules/rust/PLAN.md`
+§33). The bridge plugin's hard requirements are **Kits** and **ZoneManager**.
+
+- **BetterChat** (LaserHydra, 5.2.15). With it: **chat titles** an operator sets per server — "top 3
+ playtime", "#1 kills" on the current wipe — shown in game chat and beside the name on the web and
+ app leaderboards; and a **chat style** on any permission group this site authors, all twelve of
+ BetterChat's group fields, mirrored like the group's permissions (a field changed in game is
+ reported, never overwritten). Without it the titles still show on the web and the app, and the
+ styles wait until it is installed.
+- **PopupNotifications** (k1lly0u, 0.2.1). With it: `rust.announce` and a server's news posts can be
+ a popup instead of a chat line. Without it a popup is refused with a sentence saying so, and chat
+ works as before.
+
+News and event lines said in chat can wear one styled group's title and colours — the
+**announcement voice**, chosen in Admin → Rust servers. The line is said by the bridge plugin with no
+player as its sender, so it looks the same with or without BetterChat.
+
**The Discord commands answer from the tables, never from a game server**, inside core's three-second
budget. Every refusal is private. **An answer narrower than public goes to the caller alone:** a
moderator's `/online` in a public channel shows the names to the moderator, never to the channel, and
diff --git a/ci/bundle.json b/ci/bundle.json
index 39aa1a2..9697e51 100644
--- a/ci/bundle.json
+++ b/ci/bundle.json
@@ -45,7 +45,8 @@
"package.json",
"permSync.js",
"router",
- "sidecarClient.js"
+ "sidecarClient.js",
+ "titleSync.js"
],
"root": [
"engagement-triggers.json",
diff --git a/client/src/api.js b/client/src/api.js
index 373d6d1..c31c3b6 100644
--- a/client/src/api.js
+++ b/client/src/api.js
@@ -132,6 +132,11 @@ export const admin = {
// picture to draw one — which stalls that game for seconds (D109).
fetchMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/fetch`, { method: 'POST' }),
renderMap: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/map/render`, { method: 'POST' }),
+ // Phase 17: a server's chat titles, what optional mods it has, and the voice.
+ saveTitles: (id, body) => req(`/admin/rust/servers/${encodeURIComponent(id)}/titles`, { method: 'PUT', body }),
+ integrations: (id) => req(`/admin/rust/servers/${encodeURIComponent(id)}/integrations`),
+ voice: () => req('/admin/rust/voice'),
+ saveVoice: (group) => req('/admin/rust/voice', { method: 'PUT', body: { group } }),
}
// ── admin · permissions (R2) ──────────────────────────────────────────────
diff --git a/client/src/components/Leaderboard.jsx b/client/src/components/Leaderboard.jsx
index 02eff97..d4f7ca9 100644
--- a/client/src/components/Leaderboard.jsx
+++ b/client/src/components/Leaderboard.jsx
@@ -12,7 +12,7 @@
import { ErrorState, Loading, useAsync } from '../core.js'
import Empty from './Empty.jsx'
-import { ago, count, duration, shortId } from '../lib/format.js'
+import { ago, contrastInk, count, duration, shortId } from '../lib/format.js'
import api from '../api.js'
// `sort` is the API's own vocabulary (`kills`, `deaths`, `npcKills`, `playtime`),
@@ -101,6 +101,26 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
of their id rather than as a blank: the row is real, and a
nameless one reads as a rendering fault. */}
{row.name || shortId(row.steamId)}
+ {/* The chat titles this player holds now (phase 17, D137) — the
+ same ones the game shows, ranked on the current wipe whichever
+ wipe this table is showing. Absent on an older module. */}
+ {(row.titles || []).map((title, i) => (
+
+ {title.text}
+
+ ))}
{COLUMNS.map((column) => (
diff --git a/client/src/lib/format.js b/client/src/lib/format.js
index 9140226..6b6b1c1 100644
--- a/client/src/lib/format.js
+++ b/client/src/lib/format.js
@@ -156,4 +156,24 @@ function toMillis(value) {
return Number.isNaN(parsed) ? null : parsed
}
-export default { ago, clock, day, duration, count, prefab, shortId, nextWipe }
+/**
+ * The ink that reads on a background of `hex` — black or white, whichever has
+ * the higher WCAG contrast. A chat title's colour is the operator's, chosen for
+ * a dark game chat, and this page is drawn in the reader's theme: yellow text on
+ * a white page is unreadable, so the colour becomes the chip and the text is
+ * picked for it (phase 17, D137). Anything that is not `#rrggbb` gets black on
+ * the caller's fallback.
+ */
+export function contrastInk(hex) {
+ const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(String(hex || ''))
+ if (!m) return '#000000'
+ const linear = (c) => {
+ const v = parseInt(c, 16) / 255
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
+ }
+ const L = 0.2126 * linear(m[1]) + 0.7152 * linear(m[2]) + 0.0722 * linear(m[3])
+ // Contrast against white is 1.05 / (L + 0.05); against black (L + 0.05) / 0.05.
+ return (L + 0.05) / 0.05 >= 1.05 / (L + 0.05) ? '#000000' : '#ffffff'
+}
+
+export default { ago, clock, day, duration, count, prefab, shortId, nextWipe, contrastInk }
diff --git a/client/src/routes/admin/ChatStyle.jsx b/client/src/routes/admin/ChatStyle.jsx
new file mode 100644
index 0000000..d6adad3
--- /dev/null
+++ b/client/src/routes/admin/ChatStyle.jsx
@@ -0,0 +1,163 @@
+// ── Admin · Rust · Permissions — a group's chat style (phase 17, D138) ─────
+//
+// All twelve of BetterChat's group fields, on a group this site authors. A style
+// is the whole of a BetterChat group or nothing, so the editor always shows all
+// twelve, starting from BetterChat's own defaults.
+//
+// Three things the section says out loud, because each looks like success from
+// here:
+//
+// • a server where BetterChat is not loaded holds nothing yet — the style
+// lands when BetterChat does, at the next sync;
+// • a field somebody changed in game is NOT overwritten — it shows under
+// "Changed in game", to adopt or put back;
+// • a field BetterChat refused (`InvalidValue`) is named with its server.
+//
+// Removing a style removes the group from BetterChat on the next sync (D139),
+// which is BetterChat's own `chat group remove` — it has no quieter way.
+
+import { useState } from 'react'
+
+const LABELS = {
+ Priority: 'Priority (lower wins when a player is in several groups)',
+ Title: 'Title',
+ TitleColor: 'Title colour',
+ TitleSize: 'Title size',
+ TitleHidden: 'Hide the title',
+ TitleHiddenIfNotPrimary: 'Hide the title unless this is the player’s main group',
+ UsernameColor: 'Name colour',
+ UsernameSize: 'Name size',
+ MessageColor: 'Message colour',
+ MessageSize: 'Message size',
+ ChatFormat: 'Chat format',
+ ConsoleFormat: 'Console format',
+}
+
+/** BetterChat's defaults for this group, from the field list the server sent. */
+function defaultsFor(group, fields) {
+ const out = {}
+ for (const f of fields) out[f.name] = f.default === null ? '' : f.default
+ out.Title = group === 'default' ? '[Player]' : `[${group}]`
+ return out
+}
+
+/** What each server's last sync said about this group's style. */
+function styleNotes(group, servers) {
+ const notes = []
+ for (const s of servers) {
+ const chat = s.report && s.report.chat
+ if (!chat) continue
+ if (chat.loaded === false) {
+ notes.push(`${s.serverId}: BetterChat is not loaded, so nothing is styled there yet.`)
+ continue
+ }
+ for (const f of chat.failed || []) {
+ if (f.group !== group) continue
+ notes.push(`${s.serverId}: BetterChat refused ${f.field || 'the group'} (${f.reason}).`)
+ }
+ }
+ return notes
+}
+
+export default function ChatStyleSection({ group, fields, servers, busy, onSave }) {
+ const [editing, setEditing] = useState(null)
+
+ if (!fields || !fields.length) return null
+
+ const notes = group.chat ? styleNotes(group.name, servers) : []
+
+ const set = (name, value) => setEditing((e) => ({ ...e, [name]: value }))
+
+ return (
+ <>
+
+ Chat style (BetterChat)
+
+ {!editing && !group.chat && (
+
+ No chat style. BetterChat, where it is installed, styles this group’s members as it does anybody else.
+
+ )}
+
+ {editing && (
+
+ )}
+ >
+ )
+}
diff --git a/client/src/routes/admin/ChatTitles.jsx b/client/src/routes/admin/ChatTitles.jsx
new file mode 100644
index 0000000..ba71cda
--- /dev/null
+++ b/client/src/routes/admin/ChatTitles.jsx
@@ -0,0 +1,224 @@
+// ── Admin · Rust · Servers — chat titles and the voice (phase 17) ─────────
+//
+// Two things the servers page gained with BetterChat and PopupNotifications
+// becoming optional (PLAN.md §33):
+//
+// • **A server's chat titles** (D135, D136): rules that rank the current wipe,
+// in the operator's order, and how many a player shows. Saved with their own
+// PUT, because they are not part of the server row — an operator retitling
+// "Top Killer" must not have to re-type a sidecar address.
+// • **The voice** (D140): the one styled permission group news and event lines
+// are said in. A fleet setting, so it sits once at the foot of the page.
+//
+// Neither needs BetterChat to save. Titles are held by the plugin until BetterChat
+// arrives, and the voice is said by our own plugin whether BetterChat is there or
+// not; the "What this server has" line says which is which.
+
+import { useState } from 'react'
+
+import { ErrorState, Loading, useAsync } from '../../core.js'
+import api from '../../api.js'
+import { contrastInk } from '../../lib/format.js'
+
+const STATS = [
+ { id: 'kills', label: 'Kills' },
+ { id: 'npckills', label: 'NPC kills' },
+ { id: 'playtime', label: 'Playtime' },
+]
+
+const MODES = [
+ { id: 'first', label: 'The first title they earn', hint: 'The rule highest in the list wins.' },
+ { id: 'all', label: 'Every title they earn' },
+ { id: 'upto', label: 'Up to a number of titles', hint: 'In list order.' },
+]
+
+const statLabel = (id) => (STATS.find((s) => s.id === id) || { label: id }).label
+
+/** One line summarising a server's titles, for its row on the list. */
+export function titlesSummary(titles, push) {
+ const rules = (titles && titles.rules) || []
+ if (!rules.length) return 'No chat titles.'
+ const list = rules.map((r) => `${r.text} (top ${r.topN} ${statLabel(r.stat).toLowerCase()})`).join(', ')
+ const shown = push ? ` Last pushed: ${push.count} player${push.count === 1 ? '' : 's'} hold one${push.betterChat ? '' : ' — BetterChat is not loaded, so they are not showing in game yet'}.` : ''
+ return `Chat titles: ${list}.${shown}`
+}
+
+/** A title as the leaderboard will show it. */
+function Chip({ text, color }) {
+ return (
+
+ {text || '…'}
+
+ )
+}
+
+export function TitlesForm({ server, onSaved, onCancel }) {
+ const start = server.titles || { mode: 'first', max: 2, rules: [] }
+ const [mode, setMode] = useState(start.mode)
+ const [max, setMax] = useState(start.max)
+ const [rules, setRules] = useState(start.rules.map((r) => ({ ...r })))
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+
+ const setRule = (i, key) => (e) => {
+ const value = e.target.value
+ setRules((list) => list.map((r, n) => (n === i ? { ...r, [key]: key === 'topN' ? Number(value) : value } : r)))
+ }
+ const move = (i, by) => setRules((list) => {
+ const next = [...list]
+ const [r] = next.splice(i, 1)
+ next.splice(i + by, 0, r)
+ return next
+ })
+
+ const save = async (e) => {
+ e.preventDefault()
+ setBusy(true)
+ setError('')
+ try {
+ await api.admin.saveTitles(server.id, { mode, max: Number(max), rules })
+ onSaved()
+ } catch (err) {
+ setError(err.message || 'That did not save.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+/** What a server says it has loaded, as one sentence (§33.2 `integrations`). */
+export function integrationsLine(r) {
+ if (!r || !r.integrations) {
+ return r && r.ok === false
+ ? `The game could not be asked (${r.status}).`
+ : 'This server’s plugin is older than protocol 12, so it cannot say which optional mods it has.'
+ }
+ const one = (name, x, without) => (x && x.loaded ? `${name} ${x.version || ''} is loaded`.trim() : `${name} is not loaded — ${without}`)
+ return `${one('BetterChat', r.integrations.betterChat, 'titles and group styles wait for it')}. ${one('PopupNotifications', r.integrations.popupNotifications, 'a popup is refused, and chat still works')}.`
+}
+
+export function VoiceCard() {
+ const [reloads, setReloads] = useState(0)
+ const { data, error: loadError } = useAsync(() => api.admin.voice(), [reloads])
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+
+ if (loadError) return
+ if (!data) return
+
+ const choose = async (group) => {
+ setBusy(true)
+ setError('')
+ try {
+ await api.admin.saveVoice(group)
+ setReloads((n) => n + 1)
+ } catch (err) {
+ setError(err.message || 'That did not save.')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const current = data.options.find((o) => o.group === data.voice)
+
+ return (
+
+
Announcement voice
+
+ News posts and event announcements said in game chat can wear the title and colours of one permission group that
+ has a chat style. The line has no sender, so no player’s name appears. It works whether or not BetterChat is
+ installed. Popups are plain text.
+
+
+ {data.voice && !current && (
+
+ The group “{data.voice}” no longer has a chat style, so lines are said in plain chat until it has one again or
+ another voice is chosen.
+
+ )}
+ {current &&
The line: {current.format}
}
+ {!data.options.length && (
+
No group has a chat style yet. Give one a style under Permissions.
+ )}
+ {error &&
{error}
}
+
+ )
+}
+
+const inputStyle = {
+ background: 'var(--panel-flat, transparent)',
+ color: 'var(--text)',
+ border: '1px solid var(--line)',
+ borderRadius: 'var(--radius-input, 6px)',
+ padding: '5px 8px',
+ fontSize: '0.84rem',
+}
diff --git a/client/src/routes/admin/Permissions.jsx b/client/src/routes/admin/Permissions.jsx
index d57d130..ea78bbb 100644
--- a/client/src/routes/admin/Permissions.jsx
+++ b/client/src/routes/admin/Permissions.jsx
@@ -27,6 +27,7 @@ import { useCallback, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import { ago } from '../../lib/format.js'
import api from '../../api.js'
+import ChatStyleSection from './ChatStyle.jsx'
const FLEET = '*'
@@ -159,6 +160,28 @@ function ServerState({ row, onSync, busy }) {
function DriftRow({ row, onAdopt, onRevoke, busy }) {
const subject = row.username ? `${row.username} (${row.subject})` : row.subject
+ // Phase 17: a field of a group's chat style somebody changed in game. Adopt
+ // takes the game's value into the style; Revoke puts the site's value back.
+ if (row.kind === 'chat-field') {
+ return (
+
+
+ {row.object}{' '}
+
+ of group {row.subject}’s chat style is {row.detail === null ? '(empty)' : row.detail} in game ·{' '}
+ {row.serverId} · seen {ago(row.firstSeen)}
+
+
+
+
+
+ )
+ }
+
return (
@@ -199,7 +222,7 @@ function pendingSet(servers) {
return pending
}
-function GroupCard({ group, catalogue, servers, pending, onChanged, setError }) {
+function GroupCard({ group, catalogue, servers, pending, chatFields, onChanged, setError }) {
const [busy, setBusy] = useState(false)
const [member, setMember] = useState('')
const [permission, setPermission] = useState('')
@@ -227,6 +250,29 @@ function GroupCard({ group, catalogue, servers, pending, onChanged, setError })
}),
)
+ // The style is saved with the group, like its permissions (D138). Answers
+ // whether it saved, so the editor stays open on a refusal and shows why.
+ const saveStyle = async (chat) => {
+ setBusy(true)
+ setError('')
+ try {
+ await api.adminPermissions.saveGroup(group.name, {
+ title: group.title,
+ rank: group.rank,
+ scope: group.scope,
+ permissions: group.permissions,
+ chat,
+ })
+ await onChanged()
+ return true
+ } catch (err) {
+ setError(err.message || 'That style did not save.')
+ return false
+ } finally {
+ setBusy(false)
+ }
+ }
+
return (
+
+
{servers.length > 1 && group.scope !== FLEET && (
This group exists on {group.scope} only. The other servers never receive it.
@@ -453,7 +501,8 @@ export default function Permissions() {
Nothing here is undone automatically. Adopt records it as the site’s
own, so it survives the next wipe; Revoke removes it from the game on
- the next sync.
+ the next sync. A chat style field changed in game is adopted into the style — which then
+ reaches every server the group does — or put back to the site’s value.
{data.drift.map((row) => (
diff --git a/client/src/routes/admin/ServerSettings.jsx b/client/src/routes/admin/ServerSettings.jsx
index 8c9e823..c05147e 100644
--- a/client/src/routes/admin/ServerSettings.jsx
+++ b/client/src/routes/admin/ServerSettings.jsx
@@ -20,12 +20,16 @@
//
// Test and Delete act at once rather than on Save — they are questions put to a
// sidecar and a removal, not settings.
+//
+// Phase 17 added a server's chat titles and the announcement voice, in
+// `ChatTitles.jsx`, each saved on its own.
import { useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
import { ago, nextWipe } from '../../lib/format.js'
+import { TitlesForm, VoiceCard, integrationsLine, titlesSummary } from './ChatTitles.jsx'
const RULES = [
{ id: 'none', label: 'No schedule', hint: 'Nothing is forecast, not even the monthly forced wipe.' },
@@ -139,6 +143,7 @@ export default function ServerSettings() {
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const [confirming, setConfirming] = useState(null)
+ const [titling, setTitling] = useState(null)
if (loadError) return
if (!data) return
@@ -178,6 +183,16 @@ export default function ServerSettings() {
}
}
+ const mods = async (server) => {
+ setNotes((n) => ({ ...n, [server.id]: 'Asking the game…' }))
+ try {
+ const line = integrationsLine(await api.admin.integrations(server.id))
+ setNotes((n) => ({ ...n, [server.id]: line }))
+ } catch (err) {
+ setNotes((n) => ({ ...n, [server.id]: err.message || 'The game could not be asked.' }))
+ }
+ }
+
const remove = async (server) => {
setConfirming(null)
try {
@@ -219,9 +234,12 @@ export default function ServerSettings() {
? `Next wipe ${nextWipe(s.nextWipe)}, from ${SOURCE[s.nextWipe.source] || s.nextWipe.source}.`
: 'No wipe schedule set.'}
+
When a news post is published, its title is said in the chat of every server switched on
- here. A server that is down when a post is published is skipped rather than told late.
+ here. A server that is down when a post is published is skipped rather than told late. A popup
+ needs PopupNotifications on that server; one without it refuses the post and says why.
{newsRows.length === 0 && (
No servers are configured yet.
@@ -285,6 +290,17 @@ export default function Visibility() {
{!s.enabled && · disabled}
{news[s.id] ? 'says news' : 'off'}
+ {/* D142: where the post goes on this server when it is switched on. */}
+
))}
diff --git a/client/test/format.test.js b/client/test/format.test.js
index aaf4417..5852a17 100644
--- a/client/test/format.test.js
+++ b/client/test/format.test.js
@@ -11,7 +11,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
-import { ago, clock, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
+import { ago, clock, contrastInk, count, day, duration, nextWipe, prefab, shortId } from '../src/lib/format.js'
const NOW = Date.parse('2026-09-16T12:00:00Z')
@@ -107,3 +107,11 @@ test('the next wipe: the reader’s own clock, how far away, and whether it move
assert.equal(nextWipe(null, now), null)
assert.equal(nextWipe({ at: 'soon', source: 'rule' }, now), null)
})
+
+test('a title chip’s ink is whichever of black and white reads on its colour', () => {
+ assert.equal(contrastInk('#ffff00'), '#000000')
+ assert.equal(contrastInk('#FFAA55'), '#000000')
+ assert.equal(contrastInk('#1a1a8c'), '#ffffff')
+ assert.equal(contrastInk('#ff0000'), '#000000')
+ assert.equal(contrastInk('red'), '#000000', 'not a hex colour: black, on the caller’s fallback')
+})
diff --git a/routes.manifest.json b/routes.manifest.json
index 52d6ad1..1575d14 100644
--- a/routes.manifest.json
+++ b/routes.manifest.json
@@ -66,11 +66,21 @@
"path": "/api/v1/admin/rust/servers",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/rust/servers/:id/integrations",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/admin/rust/visibility",
"tier": "public"
},
+ {
+ "method": "GET",
+ "path": "/api/v1/admin/rust/voice",
+ "tier": "public"
+ },
{
"method": "GET",
"path": "/api/v1/admin/users/:id/rust/links",
@@ -216,10 +226,20 @@
"path": "/api/v1/admin/rust/servers/:id",
"tier": "public"
},
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/rust/servers/:id/titles",
+ "tier": "public"
+ },
{
"method": "PUT",
"path": "/api/v1/admin/rust/visibility",
"tier": "public"
+ },
+ {
+ "method": "PUT",
+ "path": "/api/v1/admin/rust/voice",
+ "tier": "public"
}
]
}
diff --git a/server/boot.js b/server/boot.js
index 27ad9af..0fa2dc3 100644
--- a/server/boot.js
+++ b/server/boot.js
@@ -49,6 +49,7 @@ const eventWorld = require('./eventWorld')
const ingest = require('./ingest')
const mapImages = require('./mapImages')
const permSync = require('./permSync')
+const titleSync = require('./titleSync')
const servers = require('./model/servers/servers.model')
const sidecar = require('./sidecarClient')
@@ -261,6 +262,8 @@ async function onBoot() {
// to every configured game server before the website had finished booting, and
// nothing about R2 is urgent enough to delay a listener for.
permSync.start()
+ // The chat titles have a loop of their own for the same reason (phase 17).
+ titleSync.start()
refreshTimer = setInterval(refresh, REFRESH_MS)
ingestTimer = setInterval(ingestAll, INGEST_MS)
pruneTimer = setInterval(prune, PRUNE_MS)
@@ -285,6 +288,7 @@ async function onBoot() {
*/
async function onShutdown() {
permSync.stop()
+ titleSync.stop()
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
if (timer) clearInterval(timer)
diff --git a/server/db/purge.sql b/server/db/purge.sql
index 8a1ecfc..e80b5f0 100644
--- a/server/db/purge.sql
+++ b/server/db/purge.sql
@@ -19,6 +19,11 @@
-- it knows this module registered, because it is the side that knows which
-- registrant owned what.
+-- Phase 17. `rust_perm_group_chat` before `rust_perm_groups`, which it
+-- references; the rest of this phase is columns, which go with their tables.
+DROP TABLE IF EXISTS rust_perm_group_chat;
+DROP TABLE IF EXISTS rust_title_rules;
+
-- Phase 14.
DROP TABLE IF EXISTS rust_map_overrides;
DROP TABLE IF EXISTS rust_map_images;
diff --git a/server/db/schema.sql b/server/db/schema.sql
index b45c346..2886ebd 100644
--- a/server/db/schema.sql
+++ b/server/db/schema.sql
@@ -927,3 +927,65 @@ ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_time CHAR(5) NULL;
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_tz VARCHAR(64) NULL;
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_anchor DATE NULL;
ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS wipe_once_at DATETIME NULL;
+
+-- ── The optional mods (phase 17, protocol 12) ─────────────────────────────
+--
+-- Chat titles (D135): an operator's rules, per server, in their own order. A
+-- rule ranks the CURRENT wipe by one stat and gives its top N a title. The
+-- titles themselves are not stored: they are computed from the standings each
+-- time they are pushed or shown, so nothing rolls them over at a wipe and
+-- nothing can go stale (`model/titles`).
+--
+-- stat `kills` · `npckills` · `playtime`
+-- top_n 1–10
+-- text at most 24 characters, with `[ ] < > { }` stripped on save so an
+-- operator's title can carry no markup of its own (§33.4)
+-- color `#rrggbb`
+-- position the operator's order, which is also precedence (D136)
+CREATE TABLE IF NOT EXISTS rust_title_rules (
+ id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ server_id VARCHAR(64) NOT NULL,
+ position INT NOT NULL DEFAULT 0,
+ stat VARCHAR(16) NOT NULL,
+ top_n TINYINT NOT NULL DEFAULT 1,
+ text VARCHAR(24) NOT NULL,
+ color CHAR(7) NOT NULL DEFAULT '#ffaa55',
+ KEY idx_rust_title_rules_server (server_id, position),
+ CONSTRAINT fk_rust_title_rules_server
+ FOREIGN KEY (server_id) REFERENCES rust_servers (id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- How many titles one player shows (D136): `first` the first rule they meet,
+-- `all`, or `upto` `title_max` of them. A word this build does not know reads
+-- as `first`, the fewest.
+ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS title_mode VARCHAR(8) NOT NULL DEFAULT 'first';
+ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS title_max TINYINT NOT NULL DEFAULT 2;
+
+-- Where a news post goes on this server when `announce_news` is on (D142):
+-- `chat` or `popup`. A popup needs PopupNotifications, which is optional
+-- (D141), and a server without it refuses with a reason.
+ALTER TABLE rust_servers ADD COLUMN IF NOT EXISTS news_delivery VARCHAR(8) NOT NULL DEFAULT 'chat';
+
+-- A group's BetterChat style (D138): all twelve fields or none, one row each,
+-- as the text BetterChat's own `chat group set` takes. A style is authored with
+-- the group and dies with it; the push, the drift and the removal (D139) are the
+-- permission mirror's, like everything else about a group.
+CREATE TABLE IF NOT EXISTS rust_perm_group_chat (
+ group_name VARCHAR(64) NOT NULL,
+ field VARCHAR(32) NOT NULL,
+ value VARCHAR(255) NOT NULL,
+ PRIMARY KEY (group_name, field),
+ CONSTRAINT fk_rust_perm_group_chat_group
+ FOREIGN KEY (group_name) REFERENCES rust_perm_groups (name) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- What a pushed style field HELD when it landed (kind `chat-field`, subject the
+-- group, object the field). Every other ledger row is a fact with no value — a
+-- grant is held or it is not — and a field is not: the value is what tells a
+-- field this site set from one somebody changed by hand (§33.2). NULL on every
+-- other kind.
+ALTER TABLE rust_perm_pushed ADD COLUMN IF NOT EXISTS value VARCHAR(255) NULL;
+
+-- The value a changed field holds in the game, for a `chat-field` drift row.
+-- NULL on every other kind: a foreign grant is its own description.
+ALTER TABLE rust_perm_drift ADD COLUMN IF NOT EXISTS detail VARCHAR(255) NULL;
diff --git a/server/eventRewards.js b/server/eventRewards.js
index 938c366..28b6159 100644
--- a/server/eventRewards.js
+++ b/server/eventRewards.js
@@ -12,6 +12,10 @@
// …and the announce leg, `rust.chat`, which says a published news post in the
// chat of every server whose switch is on (D104).
//
+// Phase 17 gave both a delivery — chat, or a popup through PopupNotifications
+// (D141, D142) — and a VOICE: the style of one permission group, which the
+// plugin says the line in with no player as its sender (D140).
+//
// ── Who decides what ─────────────────────────────────────────────────────────
//
// The GAME counts: presence, kills, the score (D81, D99). The SITE picks the
@@ -36,6 +40,7 @@ const servers = require('./model/servers/servers.model')
const permDb = require('./model/permissions/permissions.db')
const linksDb = require('./model/links/links.db')
const emit = require('./engagement/emit')
+const voice = require('./model/permissions/voice')
const { serverFor, transportError, pluginError, perServer, bounded } = require('./eventLeases')
const { BUDGET_MS } = require('./eventWorld')
@@ -57,6 +62,9 @@ const MODES = ['everyone', 'top', 'minScore', 'random', 'topPercent']
/** The fleet, in `rust.announce`'s `server` param (D105). */
const EVERY_SERVER = '*'
+/** Where a line goes (D141). Chat is the default and what every server can do. */
+const DELIVERIES = ['chat', 'popup']
+
/** The plugin's refusals a second attempt would repeat. */
const PERMANENT = new Set([
'events-disabled',
@@ -68,6 +76,9 @@ const PERMANENT = new Set([
'too-many',
'too-long',
'kits-missing',
+ // Protocol 12: a popup on a server without PopupNotifications. Waiting does not
+ // install it.
+ 'popup-unavailable',
])
const BUDGETS = [
@@ -620,6 +631,20 @@ const kitEntitle = {
},
}
+/**
+ * The body of a chat line: its delivery, and the voice's format when the line
+ * goes to chat and a voice is chosen (D140). A popup is not a chat line and
+ * carries no format. Chat, the default, is left off the wire — the shape a
+ * protocol-11 caller sent — so a line with nothing new looks exactly as before.
+ */
+function lineBody(base, delivery, format) {
+ return {
+ ...base,
+ ...(delivery === 'popup' ? { delivery } : {}),
+ ...(delivery !== 'popup' && format ? { format } : {}),
+ }
+}
+
/**
* Say one line on one server, and classify the answer. `repeat` is a success:
* the plugin remembered the key, and the line was already said.
@@ -645,6 +670,10 @@ const announce = {
description: 'Which server, or * for every server (D105).' },
{ name: 'message', type: 'string', required: true, example: 'The airfield brawl starts in five minutes!',
description: `The line, up to ${MAX_CHAT} characters.` },
+ // Optional, and the action stays version 1: a bump would stop every step
+ // already written from dispatching until somebody re-saved it (§33.2).
+ { name: 'delivery', type: 'string', required: false, example: 'chat', source: 'rust.options.delivery',
+ description: 'chat (the default), or popup — which needs PopupNotifications on the server, and is refused with a reason where it is missing (D141).' },
],
// One per server reached. `*` is priced at the enabled servers when core asks,
@@ -658,6 +687,10 @@ const announce = {
return { ok: false, retry: false, error: `a chat line is at most ${MAX_CHAT} characters, and this one is ${message.length}` }
}
+ const rawDelivery = params.delivery === undefined || params.delivery === null || params.delivery === '' ? 'chat' : params.delivery
+ const delivery = oneOf(rawDelivery, DELIVERIES)
+ if (!delivery) return { ok: false, retry: false, error: `a line is delivered to chat or to a popup, not to "${rawDelivery}"` }
+
const target = String(params.server || '').trim()
let list
if (target === EVERY_SERVER) {
@@ -671,7 +704,8 @@ const announce = {
if (verify) return { ok: true }
- const body = { key: idempotencyKey || `run:${runId}`, message, event: true }
+ const format = delivery === 'chat' ? await voice.currentFormat() : null
+ const body = lineBody({ key: idempotencyKey || `run:${runId}`, message, event: true }, delivery, format)
const outcomes = await Promise.all(list.map(async (server) => ({ server, ...(await sayOn(server, body)) })))
const name = (o) => o.server.name || o.server.id
@@ -730,8 +764,14 @@ const LEG = {
const list = (await servers.listForPolling()).filter((s) => s.announceNews)
const key = chatKey(post, line)
+ // Read once for the post, not once per server: every server says it in the
+ // same voice (D140). Each server's own delivery decides chat or popup (D142).
+ const format = list.some((s) => s.newsDelivery !== 'popup') ? await voice.currentFormat() : null
const outcomes = await Promise.all(
- list.map(async (server) => ({ server: server.name || server.id, ...(await sayOn(server, { key, message: line })) })),
+ list.map(async (server) => ({
+ server: server.name || server.id,
+ ...(await sayOn(server, lineBody({ key, message: line }, server.newsDelivery, format))),
+ })),
)
return { ok: true, outcomes }
} catch (err) {
@@ -823,6 +863,10 @@ const OPTION_SOURCES = [
{ value: 'random', label: 'N drawn at random' },
{ value: 'topPercent', label: 'The top X per cent (ties in)' },
]),
+ fixed('rust.options.delivery', 'Delivery', 'Where a line goes (D141).', [
+ { value: 'chat', label: 'Chat' },
+ { value: 'popup', label: 'A popup — needs PopupNotifications on the server' },
+ ]),
{
id: 'rust.options.chatservers',
label: 'Chat servers',
@@ -840,6 +884,7 @@ module.exports = {
MAX_RECIPIENTS,
MAX_CHAT,
EVERY_SERVER,
+ DELIVERIES,
BUDGETS,
ACTIONS,
LEG,
@@ -850,5 +895,6 @@ module.exports = {
kitReward,
chatLine,
chatKey,
+ lineBody,
refParts,
}
diff --git a/server/model/permissions/chatStyle.js b/server/model/permissions/chatStyle.js
new file mode 100644
index 0000000..a08184e
--- /dev/null
+++ b/server/model/permissions/chatStyle.js
@@ -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 }
diff --git a/server/model/permissions/permissions.db.js b/server/model/permissions/permissions.db.js
index 9eab355..b7ac1ea 100644
--- a/server/model/permissions/permissions.db.js
+++ b/server/model/permissions/permissions.db.js
@@ -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,
diff --git a/server/model/permissions/permissions.model.js b/server/model/permissions/permissions.model.js
index 0ef145e..b9345c5 100644
--- a/server/model/permissions/permissions.model.js
+++ b/server/model/permissions/permissions.model.js
@@ -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,
}
diff --git a/server/model/permissions/voice.js b/server/model/permissions/voice.js
new file mode 100644
index 0000000..ed6d224
--- /dev/null
+++ b/server/model/permissions/voice.js
@@ -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 }
diff --git a/server/model/servers/servers.db.js b/server/model/servers/servers.db.js
index 439acd3..8ff61a6 100644
--- a/server/model/servers/servers.db.js
+++ b/server/model/servers/servers.db.js
@@ -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
diff --git a/server/model/servers/servers.model.js b/server/model/servers/servers.model.js
index a17408e..073267e 100644
--- a/server/model/servers/servers.model.js
+++ b/server/model/servers/servers.model.js
@@ -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',
}
}
diff --git a/server/model/titles/titles.db.js b/server/model/titles/titles.db.js
new file mode 100644
index 0000000..1ab45e7
--- /dev/null
+++ b/server/model/titles/titles.db.js
@@ -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 }
diff --git a/server/model/titles/titles.js b/server/model/titles/titles.js
new file mode 100644
index 0000000..fe08886
--- /dev/null
+++ b/server/model/titles/titles.js
@@ -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>} 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,
+}
diff --git a/server/model/titles/titles.model.js b/server/model/titles/titles.model.js
new file mode 100644
index 0000000..0628805
--- /dev/null
+++ b/server/model/titles/titles.model.js
@@ -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 }
diff --git a/server/model/visibility/visibility.db.js b/server/model/visibility/visibility.db.js
index 6eb63fd..f008510 100644
--- a/server/model/visibility/visibility.db.js
+++ b/server/model/visibility/visibility.db.js
@@ -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,
+}
diff --git a/server/model/visibility/visibility.model.js b/server/model/visibility/visibility.model.js
index f8d9d94..91dab02 100644
--- a/server/model/visibility/visibility.model.js
+++ b/server/model/visibility/visibility.model.js
@@ -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) } : {}),
},
}
}
diff --git a/server/permSync.js b/server/permSync.js
index ad53b59..cfa4a2a 100644
--- a/server/permSync.js
+++ b/server/permSync.js
@@ -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,
}
diff --git a/server/router/admin/permissions.controller.js b/server/router/admin/permissions.controller.js
index e2649c7..8587f71 100644
--- a/server/router/admin/permissions.controller.js
+++ b/server/router/admin/permissions.controller.js
@@ -17,6 +17,7 @@
const core = require('../../core')
+const chatStyle = require('../../model/permissions/chatStyle')
const db = require('../../model/permissions/permissions.db')
const model = require('../../model/permissions/permissions.model')
const permSync = require('../../permSync')
@@ -27,7 +28,9 @@ const log = core.logger('admin:permissions')
/** Everything the screen renders: groups, grants, drift, the catalogue, per-server state. */
async function overview(req, res) {
try {
- res.json(await model.overview())
+ // The twelve BetterChat fields travel with the model, so the form's editor
+ // is built from the same list the server validates against (D138).
+ res.json({ ...(await model.overview()), chatFields: chatStyle.FIELDS })
} catch (err) {
log.error('failed to read the permission model', { error: err.message })
res.status(500).json({ message: 'Failed to read the permission model' })
@@ -50,6 +53,18 @@ async function putGroup(req, res) {
return res.status(400).json({ message: 'That scope names no configured server' })
}
+ // Phase 17: `chat` is the group's BetterChat style — all twelve fields, or
+ // null to take the style away. Absent leaves it as it is, so a client that
+ // predates styles cannot erase one by saving a group.
+ let style
+ if (req.body.chat !== undefined && req.body.chat !== null) {
+ const checked = chatStyle.validateStyle(req.body.chat)
+ if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
+ style = checked.fields
+ } else if (req.body.chat === null) {
+ style = null
+ }
+
const previous = await db.getGroup(name)
await db.upsertGroup({
@@ -61,6 +76,7 @@ async function putGroup(req, res) {
const permissions = [...new Set((req.body.permissions || []).map(model.normaliseName))].filter(Boolean)
await db.setGroupPermissions(name, permissions)
+ if (style !== undefined) await db.setGroupChat(name, style)
// Both scopes: a group that moved from one server to another has to be
// retired from where it was as well as applied where it now is, and only the
@@ -71,7 +87,12 @@ async function putGroup(req, res) {
await core.activity.log({
req,
action: previous ? 'rust.perm.group.update' : 'rust.perm.group.create',
- detail: { group: name, scope, permissions: permissions.length },
+ detail: {
+ group: name,
+ scope,
+ permissions: permissions.length,
+ ...(style !== undefined ? { chatStyle: style ? 'set' : 'removed' } : {}),
+ },
})
return res.status(204).end()
@@ -238,6 +259,8 @@ async function adoptDrift(req, res) {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
+ if (row.kind === 'chat-field') return adoptStyleField(req, res, row)
+
if (row.kind !== 'grant' && row.kind !== 'member') {
return res.status(400).json({
message: 'Only a grant or a membership can be adopted. A permission on a group is edited on the group itself.',
@@ -306,6 +329,8 @@ async function revokeDrift(req, res) {
const row = await db.getDrift(id)
if (!row) return res.status(404).json({ message: 'No such drift' })
+ if (row.kind === 'chat-field') return revokeStyleField(req, res, row)
+
await db.queueRevocation({
serverId: row.serverId,
kind: row.kind,
@@ -330,6 +355,65 @@ async function revokeDrift(req, res) {
}
}
+/**
+ * Adopt a hand edit to a style field: the game's value becomes the site's.
+ *
+ * The style belongs to the GROUP, and a group may reach every server — so the
+ * value adopted from one server is the value every server in its scope is
+ * pushed next. That is what adopting means for a fleet-wide group, and the
+ * activity row names the server it came from.
+ */
+async function adoptStyleField(req, res, row) {
+ const style = await db.getGroupChat(row.subject)
+ if (!style || style[row.object] === undefined) {
+ return res.status(409).json({ message: 'That group has no chat style on this site to adopt the change into' })
+ }
+
+ const field = chatStyle.FIELDS.find((f) => f.name === row.object)
+ const checked = field ? chatStyle.checkField(field, row.detail === null ? '' : row.detail) : { error: 'unknown field' }
+ if (checked.error) {
+ return res.status(409).json({
+ message: `The game's value cannot be adopted: ${checked.error}. Revoke it instead, or edit the style.`,
+ })
+ }
+
+ await db.setGroupChatField(row.subject, row.object, checked.value)
+ // Already in that game, so already pushed there — the same reasoning as a grant.
+ await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
+ await db.deleteDrift(row.id)
+ await db.markDirty(model.FLEET)
+
+ await core.activity.log({
+ req,
+ action: 'rust.perm.drift.adopt',
+ detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object, value: checked.value },
+ })
+
+ return res.status(204).end()
+}
+
+/**
+ * Revoke a hand edit to a style field: put the site's value back.
+ *
+ * Not a queued revocation — there is nothing to remove, only a value to
+ * overwrite. The ledger is told the game's value is this site's own, so the
+ * next sync expects to find it and writes over it. That is a person choosing to
+ * overwrite, which R2 allows (§33.2).
+ */
+async function revokeStyleField(req, res, row) {
+ await db.setPushedValue(row.serverId, { kind: 'chat-field', subject: row.subject, object: row.object, value: row.detail })
+ await db.deleteDrift(row.id)
+ await db.markDirty(row.serverId)
+
+ await core.activity.log({
+ req,
+ action: 'rust.perm.drift.revoke',
+ detail: { server: row.serverId, kind: row.kind, group: row.subject, field: row.object },
+ })
+
+ return res.status(202).json({ queued: true })
+}
+
/** Run the loop's pass now, for one server or for all of them, and report what happened. */
async function syncNow(req, res) {
const serverId = req.body && req.body.serverId ? String(req.body.serverId) : null
diff --git a/server/router/admin/permissions.router.js b/server/router/admin/permissions.router.js
index 69a3f3f..ccfea1a 100644
--- a/server/router/admin/permissions.router.js
+++ b/server/router/admin/permissions.router.js
@@ -32,7 +32,7 @@ permissionsRouter.get(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'The whole permission model'
- // #swagger.description = 'Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.'
+ // #swagger.description = 'Groups with their permissions, members and BetterChat style (`chat`, or null), direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server. A drift row of kind `chat-field` is a style field somebody changed in game: `subject` is the group, `object` the field and `detail` what the game holds. `chatFields` lists the twelve BetterChat fields with their types and defaults, for the style editor.'
/* #swagger.responses[200] = { description: 'The authored model and what each game reported', content: { "application/json": { schema: { $ref: "#/components/schemas/RustPermissionModel" } } } } */
requireRole('admin'),
permissions.overview,
@@ -52,7 +52,7 @@ permissionsRouter.put(
'/groups/:name',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Create or update a permission group'
- // #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.'
+ // #swagger.description = 'Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it. `chat` is the group’s BetterChat style: all twelve fields (`Priority`, `Title`, `TitleColor`, `TitleSize`, `TitleHidden`, `TitleHiddenIfNotPrimary`, `UsernameColor`, `UsernameSize`, `MessageColor`, `MessageSize`, `ChatFormat`, `ConsoleFormat`), each as text; `null` removes the style, which removes the group from BetterChat on the next sync; absent leaves it alone. A format must hold `{Message}` exactly once. A 400 carries one sentence per problem in `errors`.'
/* #swagger.responses[204] = { description: 'Saved' } */
/* #swagger.responses[400] = { description: 'Invalid body, or a scope naming no configured server' } */
requireRole('admin'),
@@ -62,6 +62,9 @@ permissionsRouter.put(
body('scope').optional().isString().isLength({ min: 1, max: 64 }),
body('permissions').optional().isArray({ max: 500 }),
body('permissions.*').isString().matches(NAME),
+ // Shape only; the twelve fields and their rules are `chatStyle.validateStyle`'s,
+ // in the controller, so the form gets one sentence per problem.
+ body('chat').optional({ values: 'null' }).isObject().withMessage('chat is an object of BetterChat fields, or null'),
validate,
permissions.putGroup,
)
@@ -145,7 +148,7 @@ permissionsRouter.post(
'/drift/:id/adopt',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Adopt a hand edit'
- // #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.'
+ // #swagger.description = 'Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link. For a `chat-field` row it copies the game’s value into the group’s style — which every server in the group’s scope is then pushed — and answers 409 when the group has no style or the value is not one the site accepts.'
/* #swagger.responses[204] = { description: 'Adopted' } */
/* #swagger.responses[400] = { description: 'That kind of drift cannot be adopted' } */
/* #swagger.responses[409] = { description: 'That Steam account is linked to nobody on this site' } */
@@ -159,7 +162,7 @@ permissionsRouter.post(
'/drift/:id/revoke',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Revoke a hand edit'
- // #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.'
+ // #swagger.description = 'Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own. For a `chat-field` row it puts the site’s value back over the hand edit on the next sync.'
/* #swagger.responses[202] = { description: 'Queued for the next sync' } */
/* #swagger.responses[404] = { description: 'No such drift' } */
requireRole('admin'),
diff --git a/server/router/admin/rust.controller.js b/server/router/admin/rust.controller.js
index ab37e8d..cedcf26 100644
--- a/server/router/admin/rust.controller.js
+++ b/server/router/admin/rust.controller.js
@@ -17,12 +17,25 @@ const mapImages = require('../../mapImages')
const nextWipe = require('../../model/servers/nextWipe')
const servers = require('../../model/servers/servers.model')
const sidecar = require('../../sidecarClient')
+const titleSync = require('../../titleSync')
+const titles = require('../../model/titles/titles')
+const titlesModel = require('../../model/titles/titles.model')
+const voice = require('../../model/permissions/voice')
const log = core.logger('admin')
async function listServers(req, res) {
try {
- res.json({ servers: await servers.listForAdmin() })
+ const [rows, settings] = await Promise.all([servers.listForAdmin(), titlesModel.settingsByServer()])
+
+ // Phase 17: each server's chat titles, and what the last push of them found.
+ res.json({
+ servers: rows.map((row) => ({
+ ...row,
+ titles: settings.get(row.id) || { mode: 'first', max: 2, rules: [] },
+ titlePush: titleSync.lastPush(row.id),
+ })),
+ })
} catch (err) {
log.error('failed to read the server list', { error: err.message })
res.status(500).json({ message: 'Failed to read the server list' })
@@ -218,4 +231,96 @@ async function renderMap(req, res) {
}
}
-module.exports = { scheduleFrom, listServers, putServer, deleteServer, testServer, fetchMap, renderMap }
+// ── The optional mods (phase 17) ─────────────────────────────────────────────
+
+/**
+ * Replace one server's chat titles: the rules, the mode and N (D135, D136).
+ * Validated whole, so a bad rule saves nothing. The titles reach the game on
+ * the push loop's next tick, and the web the next time it reads.
+ */
+async function putTitles(req, res) {
+ const { id } = req.params
+
+ try {
+ const existing = await db.getServer(id)
+ if (!existing) return res.status(404).json({ message: 'No such server' })
+
+ const checked = titles.validateSettings(req.body)
+ if (!checked.ok) return res.status(400).json({ message: checked.errors.join(' '), errors: checked.errors })
+
+ await titlesModel.saveSettings(id, checked.value)
+ await core.activity.log({
+ req,
+ action: 'rust.titles.save',
+ detail: { server: id, mode: checked.value.mode, max: checked.value.max, rules: checked.value.rules },
+ })
+
+ return res.json({ titles: await titlesModel.settingsFor(id) })
+ } catch (err) {
+ log.error('failed to save chat titles', { server: id, error: err.message })
+ return res.status(500).json({ message: 'Failed to save the chat titles' })
+ }
+}
+
+/**
+ * Which optional mods one server has loaded right now, read live from the game
+ * (`integrations` on `server.status`). Live, because an operator installs
+ * BetterChat and then looks here, and the board the site keeps is from the last
+ * time the plugin connected.
+ */
+async function integrations(req, res) {
+ const { id } = req.params
+
+ try {
+ const server = await servers.getForCalling(id)
+ if (!server) return res.status(404).json({ message: 'No such server, or it is disabled' })
+
+ const result = await sidecar.liveStatus(server)
+ const data = result.ok && result.data ? result.data : null
+
+ return res.json({
+ ok: Boolean(data),
+ status: result.status,
+ integrations: data && data.integrations ? data.integrations : null,
+ })
+ } catch (err) {
+ log.error('failed to read integrations', { server: id, error: err.message })
+ return res.status(500).json({ message: 'Failed to ask the server what it has loaded' })
+ }
+}
+
+async function getVoice(req, res) {
+ try {
+ res.json(await voice.describe())
+ } catch (err) {
+ log.error('failed to read the voice', { error: err.message })
+ res.status(500).json({ message: 'Failed to read the announcement voice' })
+ }
+}
+
+async function putVoice(req, res) {
+ try {
+ const result = await voice.choose(req.body.group || '', req.user ? req.user.id : null)
+ if (!result.ok) return res.status(400).json({ message: result.message })
+
+ await core.activity.log({ req, action: 'rust.voice.save', detail: { group: result.voice || null } })
+ return res.json(await voice.describe())
+ } catch (err) {
+ log.error('failed to save the voice', { error: err.message })
+ return res.status(500).json({ message: 'Failed to save the announcement voice' })
+ }
+}
+
+module.exports = {
+ scheduleFrom,
+ listServers,
+ putServer,
+ deleteServer,
+ testServer,
+ fetchMap,
+ renderMap,
+ putTitles,
+ integrations,
+ getVoice,
+ putVoice,
+}
diff --git a/server/router/admin/rust.router.js b/server/router/admin/rust.router.js
index 20440f8..e489978 100644
--- a/server/router/admin/rust.router.js
+++ b/server/router/admin/rust.router.js
@@ -145,4 +145,64 @@ adminRustRouter.post(
admin.renderMap,
)
+// ── The optional mods (phase 17) ──────────────────────────────────────────
+
+adminRustRouter.put(
+ '/servers/:id/titles',
+ // #swagger.tags = ['Admin · Rust']
+ // #swagger.summary = 'Set a server’s chat titles'
+ // #swagger.description = 'Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by `kills`, `npckills` or `playtime` and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.'
+ // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustTitleSettings" } } } } */
+ /* #swagger.responses[200] = { description: 'Saved; answers the server’s settings as stored' } */
+ /* #swagger.responses[400] = { description: 'A rule, the mode or N is not valid' } */
+ /* #swagger.responses[404] = { description: 'No such server' } */
+ requireRole('admin'),
+ param('id').isString().isLength({ min: 1, max: 64 }),
+ // Shape only. The words, the bounds and the per-rule sentences are
+ // `titles.validateSettings`'s, in the controller, so a bad mode reaches the
+ // form as a sentence rather than as "Invalid value".
+ body('rules').isArray().withMessage('rules is a list'),
+ validate,
+ admin.putTitles,
+)
+
+adminRustRouter.get(
+ '/servers/:id/integrations',
+ // #swagger.tags = ['Admin · Rust']
+ // #swagger.summary = 'Which optional mods a server has loaded'
+ // #swagger.description = 'Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.'
+ // #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
+ /* #swagger.responses[200] = { description: 'What the server has loaded', content: { "application/json": { schema: { $ref: "#/components/schemas/RustIntegrations" } } } } */
+ /* #swagger.responses[404] = { description: 'No such server, or it is disabled' } */
+ requireRole('admin'),
+ param('id').isString().isLength({ min: 1, max: 64 }),
+ validate,
+ admin.integrations,
+)
+
+adminRustRouter.get(
+ '/voice',
+ // #swagger.tags = ['Admin · Rust']
+ // #swagger.summary = 'The voice announcements are said in'
+ // #swagger.description = 'The permission group whose BetterChat style news and `rust.announce` lines are said in, or empty for plain chat, and every group that could be one. The line is composed on the site and said by the bridge plugin with no player as its sender, so it works whether or not BetterChat is loaded.'
+ /* #swagger.responses[200] = { description: 'The voice and the groups that could be one', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVoice" } } } } */
+ requireRole('admin'),
+ admin.getVoice,
+)
+
+adminRustRouter.put(
+ '/voice',
+ // #swagger.tags = ['Admin · Rust']
+ // #swagger.summary = 'Choose the voice announcements are said in'
+ // #swagger.description = '`group` is a permission group with a chat style, or empty for plain chat. A group without a style is refused.'
+ /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: 'object', properties: { group: { type: 'string', example: 'staff' } } } } } } */
+ /* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVoice" } } } } */
+ /* #swagger.responses[400] = { description: 'That group has no chat style' } */
+ requireRole('admin'),
+ body('group').optional({ values: 'null' }).isString().isLength({ max: 64 }),
+ validate,
+ admin.putVoice,
+)
+
module.exports = adminRustRouter
diff --git a/server/router/admin/visibility.controller.js b/server/router/admin/visibility.controller.js
index 1e7a483..77e72dd 100644
--- a/server/router/admin/visibility.controller.js
+++ b/server/router/admin/visibility.controller.js
@@ -74,7 +74,7 @@ async function read(req, res) {
async function update(req, res) {
try {
- const { fleet, servers, clanRoster, news, map: mapSwitches } = req.body || {}
+ const { fleet, servers, clanRoster, news, newsDelivery, map: mapSwitches } = req.body || {}
// The map's switches are validated whole FIRST, before the rest is written:
// the page saves everything with one PUT, and a refused map switch must not
@@ -88,7 +88,7 @@ async function update(req, res) {
}
}
- const result = await visibility.update({ fleet, servers, clanRoster, news }, req.user)
+ const result = await visibility.update({ fleet, servers, clanRoster, news, newsDelivery }, req.user)
if (!result.ok) {
res.status(result.status || 400).json({ message: result.message })
return
diff --git a/server/router/admin/visibility.router.js b/server/router/admin/visibility.router.js
index ee31824..75667c0 100644
--- a/server/router/admin/visibility.router.js
+++ b/server/router/admin/visibility.router.js
@@ -37,7 +37,7 @@ visibilityRouter.put(
'/',
// #swagger.tags = ['Admin · Rust']
// #swagger.summary = 'Change who may see who is online, who may see a clan roster, or which servers say news in chat'
- // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.'
+ // #swagger.description = 'Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.'
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibilityUpdate" } } } } */
/* #swagger.responses[200] = { description: 'Saved; answers the new state', content: { "application/json": { schema: { $ref: "#/components/schemas/RustVisibility" } } } } */
/* #swagger.responses[400] = { description: 'An audience that does not exist' } */
@@ -47,6 +47,7 @@ visibilityRouter.put(
body('servers').optional().isObject().withMessage('servers maps a server id to an audience or null'),
body('clanRoster').optional().isIn(CLAN_AUDIENCES).withMessage(`clanRoster must be one of ${CLAN_AUDIENCES.join(', ')}`),
body('news').optional().isObject().withMessage('news maps a server id to true or false'),
+ body('newsDelivery').optional().isObject().withMessage('newsDelivery maps a server id to chat or popup'),
body('map').optional().isObject().withMessage('map carries fleet and servers, each an object of layer switches'),
validate,
visibility.update,
diff --git a/server/router/public/rust.controller.js b/server/router/public/rust.controller.js
index 75b0d65..f7263c6 100644
--- a/server/router/public/rust.controller.js
+++ b/server/router/public/rust.controller.js
@@ -17,6 +17,7 @@ const map = require('../../model/map/map.model')
const mapDb = require('../../model/map/map.db')
const mapLive = require('../../mapLive')
const servers = require('../../model/servers/servers.model')
+const titles = require('../../model/titles/titles.model')
const visibility = require('../../model/visibility/visibility.model')
const log = core.logger('public')
@@ -105,15 +106,23 @@ async function listEvents(req, res) {
async function listLeaderboard(req, res) {
try {
const presence = await visibility.canSeePresence(req, req.params.id)
- perViewer(res)
- res.json({
- leaderboard: await events.leaderboard({
+ const [rows, held] = await Promise.all([
+ events.leaderboard({
serverId: req.params.id,
wipeId: req.query.wipe || null,
sort: req.query.sort,
limit: req.query.limit,
presence: presence.visible,
}),
+ titles.currentFor(req.params.id),
+ ])
+ perViewer(res)
+ res.json({
+ // D137: the titles each player holds NOW — the same ones, after the same
+ // mode, that the game shows. They rank the current wipe whichever wipe
+ // this page is showing, because a title is what a player is, not what a
+ // past wipe was.
+ leaderboard: rows.map((row) => ({ ...row, titles: held.get(row.steamId) || [] })),
})
} catch (err) {
log.error('failed to read the leaderboard', { server: req.params.id, error: err.message })
diff --git a/server/router/public/rust.router.js b/server/router/public/rust.router.js
index 72f6889..2bde967 100644
--- a/server/router/public/rust.router.js
+++ b/server/router/public/rust.router.js
@@ -82,7 +82,7 @@ rustRouter.get(
'/servers/:id/leaderboard',
// #swagger.tags = ['Public · Rust']
// #swagger.summary = 'The leaderboard for one Rust server'
- // #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.'
+ // #swagger.description = 'Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.'
// #swagger.parameters['id'] = { in: 'path', required: true, description: 'The server’s slug', schema: { type: 'string' } }
// #swagger.parameters['wipe'] = { in: 'query', required: false, description: 'Restrict to one wipe id', schema: { type: 'string' } }
// #swagger.parameters['sort'] = { in: 'query', required: false, description: 'kills, deaths, npcKills or playtime', schema: { type: 'string' } }
diff --git a/server/sidecarClient.js b/server/sidecarClient.js
index bbde5a7..91075ef 100644
--- a/server/sidecarClient.js
+++ b/server/sidecarClient.js
@@ -67,7 +67,9 @@ const TIMEOUT_MS = 12000
* `/tally/close`, `/kits` and `/chat`, and a `credits` field on the permission
* sync (PLAN.md §29); **11** adds the map — `/map`, `/map/chunk`,
* `/map/render` and `/map/live`, its picture and what moves on it (PLAN.md
- * §30). The bump lands here in the same change as the emitters,
+ * §30); **12** adds `/titles`, the chat titles BetterChat shows, and gives a
+ * permission sync a group's BetterChat style and a chat line a delivery and a
+ * voice (PLAN.md §33). The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -77,7 +79,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
-const PROTOCOL_VERSION = 11
+const PROTOCOL_VERSION = 12
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -388,6 +390,12 @@ const kits = (server) => request(server, '/kits')
*/
const chat = (server, body) => request(server, '/chat', { method: 'POST', body })
+/**
+ * Replace the chat titles a server's plugin holds (protocol 12). Whole, so a
+ * retry is harmless; `titles.ok` says whether BetterChat is there to show them.
+ */
+const titles = (server, body) => request(server, '/titles', { method: 'POST', body })
+
/**
* What one server's map is and where its picture comes from (protocol 11,
* stage one): `mapKey`, `source` (`companion`, `rendered` or `none`), the
@@ -449,6 +457,7 @@ module.exports = {
tallyClose,
kits,
chat,
+ titles,
mapInfo,
mapChunk,
mapRender,
diff --git a/server/swagger/doc.js b/server/swagger/doc.js
index 3d4c469..34a6097 100644
--- a/server/swagger/doc.js
+++ b/server/swagger/doc.js
@@ -140,6 +140,90 @@ module.exports = {
stale: { type: 'boolean', example: false },
nextWipe: { $ref: '#/components/schemas/RustNextWipe' },
schedule: { $ref: '#/components/schemas/RustWipeSchedule' },
+ titles: { $ref: '#/components/schemas/RustTitleSettings' },
+ titlePush: {
+ type: 'object',
+ nullable: true,
+ description: 'What the last push of titles to this server found, since this site last started: how many players hold one and whether BetterChat was there to show them. Null before the first push.',
+ properties: {
+ count: { type: 'integer', example: 4 },
+ betterChat: { type: 'boolean', example: true },
+ at: { type: 'string', format: 'date-time' },
+ },
+ },
+ },
+ },
+ RustTitleSettings: {
+ type: 'object',
+ description: 'One server’s chat titles (phase 17, D135–D136): rules that rank the current wipe, in precedence order, and how many titles a player shows.',
+ properties: {
+ mode: {
+ type: 'string',
+ enum: ['first', 'all', 'upto'],
+ description: '`first` shows the first rule a player meets, `all` every one, `upto` at most `max`.',
+ example: 'first',
+ },
+ max: { type: 'integer', minimum: 1, maximum: 5, example: 2 },
+ rules: {
+ type: 'array',
+ maxItems: 10,
+ items: {
+ type: 'object',
+ properties: {
+ stat: { type: 'string', enum: ['kills', 'npckills', 'playtime'], example: 'kills' },
+ topN: { type: 'integer', minimum: 1, maximum: 10, example: 1 },
+ text: {
+ type: 'string',
+ maxLength: 24,
+ description: 'The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own.',
+ example: 'Top Killer',
+ },
+ color: { type: 'string', example: '#ff8800' },
+ },
+ },
+ },
+ },
+ },
+ RustIntegrations: {
+ type: 'object',
+ description: 'Which optional mods a server has loaded right now (GET /admin/rust/servers/{id}/integrations), read live from the game.',
+ properties: {
+ ok: { type: 'boolean', example: true },
+ status: { type: 'string', description: 'The sidecar client’s one-word status when the game could not be asked.', example: 'ok' },
+ integrations: {
+ type: 'object',
+ nullable: true,
+ description: 'Null when the game could not be asked, or its plugin is older than protocol 12.',
+ properties: {
+ betterChat: {
+ type: 'object',
+ properties: { loaded: { type: 'boolean', example: true }, version: { type: 'string', example: '5.2.15' } },
+ },
+ popupNotifications: {
+ type: 'object',
+ properties: { loaded: { type: 'boolean', example: false } },
+ },
+ },
+ },
+ },
+ },
+ RustVoice: {
+ type: 'object',
+ description: 'The voice news and `rust.announce` lines are said in (D140): a styled permission group, or none for plain chat.',
+ properties: {
+ voice: { type: 'string', description: 'A group name, or empty for plain chat.', example: 'staff' },
+ options: {
+ type: 'array',
+ description: 'Every group that has a chat style — the only groups that can be a voice.',
+ items: {
+ type: 'object',
+ properties: {
+ group: { type: 'string', example: 'staff' },
+ title: { type: 'string', example: '[Staff]' },
+ format: { type: 'string', description: 'The line the voice makes, with `{message}` where the words go.', example: '[#55aaff][+15][Staff][/+][/#] [#ffffff][+15]{message}[/+][/#]' },
+ },
+ },
+ },
},
},
RustLink: {
@@ -309,6 +393,13 @@ module.exports = {
example: '*',
},
permissions: { type: 'array', items: { type: 'string', example: 'kits.vip' } },
+ chat: {
+ type: 'object',
+ nullable: true,
+ description: 'The group’s BetterChat style — all twelve fields as text — or null for a group without one (D138).',
+ additionalProperties: { type: 'string' },
+ example: { Title: '[VIP]', TitleColor: '#ffaa55', ChatFormat: '{Title} {Username}: {Message}' },
+ },
members: {
type: 'array',
items: {
@@ -366,6 +457,18 @@ module.exports = {
description: 'The state of the mirror, per configured server.',
items: { $ref: '#/components/schemas/RustPermissionSyncState' },
},
+ chatFields: {
+ type: 'array',
+ description: 'The twelve BetterChat group fields a style carries, with each one’s type and BetterChat’s default, for the style editor.',
+ items: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', example: 'TitleColor' },
+ type: { type: 'string', enum: ['int', 'title', 'color', 'size', 'bool', 'format'], example: 'color' },
+ default: { type: 'string', nullable: true, example: '#55aaff' },
+ },
+ },
+ },
drift: {
type: 'array',
description: 'What a game holds that the site did not author. Reported, never undone.',
@@ -376,9 +479,15 @@ module.exports = {
serverId: { type: 'string', example: 'main' },
kind: {
type: 'string',
- description: 'One of `grant`, `member`, `group-permission`.',
+ description: 'One of `grant`, `member`, `group-permission`, or `chat-field` for a style field changed in game.',
example: 'grant',
},
+ detail: {
+ type: 'string',
+ nullable: true,
+ description: 'For `chat-field`, the value the game holds now. Null for every other kind.',
+ example: '#ff0000',
+ },
subject: {
type: 'string',
description: 'A Steam id, or a group name.',
@@ -660,6 +769,12 @@ module.exports = {
name: { type: 'string', example: 'Main · Vanilla' },
enabled: { type: 'boolean', example: true },
on: { type: 'boolean', example: false },
+ delivery: {
+ type: 'string',
+ enum: ['chat', 'popup'],
+ description: 'Where the post goes when `on`: chat, or a popup — which needs PopupNotifications on that server (D142).',
+ example: 'chat',
+ },
},
},
},
@@ -924,6 +1039,12 @@ module.exports = {
additionalProperties: { type: 'boolean' },
example: { main: true },
},
+ newsDelivery: {
+ type: 'object',
+ description: 'A server id to where a news post goes on it: `chat` or `popup` (D142).',
+ additionalProperties: { type: 'string', enum: ['chat', 'popup'] },
+ example: { main: 'popup' },
+ },
map: {
type: 'object',
description: 'The live map’s switches. `fleet` maps a layer to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet.',
diff --git a/server/test/optionalMods.test.js b/server/test/optionalMods.test.js
new file mode 100644
index 0000000..669b9c9
--- /dev/null
+++ b/server/test/optionalMods.test.js
@@ -0,0 +1,433 @@
+// ── The optional mods (PLAN.md §33, protocol 12) ──────────────────────────
+//
+// BetterChat titles, BetterChat group styles, the voice, and popups. Every test
+// here is one of the ways the phase can look right and be wrong:
+//
+// a title goes to nobody on a fresh wipe; ties follow the leaderboard; a mode keeps what it says
+// an operator's title cannot carry markup, and a title made only of markup is refused
+// a style is all twelve fields, in the spelling BetterChat's setter takes
+// a voice has no sender: no username, and no stray colon where one was
+// a style field carries what the site last pushed THERE, and landed only if the game took it
+// a withdrawn style is one `chat-group` retirement, never for `default`, and leaves the
+// ledger only once BetterChat has removed the group
+// titles skip a plugin older than protocol 12, and push again on a restart or a wipe
+// a popup refused for want of PopupNotifications is not retried
+
+const test = require('node:test')
+const assert = require('node:assert')
+
+const { fakeCtx } = require('./_fakes')
+
+function withCore() {
+ const queries = []
+
+ require('../core')._reset()
+ require('../core').init(
+ fakeCtx({
+ db: {
+ query: (sql, params) => {
+ queries.push({ sql: sql.trim().replace(/\s+/g, ' '), params })
+ const verb = sql.trim().split(/\s+/)[0].toUpperCase()
+ if (verb === 'SELECT') return Promise.resolve([])
+ return Promise.resolve({ affectedRows: 1 })
+ },
+ pool: {},
+ },
+ }),
+ )
+
+ return queries
+}
+
+withCore()
+
+const titles = require('../model/titles/titles')
+const chatStyle = require('../model/permissions/chatStyle')
+
+const row = (steamId, stats) => ({ steamId, kills: 0, npcKills: 0, playtimeSec: 0, ...stats })
+
+// ── Titles ─────────────────────────────────────────────────────────────────
+
+test('a title rule counts only a stat above zero, so a fresh wipe gives no titles (§33.4 reading 1)', () => {
+ const held = titles.evaluate(
+ [{ stat: 'kills', topN: 3, text: 'Killer', color: '#ff0000' }],
+ { kills: [row('1', { kills: 5 }), row('2', { kills: 0 }), row('3', { kills: 0 })] },
+ )
+ assert.deepStrictEqual([...held.keys()], ['1'])
+
+ const empty = titles.evaluate([{ stat: 'kills', topN: 1, text: 'K', color: '#ff0000' }], { kills: [row('1')] })
+ assert.strictEqual(empty.size, 0)
+})
+
+test('ties follow the leaderboard order: a rule’s top N is the first N rows it was given (reading 2)', () => {
+ const held = titles.evaluate(
+ [{ stat: 'playtime', topN: 2, text: 'Regular', color: '#00ff00' }],
+ { playtime: [row('9', { playtimeSec: 60 }), row('4', { playtimeSec: 60 }), row('7', { playtimeSec: 60 })] },
+ )
+ assert.deepStrictEqual([...held.keys()], ['9', '4'])
+})
+
+test('the mode keeps the first rule met, every rule, or up to N — in the operator’s order (D136)', () => {
+ const rules = [
+ { stat: 'kills', topN: 1, text: 'Killer', color: '#ff0000' },
+ { stat: 'npckills', topN: 1, text: 'Hunter', color: '#00ff00' },
+ { stat: 'playtime', topN: 1, text: 'Regular', color: '#0000ff' },
+ ]
+ const standings = {
+ kills: [row('1', { kills: 3 })],
+ npckills: [row('1', { npcKills: 2 })],
+ playtime: [row('1', { playtimeSec: 9 })],
+ }
+ const texts = (mode, max) => titles.evaluate(rules, standings, { mode, max }).get('1').map((t) => t.text)
+
+ assert.deepStrictEqual(texts('first'), ['Killer'])
+ assert.deepStrictEqual(texts('all'), ['Killer', 'Hunter', 'Regular'])
+ assert.deepStrictEqual(texts('upto', 2), ['Killer', 'Hunter'])
+ assert.deepStrictEqual(texts('nonsense'), ['Killer'], 'an unknown mode shows the fewest')
+})
+
+test('a title set is BetterChat markup, sorted, and digests the same however it was built', () => {
+ const held = new Map([
+ ['2', [{ text: 'B', color: '#00ff00' }]],
+ ['1', [{ text: 'A', color: '#ff0000' }, { text: 'C', color: '#0000ff' }]],
+ ])
+ const set = titles.wireSet(held)
+ assert.deepStrictEqual(set, [
+ { steamId: '1', text: '[#ff0000]A[/#] [#0000ff]C[/#]' },
+ { steamId: '2', text: '[#00ff00]B[/#]' },
+ ])
+ const again = titles.wireSet(new Map([...held.entries()].reverse()))
+ assert.strictEqual(titles.digest(set), titles.digest(again))
+ assert.notStrictEqual(titles.digest(set), titles.digest([]))
+})
+
+test('an operator’s title cannot carry markup, and one made only of markup is refused (reading 3)', () => {
+ assert.strictEqual(titles.cleanText(' [#ff0000]Top {Message} '), '#ff0000bTop/b Message')
+
+ const ok = titles.validateSettings({ mode: 'upto', max: 3, rules: [{ stat: 'kills', topN: 1, text: '[Top Killer]', color: '#FF8800' }] })
+ assert.strictEqual(ok.ok, true)
+ assert.deepStrictEqual(ok.value.rules[0], { stat: 'kills', topN: 1, text: 'Top Killer', color: '#ff8800' })
+
+ const bad = titles.validateSettings({
+ mode: 'most',
+ max: 9,
+ rules: [
+ { stat: 'deaths', topN: 0, text: '[]<>', color: 'red' },
+ { stat: 'kills', topN: 1, text: 'x'.repeat(25), color: '#ffffff' },
+ ],
+ })
+ assert.strictEqual(bad.ok, false)
+ assert.ok(bad.errors.some((e) => /mode/.test(e)))
+ assert.ok(bad.errors.some((e) => /max/.test(e)))
+ assert.ok(bad.errors.some((e) => /rule 1: stat/.test(e)))
+ assert.ok(bad.errors.some((e) => /rule 1: top/.test(e)))
+ assert.ok(bad.errors.some((e) => /rule 1: the title needs some text/.test(e)))
+ assert.ok(bad.errors.some((e) => /rule 1: colour/.test(e)))
+ assert.ok(bad.errors.some((e) => /rule 2: a title is at most 24/.test(e)))
+
+ const many = titles.validateSettings({ rules: new Array(11).fill({ stat: 'kills', topN: 1, text: 'K', color: '#ffffff' }) })
+ assert.strictEqual(many.ok, false)
+})
+
+// ── Styles and the voice ───────────────────────────────────────────────────
+
+const STYLE = {
+ Priority: 5,
+ Title: '[Staff]',
+ TitleColor: '#FF0000',
+ TitleSize: '16',
+ TitleHidden: false,
+ TitleHiddenIfNotPrimary: 'FALSE',
+ UsernameColor: '#55aaff',
+ UsernameSize: 15,
+ MessageColor: 'white',
+ MessageSize: '15',
+ ChatFormat: '{Title} {Username}: {Message}',
+ ConsoleFormat: '{Title} {Username}: {Message}',
+}
+
+test('a style is all twelve fields, each in the spelling BetterChat’s setter takes (D138)', () => {
+ const checked = chatStyle.validateStyle(STYLE)
+ assert.strictEqual(checked.ok, true)
+ assert.strictEqual(checked.fields.Priority, '5')
+ assert.strictEqual(checked.fields.TitleColor, '#ff0000')
+ assert.strictEqual(checked.fields.TitleHidden, 'false', 'a boolean is true or false, never True')
+ assert.strictEqual(checked.fields.TitleHiddenIfNotPrimary, 'false')
+ assert.strictEqual(checked.fields.UsernameSize, '15')
+
+ const partial = chatStyle.validateStyle({ Title: '[x]' })
+ assert.strictEqual(partial.ok, false)
+ assert.ok(partial.errors.some((e) => /ChatFormat is missing/.test(e)))
+
+ const bad = chatStyle.validateStyle({ ...STYLE, ChatFormat: '{Title}', ConsoleFormat: '{Message} {Message}', TitleColor: 'rgb(1,2,3)', Nope: 1 })
+ assert.ok(bad.errors.some((e) => /ChatFormat must contain \{Message\} exactly once/.test(e)))
+ assert.ok(bad.errors.some((e) => /ConsoleFormat must contain/.test(e)))
+ assert.ok(bad.errors.some((e) => /TitleColor/.test(e)))
+ assert.ok(bad.errors.some((e) => /Nope is not a BetterChat group field/.test(e)))
+
+ assert.strictEqual(chatStyle.defaults('vip').Title, '[vip]')
+ assert.strictEqual(chatStyle.defaults('default').Title, '[Player]')
+})
+
+test('a voice has no sender: no username, and no stray colon where BetterChat’s default puts one (D140)', () => {
+ const { fields } = chatStyle.validateStyle(STYLE)
+ assert.strictEqual(chatStyle.voiceFormat(fields), '[#ff0000][+16][Staff][/+][/#] [#white][+15]{message}[/+][/#]')
+
+ const hidden = chatStyle.voiceFormat({ ...fields, TitleHidden: 'true', ChatFormat: '<{Time}> {Title} {Username} » {Message}' })
+ assert.strictEqual(hidden, '<> » [#white][+15]{message}[/+][/#]')
+
+ // A title is operator text, and `$&` in a replacement string is a pattern.
+ const dollars = chatStyle.voiceFormat({ ...fields, Title: '[$&]' })
+ assert.match(dollars, /\[\$&\]/)
+ assert.strictEqual(chatStyle.voiceFormat({ ...fields, ChatFormat: '{Title}' }), null)
+})
+
+// ── The permission mirror's style half ─────────────────────────────────────
+
+test('the desired set carries a style per field, and its value moves the digest but not the row’s identity', () => {
+ const model = require('../model/permissions/permissions.model')
+ const base = {
+ groups: [{ name: 'staff', title: 'Staff', rank: 0, scope: '*' }],
+ groupPermissions: [],
+ members: [],
+ grants: [],
+ steamIdsByUser: new Map(),
+ groupChat: [
+ { groupName: 'staff', field: 'Title', value: '[Staff]' },
+ { groupName: 'staff', field: 'TitleColor', value: '#ff0000' },
+ ],
+ }
+ const a = model.buildDesired('main', base)
+ assert.deepStrictEqual(a.payload.groups[0].chat, { Title: '[Staff]', TitleColor: '#ff0000' })
+ assert.deepStrictEqual(
+ a.rows.filter((r) => r.kind === 'chat-field').map((r) => `${r.object}=${r.value}`),
+ ['Title=[Staff]', 'TitleColor=#ff0000'],
+ )
+
+ const b = model.buildDesired('main', { ...base, groupChat: [{ ...base.groupChat[0] }, { ...base.groupChat[1], value: '#00ff00' }] })
+ assert.notStrictEqual(a.hash, b.hash, 'a recoloured title must push')
+ assert.strictEqual(model.retirements(a.rows, b.rows).length, 0, 'a changed value is not a retirement')
+
+ const none = model.buildDesired('main', { ...base, groupChat: [] })
+ assert.strictEqual(none.payload.groups[0].chat, undefined)
+})
+
+test('each style field carries what the site last pushed to THAT server, or null (§33.2)', () => {
+ const permSync = require('../permSync')
+ const groups = [{ name: 'staff', chat: { Title: '[Staff]', TitleColor: '#ff0000' } }, { name: 'vip' }]
+ const pushed = [
+ { kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Old]' },
+ { kind: 'grant', subject: '1', object: 'x', value: null },
+ ]
+ assert.deepStrictEqual(permSync.withExpect(groups, pushed), [
+ { name: 'staff', chat: { Title: { value: '[Staff]', expect: '[Old]' }, TitleColor: { value: '#ff0000', expect: null } } },
+ { name: 'vip' },
+ ])
+})
+
+test('a withdrawn style is one chat-group retirement per group, and never for default (D139)', () => {
+ const permSync = require('../permSync')
+ const retired = [
+ { kind: 'chat-field', subject: 'staff', object: 'Title' },
+ { kind: 'chat-field', subject: 'staff', object: 'TitleColor' },
+ { kind: 'chat-field', subject: 'default', object: 'Title' },
+ { kind: 'grant', subject: '1', object: 'kits.vip' },
+ ]
+ const { styleRetired, sent } = permSync.styleRetirements(retired)
+ assert.strictEqual(styleRetired.length, 3)
+ assert.deepStrictEqual(sent, [{ kind: 'chat-group', subject: 'staff', object: '' }])
+})
+
+test('a style field landed only when BetterChat took it; drift keeps the game’s value; a removal clears the ledger', async () => {
+ const queries = withCore()
+ const permSync = require('../permSync')
+ require('../sidecarClient').permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
+
+ const desired = {
+ hash: 'h',
+ rows: [
+ { kind: 'group', subject: 'staff', object: '' },
+ { kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Staff]' },
+ { kind: 'chat-field', subject: 'staff', object: 'TitleColor', value: '#ff0000' },
+ { kind: 'chat-field', subject: 'staff', object: 'MessageSize', value: '15' },
+ ],
+ }
+ const report = {
+ kind: 'perm.report',
+ foreign: [],
+ chat: {
+ loaded: true,
+ applied: 1,
+ saved: 1,
+ drift: [{ group: 'staff', field: 'TitleColor', game: '#123456' }],
+ failed: [{ group: 'staff', field: 'MessageSize', reason: 'InvalidValue' }],
+ removed: ['old'],
+ },
+ }
+ const styleRetired = [
+ { kind: 'chat-field', subject: 'old', object: 'Title' },
+ { kind: 'chat-field', subject: 'gone', object: 'Title' },
+ ]
+
+ await permSync.applyReport({ id: 'main' }, { desired, retire: [{ kind: 'chat-group', subject: 'old', object: '' }], styleRetired, report })
+
+ const insert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_pushed'))
+ const recorded = insert.params.join(' ')
+ assert.ok(recorded.includes('Title [Staff]'), 'the field BetterChat took is pushed, with its value')
+ assert.ok(!recorded.includes('TitleColor'), 'a hand-edited field is not ours')
+ assert.ok(!recorded.includes('MessageSize'), 'a field BetterChat refused did not land')
+
+ const deletes = queries.filter((q) => q.sql.startsWith('DELETE FROM rust_perm_pushed')).map((q) => q.params.join(' '))
+ assert.ok(deletes.includes('main chat-field old Title'), 'a removed group leaves the ledger')
+ assert.ok(!deletes.some((d) => d.includes('gone')), 'a group BetterChat has not removed stays, to be retired again')
+ assert.ok(!deletes.some((d) => d.includes('chat-group')), 'a chat-group retirement is not a ledger row')
+
+ const drift = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_drift'))
+ assert.deepStrictEqual(drift.params, ['main', 'chat-field', 'staff', 'TitleColor', '#123456'])
+})
+
+test('with BetterChat absent no style field landed, and a withdrawn style is kept for later', async () => {
+ const queries = withCore()
+ const permSync = require('../permSync')
+ require('../sidecarClient').permCatalogue = async () => ({ ok: false, status: 'no-token', data: null })
+
+ await permSync.applyReport(
+ { id: 'main' },
+ {
+ desired: { hash: 'h', rows: [{ kind: 'chat-field', subject: 'staff', object: 'Title', value: '[Staff]' }] },
+ retire: [],
+ styleRetired: [{ kind: 'chat-field', subject: 'old', object: 'Title' }],
+ report: { kind: 'perm.report', foreign: [], chat: { loaded: false } },
+ },
+ )
+
+ assert.ok(!queries.some((q) => q.sql.startsWith('INSERT INTO rust_perm_pushed')))
+ assert.ok(!queries.some((q) => q.sql.startsWith('DELETE FROM rust_perm_pushed') && q.params.includes('old')))
+})
+
+// ── The title push ─────────────────────────────────────────────────────────
+
+test('titles push on a change, a restart or a wipe, and not on a quiet tick', () => {
+ const titleSync = require('../titleSync')
+ const last = { digest: 'd', bootId: 'b1', wipeId: 'w1' }
+ const state = { bootId: 'b1', wipeId: 'w1' }
+ assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state, last: null }), 'first')
+ assert.strictEqual(titleSync.reasonToPush({ digest: 'e', state, last }), 'changed')
+ assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state: { ...state, bootId: 'b2' }, last }), 'restart')
+ assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state: { ...state, wipeId: 'w2' }, last }), 'wipe')
+ assert.strictEqual(titleSync.reasonToPush({ digest: 'd', state, last }), null)
+})
+
+test('a plugin older than protocol 12, or a server that is off, is not asked', async () => {
+ withCore()
+ const titleSync = require('../titleSync')
+ const client = require('../sidecarClient')
+ const calls = []
+ const saved = client.titles
+ client.titles = async (...args) => {
+ calls.push(args)
+ return { ok: true, data: { kind: 'titles.ok', count: 0, betterChat: false } }
+ }
+ try {
+ assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 1, protocol: 11, wipeId: 'w' }), null)
+ assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 0, protocol: 12, wipeId: 'w' }), null)
+ assert.strictEqual(calls.length, 0)
+
+ assert.strictEqual(await titleSync.syncOne({ id: 'main' }, { online: 1, protocol: 12, wipeId: 'w', bootId: 'b' }), 'ok')
+ assert.deepStrictEqual(calls[0][1].titles, [], 'no rules is an empty set, which clears the game')
+ assert.deepStrictEqual(titleSync.lastPush('main').betterChat, false)
+ } finally {
+ client.titles = saved
+ }
+})
+
+// ── Delivery ───────────────────────────────────────────────────────────────
+
+test('a line’s body: a popup carries no format; chat carries the voice; plain chat looks as it did', () => {
+ const rewards = require('../eventRewards')
+ const base = { key: 'k', message: 'm' }
+ assert.deepStrictEqual(rewards.lineBody(base, 'chat', null), base)
+ assert.deepStrictEqual(rewards.lineBody(base, 'chat', 'F {message}'), { ...base, format: 'F {message}' })
+ assert.deepStrictEqual(rewards.lineBody(base, 'popup', 'F {message}'), { ...base, delivery: 'popup' })
+})
+
+test('rust.announce: a popup where PopupNotifications is missing is refused and not retried (D141, R3)', async () => {
+ withCore()
+ const rewards = require('../eventRewards')
+ const client = require('../sidecarClient')
+ const serversDb = require('../model/servers/servers.db')
+ const voice = require('../model/permissions/voice')
+ const saved = { chat: client.chat, getServer: serversDb.getServer, currentFormat: voice.currentFormat }
+ const bodies = []
+
+ serversDb.getServer = async (id) => ({ id, name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 })
+ voice.currentFormat = async () => '[#ff0000]S[/#] {message}'
+ client.chat = async (server, body) => {
+ bodies.push(body)
+ return body.delivery === 'popup'
+ ? { ok: true, data: { kind: 'chat.error', reason: 'popup-unavailable', message: 'PopupNotifications is not loaded on this server' } }
+ : { ok: true, data: { kind: 'chat.ok', said: true } }
+ }
+
+ try {
+ const announce = rewards.ACTIONS.find((a) => a.id === 'rust.announce')
+ assert.strictEqual(announce.version, 1, 'a bump would stop every existing step from dispatching')
+
+ const refused = await announce.perform({ runId: 1, idempotencyKey: 'k', params: { server: 'main', message: 'hi', delivery: 'popup' } })
+ assert.strictEqual(refused.ok, false)
+ assert.strictEqual(refused.retry, false)
+ assert.match(refused.error, /PopupNotifications/)
+ assert.strictEqual(bodies[0].format, undefined, 'a popup carries no format')
+
+ const said = await announce.perform({ runId: 1, idempotencyKey: 'k2', params: { server: 'main', message: 'hi' } })
+ assert.strictEqual(said.ok, true)
+ assert.strictEqual(bodies[1].format, '[#ff0000]S[/#] {message}', 'chat is said in the voice')
+
+ const wrong = await announce.perform({ runId: 1, params: { server: 'main', message: 'hi', delivery: 'carrier pigeon' } })
+ assert.strictEqual(wrong.retry, false)
+ } finally {
+ Object.assign(client, { chat: saved.chat })
+ serversDb.getServer = saved.getServer
+ voice.currentFormat = saved.currentFormat
+ }
+})
+
+test('the news leg: each server’s own delivery, and one voice for every server that chats (D142)', async () => {
+ withCore()
+ const rewards = require('../eventRewards')
+ const client = require('../sidecarClient')
+ const servers = require('../model/servers/servers.model')
+ const voice = require('../model/permissions/voice')
+ const saved = { chat: client.chat, listForPolling: servers.listForPolling, currentFormat: voice.currentFormat }
+ const calls = []
+
+ servers.listForPolling = async () => [
+ { id: 'main', name: 'Main', announceNews: true, newsDelivery: 'chat' },
+ { id: 'pve', name: 'PvE', announceNews: true, newsDelivery: 'popup' },
+ { id: 'off', name: 'Off', announceNews: false, newsDelivery: 'chat' },
+ ]
+ voice.currentFormat = async () => 'V {message}'
+ client.chat = async (server, body) => {
+ calls.push({ server: server.id, body })
+ return { ok: true, data: { kind: 'chat.ok', said: true } }
+ }
+
+ try {
+ const result = await rewards.LEG.dispatch({ id: 3, title: 'Wipe tonight' })
+ assert.deepStrictEqual(calls, [
+ { server: 'main', body: { key: 'news:3', message: 'Wipe tonight', format: 'V {message}' } },
+ { server: 'pve', body: { key: 'news:3', message: 'Wipe tonight', delivery: 'popup' } },
+ ])
+ assert.deepStrictEqual(rewards.LEG.classify(result), { outcome: 'done' })
+ } finally {
+ client.chat = saved.chat
+ servers.listForPolling = saved.listForPolling
+ voice.currentFormat = saved.currentFormat
+ }
+})
+
+test('the delivery option source offers chat and popup, since core has no enum type', async () => {
+ const rewards = require('../eventRewards')
+ const source = rewards.OPTION_SOURCES.find((s) => s.id === 'rust.options.delivery')
+ assert.deepStrictEqual((await source.resolve()).map((r) => r.value), ['chat', 'popup'])
+})
diff --git a/server/test/permissions.test.js b/server/test/permissions.test.js
index 600e133..0ba9f61 100644
--- a/server/test/permissions.test.js
+++ b/server/test/permissions.test.js
@@ -195,7 +195,7 @@ test('a permission the server could not resolve is not recorded as pushed', asyn
await permSync.applyReport({ id: 'main' }, { desired, retire: [], report, bootId: null, wipeId: null })
- const insert = queries.find((q) => q.sql.startsWith('INSERT IGNORE INTO rust_perm_pushed'))
+ const insert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_pushed'))
assert.ok(insert, 'the rows that landed must be recorded')
const recorded = insert.params.join(' ')
@@ -235,7 +235,7 @@ test('a grant the store did not hold after the plugin read it back is not record
await permSync.applyReport({ id: 'main' }, { desired, retire: [], report, bootId: null, wipeId: null })
- const insert = queries.find((q) => q.sql.startsWith('INSERT IGNORE INTO rust_perm_pushed'))
+ const insert = queries.find((q) => q.sql.startsWith('INSERT INTO rust_perm_pushed'))
const recorded = insert.params.join(' ')
assert.ok(recorded.includes('kits.gold'), 'a grant that landed is pushed')
assert.ok(!recorded.includes('zonemanager.zone'), 'a grant that did not land is not, whatever its case')
diff --git a/server/test/visibility.test.js b/server/test/visibility.test.js
index 98603ec..d1592f7 100644
--- a/server/test/visibility.test.js
+++ b/server/test/visibility.test.js
@@ -191,8 +191,8 @@ test('news in game chat is off by default, on or off per server, and validated w
const news = []
db.setServerNews = async (id, on) => news.push({ id, on })
try {
- // A row that never had the column set reads as off.
- assert.deepEqual((await model.describe()).news.servers, [{ id: 'main', name: 'MAIN', enabled: true, on: false }])
+ // A row that never had the column set reads as off, delivered to chat (D142).
+ assert.deepEqual((await model.describe()).news.servers, [{ id: 'main', name: 'MAIN', enabled: true, on: false, delivery: 'chat' }])
const notBoolean = await model.update({ news: { main: 'yes' } })
assert.equal(notBoolean.status, 400)
diff --git a/server/titleSync.js b/server/titleSync.js
new file mode 100644
index 0000000..4d307be
--- /dev/null
+++ b/server/titleSync.js
@@ -0,0 +1,128 @@
+// ── Keeping each game's chat titles equal to the standings (phase 17) ─────
+//
+// The plugin holds minute tallies, not standings (§33.1): who is top of the
+// wipe is known here, in `rust_player_wipe_stats`, and nowhere in the game. So
+// a title is worked out on the site and PUSHED, as one whole set the plugin
+// swaps in, and BetterChat's callback reads what was pushed.
+//
+// The same shape as `permSync.js`, smaller: every tick asks whether the set this
+// server should hold still digests to what it last took, and does nothing when
+// it does. A push happens when:
+//
+// • the standings moved somebody into or out of a title
+// • an operator changed a rule, the mode or N
+// • the game restarted (a new boot id — the plugin holds titles in memory only)
+// • the wipe changed (a new wipe id — the titles rank the current wipe)
+// • the last attempt failed
+//
+// What was sent is remembered in memory, not in a table. Forgetting it on a
+// restart of this module costs one push per server, which the plugin answers by
+// swapping in the same set.
+//
+// A server whose plugin is older than protocol 12 is skipped rather than asked:
+// its sidecar has no `/titles`, and its plugin would not answer.
+
+const core = require('./core')
+
+const client = require('./sidecarClient')
+const servers = require('./model/servers/servers.model')
+const serversDb = require('./model/servers/servers.db')
+const titles = require('./model/titles/titles')
+const model = require('./model/titles/titles.model')
+
+const log = core.logger('titles')
+
+/** How often the loop asks whether anything needs pushing. */
+const TICK_MS = 30 * 1000
+
+/** The first protocol whose plugin holds titles. */
+const TITLES_PROTOCOL = 12
+
+/** Per server: `{ digest, bootId, wipeId, betterChat, count, at }` of the last set a game took. */
+const sent = new Map()
+
+let timer = null
+
+function start() {
+ if (timer) return
+
+ timer = setInterval(() => {
+ tick().catch((err) => log.error('title sync tick failed', { error: err.message }))
+ }, TICK_MS)
+
+ if (timer.unref) timer.unref()
+}
+
+function stop() {
+ if (!timer) return
+
+ clearInterval(timer)
+ timer = null
+}
+
+async function tick() {
+ const [rows, states] = await Promise.all([servers.listForPolling(), serversDb.listState()])
+ const stateById = new Map(states.map((s) => [s.serverId, s]))
+
+ await Promise.allSettled(rows.map((server) => syncOne(server, stateById.get(server.id) || null)))
+}
+
+/** Whether this server needs the set again, and why — worth a log line either way. */
+function reasonToPush({ digest, state, last }) {
+ if (!last) return 'first'
+ if (digest !== last.digest) return 'changed'
+ if (state.bootId && state.bootId !== last.bootId) return 'restart'
+ if (state.wipeId && state.wipeId !== last.wipeId) return 'wipe'
+ return null
+}
+
+async function syncOne(server, state) {
+ if (!state || !state.online || Number(state.protocol) < TITLES_PROTOCOL) return null
+
+ const held = await model.heldFor(server.id, { wipeId: state.wipeId || null })
+ const set = titles.wireSet(held)
+ const digest = titles.digest(set)
+ const reason = reasonToPush({ digest, state, last: sent.get(server.id) })
+
+ if (!reason) return null
+
+ const result = await client.titles(server, { setId: digest, titles: set })
+ const data = (result && result.data) || {}
+
+ if (!result.ok || data.kind !== 'titles.ok') {
+ // Forgotten, so the next tick tries again whatever the digest says.
+ sent.delete(server.id)
+ log.warn('title push failed', {
+ server: server.id,
+ reason,
+ status: result.status,
+ ...(data.kind === 'titles.error' ? { refused: data.reason, message: data.message } : {}),
+ })
+ return 'failed'
+ }
+
+ sent.set(server.id, {
+ digest,
+ bootId: state.bootId || null,
+ wipeId: state.wipeId || null,
+ betterChat: data.betterChat === true,
+ count: Number(data.count) || 0,
+ at: new Date().toISOString(),
+ })
+
+ log.info('titles pushed', { server: server.id, reason, count: data.count, betterChat: data.betterChat === true })
+ return 'ok'
+}
+
+/** What the last push to one server found, for the admin page, or null. */
+function lastPush(serverId) {
+ const last = sent.get(serverId)
+ return last ? { count: last.count, betterChat: last.betterChat, at: last.at } : null
+}
+
+/** Forget one server's last push, so the next tick sends its set whatever the digest says. */
+function invalidate(serverId) {
+ sent.delete(serverId)
+}
+
+module.exports = { TICK_MS, TITLES_PROTOCOL, start, stop, tick, syncOne, reasonToPush, lastPush, invalidate }
diff --git a/swagger-fragment.json b/swagger-fragment.json
index 3aae9a5..dea0625 100644
--- a/swagger-fragment.json
+++ b/swagger-fragment.json
@@ -164,7 +164,7 @@
"Admin · Rust"
],
"summary": "The whole permission model",
- "description": "Groups with their permissions and members, direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server.",
+ "description": "Groups with their permissions, members and BetterChat style (`chat`, or null), direct grants, the drift each server reported, the option source of registered permission names, and the sync state of every configured server. A drift row of kind `chat-field` is a style field somebody changed in game: `subject` is the group, `object` the field and `detail` what the game holds. `chatFields` lists the twelve BetterChat fields with their types and defaults, for the style editor.",
"responses": {
"200": {
"description": "The authored model and what each game reported",
@@ -212,7 +212,7 @@
"Admin · Rust"
],
"summary": "Adopt a hand edit",
- "description": "Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link.",
+ "description": "Records a grant or membership somebody made in game as one the site authors, so it stops being reported and starts being maintained. It needs a website account holding that Steam id; without one there is nobody to author it against, and the answer is to revoke it or to ask the player to link. For a `chat-field` row it copies the game’s value into the group’s style — which every server in the group’s scope is then pushed — and answers 409 when the group has no style or the value is not one the site accepts.",
"parameters": [
{
"name": "id",
@@ -248,7 +248,7 @@
"Admin · Rust"
],
"summary": "Revoke a hand edit",
- "description": "Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own.",
+ "description": "Queues the removal rather than performing it: a server that is down keeps the instruction until it comes back. This is the only way the site removes something it did not put there — a sync never does it on its own. For a `chat-field` row it puts the site’s value back over the hand edit on the next sync.",
"parameters": [
{
"name": "id",
@@ -351,7 +351,7 @@
"Admin · Rust"
],
"summary": "Create or update a permission group",
- "description": "Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it.",
+ "description": "Writes the group and the permissions it carries in one request, because they are one idea on the form. `scope` is a server id or `*` for the whole fleet. The group is mirrored into each in-scope game as a real group, so third-party plugins that read group membership see it. `chat` is the group’s BetterChat style: all twelve fields (`Priority`, `Title`, `TitleColor`, `TitleSize`, `TitleHidden`, `TitleHiddenIfNotPrimary`, `UsernameColor`, `UsernameSize`, `MessageColor`, `MessageSize`, `ChatFormat`, `ConsoleFormat`), each as text; `null` removes the style, which removes the group from BetterChat on the next sync; absent leaves it alone. A format must hold `{Message}` exactly once. A 400 carries one sentence per problem in `errors`.",
"parameters": [
{
"name": "name",
@@ -382,6 +382,9 @@
"scope": {
"example": "any"
},
+ "chat": {
+ "example": "any"
+ },
"title": {
"example": "any"
},
@@ -627,6 +630,44 @@
}
}
},
+ "/api/v1/admin/rust/servers/{id}/integrations": {
+ "get": {
+ "tags": [
+ "Admin · Rust"
+ ],
+ "summary": "Which optional mods a server has loaded",
+ "description": "Asks the game, live, whether BetterChat and PopupNotifications are loaded, and which versions. Both are optional: without BetterChat titles and group styles wait for it, and without PopupNotifications a popup is refused with a reason. `integrations` is null when the game could not be asked or its plugin is older than protocol 12, and `status` then says why.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The server’s slug"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "What the server has loaded",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustIntegrations"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "No such server, or it is disabled"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ }
+ },
"/api/v1/admin/rust/servers/{id}/map/fetch": {
"post": {
"tags": [
@@ -738,6 +779,50 @@
}
}
},
+ "/api/v1/admin/rust/servers/{id}/titles": {
+ "put": {
+ "tags": [
+ "Admin · Rust"
+ ],
+ "summary": "Set a server’s chat titles",
+ "description": "Replaces the server’s title rules and how many titles a player shows. A rule ranks the CURRENT wipe by `kills`, `npckills` or `playtime` and gives its top N (1–10) a title; only a stat above zero counts, so a fresh wipe gives no titles. Rules are in precedence order, at most ten. `mode` is `first` (the first rule a player meets), `all`, or `upto` `max` (1–5). The titles are shown in game by BetterChat when it is loaded — they are pushed whether or not it is, and it shows them as soon as it is — and on the web and app leaderboards. A 400 carries one sentence per problem in `errors`.",
+ "parameters": [
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ },
+ "description": "The server’s slug"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Saved; answers the server’s settings as stored"
+ },
+ "400": {
+ "description": "A rule, the mode or N is not valid"
+ },
+ "404": {
+ "description": "No such server"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustTitleSettings"
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/rust/visibility": {
"get": {
"tags": [
@@ -766,7 +851,7 @@
"Admin · Rust"
],
"summary": "Change who may see who is online, who may see a clan roster, or which servers say news in chat",
- "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.",
+ "description": "Sets the presence fleet default, one or more server overrides, the clan roster audience, the per-server news-in-chat switches, or any of them together. A server set to `null` follows the fleet default again. `news` maps a server id to `true` or `false`: whether a published news post is also said in the in-game chat of that server (off by default). `newsDelivery` maps a server id to `chat` or `popup`: where that post goes — a popup needs PopupNotifications on the server, and one without it refuses the post with a reason. Validated whole before anything is written: a request naming a server that does not exist changes nothing. Widening the clan roster audience also shows which members are online to that audience, because a roster row carries it. `map` is `{ fleet, servers }`: `fleet` maps a layer (`world`, `events`, `players`, `bases`) to an audience and `mates` to true or false; `servers` maps a server id to the same shape, where null follows the fleet. The players layer never shows more than who may see who is online, whatever it is set to.",
"responses": {
"200": {
"description": "Saved; answers the new state",
@@ -800,6 +885,71 @@
}
}
},
+ "/api/v1/admin/rust/voice": {
+ "get": {
+ "tags": [
+ "Admin · Rust"
+ ],
+ "summary": "The voice announcements are said in",
+ "description": "The permission group whose BetterChat style news and `rust.announce` lines are said in, or empty for plain chat, and every group that could be one. The line is composed on the site and said by the bridge plugin with no player as its sender, so it works whether or not BetterChat is loaded.",
+ "responses": {
+ "200": {
+ "description": "The voice and the groups that could be one",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustVoice"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ }
+ },
+ "put": {
+ "tags": [
+ "Admin · Rust"
+ ],
+ "summary": "Choose the voice announcements are said in",
+ "description": "`group` is a permission group with a chat style, or empty for plain chat. A group without a style is refused.",
+ "responses": {
+ "200": {
+ "description": "Saved; answers the new state",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/RustVoice"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "That group has no chat style"
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "group": {
+ "type": "string",
+ "example": "staff"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/api/v1/admin/users/{id}/rust/links": {
"get": {
"tags": [
@@ -1490,7 +1640,7 @@
"Public · Rust"
],
"summary": "The leaderboard for one Rust server",
- "description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online.",
+ "description": "Per-wipe when `wipe` is given, all-time otherwise. All-time is the per-wipe rows summed rather than a second set of counters, so a wipe splits a player’s history without ending it. `lastSeen` is withheld below the operator’s presence audience: a gather tally refreshes it every minute a player is on, so it would name who is online. Each row carries `titles`: the chat titles that player holds now, as `[{ text, color }]` in the order the game shows them, and an empty list for a player with none. Titles rank the CURRENT wipe whichever wipe the page asks for, and are set by the operator’s rules.",
"parameters": [
{
"name": "id",
@@ -2600,6 +2750,485 @@
},
"schedule": {
"$ref": "#/components/schemas/RustWipeSchedule"
+ },
+ "titles": {
+ "$ref": "#/components/schemas/RustTitleSettings"
+ },
+ "titlePush": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "What the last push of titles to this server found, since this site last started: how many players hold one and whether BetterChat was there to show them. Null before the first push."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "count": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "example": {
+ "type": "number",
+ "example": 4
+ }
+ }
+ },
+ "betterChat": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "at": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "format": {
+ "type": "string",
+ "example": "date-time"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustTitleSettings": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "One server’s chat titles (phase 17, D135–D136): rules that rank the current wipe, in precedence order, and how many titles a player shows."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "mode": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "first",
+ "all",
+ "upto"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": {
+ "type": "string",
+ "example": "`first` shows the first rule a player meets, `all` every one, `upto` at most `max`."
+ },
+ "example": {
+ "type": "string",
+ "example": "first"
+ }
+ }
+ },
+ "max": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "minimum": {
+ "type": "number",
+ "example": 1
+ },
+ "maximum": {
+ "type": "number",
+ "example": 5
+ },
+ "example": {
+ "type": "number",
+ "example": 2
+ }
+ }
+ },
+ "rules": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "maxItems": {
+ "type": "number",
+ "example": 10
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "stat": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "kills",
+ "npckills",
+ "playtime"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "example": {
+ "type": "string",
+ "example": "kills"
+ }
+ }
+ },
+ "topN": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "integer"
+ },
+ "minimum": {
+ "type": "number",
+ "example": 1
+ },
+ "maximum": {
+ "type": "number",
+ "example": 10
+ },
+ "example": {
+ "type": "number",
+ "example": 1
+ }
+ }
+ },
+ "text": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "maxLength": {
+ "type": "number",
+ "example": 24
+ },
+ "description": {
+ "type": "string",
+ "example": "The title. `[`, `]`, `<`, `>`, `{` and `}` are taken out on save, so a title carries no markup of its own."
+ },
+ "example": {
+ "type": "string",
+ "example": "Top Killer"
+ }
+ }
+ },
+ "color": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "#ff8800"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustIntegrations": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "Which optional mods a server has loaded right now (GET /admin/rust/servers/{id}/integrations), read live from the game."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "ok": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "status": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "The sidecar client’s one-word status when the game could not be asked."
+ },
+ "example": {
+ "type": "string",
+ "example": "ok"
+ }
+ }
+ },
+ "integrations": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "Null when the game could not be asked, or its plugin is older than protocol 12."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "betterChat": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "loaded": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": true
+ }
+ }
+ },
+ "version": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "5.2.15"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "popupNotifications": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "loaded": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "boolean"
+ },
+ "example": {
+ "type": "boolean",
+ "example": false
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "RustVoice": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "The voice news and `rust.announce` lines are said in (D140): a styled permission group, or none for plain chat."
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "voice": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "A group name, or empty for plain chat."
+ },
+ "example": {
+ "type": "string",
+ "example": "staff"
+ }
+ }
+ },
+ "options": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "Every group that has a chat style — the only groups that can be a voice."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "group": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "staff"
+ }
+ }
+ },
+ "title": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "[Staff]"
+ }
+ }
+ },
+ "format": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "description": {
+ "type": "string",
+ "example": "The line the voice makes, with `{message}` where the words go."
+ },
+ "example": {
+ "type": "string",
+ "example": "[#55aaff][+15][Staff][/+][/#] [#ffffff][+15]{message}[/+][/#]"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
@@ -3547,6 +4176,49 @@
}
}
},
+ "chat": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "The group’s BetterChat style — all twelve fields as text — or null for a group without one (D138)."
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ }
+ }
+ },
+ "example": {
+ "type": "object",
+ "properties": {
+ "Title": {
+ "type": "string",
+ "example": "[VIP]"
+ },
+ "TitleColor": {
+ "type": "string",
+ "example": "#ffaa55"
+ },
+ "ChatFormat": {
+ "type": "string",
+ "example": "{Title} {Username}: {Message}"
+ }
+ }
+ }
+ }
+ },
"members": {
"type": "object",
"properties": {
@@ -3849,6 +4521,90 @@
}
}
},
+ "chatFields": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "array"
+ },
+ "description": {
+ "type": "string",
+ "example": "The twelve BetterChat group fields a style carries, with each one’s type and BetterChat’s default, for the style editor."
+ },
+ "items": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "example": {
+ "type": "string",
+ "example": "TitleColor"
+ }
+ }
+ },
+ "type": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "int",
+ "title",
+ "color",
+ "size",
+ "bool",
+ "format"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "example": {
+ "type": "string",
+ "example": "color"
+ }
+ }
+ },
+ "default": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "example": {
+ "type": "string",
+ "example": "#55aaff"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"drift": {
"type": "object",
"properties": {
@@ -3905,7 +4661,7 @@
},
"description": {
"type": "string",
- "example": "One of `grant`, `member`, `group-permission`."
+ "example": "One of `grant`, `member`, `group-permission`, or `chat-field` for a style field changed in game."
},
"example": {
"type": "string",
@@ -3913,6 +4669,27 @@
}
}
},
+ "detail": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "nullable": {
+ "type": "boolean",
+ "example": true
+ },
+ "description": {
+ "type": "string",
+ "example": "For `chat-field`, the value the game holds now. Null for every other kind."
+ },
+ "example": {
+ "type": "string",
+ "example": "#ff0000"
+ }
+ }
+ },
"subject": {
"type": "object",
"properties": {
@@ -5490,6 +6267,33 @@
"example": false
}
}
+ },
+ "delivery": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "chat",
+ "popup"
+ ],
+ "items": {
+ "type": "string"
+ }
+ },
+ "description": {
+ "type": "string",
+ "example": "Where the post goes when `on`: chat, or a popup — which needs PopupNotifications on that server (D142)."
+ },
+ "example": {
+ "type": "string",
+ "example": "chat"
+ }
+ }
}
}
}
@@ -7289,6 +8093,47 @@
}
}
},
+ "newsDelivery": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "object"
+ },
+ "description": {
+ "type": "string",
+ "example": "A server id to where a news post goes on it: `chat` or `popup` (D142)."
+ },
+ "additionalProperties": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "example": "string"
+ },
+ "enum": {
+ "type": "array",
+ "example": [
+ "chat",
+ "popup"
+ ],
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "example": {
+ "type": "object",
+ "properties": {
+ "main": {
+ "type": "string",
+ "example": "popup"
+ }
+ }
+ }
+ }
+ },
"map": {
"type": "object",
"properties": {