Files
Module-Rust/server/engagement/seeds.js
wtclaude cc185db26b feat(rust): the rewards — tally, kit reward, chat and the news leg (phase 13b, protocol 10)
Four event verbs and the announce leg, per PLAN.md §29:

- rust.participation.open / .collect: the plugin counts who takes part
  (seconds, kills or both, in a zone this run opened or the whole server)
  and collect files them as the run's participants, keyed by Steam id.
- rust.kit.entitle: the five recipient modes (D101), rows in the new
  rust_perm_run_grants (D84) unioned into the permission push, one extra
  use of the kit per reward as site-held credits on perm.sync (D103),
  and the rust.kit.entitled notice deferred from phase 10 (D64).
- rust.announce: one server or every server (D105).
- rust.chat announce leg, speaking only on servers whose new news switch
  is on (D104) - a card on Admin -> Rust visibility (D106).

Budgets rust.grants and rust.announcements; the kit source and four
fixed-choice sources (core has no enum param type). rust_perm_run_grants
carries core's idempotency key so a revert of a lost answer can find its
rows. Protocol 10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 07:00:44 -05:00

335 lines
12 KiB
JavaScript

// ── What the notifications read like, and the rules that use them ─────────
//
// `registerEngagementSeeds` (MODULE_API.md §2.4 and §1.1 under 1.9.0; PLAN.md
// §25.2). Data only: nothing here names a recipient, and nothing here turns a
// rule on.
//
// ── Two bespoke bodies, and why only two ──────────────────────────────────
//
// A body earns its place when the message has something to say that core's
// structural projection cannot. The raid alert does — it is the one message
// here somebody acts on at 3am, and it must say WHERE and WHAT in the first
// line. The wipe does — it is the one broadcast a whole community waits for.
// Everything else is "this happened, here is the link", which is exactly what
// core's `notify.event` / `inapp.event` already say, so it points at those and
// authors nothing (§4.6.1 property 1).
//
// The register is plain, not in-universe. Rust has no court or herald to write
// in the voice of, and a raid alert dressed as fiction is a raid alert read a
// second later than it should be.
//
// ── Three rules for editing a body ────────────────────────────────────────
//
// 1. **No conditionals, and never an optional inside a clause.** An unset
// optional interpolates to the EMPTY STRING. `atGrid` is a fragment that
// carries its own leading space for exactly that reason; `grid` on its own
// belongs on a line of its own or nowhere.
// 2. **No brand.** `siteName` and friends are supplied by the renderer, so one
// image mails as whichever site it is running as.
// 3. **Bump `seedVersion` when a body changes, never for a comment.** It is how
// a better default reaches deployments whose operators did not edit it.
//
// ── One rule group per family ─────────────────────────────────────────────
//
// A group is seeded ONCE (per deployment, per key), so a rule appended to a
// group in a later version reaches fresh installs only. Eight families, eight
// keys: a future raid rule takes `raid-v2` without disturbing anybody's clan
// rules. Every rule is disabled — core ignores `enabled` rather than trusting it
// — so installing this module mails nobody until an operator decides it should.
// ── Block helpers ──────────────────────────────────────────────────────────
const text = (id, body, opts = {}) => ({
id,
type: 'email.text',
props: opts.muted ? { text: body, muted: true } : { text: body },
})
const heading = (id, body, level = 'h1') => ({ id, type: 'email.heading', props: { level, text: body } })
const button = (id, label, url, textLead) => ({
id,
type: 'email.button',
props: textLead ? { label, url, textLead } : { label, url },
})
const divider = (id) => ({ id, type: 'email.divider', props: {} })
// Every email ends with the unsubscribe pair; `unsubscribeUrl` is core's
// per-delivery variable, not something a trigger declares.
const unsubscribe = () => [
divider('rule'),
button('unsub', 'Unsubscribe', '{{unsubscribeUrl}}', 'To stop these messages, use this link:'),
]
const email = (key, name, triggerId, subject, blocks) => ({
key,
name,
channel: 'email',
triggerId,
triggerVersion: 1,
seedVersion: 1,
subject,
blocks: [...blocks, ...unsubscribe()],
})
/** In-app: heading = the row's title, button = its one action, the rest = its body. */
const inapp = (key, name, triggerId, title, body, action, url) => ({
key,
name,
channel: 'inapp',
triggerId,
triggerVersion: 1,
seedVersion: 1,
subject: null,
blocks: [heading('h', title, 'h3'), text('intro', body), button('cta', action, url)],
})
const TEMPLATES = Object.freeze([
email(
'rust.base.destroyed',
'Rust — your base was raided',
'rust.base.destroyed',
'Your base on {{server}} is being raided',
[
heading('h', 'Your base is being raided'),
text('p1', 'A {{structure}} of a base you are authorised on was destroyed{{atGrid}} on {{server}}.'),
text('p2',
'You are getting this because you are on the base\'s tool cupboard. Further damage to the '
+ 'same base will not send another alert for a while.', { muted: true }),
button('cta', 'Open the server page', '{{serverUrl}}'),
],
),
inapp(
'rust.base.destroyed-inapp',
'Rust — your base was raided (in-app)',
'rust.base.destroyed',
'Your base is being raided',
'A {{structure}} was destroyed{{atGrid}} on {{server}}.',
'Open the server',
'{{serverUrl}}',
),
email(
'rust.wipe.started',
'Rust — a server wiped',
'rust.wipe.started',
'{{server}} has wiped',
[
heading('h', '{{server}} has wiped'),
text('p1', 'A new wipe has started on {{server}}: a fresh map, and a fresh start for everyone.'),
button('cta', 'Open the server page', '{{serverUrl}}'),
],
),
inapp(
'rust.wipe.started-inapp',
'Rust — a server wiped (in-app)',
'rust.wipe.started',
'{{server}} has wiped',
'A new wipe has started: a fresh map, and a fresh start for everyone.',
'Open the server',
'{{serverUrl}}',
),
])
// ── The rules — every one of them off ──────────────────────────────────────
/** Core's generic bodies (§4.6.1 property 1). */
const GENERIC = { email: 'notify.event', inapp: 'inapp.event', digest: 'notify.digest' }
/** This module's bodies for a trigger, and core's digest. */
const bodies = (key) => ({ email: key, inapp: `${key}-inapp`, digest: 'notify.digest' })
const RULE_GROUPS = Object.freeze([
{
key: 'raid-v1',
note: 'module-rust: the raid alert (disabled)',
rules: [
{
trigger_id: 'rust.base.destroyed',
name: 'Raid alert — offline owners',
audience: 'owner',
// Push is allowed because this trigger is also a stream (D65); the
// tickle carries no content, and the app pulls the inbox row.
channels: ['email', 'inapp', 'push'],
template_keys: bodies('rust.base.destroyed'),
// Per BUILDING (the subjectKey): a raid is dozens of walls and one alert.
cooldown_seconds: 1800,
max_sends_per_hour: 500,
// D61: "offline raid alert" is this condition, not code. An operator who
// wants online raids too deletes it.
conditions: { variable: 'ownerOnline', cmp: 'eq', value: false },
},
],
},
{
key: 'wipe-v1',
note: 'module-rust: wipe announcements (disabled)',
rules: [
{
trigger_id: 'rust.wipe.started',
name: 'Server wiped',
audience: 'subscribers',
channels: ['email', 'inapp', 'push'],
template_keys: bodies('rust.wipe.started'),
cooldown_seconds: 6 * 3600,
max_sends_per_hour: 2000,
},
],
},
{
key: 'server-v1',
note: 'module-rust: server up and down (disabled)',
rules: [
{
trigger_id: 'rust.server.online',
name: 'Server came online',
audience: 'subscribers',
channels: ['inapp', 'push'],
template_keys: { inapp: GENERIC.inapp },
cooldown_seconds: 3600,
max_sends_per_hour: 2000,
},
{
trigger_id: 'rust.server.offline',
name: 'Server went offline',
audience: 'subscribers',
channels: ['inapp', 'push'],
template_keys: { inapp: GENERIC.inapp },
cooldown_seconds: 3600,
max_sends_per_hour: 2000,
// A plugin reload, or a restart that is back within five minutes, is not
// an outage anybody needs to hear about. `cancel_on` withdraws the
// pending notice when the server comes back inside the window.
delay_seconds: 300,
cancel_on: ['rust.server.online'],
},
],
},
{
key: 'leaderboard-v1',
note: 'module-rust: a new kills leader (disabled)',
rules: [
{
trigger_id: 'rust.leaderboard.topped',
name: 'New kills leader',
audience: 'subscribers',
channels: ['inapp'],
template_keys: { inapp: GENERIC.inapp },
cooldown_seconds: 3600,
max_sends_per_hour: 2000,
},
],
},
{
key: 'account-v1',
note: 'module-rust: a Steam account was linked (disabled)',
rules: [
{
trigger_id: 'rust.player.linked',
name: 'Steam account linked',
audience: 'owner',
// Email as well as in-app: the case this exists for is a link the person
// did NOT make, and they will not be looking at the site's inbox for it.
channels: ['email', 'inapp'],
template_keys: GENERIC,
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
],
},
{
// Its own group, not a rule appended to `account-v1`: a group is seeded once,
// so an appended rule would reach fresh installs only (R7).
key: 'rewards-v1',
note: 'module-rust: an event rewarded you a kit (disabled)',
rules: [
{
trigger_id: 'rust.kit.entitled',
name: 'Kit reward earned',
audience: 'owner',
// Email as well: a reward granted at 03:00 is news the person reads the
// next morning, before they are next in game or on the site.
channels: ['email', 'inapp'],
template_keys: GENERIC,
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
],
},
{
key: 'clans-v1',
note: 'module-rust: clan departures and disbands (disabled)',
rules: [
{
trigger_id: 'rust.clan.member.left',
name: 'Clan — a member left',
audience: 'members',
channels: ['inapp'],
template_keys: { inapp: GENERIC.inapp },
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'rust.clan.member.kicked',
name: 'Clan — a member was removed',
audience: 'members',
channels: ['inapp'],
template_keys: { inapp: GENERIC.inapp },
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
{
trigger_id: 'rust.clan.disbanded',
name: 'Clan — disbanded',
audience: 'members',
channels: ['email', 'inapp'],
template_keys: GENERIC,
cooldown_seconds: 0,
max_sends_per_hour: 500,
},
],
},
{
key: 'moderation-v1',
note: 'module-rust: reports, bans and unapproved logins, to staff (disabled)',
rules: [
{
trigger_id: 'rust.player.reported',
name: 'Player reported',
audience: 'staff',
channels: ['email', 'inapp'],
template_keys: GENERIC,
// Per REPORTED player: a pile-on of ten reports is one notice an hour.
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
{
trigger_id: 'rust.player.banned',
name: 'Player banned',
audience: 'staff',
channels: ['inapp'],
template_keys: { inapp: GENERIC.inapp },
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
{
trigger_id: 'rust.player.unbanned',
name: 'Player unbanned',
audience: 'staff',
channels: ['inapp'],
template_keys: { inapp: GENERIC.inapp },
cooldown_seconds: 0,
max_sends_per_hour: 200,
},
{
trigger_id: 'rust.login.denied',
name: 'Login not approved',
audience: 'staff',
channels: ['inapp'],
template_keys: { inapp: GENERIC.inapp },
cooldown_seconds: 3600,
max_sends_per_hour: 200,
},
],
},
])
module.exports = { TEMPLATES, RULE_GROUPS }