Files
Module-Rust/server/engagement/triggers.js
wtclaude 648d3fd2e1
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
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
2026-09-23 13:37:05 -05:00

382 lines
16 KiB
JavaScript

// ── What can happen, as core's engagement engine is told it ───────────────
//
// The payload contracts behind every notification this module can cause
// (MODULE_API.md §2.4, `registerEventTriggers`; PLAN.md §25). A trigger says
// what an event IS, what a template may interpolate, and — the part that is a
// security boundary — the widest audience a rule on it may EVER be given.
//
// ── The ceiling is containment, not size ──────────────────────────────────
//
// `owner` is not a small `staff`, and `staff` does not permit `owner`. For the
// raid alert "one person" is the person whose base it was; for a ban it is
// nobody outside the staff room. Each ceiling below is chosen against that
// lattice and not against a ladder, and core refuses a rule that widens one.
//
// ── What no variable here carries, on purpose ─────────────────────────────
//
// • An IP address. The login and ban frames carry one; the triggers do not,
// so no template an operator writes can put an address in a mail. The
// admin feed still shows it, to staff, where it is useful.
// • The raider (D66). The raid alert says what was destroyed, where and
// when. Who did it is gameplay intelligence the game does not hand the
// victim, and a variable that is not declared cannot be interpolated.
// • A Steam id other than the subject's own.
//
// ── Why `subjectKey` is what it is ────────────────────────────────────────
//
// Core's cooldown is per (rule, user, subject, channel). So the subject is the
// thing a recipient should hear about once per cooldown: a BUILDING for a raid
// (however many walls fall), a SERVER for a broadcast (however often it
// bounces), a CLAN for a membership change. A subject that changed every firing
// — a boot id, a timestamp — would make every cooldown a no-op.
//
// ── `version` ──────────────────────────────────────────────────────────────
//
// The prop-schema version a template records it was authored against. Bump one
// on a rename or a type change, never for a label.
const ID = 'rust'
/** Site-relative paths, built the way the client registers them. */
const PATHS = {
servers: `/${ID}`,
account: `/player/${ID}`,
}
// A server id is VARCHAR(64) of the operator's choosing, and a clan's
// `externalId` is `<serverId>:<clanId>:<createdMs>`. Core validates a `url`
// variable against a character class with no `:` in it, so every id that goes
// into a path is percent-encoded — without it the clan link would be dropped at
// emit in production, silently, for every clan there is.
const serverPath = (serverId) => `/${ID}/servers/${encodeURIComponent(serverId)}`
const leaderboardPath = (serverId) => `${serverPath(serverId)}?tab=leaderboard`
const clanPath = (externalId) => `/${ID}/clans/${encodeURIComponent(externalId)}`
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.' },
{ name: 'server', type: 'string', required: true, example: 'Runic Gateway | Main',
description: 'The server\'s display name.' },
{ name: 'serverUrl', type: 'url', required: false, example: '/rust/servers/main',
description: 'Site-relative path to the server\'s page.' },
]
const CLAN = [
{ name: 'clanKey', type: 'string', required: true, example: 'main:12:1790142840000',
description: 'The clan\'s stable identity. The cooldown subject; not meant for display.' },
{ name: 'clan', type: 'string', required: true, example: 'The Rust Belt',
description: 'The clan\'s name.' },
{ name: 'clanUrl', type: 'url', required: false, example: '/rust/clans/main%3A12%3A1790142840000',
description: 'Site-relative path to the clan\'s page.' },
]
// ── The raid alert ─────────────────────────────────────────────────────────
const RAID = {
id: 'rust.base.destroyed',
label: 'Your base was raided',
description:
'Part of a base you are authorised on was destroyed by another player: a wall, a door, ' +
'an external wall or gate, or the tool cupboard.',
kind: 'event',
// One per base per cooldown, however many walls fall. The building is the
// tool cupboard's id — the game's own answer to "which base is this".
subjectKey: 'building',
// One emit per authorised, linked person, each with `ownerUserId` set (D59).
// `owner` is the ceiling AND the default: there is nobody else this may reach.
audience: 'owner',
ceiling: 'owner',
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',
description: 'What was destroyed: "building block", "door", "external wall" or "tool cupboard".' },
{ name: 'grid', type: 'string', required: false, example: 'H7',
description: 'The map grid square. Absent when the server could not work one out.' },
// A FRAGMENT, for use inside a sentence. An unset optional interpolates to
// the empty string, so "your door in {{grid}} was destroyed" reads "your
// door in was destroyed" when the grid is unknown; this carries its own
// leading space and vanishes cleanly instead.
{ name: 'atGrid', type: 'string', required: false, example: ' in H7',
description: 'Sentence fragment: " in H7" with its own leading space, or nothing when the grid is unknown.' },
{ name: 'ownerOnline', type: 'boolean', required: true, example: false,
description: 'Whether YOU were online when it happened. The seeded rule alerts only when this is false.' },
],
}
// ── Server lifecycle ───────────────────────────────────────────────────────
//
// `everyone` because a server being up is what a server page already says to
// anyone. The DEFAULT is `subscribers` — the people who asked — and an operator
// widens deliberately.
const BROADCASTS = [
{
id: 'rust.wipe.started',
label: 'A server wiped',
description: 'A server started a new wipe: a fresh map, and everything built on the old one gone.',
kind: 'event',
subjectKey: 'serverId',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [
...SERVER,
...HEADLINE,
{ name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234',
description: 'The new wipe\'s identity.' },
],
},
{
id: 'rust.server.online',
label: 'A server came online',
description: 'A server\'s game started, or came back after being unreachable.',
kind: 'event',
subjectKey: 'serverId',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE],
},
{
id: 'rust.server.offline',
label: 'A server went offline',
description: 'A server\'s game stopped, crashed, or stopped talking to the website.',
kind: 'event',
subjectKey: 'serverId',
audience: 'subscribers',
ceiling: 'everyone',
version: V1,
variables: [...SERVER, ...HEADLINE],
},
{
id: 'rust.leaderboard.topped',
label: 'A new kills leader',
description: 'Somebody new leads the current wipe\'s kills on a server.',
kind: 'event',
subjectKey: 'serverId',
audience: 'subscribers',
ceiling: 'everyone',
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,
description: 'Their kills this wipe.' },
{ name: 'leaderboardUrl', type: 'url', required: false, example: '/rust/servers/main?tab=leaderboard',
description: 'Site-relative path to the server\'s leaderboard.' },
],
},
]
// ── The player's own account ───────────────────────────────────────────────
const ACCOUNT = {
id: 'rust.player.linked',
label: 'A Steam account was linked',
description: 'A Steam account was linked to your website account with an in-game code.',
kind: 'event',
subjectKey: 'steamId',
// PLAN.md §10 said `self`; core has no such ceiling (§25.1). `owner` with the
// linking user as `ownerUserId` is the value that exists and means the same.
audience: 'owner',
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',
description: 'The in-game name the game reported when it was linked.' },
{ name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
description: 'Site-relative path to your Rust account page.' },
],
}
// ── Clans ──────────────────────────────────────────────────────────────────
//
// `members` ceiling — clan membership is the clan's business (D49). Recipients
// travel on the envelope as `recipientUserIds`, because "the clan this was
// about" is a different answer every firing and cannot be a saved audience.
//
// No `rust.clan.member.added`: core already fires `team.member.joined` for our
// clans through the Team sync, and a second trigger would notify twice (D64).
const CLANS = [
{
id: 'rust.clan.member.left',
label: 'Someone left your clan',
description: 'A member left a clan you are in.',
kind: 'event',
subjectKey: 'clanKey',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'member', type: 'string', required: false, example: 'Darrow',
description: 'Who left.' },
],
},
{
id: 'rust.clan.member.kicked',
label: 'Someone was removed from your clan',
description: 'A member was removed from a clan you are in — or you were.',
kind: 'event',
subjectKey: 'clanKey',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'member', type: 'string', required: false, example: 'Darrow',
description: 'Who was removed.' },
{ name: 'by', type: 'string', required: false, example: 'Marisol',
description: 'Who removed them.' },
],
},
{
id: 'rust.clan.disbanded',
label: 'Your clan was disbanded',
description: 'A clan you were in was disbanded.',
kind: 'event',
subjectKey: 'clanKey',
audience: 'members',
ceiling: 'members',
version: V1,
variables: [
...CLAN,
...SERVER,
...HEADLINE,
{ name: 'by', type: 'string', required: false, example: 'Marisol',
description: 'Who disbanded it.' },
],
},
]
// ── Moderation — staff, and never wider ────────────────────────────────────
const MODERATION = [
{
id: 'rust.player.reported',
label: 'A player was reported',
description: 'A player filed an in-game report against another.',
kind: 'event',
subjectKey: 'steamId',
audience: 'staff',
ceiling: 'staff',
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',
description: 'The reported player\'s name.' },
{ name: 'reporter', type: 'string', required: false, example: 'Marisol',
description: 'Who filed the report.' },
{ name: 'reportType', type: 'string', required: false, example: 'cheat',
description: 'The category the reporter chose.' },
{ name: 'topic', type: 'string', required: false, example: 'Aimbot at the dome',
description: 'The report\'s subject line.' },
{ name: 'message', type: 'string', required: false, example: 'Headshots through two walls.',
description: 'The report\'s text.' },
],
},
{
id: 'rust.player.banned',
label: 'A player was banned',
description: 'A player was banned on a server.',
kind: 'event',
subjectKey: 'steamId',
audience: 'staff',
ceiling: 'staff',
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',
description: 'The banned player\'s name.' },
{ name: 'reason', type: 'string', required: false, example: 'Cheating',
description: 'The reason given.' },
],
},
{
id: 'rust.player.unbanned',
label: 'A player was unbanned',
description: 'A ban on a server was lifted.',
kind: 'event',
subjectKey: 'steamId',
audience: 'staff',
ceiling: 'staff',
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',
description: 'The player\'s name.' },
],
},
{
id: 'rust.login.denied',
label: 'A login was not approved',
description:
'Somebody tried to join a server and was not let in within a minute: a ban, a failed ' +
'authentication, or a player who gave up while connecting.',
kind: 'event',
subjectKey: 'steamId',
audience: 'staff',
ceiling: 'staff',
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',
description: 'The name it connected with.' },
{ name: 'attemptedAt', type: 'datetime', required: true, example: '2026-09-23T03:10:00Z',
description: 'When the attempt was made.' },
],
},
]
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, ...CLANS, ...MODERATION])
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
module.exports = { TRIGGERS, TRIGGER_IDS, PATHS, serverPath, leaderboardPath, clanPath }