fix(rust): every notice says which server, clan or player it is about
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / frozen-manifest (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 7m48s

The live walk rendered a generic in-app notice as "A server came online.
A server's game started..." Core's structural projection falls back to
the trigger's label and description when the payload has no title, and
on a multi-server site that never says which server. Core's rule is that
the payload wins, so every trigger now declares `title` and `intro`, and
the emitter writes the sentence ("Oxide rig is online"). An operator's
own template can still ignore it and use the parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 13:37:05 -05:00
parent 285db0baa7
commit 648d3fd2e1
4 changed files with 302 additions and 3 deletions

View File

@@ -33,6 +33,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "building",
"type": "string",
@@ -122,6 +136,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "by",
"type": "string",
@@ -183,6 +211,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "member",
"type": "string",
@@ -251,6 +293,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "member",
"type": "string",
@@ -291,6 +347,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "leader",
"type": "string",
@@ -345,6 +415,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "steamId",
"type": "string",
@@ -399,6 +483,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "steamId",
"type": "string",
@@ -432,6 +530,20 @@
"ceiling": "owner",
"version": 1,
"variables": [
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "steamId",
"type": "string",
@@ -486,6 +598,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "steamId",
"type": "string",
@@ -561,6 +687,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "steamId",
"type": "string",
@@ -607,6 +747,20 @@
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
}
]
},
@@ -640,6 +794,20 @@
"required": false,
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
}
]
},
@@ -674,6 +842,20 @@
"example": "/rust/servers/main",
"description": "Site-relative path to the server's page."
},
{
"name": "title",
"type": "string",
"required": false,
"example": "Main is back online",
"description": "A one-line headline naming what happened and where. Core generic bodies use it as the title."
},
{
"name": "intro",
"type": "string",
"required": false,
"example": "Main is back up and taking players.",
"description": "One sentence of detail. Core generic bodies use it as the body."
},
{
"name": "wipeId",
"type": "string",

View File

@@ -94,6 +94,77 @@ function serverVars(server) {
return { serverId, server: server.name || serverId, serverUrl: serverPath(serverId) }
}
/**
* The headline every trigger carries (`triggers.js` HEADLINE).
*
* Core's generic bodies fall back to a trigger's LABEL and DESCRIPTION when the
* payload has no `title`/`intro`, and on a multi-server site that fallback says
* "A server came online" without ever saying which. So the sentence is written
* here, from the payload, and core renders it. Plain register, no conditionals:
* a missing part falls back to a neutral word rather than leaving a hole.
*/
const HEADLINES = Object.freeze({
'rust.base.destroyed': (d) => ({
title: `Your base on ${d.server} is being raided`,
intro: `A ${d.structure} was destroyed${d.atGrid || ''} on ${d.server}.`,
}),
'rust.wipe.started': (d) => ({
title: `${d.server} has wiped`,
intro: `A new wipe has started on ${d.server}: a fresh map, and a fresh start for everyone.`,
}),
'rust.server.online': (d) => ({
title: `${d.server} is online`,
intro: `${d.server} is back up and talking to the website.`,
}),
'rust.server.offline': (d) => ({
title: `${d.server} is offline`,
intro: `${d.server} stopped, or stopped talking to the website.`,
}),
'rust.leaderboard.topped': (d) => ({
title: `${d.leader} leads ${d.server}`,
intro: `${d.leader} now leads this wipe's kills on ${d.server}, with ${d.kills}.`,
}),
'rust.player.linked': (d) => ({
title: 'A Steam account was linked to your account',
intro: `The Steam account ${d.player || d.steamId} was linked with an in-game code. `
+ 'If that was not you, unlink it from your Rust account page.',
}),
'rust.clan.member.left': (d) => ({
title: `${d.member || 'A member'} left ${d.clan}`,
intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`,
}),
'rust.clan.member.kicked': (d) => ({
title: `${d.member || 'A member'} was removed from ${d.clan}`,
intro: `${d.by || 'A clan leader'} removed ${d.member || 'a member'} from ${d.clan} on ${d.server}.`,
}),
'rust.clan.disbanded': (d) => ({
title: `${d.clan} was disbanded`,
intro: `${d.by || 'A clan leader'} disbanded ${d.clan} on ${d.server}.`,
}),
'rust.player.reported': (d) => ({
title: `${d.player || d.steamId} was reported on ${d.server}`,
intro: `${d.reporter || 'A player'} reported ${d.player || d.steamId}`
+ `${d.reportType ? ` (${d.reportType})` : ''}${d.topic ? `: ${d.topic}` : '.'}`,
}),
'rust.player.banned': (d) => ({
title: `${d.player || d.steamId} was banned on ${d.server}`,
intro: d.reason ? `Reason given: ${d.reason}` : 'No reason was given.',
}),
'rust.player.unbanned': (d) => ({
title: `${d.player || d.steamId} was unbanned on ${d.server}`,
intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`,
}),
'rust.login.denied': (d) => ({
title: `A login to ${d.server} was not approved`,
intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`,
}),
})
function headline(triggerId, data) {
const make = HEADLINES[triggerId]
return make ? make(data || {}) : {}
}
/**
* Hands one event to core. Never throws.
*
@@ -104,7 +175,8 @@ function serverVars(server) {
*/
function fire(triggerId, envelope) {
try {
core.emit(triggerId, envelope)
const data = envelope.data || {}
core.emit(triggerId, { ...envelope, data: { ...headline(triggerId, data), ...data } })
return true
} catch (err) {
log.error('core refused an emit', { trigger: triggerId, error: err.message })
@@ -482,6 +554,7 @@ module.exports = {
linked,
reset,
dedupeKey,
headline,
stillNews,
BROADCAST_MAX_AGE_MS,
PERSONAL_MAX_AGE_MS,

View File

@@ -56,6 +56,22 @@ const V1 = 1
// ── Shared variables ───────────────────────────────────────────────────────
// **Every trigger carries its own headline.** Most rules here point at core's
// generic `notify.event` / `inapp.event`, and core's structural projection fills
// `title` and `intro` from the trigger's LABEL and DESCRIPTION only when the
// payload does not define them — "the payload wins, the projection fills gaps"
// (ENGAGEMENT.md §4.6.1). Without these two, the phase-10 walk rendered a
// multi-server site's notice as "A server came online. A server's game
// started…" — true, and useless, because it never said which. The emitter
// writes the sentence (`emit.js` `headline`); an operator's own template can
// still ignore it and interpolate the parts.
const HEADLINE = [
{ name: 'title', type: 'string', required: false, example: 'Main is back online',
description: 'A one-line headline naming what happened and where. Core generic bodies use it as the title.' },
{ name: 'intro', type: 'string', required: false, example: 'Main is back up and taking players.',
description: 'One sentence of detail. Core generic bodies use it as the body.' },
]
const SERVER = [
{ name: 'serverId', type: 'string', required: true, example: 'main',
description: 'The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts.' },
@@ -93,6 +109,7 @@ const RAID = {
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'building', type: 'string', required: true, example: '8113',
description: 'The base, as the id of its tool cupboard. The cooldown subject.' },
{ name: 'structure', type: 'string', required: true, example: 'door',
@@ -128,6 +145,7 @@ const BROADCASTS = [
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234',
description: 'The new wipe\'s identity.' },
],
@@ -141,7 +159,7 @@ const BROADCASTS = [
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER],
variables: [...SERVER, ...HEADLINE],
},
{
id: 'rust.server.offline',
@@ -152,7 +170,7 @@ const BROADCASTS = [
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER],
variables: [...SERVER, ...HEADLINE],
},
{
id: 'rust.leaderboard.topped',
@@ -165,6 +183,7 @@ const BROADCASTS = [
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'leader', type: 'string', required: true, example: 'Marisol',
description: 'The new leader\'s in-game name.' },
{ name: 'kills', type: 'int', required: true, example: 42,
@@ -189,6 +208,7 @@ const ACCOUNT = {
ceiling: 'owner',
version: V1,
variables: [
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000001',
description: 'The Steam account that was linked. Also the cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Marisol',
@@ -220,6 +240,7 @@ const CLANS = [
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'member', type: 'string', required: false, example: 'Darrow',
description: 'Who left.' },
],
@@ -236,6 +257,7 @@ const CLANS = [
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'member', type: 'string', required: false, example: 'Darrow',
description: 'Who was removed.' },
{ name: 'by', type: 'string', required: false, example: 'Marisol',
@@ -254,6 +276,7 @@ const CLANS = [
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'by', type: 'string', required: false, example: 'Marisol',
description: 'Who disbanded it.' },
],
@@ -274,6 +297,7 @@ const MODERATION = [
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The reported player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
@@ -299,6 +323,7 @@ const MODERATION = [
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The banned player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
@@ -318,6 +343,7 @@ const MODERATION = [
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The player\'s Steam id. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',
@@ -337,6 +363,7 @@ const MODERATION = [
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
description: 'The Steam id that tried to connect. The cooldown subject.' },
{ name: 'player', type: 'string', required: false, example: 'Darrow',

View File

@@ -152,6 +152,23 @@ test('a clan path survives core\'s url check, colons and all', () => {
assert.match(leaderboardPath('main'), RELATIVE_URL)
})
test('every trigger names what happened and where, even with its optionals missing', () => {
// The walk found a multi-server site's generic notice reading "A server came
// online" — core's projection falls back to the LABEL when the payload carries
// no `title`. Every trigger therefore declares its own, and the emitter writes
// it; a headline that printed "undefined" would be worse than the label.
const { TRIGGERS } = require('../engagement/triggers')
const { headline } = require('../engagement/emit')
for (const t of TRIGGERS) {
const minimal = Object.fromEntries(t.variables.filter((v) => v.required).map((v) => [v.name, v.example]))
const h = headline(t.id, minimal)
assert.ok(h.title && h.intro, `${t.id} has a headline`)
assert.ok(!/undefined|null/.test(h.title + h.intro), `${t.id}: ${h.title} / ${h.intro}`)
}
const online = headline('rust.server.online', { server: 'EU 2' })
assert.match(online.title, /EU 2/, 'the notice says WHICH server')
})
// ── The seeds ──────────────────────────────────────────────────────────────
test('every seeded rule is ours, off, and names bodies that exist', () => {