From 648d3fd2e1af78fdcf1a7620580792caa4181492 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 23 Sep 2026 13:37:05 -0500 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- engagement-triggers.json | 182 +++++++++++++++++++++++++++++++++ server/engagement/emit.js | 75 +++++++++++++- server/engagement/triggers.js | 31 +++++- server/test/engagement.test.js | 17 +++ 4 files changed, 302 insertions(+), 3 deletions(-) diff --git a/engagement-triggers.json b/engagement-triggers.json index bc2b357..859e228 100644 --- a/engagement-triggers.json +++ b/engagement-triggers.json @@ -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", diff --git a/server/engagement/emit.js b/server/engagement/emit.js index 89acc9c..9021220 100644 --- a/server/engagement/emit.js +++ b/server/engagement/emit.js @@ -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, diff --git a/server/engagement/triggers.js b/server/engagement/triggers.js index eeb9cdb..ea8485c 100644 --- a/server/engagement/triggers.js +++ b/server/engagement/triggers.js @@ -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', diff --git a/server/test/engagement.test.js b/server/test/engagement.test.js index 9fa0007..6445e01 100644 --- a/server/test/engagement.test.js +++ b/server/test/engagement.test.js @@ -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', () => {