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
382 lines
16 KiB
JavaScript
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 }
|