fix(rust): every notice says which server, clan or player it is about
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user