feat(rust): notifications and engagement (phase 10, protocol 7)

Registers the engagement set R7 put in v1: thirteen triggers, four push
streams, three audiences, four bodies (two triggers, email and in-app)
and thirteen disabled rules in seven groups (PLAN.md §25, D59-D68).

The raid alert goes to everyone authorised on the tool cupboard, one
emit per linked person with ownerUserId, so the owner ceiling holds per
emit. It covers doors and walls (protocol 7), never names the raider,
alerts nobody when there is no cupboard, and carries ownerOnline so
"offline only" is the seeded rule's condition rather than code.

The fan-out runs off ingest before a frame is applied, since applying a
disband deletes the roster the notice is sent to. A replayed event is
told only while it is news: 15 minutes for broadcasts, 24 hours for
personal and staff events. Dedupe keys come from the event, not the
sidecar's row id. Server online/offline and a new kills leader are
in-memory transitions, never on first sight, and a tie is not a lead.
A login with no approval within a minute becomes a staff notice via a
query, so a restart loses nothing.

Also fixes a phase-4 gap (D68): the refresh now asks /health, so a game
that hung, or whose bridge was unloaded, while the sidecar stayed up no
longer reads as online. It stops naming players as online, and a stale
board no longer moves "last seen".

engagement-triggers.json is the committed freeze of all of it, checked
in CI with line endings normalised. The check was verified by breaking
it both ways.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-23 06:06:08 -05:00
parent dc3c9689b4
commit 285db0baa7
22 changed files with 3256 additions and 32 deletions

View File

@@ -130,6 +130,9 @@ jobs:
- name: Check the OpenAPI fragment is current (MODULE_API.md §2.8)
run: npm run check:swagger --prefix server
- name: Check the engagement freeze is current (PLAN.md §25)
run: npm run check:engagement --prefix server
client-build:
runs-on: ubuntu-latest
timeout-minutes: 20

View File

@@ -32,6 +32,7 @@
"configEdit.js",
"core.js",
"db",
"engagement",
"index.js",
"ingest.js",
"model",
@@ -41,6 +42,7 @@
"sidecarClient.js"
],
"root": [
"engagement-triggers.json",
"swagger-fragment.json",
"LICENSE.md",
"README.md"

1021
engagement-triggers.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,10 +21,11 @@
// letting that fail the boot would make installing the module before installing
// the bridge impossible.
//
// ── Three timers, and they answer three different questions ───────────────
// ── Four timers, and they answer four different questions ─────────────────
//
// refresh (30s) what is each server, and who is on it — the BOARDS
// ingest (5s) what has happened since we last looked — the CURSOR
// sweep (1m) which login attempts were never let in (PLAN.md §25)
// prune (1h) forgetting the detail we promised not to keep for ever
//
// The boards poll and the ingest are deliberately separate rather than one loop
@@ -42,6 +43,7 @@
const core = require('./core')
const db = require('./model/servers/servers.db')
const engagement = require('./engagement/emit')
const eventsDb = require('./model/events/events.db')
const ingest = require('./ingest')
const permSync = require('./permSync')
@@ -53,10 +55,12 @@ const log = core.logger('boot')
let refreshTimer = null
let ingestTimer = null
let pruneTimer = null
let sweepTimer = null
const REFRESH_MS = 30 * 1000
const INGEST_MS = 5 * 1000
const PRUNE_MS = 60 * 60 * 1000
const SWEEP_MS = 60 * 1000
/**
* How long this module keeps raw events.
@@ -94,7 +98,15 @@ async function refreshOne(server) {
// One call for both boards. `/server` would answer the same question about
// the server itself, but presence would then be a second round trip to the
// same process for a fact it already had in hand.
const board = await sidecar.boards(server)
//
// **And one for `/health`, because the boards cannot say whether the game is
// there NOW** (D68, PLAN.md §25.1). The sidecar keeps its last `server.hello`
// after the plugin disconnects — that is what lets a page render a server
// that is off — so a game that hung, or whose bridge was unloaded, while the
// sidecar stayed up read as online here from phase 4 until phase 10. Only
// `/health`'s `plugin_connected` answers the question, and the two are asked
// together so they describe the same moment.
const [board, health] = await Promise.all([sidecar.boards(server), sidecar.health(server)])
// Three outcomes, and collapsing any two of them loses something an operator
// needs:
@@ -113,6 +125,7 @@ async function refreshOne(server) {
// server said, which is exactly what the pages exist to render while it is
// off.
await db.markUnreachable(server.id, false)
engagement.serverObserved(server, false)
return
}
@@ -125,20 +138,34 @@ async function refreshOne(server) {
// reach is worse than an empty one, because it looks current.
await db.markUnreachable(server.id, true)
await ingest.applyBoards(server.id, {})
engagement.serverObserved(server, false)
return
}
await ingest.applyBoards(server.id, boards)
// Unknown is not connected. A `/health` that did not answer while `/boards`
// did is odd enough to be worth a line, and reporting the server up on the
// strength of a board the game may have left behind hours ago is the defect
// this call exists to remove.
const connected = Boolean(health.ok && health.data && health.data.plugin_connected === true)
if (!health.ok) log.warn('the sidecar answered /boards but not /health', { server: server.id })
// The presence board is the plugin's last word too. While the game is not
// connected it names people as online who may have left hours ago, which is
// both wrong and — under §23's rule — a claim about named people nobody made.
await ingest.applyBoards(
server.id,
connected ? boards : { ...boards, 'players.online': { players: [] } },
)
await db.putState({
serverId: server.id,
reachable: true,
// A stored `server.hello` means the game connected; whether it is connected
// NOW is a different question, and `/health` is what answers it. The board
// alone cannot say, which is why `online` is not simply `true` here — it is
// decided by freshness in the model, from `updated_at`.
online: true,
players: Number(frame.players) || 0,
// The plugin is connected NOW (D68). A stored `server.hello` only says it
// connected once; the model still applies its own freshness on top.
online: connected,
// A board the game left behind is a description, not a sighting.
seen: connected,
players: connected ? Number(frame.players) || 0 : 0,
maxPlayers: Number(frame.maxPlayers) || 0,
hostname: frame.hostname || null,
level: frame.level || null,
@@ -150,6 +177,9 @@ async function refreshOne(server) {
protocol: frame.protocol === undefined ? null : Number(frame.protocol),
raw: frame,
})
// After the write, so a transition announced is one a page already shows.
engagement.serverObserved(server, connected)
} catch (err) {
// A failure here is one server's, and it must not reach `Promise.allSettled`
// as a rejection that hides which one. Log with the id and carry on.
@@ -189,6 +219,26 @@ async function prune() {
}
}
/**
* Login attempts that were never approved (D64, PLAN.md §25).
*
* On its own minute timer rather than the prune's hour: an attempt waits a
* minute for its approval, and a staff alert an hour late is not an alert. A
* query over stored rows, so it needs nothing kept in memory and a restart loses
* nothing; the dedupe key makes a second pass over the same attempt a no-op.
*/
async function sweep() {
let rows
try {
rows = await servers.listForPolling()
} catch (err) {
log.warn('could not read the server list', { error: err.message })
return
}
const sent = await engagement.sweepLoginDenied(rows)
if (sent > 0) log.info('unapproved logins reported', { attempts: sent })
}
async function onBoot() {
await refresh()
// The permission mirror owns its own loop and its own cadence (see
@@ -199,10 +249,11 @@ async function onBoot() {
refreshTimer = setInterval(refresh, REFRESH_MS)
ingestTimer = setInterval(ingestAll, INGEST_MS)
pruneTimer = setInterval(prune, PRUNE_MS)
sweepTimer = setInterval(sweep, SWEEP_MS)
// Node keeps the process alive for a pending timer. Core's own intervals are
// unref'd for exactly this reason: a module that forgets turns `Ctrl-C` into a
// thirty-second wait, and on a host it turns a `systemctl stop` into a SIGKILL.
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
if (timer && typeof timer.unref === 'function') timer.unref()
}
@@ -220,13 +271,14 @@ async function onBoot() {
async function onShutdown() {
permSync.stop()
for (const timer of [refreshTimer, ingestTimer, pruneTimer]) {
for (const timer of [refreshTimer, ingestTimer, pruneTimer, sweepTimer]) {
if (timer) clearInterval(timer)
}
refreshTimer = null
ingestTimer = null
pruneTimer = null
sweepTimer = null
log.info('shut down')
}

View File

@@ -0,0 +1,107 @@
// ── Named sets of people, over this module's own data ─────────────────────
//
// `registerAudiences` (MODULE_API.md §2.4; PLAN.md §25.2). An operator points a
// rule or a segment at one of these; core calls `resolve` when a rule fires.
//
// Three properties, each the contract rather than a style:
//
// • **A resolver returns website user ids and nothing else.** Never an
// address, a channel or a Steam id: core maps ids to people after the
// preferences, the suppression list and the verification gate, and a module
// that could hand it anything else would have a way to send mail.
// • **One that fails answers NOBODY** — never everybody, never its last good
// answer. A throw here is caught and returned as `[]`, and core treats a
// throw the same way; both are here so the property does not rest on
// either side alone.
// • **Params are constants**, fixed when an operator saves the rule. "The clan
// this event was about" is therefore not expressible as an audience — a
// clan trigger carries its own recipients instead (`emit.js`).
const core = require('../core')
const log = core.logger('audiences')
const LINKS = 'rust_account_links'
/** Wraps a resolver so a failure is an empty set, logged, and never a throw. */
function safe(id, fn) {
return async (params) => {
try {
const rows = await fn(params || {})
return rows.map((r) => Number(r.userId)).filter((n) => Number.isInteger(n) && n > 0)
} catch (err) {
log.warn('an audience could not be resolved; it answers nobody', { audience: id, error: err.message })
return []
}
}
}
const text = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null)
const AUDIENCES = Object.freeze([
{
id: 'rust.clan.members',
label: 'Members of a clan',
params: [{ id: 'clan', type: 'string', required: true }],
ceiling: 'members',
// A clan's LINKED members, as the store holds them now. A clan that has
// been disbanded has no roster, so a rule saved against it resolves to
// nobody — which is the truth, and not the same as the audience being gone.
resolve: safe('rust.clan.members', async ({ clan }) => {
const key = text(clan)
if (!key) return []
return core.query(
`SELECT DISTINCT l.user_id AS userId
FROM rust_clan_members m
JOIN rust_clans c ON c.external_id = m.external_id AND c.gone_at IS NULL
JOIN ${LINKS} l ON l.steam_id = m.steam_id
WHERE m.external_id = ?`,
[key],
)
}),
},
{
id: 'rust.server.players',
label: 'Everyone who has played on a server',
params: [{ id: 'serverId', type: 'string', required: true }],
ceiling: 'authenticated',
// Linked accounts with a stats row on this server in ANY wipe. The stats
// table is the record of having played, and it outlives both wipes and the
// raw event history (R12).
resolve: safe('rust.server.players', async ({ serverId }) => {
const id = text(serverId)
if (!id) return []
return core.query(
`SELECT DISTINCT l.user_id AS userId
FROM rust_player_wipe_stats s
JOIN ${LINKS} l ON l.steam_id = s.steam_id
WHERE s.server_id = ?`,
[id],
)
}),
},
{
id: 'rust.wipe.participants',
label: 'Everyone playing a server\'s current wipe',
params: [{ id: 'serverId', type: 'string', required: true }],
ceiling: 'authenticated',
// The same, narrowed to the wipe the server is on NOW. Resolved at send
// time, so a rule saved last month reaches this month's players — which is
// what "current" has to mean for a parameter fixed when the rule was saved.
// A server with no known wipe resolves to nobody rather than to every wipe.
resolve: safe('rust.wipe.participants', async ({ serverId }) => {
const id = text(serverId)
if (!id) return []
return core.query(
`SELECT DISTINCT l.user_id AS userId
FROM rust_server_state st
JOIN rust_player_wipe_stats s ON s.server_id = st.server_id AND s.wipe_id = st.wipe_id
JOIN ${LINKS} l ON l.steam_id = s.steam_id
WHERE st.server_id = ? AND st.wipe_id IS NOT NULL`,
[id],
)
}),
},
])
module.exports = { AUDIENCES }

490
server/engagement/emit.js Normal file
View File

@@ -0,0 +1,490 @@
// ── What happened, told to core's engagement engine ───────────────────────
//
// The fan-out behind `triggers.js` (PLAN.md §25). Ingest calls `onEvent` for
// every frame it stores; the refresh calls `serverObserved` for every poll;
// ingest calls `checkLeader` after a batch; the prune timer calls
// `sweepLoginDenied`; the link route calls `linked`.
//
// **Nothing here decides who is told.** It says what happened and, for a
// personal or clan event, who it is ABOUT. Core applies the rule, the ceiling,
// the preference, the suppression list and the verification gate. A module
// cannot send mail (MODULE_API.md §2.7), and this file is not the back door.
//
// **Nothing here throws into its caller.** Every entry point catches, because
// its callers are the ingest cursor and the refresh loop — a malformed frame or
// a core-side contract problem must cost one notification and never the feed.
//
// ── Three rules the whole file follows ────────────────────────────────────
//
// 1. **Emit on the transition, never on the poll** (R7). A server that is
// still up is not news. Transitions are tracked in memory, and a FIRST
// sighting is never one — so a website restart announces nothing.
//
// 2. **A replayed event notifies only while it is still news** (D63). After an
// outage the cursor replays hours of frames. A broadcast older than 15
// minutes tells nobody; a personal or staff event is kept for 24 hours,
// because "your base was raided at 03:10" is still true and still wanted.
//
// 3. **Every emit carries a dedupe key made from the EVENT, not the store.**
// Core's outbox is unique on (rule, user, channel, key), so the same frame
// replayed after a crash is a no-op. The key is built from what the event
// says — its server, time and subject — rather than from the sidecar's row
// id, because a sidecar whose database is replaced starts its ids again
// and would otherwise have every new alert swallowed as a repeat of an old
// one.
const crypto = require('crypto')
const core = require('../core')
const clans = require('../model/clans/clans.model')
const clansDb = require('../model/clans/clans.db')
const eventsDb = require('../model/events/events.db')
const linksDb = require('../model/links/links.db')
const serversDb = require('../model/servers/servers.db')
const { TRIGGER_IDS: T, PATHS, serverPath, leaderboardPath, clanPath } = require('./triggers')
const log = core.logger('engagement')
/** How old a broadcast may be and still be news (D63). */
const BROADCAST_MAX_AGE_MS = 15 * 60 * 1000
/** How old a personal or staff event may be and still be worth telling (D63). */
const PERSONAL_MAX_AGE_MS = 24 * 60 * 60 * 1000
/**
* How long a login attempt waits for its approval before it counts as denied
* (D64, PLAN.md §16.5). The game raises no rejection hook, so a denial is the
* ABSENCE of an approval — which is only knowable after a wait.
*/
const LOGIN_APPROVAL_WINDOW_MS = 60 * 1000
/** How far BEFORE an attempt an approval may be stamped and still answer it — clock grain, not policy. */
const LOGIN_APPROVAL_SLACK_MS = 5 * 1000
const STRUCTURE_LABELS = Object.freeze({
block: 'building block',
door: 'door',
wall: 'external wall',
cupboard: 'tool cupboard',
})
// ── Small helpers ──────────────────────────────────────────────────────────
const str = (value) => (value === undefined || value === null || value === '' ? undefined : String(value))
/** `rust:<what>:<sha1 of the parts>` — readable prefix, bounded length. */
function dedupeKey(what, ...parts) {
const digest = crypto.createHash('sha1').update(parts.map((p) => String(p ?? '')).join('\u0000')).digest('hex')
return `rust:${what}:${digest}`
}
function frameTime(item, frame) {
const t = Number(frame && frame.t) || Number(item && item.t)
return Number.isFinite(t) && t > 0 ? t : Date.now()
}
/** D63, as a question: is an event from `t` still worth telling, for this family? */
function stillNews(t, maxAgeMs, now = Date.now()) {
return now - t <= maxAgeMs
}
function serverVars(server) {
const serverId = String(server.id)
return { serverId, server: server.name || serverId, serverUrl: serverPath(serverId) }
}
/**
* Hands one event to core. Never throws.
*
* Core throws on a contract mismatch outside production, which is how a
* declaration and an emitter drifting apart is meant to be found. It is logged
* at `error` here rather than re-thrown, because the caller is the ingest
* cursor — and an `error` line is what a rig walk reads.
*/
function fire(triggerId, envelope) {
try {
core.emit(triggerId, envelope)
return true
} catch (err) {
log.error('core refused an emit', { trigger: triggerId, error: err.message })
return false
}
}
/** Steam id -> website user id, for the ids that are linked. Unlinked ones are simply absent. */
async function usersFor(steamIds) {
const ids = [...new Set((steamIds || []).map(String).filter(Boolean))]
if (!ids.length) return new Map()
const rows = await linksDb.userIdsForSteamIds(ids)
return new Map(rows.map((r) => [String(r.steamId), Number(r.userId)]))
}
// ── Per-kind handlers ──────────────────────────────────────────────────────
/**
* The raid alert (D59-D61, D66, D67).
*
* One emit per authorised, LINKED person, each with `ownerUserId` — so the
* `owner` ceiling holds per emit and "nobody else" is structural rather than a
* filter somebody could forget. Two Steam accounts held by one website user are
* one person: they get one alert, online if either account is.
*/
async function onRaid(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
// D67: no cupboard, nobody to tell. Absent — not empty — is also what a
// protocol-6 plugin sends, so a half-upgraded deployment alerts nobody rather
// than guessing an owner from the placer.
if (!frame.buildingId || !Array.isArray(frame.authorized)) return 0
const authorized = frame.authorized.filter((a) => a && a.steamId)
const attacker = str(frame.attackerId)
// An authorised attacker is demolishing their own base, or a teammate's.
if (attacker && authorized.some((a) => String(a.steamId) === attacker)) return 0
const users = await usersFor(authorized.map((a) => a.steamId))
if (!users.size) return 0
const byUser = new Map()
for (const a of authorized) {
const userId = users.get(String(a.steamId))
if (!userId) continue
byUser.set(userId, byUser.get(userId) === true || a.online === true)
}
const base = {
...serverVars(server),
building: String(frame.buildingId),
structure: STRUCTURE_LABELS[frame.structure] || 'structure',
grid: str(frame.grid),
atGrid: str(frame.grid) ? ` in ${frame.grid}` : undefined,
}
const key = dedupeKey('raid', server.id, frame.buildingId, t, frame.prefab)
let sent = 0
for (const [userId, online] of byUser) {
if (fire(T['rust.base.destroyed'], {
data: { ...base, ownerOnline: online },
ownerUserId: userId,
dedupeKey: key,
occurredAt: t,
})) sent += 1
}
return sent
}
async function onWipe(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, BROADCAST_MAX_AGE_MS)) return 0
const wipeId = str(frame.wipeId)
if (!wipeId) return 0
return fire(T['rust.wipe.started'], {
data: { ...serverVars(server), wipeId },
dedupeKey: dedupeKey('wipe', server.id, wipeId),
occurredAt: t,
}) ? 1 : 0
}
/**
* Clan departures and disbands. Recipients travel on the envelope, because
* "the clan this was about" is a different set every firing.
*
* Nobody is told about what they did themselves: the leaver is not told they
* left, the one who kicked is not told they kicked, the one who disbanded is not
* told they disbanded. The one KICKED is told — it happened to them.
*/
async function onClan(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
const externalId = await clans.resolveExternalId(server.id, frame)
if (!externalId) return 0
const kind = frame.kind
const subject = str(frame.steamId)
let steamIds
let actor
if (kind === 'clan.disbanded') {
// From the frame (protocol 7): by the time this runs the next board may
// already have removed the roster the store would answer with.
steamIds = Array.isArray(frame.members) ? frame.members.map(String) : null
if (!steamIds) steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId))
actor = subject
} else {
steamIds = (await clansDb.listMembers(externalId)).map((m) => String(m.steamId))
if (kind === 'clan.member.kicked') {
if (subject) steamIds.push(subject)
actor = str(frame.bySteamId)
} else {
actor = subject
}
}
const users = await usersFor(steamIds.filter((id) => id !== actor))
const recipientUserIds = [...new Set(users.values())]
if (!recipientUserIds.length) return 0
const data = {
...serverVars(server),
clanKey: externalId,
clan: str(frame.clanName) || 'your clan',
clanUrl: clanPath(externalId),
}
if (kind === 'clan.member.left') data.member = str(frame.name)
if (kind === 'clan.member.kicked') {
data.member = str(frame.name)
data.by = str(frame.byName)
}
if (kind === 'clan.disbanded') data.by = str(frame.name)
const triggerId = kind === 'clan.disbanded' ? T['rust.clan.disbanded'] : T[`rust.${kind}`]
return fire(triggerId, {
data,
recipientUserIds,
dedupeKey: dedupeKey(kind, server.id, externalId, subject, t),
occurredAt: t,
}) ? 1 : 0
}
async function onReported(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
const steamId = str(frame.targetId)
if (!steamId) return 0
return fire(T['rust.player.reported'], {
data: {
...serverVars(server),
steamId,
player: str(frame.targetName),
reporter: str(frame.reporterName),
reportType: str(frame.reportType),
topic: str(frame.subject),
message: str(frame.message),
},
dedupeKey: dedupeKey('reported', server.id, steamId, frame.reporterId, t),
occurredAt: t,
}) ? 1 : 0
}
async function onBan(server, item, frame) {
const t = frameTime(item, frame)
if (!stillNews(t, PERSONAL_MAX_AGE_MS)) return 0
const steamId = str(frame.steamId)
if (!steamId) return 0
const banned = frame.kind === 'player.banned'
const data = { ...serverVars(server), steamId, player: str(frame.name) }
// The address the frame carries is deliberately NOT copied: no trigger
// declares one, so no template can ever put it in a mail.
if (banned) data.reason = str(frame.reason)
return fire(banned ? T['rust.player.banned'] : T['rust.player.unbanned'], {
data,
dedupeKey: dedupeKey(frame.kind, server.id, steamId, t),
occurredAt: t,
}) ? 1 : 0
}
const HANDLERS = Object.freeze({
'entity.destroyed': onRaid,
'server.wipe': onWipe,
'clan.member.left': onClan,
'clan.member.kicked': onClan,
'clan.disbanded': onClan,
'player.reported': onReported,
'player.banned': onBan,
'player.unbanned': onBan,
})
/**
* One stored frame. Called by ingest BEFORE the frame is applied, because
* applying a disband deletes the roster a clan notification is sent to.
*
* @returns {Promise<number>} emits handed to core, for the log and the tests
*/
async function onEvent(server, item) {
const frame = (item && item.frame) || {}
const kind = (item && item.kind) || frame.kind
const handler = HANDLERS[kind]
if (!handler || !server) return 0
try {
return await handler(server, item, { ...frame, kind })
} catch (err) {
log.warn('could not raise a notification', { server: server.id, kind, error: err.message })
return 0
}
}
// ── Transitions tracked in memory ──────────────────────────────────────────
//
// Deliberately NOT persisted. The question each one answers is "has THIS
// process seen a previous value", and a value restored from the database would
// make the first poll after a restart a transition against state the game may
// have left hours ago.
const tracker = { online: new Map(), leader: new Map() }
/** Forget every tracked value. For the tests. */
function reset() {
tracker.online.clear()
tracker.leader.clear()
}
/**
* One poll's verdict on one server (D68): is its game connected now?
*
* Synchronous and fire-and-forget — the refresh must not wait on core.
*/
function serverObserved(server, connected) {
try {
if (!server) return 0
const id = String(server.id)
const now = Boolean(connected)
const before = tracker.online.get(id)
tracker.online.set(id, now)
// First sight is never a transition: a restart announces nothing.
if (before === undefined || before === now) return 0
return fire(now ? T['rust.server.online'] : T['rust.server.offline'], {
data: serverVars(server),
// A transition observed by a poll is observed NOW, so it needs no age check;
// the key is per minute so that one real flap is one event even if two
// polls land either side of a restart of this process.
dedupeKey: dedupeKey(now ? 'online' : 'offline', id, Math.floor(Date.now() / 60000)),
}) ? 1 : 0
} catch (err) {
log.warn('could not raise a server transition', { server: server && server.id, error: err.message })
return 0
}
}
/**
* After a batch: has somebody new taken the lead in this wipe's kills? (D64)
*
* Only a STRICT lead counts. The leaderboard breaks a tie on who was seen last,
* so two players level on kills trade the top row every time either one moves —
* and reading the top row alone would announce a new leader each time.
*/
async function checkLeader(server) {
try {
const state = await serversDb.getState(server.id)
const wipeId = state && state.wipeId
if (!wipeId) return 0
const rows = await eventsDb.leaderboard({ serverId: server.id, wipeId, sort: 'kills', limit: 2 })
const top = rows[0]
const kills = top ? Number(top.kills) || 0 : 0
const id = String(server.id)
const before = tracker.leader.get(id)
if (!top || kills <= 0) {
tracker.leader.set(id, { wipeId, steamId: null })
return 0
}
const tied = rows[1] && Number(rows[1].kills) === kills
const steamId = String(top.steamId)
// First sight, or a new wipe: remember, announce nothing.
if (!before || before.wipeId !== wipeId) {
tracker.leader.set(id, { wipeId, steamId: tied ? null : steamId })
return 0
}
if (tied || before.steamId === steamId) return 0
tracker.leader.set(id, { wipeId, steamId })
return fire(T['rust.leaderboard.topped'], {
data: {
...serverVars(server),
leader: str(top.name) || 'A player',
kills,
leaderboardUrl: leaderboardPath(id),
},
dedupeKey: dedupeKey('leader', id, wipeId, steamId, kills),
}) ? 1 : 0
} catch (err) {
log.warn('could not check the leaderboard', { server: server && server.id, error: err.message })
return 0
}
}
/**
* Login attempts that were never approved (D64).
*
* A query over what is stored rather than a timer per attempt, so a restart
* loses nothing and running it twice is a no-op (the key is the attempt's own
* server, Steam id and time). Bounded by D63's personal age: an attempt a day
* old is not worth a staff mail.
*/
async function sweepLoginDenied(servers, now = Date.now()) {
let sent = 0
for (const server of servers || []) {
try {
const rows = await eventsDb.unapprovedLogins({
serverId: server.id,
from: now - PERSONAL_MAX_AGE_MS,
to: now - LOGIN_APPROVAL_WINDOW_MS,
windowMs: LOGIN_APPROVAL_WINDOW_MS,
slackMs: LOGIN_APPROVAL_SLACK_MS,
})
for (const row of rows) {
const t = Number(row.t)
if (fire(T['rust.login.denied'], {
data: {
...serverVars(server),
steamId: String(row.steamId),
player: str(row.name),
attemptedAt: new Date(t),
},
dedupeKey: dedupeKey('login-denied', server.id, row.steamId, t),
occurredAt: t,
})) sent += 1
}
} catch (err) {
log.warn('could not sweep login attempts', { server: server && server.id, error: err.message })
}
}
return sent
}
/** A Steam account was just linked (R1). Called by the link route, once, on a NEW link. */
function linked({ userId, steamId, name }) {
try {
const uid = Number(userId)
if (!Number.isInteger(uid) || uid < 1 || !steamId) return 0
return fire(T['rust.player.linked'], {
data: { steamId: String(steamId), player: str(name), accountUrl: PATHS.account },
ownerUserId: uid,
dedupeKey: dedupeKey('linked', steamId, uid),
}) ? 1 : 0
} catch (err) {
log.warn('could not raise the link notification', { error: err.message })
return 0
}
}
module.exports = {
onEvent,
serverObserved,
checkLeader,
sweepLoginDenied,
linked,
reset,
dedupeKey,
stillNews,
BROADCAST_MAX_AGE_MS,
PERSONAL_MAX_AGE_MS,
LOGIN_APPROVAL_WINDOW_MS,
STRUCTURE_LABELS,
}

315
server/engagement/seeds.js Normal file
View File

@@ -0,0 +1,315 @@
// ── 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. Seven families, seven
// 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,
},
],
},
{
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 }

View File

@@ -0,0 +1,53 @@
// ── The push facet: which triggers may reach a phone ──────────────────────
//
// `registerNotificationStreams` (MODULE_API.md §2.4). A stream is what a device
// subscribes to, and **core delivers an engagement rule's push only to devices
// subscribed to a stream whose id IS the trigger id** (`pushChannel.deliver` ->
// `publishToUsers(row.trigger_id)`). So a trigger with no stream here can never
// buzz a phone, however its rule is set — which is exactly how the families
// that should not are kept off it (D65).
//
// Every id here is ALSO a trigger in `triggers.js`. That is the one namespace
// core enforces across both facets: one event, with a payload contract and a
// subscription toggle, owned by one module. An id that appeared only here would
// be a toggle nothing could ever fire.
//
// **The tickle carries nothing.** A push is `{ stream, ref }` and the app pulls
// the real item over the authenticated inbox API, so a leaked relay topic says
// that something happened and not what. That is core's guarantee and it is why
// a raid alert may be a push at all.
const STREAMS = Object.freeze([
{
id: 'rust.base.destroyed',
label: 'Your base was raided',
description: 'Part of a base you are authorised on was destroyed by another player.',
// Delivered only to the owner's devices, never fanned out: `owner` ceiling,
// one emit per authorised person (D59).
personal: true,
requiresLinkedAccount: true,
},
{
id: 'rust.server.online',
label: 'A server came online',
description: 'A Rust server started or came back.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'rust.server.offline',
label: 'A server went offline',
description: 'A Rust server stopped or stopped answering.',
personal: false,
requiresLinkedAccount: false,
},
{
id: 'rust.wipe.started',
label: 'A server wiped',
description: 'A Rust server started a new wipe.',
personal: false,
requiresLinkedAccount: false,
},
])
module.exports = { STREAMS }

View File

@@ -0,0 +1,354 @@
// ── 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 ───────────────────────────────────────────────────────
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,
{ 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,
{ 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],
},
{
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],
},
{
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,
{ 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: [
{ 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,
{ 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,
{ 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,
{ 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,
{ 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,
{ 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,
{ 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,
{ 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 }

View File

@@ -52,6 +52,10 @@ module.exports = function register(ctx, api) {
const adminRust = require('./router/admin/rust.router')
const usersRust = require('./router/admin/usersRust.router')
const teamProvider = require('./model/clans/teamProvider')
const { TRIGGERS } = require('./engagement/triggers')
const { STREAMS } = require('./engagement/streams')
const { AUDIENCES } = require('./engagement/audiences')
const seeds = require('./engagement/seeds')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -105,6 +109,31 @@ module.exports = function register(ctx, api) {
// UO + Rust site; it is recorded in §24 rather than worked around here.
api.registerTeamProvider(teamProvider)
// Notifications and engagement (R7, PLAN.md §25). Four registrations that are
// one decision, because they only mean something together:
//
// triggers what can happen, what a template may say about it, and the
// widest audience a rule on it may EVER have — the security
// boundary; core refuses a rule that widens a ceiling
// streams which of those may reach a phone. Core pushes an engagement
// rule only to devices subscribed to a stream of the SAME id, so
// a trigger missing here can never buzz anybody (D65)
// audiences named sets of people over this module's data, for an operator
// to point a rule at; each answers user ids and nothing else
// seeds the two bodies worth writing, and one disabled rule group per
// family — installing this module mails nobody
//
// What fires them is `engagement/emit.js`, off the ingest cursor and the
// refresh. Registration is a claim, not a call: nothing here touches the
// database, and the seeds are written by core after the schema is up.
//
// **Not registered, and that is D62:** no announce leg and no post hook. Both
// need something in game to deliver to, and phase 10 reaches no game.
api.registerEventTriggers(TRIGGERS)
api.registerNotificationStreams(STREAMS)
api.registerAudiences(AUDIENCES)
api.registerEngagementSeeds({ templates: seeds.TEMPLATES, ruleGroups: seeds.RULE_GROUPS })
// The lifecycle hooks (§2.5). `onBoot` runs after core's schema, after this
// module's schema fragment, and BEFORE the HTTP listener binds — so a module
// that must not serve traffic until it has warmed a cache gets that for free.
@@ -117,10 +146,9 @@ module.exports = function register(ctx, api) {
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)
// Everything else this module will register — the event triggers and
// audiences, the engagement seeds, the four event catalogues, the
// notification streams and the slash commands — is deliberately absent. Each
// arrives with the phase that has something real to put in it. A registration
// Everything else this module will register — the four event catalogues, the
// announce leg and the slash commands — is deliberately absent. Each arrives
// with the phase that has something real to put in it. A registration
// with nothing behind it is worse than a missing one: a declared trigger
// nothing emits and a declared slot nothing fills are both surfaces an operator
// can configure and then wait on.
@@ -130,5 +158,8 @@ module.exports = function register(ctx, api) {
routes: 'public:/rust player:/rust admin:/rust',
extensions: 'admin.users.detail',
teams: 'first-party clans',
triggers: TRIGGERS.length,
streams: STREAMS.length,
audiences: AUDIENCES.length,
})
}

View File

@@ -35,6 +35,7 @@ const core = require('./core')
const clans = require('./model/clans/clans.model')
const db = require('./model/events/events.db')
const engagement = require('./engagement/emit')
const links = require('./model/links/links.model')
const permissionsDb = require('./model/permissions/permissions.db')
const sidecar = require('./sidecarClient')
@@ -64,7 +65,7 @@ const MAX_BATCHES_PER_TICK = 10
* the alternative is losing the one copy of an event the next version will know
* how to read.
*/
async function apply(serverId, item) {
async function apply(serverId, item, server = null) {
const frame = (item && item.frame) || {}
const kind = item.kind || frame.kind
const wipeId = frame.wipeId || null
@@ -82,6 +83,12 @@ async function apply(serverId, item) {
raw: frame,
})
// What core's engagement engine is told (PLAN.md §25). BEFORE the frame is
// applied, because applying a disband deletes the roster the notification is
// for. Never throws, and does not hold the cursor on core: `onEvent` resolves
// who a frame is about and hands it over, and delivery is core's own time.
if (server) await engagement.onEvent(server, item)
const at = { serverId, wipeId, steamId: frame.steamId }
switch (kind) {
@@ -257,7 +264,7 @@ async function ingestServer(server) {
for (const item of items) {
try {
await apply(server.id, item)
await apply(server.id, item, server)
applied += 1
} catch (err) {
// One malformed event must not wedge a server's cursor for ever. It is
@@ -284,7 +291,11 @@ async function ingestServer(server) {
if (!res.data.more) break
}
if (applied > 0) log.info('ingested', { server: server.id, events: applied, cursor: since })
if (applied > 0) {
log.info('ingested', { server: server.id, events: applied, cursor: since })
// A leader can only change when something was applied. Never throws.
await engagement.checkLeader(server)
}
return applied
}

View File

@@ -563,6 +563,7 @@ module.exports = {
normaliseClan,
applyBoard,
applyEvent,
resolveExternalId,
reofferActivity,
activityItem,
dedupeKeyOf,

View File

@@ -272,6 +272,38 @@ async function presenceFor(serverId) {
)
}
/**
* Login attempts in `[from, to]` that no approval answered (D64).
*
* An attempt is answered by a `player.approved` for the same Steam id on the
* same server stamped from `slackMs` before it to `windowMs` after it. The
* slack is clock grain: both frames come off one game thread, and an approval
* stamped a millisecond "early" is still the answer.
*
* Grouped on (steam id, t) because a cursor replayed after a crash can store the
* same attempt twice, and one attempt is one denial however often it was
* written down.
*/
async function unapprovedLogins({ serverId, from, to, windowMs, slackMs }) {
return core.query(
`SELECT a.steam_id AS steamId, a.t AS t,
MAX(JSON_UNQUOTE(JSON_EXTRACT(a.raw, '$.name'))) AS name
FROM ${EVENTS} a
WHERE a.server_id = ? AND a.kind = 'player.login.attempt'
AND a.steam_id IS NOT NULL AND a.t BETWEEN ? AND ?
AND NOT EXISTS (
SELECT 1 FROM ${EVENTS} b
WHERE b.server_id = a.server_id AND b.kind = 'player.approved'
AND b.steam_id = a.steam_id
AND b.t BETWEEN a.t - ? AND a.t + ?
)
GROUP BY a.steam_id, a.t
ORDER BY a.t ASC
LIMIT 200`,
[serverId, from, to, slackMs, windowMs],
)
}
module.exports = {
getCursor,
setCursor,
@@ -286,4 +318,5 @@ module.exports = {
leaderboard,
listWipes,
presenceFor,
unapprovedLogins,
}

View File

@@ -148,6 +148,23 @@ async function statsForSteamId(steamId) {
)
}
/**
* Which of these Steam ids are linked, and to whom.
*
* The one question every notification asks — "who on the website is this
* player?" — asked for a set at once, because a raid names a cupboard's whole
* authorisation list and a clan event a whole roster. An unlinked id is simply
* absent from the answer: there is nobody to tell.
*/
async function userIdsForSteamIds(steamIds) {
if (!steamIds.length) return []
const marks = steamIds.map(() => '?').join(', ')
return core.query(
`SELECT steam_id AS steamId, user_id AS userId FROM ${LINKS} WHERE steam_id IN (${marks})`,
steamIds,
)
}
module.exports = {
getBySteamId,
listForUser,
@@ -156,4 +173,5 @@ module.exports = {
removeOwned,
removeBySteamId,
statsForSteamId,
userIdsForSteamIds,
}

View File

@@ -18,6 +18,7 @@
const core = require('../../core')
const db = require('./links.db')
const engagement = require('../../engagement/emit')
const servers = require('../servers/servers.model')
const sidecar = require('../../sidecarClient')
@@ -150,6 +151,10 @@ async function confirmOne({ server, code, userId }) {
const link = shape(await db.getBySteamId(steamId))
log.info('steam account linked', { steamId, userId, server: server.id })
linksChanged('rust account linked')
// Only a NEW link is news. The `already` path above is somebody pressing the
// button twice, and telling them twice would make the notice meaningless for
// the one case it exists for: a link they did not make.
engagement.linked({ userId, steamId, name: frame.name })
return { ok: true, link }
}

View File

@@ -146,7 +146,7 @@ async function putState(state) {
`INSERT INTO ${STATE}
(server_id, reachable, online, players, max_players, hostname, level, seed,
world_size, boot_id, save_created_at, wipe_id, protocol, raw, last_seen_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, IF(?, CURRENT_TIMESTAMP, NULL), CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
reachable = VALUES(reachable), online = VALUES(online), players = VALUES(players),
max_players = VALUES(max_players), hostname = VALUES(hostname), level = VALUES(level),
@@ -154,10 +154,11 @@ async function putState(state) {
save_created_at = VALUES(save_created_at), wipe_id = VALUES(wipe_id),
protocol = VALUES(protocol),
raw = VALUES(raw),
-- Only a frame moves this; an unreachable write leaves it alone, which is
-- what lets a page say how long a server has been down rather than how
-- recently we failed to reach it.
last_seen_at = CURRENT_TIMESTAMP,
-- Only a CONNECTED game moves this; an unreachable write leaves it alone,
-- and so does a board the sidecar kept after the game went away (D68).
-- That is what lets a page say how long a server has been down rather
-- than how recently we failed to reach it.
last_seen_at = IF(?, CURRENT_TIMESTAMP, last_seen_at),
updated_at = CURRENT_TIMESTAMP`,
[
state.serverId,
@@ -174,6 +175,8 @@ async function putState(state) {
state.wipeId || null,
state.protocol === undefined ? null : state.protocol,
state.raw ? JSON.stringify(state.raw) : null,
state.seen === false ? 0 : 1,
state.seen === false ? 0 : 1,
],
)
}

View File

@@ -10,7 +10,9 @@
"check:imports": "node scripts/checkImports.js",
"check:bundle": "node scripts/checkBundle.js",
"swagger": "node scripts/swaggerFragment.js",
"check:swagger": "node scripts/swaggerFragment.js --check"
"check:swagger": "node scripts/swaggerFragment.js --check",
"engagement:manifest": "node scripts/engagementManifest.js",
"check:engagement": "node scripts/engagementManifest.js --check"
},
"engines": {
"node": ">=20"

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
//
// The engagement freeze: every trigger, stream and audience this module
// declares, and every rule it seeds, as one committed file whose DIFF is the
// review signal (MODULE_API.md §2.4: "a module ships a prebuilt
// `engagement-triggers.json` in its bundle").
//
// **Why it exists when core never reads it.** A trigger declaration is what an
// operator's templates interpolate and their rules are written against.
// Renaming a variable, changing its type or widening a ceiling breaks stored
// templates and rules — silently, at send time, in a mail somebody already
// got. Committing the declarations as data turns that edit into a visible diff
// in the PR that makes it, the same job `routes.manifest.json` does for URLs.
//
// **It is generated from the registrations, not from the source files**, by
// running `register()` against a recording api — so what is frozen is what core
// would be handed, including anything `index.js` does on the way.
//
// **It records `coreApi`, not core's `MODULE_API_VERSION`.** Core's own manifest
// embeds the API version, and every API bump then makes it stale with no change
// to a single declaration — which is how it once sat stale for a whole phase.
// The range this module declares moves only when this module decides it should.
//
// Usage (from server/):
// node scripts/engagementManifest.js write ../engagement-triggers.json
// node scripts/engagementManifest.js --check exit 1 if the committed file is stale
const fs = require('fs')
const path = require('path')
const { fakeCtx, fakeApi } = require('../test/_fakes')
const MODULE_ROOT = path.resolve(__dirname, '..', '..')
const MANIFEST = path.join(MODULE_ROOT, 'engagement-triggers.json')
const COMMENT =
'Generated freeze of module-rust\'s engagement contract (docs/modules/rust/PLAN.md §25). ' +
'Regenerate with `npm run engagement:manifest` in server/. A renamed variable, a changed type ' +
'or a widened ceiling breaks stored templates and rules, so the diff here is the review signal.'
function build() {
require('../core')._reset()
const api = fakeApi()
require('../index')(fakeCtx(), api)
const { triggers, streams, audiences, engagementSeeds } = api.record
const manifest = require('../../module.json')
const byId = (a, b) => a.id.localeCompare(b.id)
return {
_comment: COMMENT,
coreApi: manifest.coreApi,
// Sorted by id: reordering a declaration in the source is not a contract
// change and must not produce a diff that looks like one. Variables keep
// their DECLARED order, which is the order the template editor shows.
triggers: [...triggers].sort(byId).map((t) => ({
id: t.id,
label: t.label,
description: t.description,
kind: t.kind,
subjectKey: t.subjectKey,
audience: t.audience,
ceiling: t.ceiling,
version: t.version,
variables: t.variables.map((v) => ({
name: v.name,
type: v.type,
required: v.required,
example: v.example,
description: v.description,
})),
})),
streams: [...streams].sort(byId).map((s) => ({
id: s.id,
label: s.label,
personal: s.personal,
requiresLinkedAccount: s.requiresLinkedAccount,
})),
// `resolve` is a function over this module's store and cannot be frozen.
// What is frozen is the part an operator's saved rule depends on.
audiences: [...audiences].sort(byId).map((a) => ({
id: a.id,
label: a.label,
params: a.params,
ceiling: a.ceiling,
})),
ruleGroups: engagementSeeds.ruleGroups.map((g) => ({
key: g.key,
rules: g.rules.map((r) => ({
trigger_id: r.trigger_id,
audience: r.audience,
channels: r.channels,
template_keys: r.template_keys,
conditions: r.conditions === undefined ? null : r.conditions,
cooldown_seconds: r.cooldown_seconds,
delay_seconds: r.delay_seconds || 0,
cancel_on: r.cancel_on || [],
})),
})),
templates: engagementSeeds.templates.map((t) => ({
key: t.key,
channel: t.channel,
triggerId: t.triggerId,
seedVersion: t.seedVersion,
})),
}
}
function main() {
const check = process.argv.includes('--check')
const next = `${JSON.stringify(build(), null, 2)}\n`
if (!check) {
fs.writeFileSync(MANIFEST, next)
process.stdout.write(`wrote ${path.relative(MODULE_ROOT, MANIFEST)}\n`)
return
}
// Line endings normalised, as `swaggerFragment.js` does: a Windows checkout
// under `core.autocrlf=true` turns the committed LF blob into CRLF, and a byte
// comparison would then call an unchanged file stale on every such machine —
// a check that cries wolf is a check nobody reads.
const lf = (s) => s.replace(/\r\n/g, '\n')
let committed
try {
committed = fs.readFileSync(MANIFEST, 'utf8')
} catch {
process.stderr.write('engagement-triggers.json is missing — run `npm run engagement:manifest`\n')
process.exit(1)
}
if (lf(committed) !== lf(next)) {
process.stderr.write(
'engagement-triggers.json is stale: a trigger, stream, audience or seeded rule changed.\n' +
'Run `npm run engagement:manifest` in server/ and commit the result — and read the diff,\n' +
'because a changed variable or ceiling is a change to every rule an operator has saved.\n',
)
process.exit(1)
}
process.stdout.write('engagement-triggers.json is current\n')
}
if (require.main === module) main()
module.exports = { build }

View File

@@ -52,11 +52,13 @@ const TIMEOUT_MS = 12000
* here, `PROTOCOL_VERSION` in the sidecar, `ProtocolVersion` in the bridge
* plugin, and `protocol` in its `overlay.toml`.
*
* **6 — first-party clans.** Protocol 2 was the read path, 3 the first
* **7 — the raid frame.** Protocol 2 was the read path, 3 the first
* message the WEBSITE originates (`link.confirm`), 4 the first that writes to
* the game's permission store, 5 the first that writes to the game HOST'S
* FILESYSTEM; 6 adds the `clans` board and five clan events core's Teams are
* built from, and no route at all. The bump lands here in the same change as the emitters,
* built from, and no route at all; **7** widens `entity.destroyed` to doors,
* walls and the cupboard and names who is authorised there, which is what the
* raid alert is sent to (PLAN.md §25). The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -66,7 +68,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 6
const PROTOCOL_VERSION = 7
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {

View File

@@ -0,0 +1,475 @@
// ── Notifications and engagement (phase 10, PLAN.md §25) ───────────────────
//
// The properties this suite holds, each with a failure behind it:
//
// • every declaration is one core will accept — a ceiling, a subjectKey that
// names a declared variable, an example on every variable, a closed type —
// because core refuses the WHOLE module at boot over one bad declaration;
// • no trigger declares an address, or the raider (D66);
// • the raid alert reaches exactly the authorised, linked people, one emit
// each with `ownerUserId`, and nobody when there is no cupboard (D59, D67);
// • a replayed event is told only while it is still news (D63);
// • a clan notice goes to the clan and never to whoever caused it;
// • a transition is announced once, and a first sighting never;
// • a tie at the top of the leaderboard is not a new leader;
// • every seed body interpolates only variables its trigger declares.
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx, spy } = require('./_fakes')
// Core's own check on a `url` variable (utils/engagementEmit.js RELATIVE_URL),
// copied so a path this module builds is held to the rule it will meet.
const RELATIVE_URL = /^\/(?!\/)[A-Za-z0-9\-._~/?#[\]@!$&'()*+,;=%]*$/
const CEILINGS = ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'admin', 'owner']
const TYPES = ['string', 'int', 'float', 'boolean', 'datetime', 'url']
const SERVER = { id: 'main', name: 'Main' }
const NOW = Date.now()
/** A fresh ctx, a recording emit, and a link table the tests control. */
function setup({ links = {}, members = {}, state = null, board = [] } = {}) {
require('../core')._reset()
const ctx = fakeCtx()
require('../core').init(ctx)
const emit = require('../engagement/emit')
emit.reset()
const linksDb = require('../model/links/links.db')
const clansDb = require('../model/clans/clans.db')
const clans = require('../model/clans/clans.model')
const serversDb = require('../model/servers/servers.db')
const eventsDb = require('../model/events/events.db')
const originals = [
[linksDb, { ...linksDb }], [clansDb, { ...clansDb }], [clans, { ...clans }],
[serversDb, { ...serversDb }], [eventsDb, { ...eventsDb }],
]
linksDb.userIdsForSteamIds = async (ids) =>
ids.filter((id) => links[id]).map((id) => ({ steamId: id, userId: links[id] }))
clans.resolveExternalId = async (serverId, frame) => (frame.clanId ? `${serverId}:${frame.clanId}:1` : null)
clansDb.listMembers = async (externalId) => (members[externalId] || []).map((steamId) => ({ steamId }))
serversDb.getState = async () => state
eventsDb.leaderboard = async () => board.shift() || []
const restore = () => {
for (const [mod, copy] of originals) Object.assign(mod, copy)
}
return { emit, calls: ctx.events.emit.calls, restore, eventsDb }
}
const raidFrame = (extra = {}) => ({
kind: 'entity.destroyed',
t: NOW,
ownerId: '100',
prefab: 'door.hinged.metal',
structure: 'door',
attackerId: '900',
attackerName: 'Raider',
grid: 'H7',
buildingId: '8113',
authorized: [
{ steamId: '101', online: false },
{ steamId: '102', online: true },
{ steamId: '103', online: false },
],
...extra,
})
// ── The declarations ───────────────────────────────────────────────────────
test('every trigger is a declaration core will accept', () => {
const { TRIGGERS } = require('../engagement/triggers')
const ids = new Set()
for (const t of TRIGGERS) {
assert.ok(t.id.startsWith('rust.'), `${t.id} is namespaced`)
assert.ok(!ids.has(t.id), `${t.id} is declared once`)
ids.add(t.id)
assert.ok(CEILINGS.includes(t.ceiling), `${t.id} has a real ceiling (there is no "self")`)
assert.ok(CEILINGS.includes(t.audience), `${t.id} has a real default audience`)
assert.strictEqual(t.kind, 'event')
const names = new Set(t.variables.map((v) => v.name))
assert.ok(names.has(t.subjectKey), `${t.id}'s subjectKey names a declared variable`)
for (const v of t.variables) {
assert.ok(TYPES.includes(v.type), `${t.id}.${v.name} has a closed type`)
assert.ok(v.example !== undefined, `${t.id}.${v.name} has an example`)
if (v.type === 'url') assert.match(v.example, RELATIVE_URL, `${t.id}.${v.name}'s example is site-relative`)
}
}
})
test('the default audience is never wider than the ceiling', () => {
const { TRIGGERS } = require('../engagement/triggers')
// The pairs this catalogue uses, each one core's `permits` accepts. A new
// pairing is a new line here — decided, not assumed.
const ALLOWED = new Set(['everyone>subscribers', 'owner>owner', 'members>members', 'staff>staff'])
for (const t of TRIGGERS) {
assert.ok(ALLOWED.has(`${t.ceiling}>${t.audience}`), `${t.id}: ${t.audience} under ${t.ceiling}`)
}
})
test('no trigger declares an address, or who raided whom (D66)', () => {
const { TRIGGERS } = require('../engagement/triggers')
for (const t of TRIGGERS) {
for (const v of t.variables) {
// By camelCase WORD: `wipeId` contains the letters "ip" and is not one.
const words = v.name.split(/(?=[A-Z])/).map((w) => w.toLowerCase())
for (const banned of ['ip', 'address', 'attacker', 'raider']) {
assert.ok(!words.includes(banned), `${t.id}.${v.name}`)
}
}
}
})
test('the ceilings are the ones §25.2 decided', () => {
const { TRIGGERS } = require('../engagement/triggers')
const by = Object.fromEntries(TRIGGERS.map((t) => [t.id, t.ceiling]))
assert.strictEqual(by['rust.base.destroyed'], 'owner')
assert.strictEqual(by['rust.player.linked'], 'owner')
for (const id of ['rust.player.reported', 'rust.player.banned', 'rust.player.unbanned', 'rust.login.denied']) {
assert.strictEqual(by[id], 'staff', id)
}
for (const id of ['rust.clan.member.left', 'rust.clan.member.kicked', 'rust.clan.disbanded']) {
assert.strictEqual(by[id], 'members', id)
}
// D64: core's team.member.joined already covers it, and kits wait for phase 13.
assert.strictEqual(by['rust.clan.member.added'], undefined)
assert.strictEqual(by['rust.kit.entitled'], undefined)
})
test('a clan path survives core\'s url check, colons and all', () => {
const { clanPath, serverPath, leaderboardPath } = require('../engagement/triggers')
assert.match(clanPath('main:12:1790142840000'), RELATIVE_URL)
assert.match(serverPath('eu 2'), RELATIVE_URL)
assert.match(leaderboardPath('main'), RELATIVE_URL)
})
// ── The seeds ──────────────────────────────────────────────────────────────
test('every seeded rule is ours, off, and names bodies that exist', () => {
const { TRIGGERS } = require('../engagement/triggers')
const { TEMPLATES, RULE_GROUPS } = require('../engagement/seeds')
const triggerIds = new Set(TRIGGERS.map((t) => t.id))
const own = new Set(TEMPLATES.map((t) => t.key))
const coreKeys = new Set(['notify.event', 'inapp.event', 'notify.digest'])
const groupKeys = new Set()
for (const group of RULE_GROUPS) {
assert.ok(!groupKeys.has(group.key), `group ${group.key} is unique`)
groupKeys.add(group.key)
for (const r of group.rules) {
assert.ok(triggerIds.has(r.trigger_id), `${r.trigger_id} is one of ours`)
assert.strictEqual(r.enabled, undefined, 'enabled is never a seed parameter')
assert.ok(Number.isInteger(r.max_sends_per_hour) && r.max_sends_per_hour >= 1)
for (const [slot, key] of Object.entries(r.template_keys)) {
assert.ok(own.has(key) || coreKeys.has(key), `${r.trigger_id}: ${key}`)
if (slot !== 'digest') assert.ok(r.channels.includes(slot), `${r.trigger_id}: ${slot} is a channel`)
}
for (const channel of r.channels) {
if (channel !== 'push') assert.ok(r.template_keys[channel], `${r.trigger_id}: a body for ${channel}`)
}
}
}
})
test('push appears only on the rules whose trigger is also a stream (D65)', () => {
const { STREAMS } = require('../engagement/streams')
const { RULE_GROUPS } = require('../engagement/seeds')
const pushable = new Set(STREAMS.map((s) => s.id))
for (const group of RULE_GROUPS) {
for (const r of group.rules) {
if (r.channels.includes('push')) assert.ok(pushable.has(r.trigger_id), r.trigger_id)
}
}
})
test('every body interpolates only what its trigger declares', () => {
const { TRIGGERS } = require('../engagement/triggers')
const { TEMPLATES } = require('../engagement/seeds')
const declared = new Map(TRIGGERS.map((t) => [t.id, new Set(t.variables.map((v) => v.name))]))
// Core's per-delivery and ambient variables — supplied by the renderer.
const ambient = new Set(['unsubscribeUrl', 'siteName', 'siteUrl', 'logoUrl', 'year'])
for (const t of TEMPLATES) {
const vars = declared.get(t.triggerId)
assert.ok(vars, `${t.key}'s trigger exists`)
const text = JSON.stringify([t.subject, t.blocks])
for (const [, name] of text.matchAll(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g)) {
assert.ok(vars.has(name) || ambient.has(name), `${t.key} uses {{${name}}}`)
}
assert.ok(t.key.startsWith('rust.'))
if (t.channel === 'email') assert.ok(t.subject)
else assert.strictEqual(t.subject, null)
}
})
test('the seeded raid rule is the OFFLINE raid alert, as a condition (D61)', () => {
const { RULE_GROUPS } = require('../engagement/seeds')
const raid = RULE_GROUPS.find((g) => g.key === 'raid-v1').rules[0]
assert.deepStrictEqual(raid.conditions, { variable: 'ownerOnline', cmp: 'eq', value: false })
assert.strictEqual(raid.audience, 'owner')
})
// ── The raid alert ─────────────────────────────────────────────────────────
test('the raid alert reaches each authorised, linked person, and nobody else', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } })
try {
const sent = await emit.onEvent(SERVER, { id: 1, kind: 'entity.destroyed', frame: raidFrame() })
assert.strictEqual(sent, 2)
assert.deepStrictEqual(calls.map((c) => c[1].ownerUserId).sort(), [11, 12])
for (const [trigger, env] of calls) {
assert.strictEqual(trigger, 'rust.base.destroyed')
assert.strictEqual(env.recipientUserIds, undefined, 'owner-shaped, never a recipient list')
assert.strictEqual(env.data.building, '8113')
assert.strictEqual(env.data.structure, 'door')
assert.strictEqual(env.data.atGrid, ' in H7')
assert.ok(!JSON.stringify(env.data).includes('Raider'), 'the raider is never named')
assert.ok(!JSON.stringify(env.data).includes('900'))
}
const online = Object.fromEntries(calls.map((c) => [c[1].ownerUserId, c[1].data.ownerOnline]))
assert.deepStrictEqual(online, { 11: false, 12: true })
} finally {
restore()
}
})
test('two Steam accounts held by one person are one alert, online if either is', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 11 } })
try {
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() })
assert.strictEqual(calls.length, 1)
assert.strictEqual(calls[0][1].data.ownerOnline, true)
} finally {
restore()
}
})
test('an authorised attacker is demolishing their own base: no alert', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11, 102: 12 } })
try {
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ attackerId: '102' }) })
assert.strictEqual(calls.length, 0)
} finally {
restore()
}
})
test('no cupboard, nobody to tell — and a protocol-6 frame is the same (D67)', async () => {
const { emit, calls, restore } = setup({ links: { 100: 10, 101: 11 } })
try {
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ buildingId: undefined, authorized: undefined }) })
// A protocol-6 frame: a BuildingBlock with an owner and no `authorized`.
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: { kind: 'entity.destroyed', t: NOW, ownerId: '100', prefab: 'wall' } })
assert.strictEqual(calls.length, 0, 'the placer is never a fallback')
} finally {
restore()
}
})
test('a replayed raid is still told for a day, and not after (D63)', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11 } })
try {
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 3 * 3600 * 1000 }) })
assert.strictEqual(calls.length, 1)
await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame({ t: NOW - 25 * 3600 * 1000 }) })
assert.strictEqual(calls.length, 1)
} finally {
restore()
}
})
test('the same frame replayed carries the same dedupe key, within core\'s bound', async () => {
const { emit, calls, restore } = setup({ links: { 101: 11 } })
try {
const item = { id: 7, kind: 'entity.destroyed', frame: raidFrame() }
await emit.onEvent(SERVER, item)
await emit.onEvent(SERVER, { ...item, id: 99 })
assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'keyed on the event, not the row id')
assert.ok(calls[0][1].dedupeKey.length <= 190)
} finally {
restore()
}
})
// ── Broadcasts ─────────────────────────────────────────────────────────────
test('a wipe is news for fifteen minutes (D63)', async () => {
const { emit, calls, restore } = setup()
try {
await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 60 * 1000, wipeId: 'w2' } })
await emit.onEvent(SERVER, { kind: 'server.wipe', frame: { kind: 'server.wipe', t: NOW - 20 * 60 * 1000, wipeId: 'w3' } })
assert.strictEqual(calls.length, 1)
assert.strictEqual(calls[0][0], 'rust.wipe.started')
assert.strictEqual(calls[0][1].data.wipeId, 'w2')
} finally {
restore()
}
})
test('online and offline are transitions, and a first sighting is not one', () => {
const { emit, calls, restore } = setup()
try {
emit.serverObserved(SERVER, true)
emit.serverObserved(SERVER, true)
assert.strictEqual(calls.length, 0, 'a restart announces nothing')
emit.serverObserved(SERVER, false)
emit.serverObserved(SERVER, false)
emit.serverObserved(SERVER, true)
assert.deepStrictEqual(calls.map((c) => c[0]), ['rust.server.offline', 'rust.server.online'])
assert.strictEqual(calls[0][1].data.serverId, 'main')
} finally {
restore()
}
})
test('a new leader is announced once; a tie is not a new leader', async () => {
const row = (steamId, kills) => ({ steamId, name: `P${steamId}`, kills })
const { emit, calls, restore } = setup({
state: { wipeId: 'w1' },
board: [
[row('1', 5), row('2', 3)], // first sight: remembered, not announced
[row('2', 5), row('1', 5)], // level on kills: not a change
[row('2', 6), row('1', 5)], // strictly ahead: announced
[row('2', 7), row('1', 5)], // the same leader: nothing
],
})
try {
for (let i = 0; i < 4; i += 1) await emit.checkLeader(SERVER)
assert.strictEqual(calls.length, 1)
assert.strictEqual(calls[0][0], 'rust.leaderboard.topped')
assert.strictEqual(calls[0][1].data.leader, 'P2')
assert.strictEqual(calls[0][1].data.kills, 6)
} finally {
restore()
}
})
// ── Clans ──────────────────────────────────────────────────────────────────
test('a disband goes to the roster on the frame, not to the one who did it', async () => {
const { emit, calls, restore } = setup({ links: { 201: 21, 202: 22, 203: 23 } })
try {
await emit.onEvent(SERVER, {
kind: 'clan.disbanded',
frame: { kind: 'clan.disbanded', t: NOW, clanId: 4, clanName: 'Belt', steamId: '201', name: 'Boss', members: ['201', '202', '203'] },
})
assert.strictEqual(calls.length, 1)
assert.strictEqual(calls[0][0], 'rust.clan.disbanded')
assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [22, 23])
assert.strictEqual(calls[0][1].data.by, 'Boss')
assert.match(calls[0][1].data.clanUrl, RELATIVE_URL)
} finally {
restore()
}
})
test('the one kicked is told; the one who kicked is not', async () => {
const { emit, calls, restore } = setup({
links: { 301: 31, 302: 32, 303: 33 },
members: { 'main:5:1': ['301', '302'] }, // the board already dropped 303
})
try {
await emit.onEvent(SERVER, {
kind: 'clan.member.kicked',
frame: { kind: 'clan.member.kicked', t: NOW, clanId: 5, steamId: '303', name: 'Out', bySteamId: '301', byName: 'Boss' },
})
assert.deepStrictEqual(calls[0][1].recipientUserIds.sort(), [32, 33])
} finally {
restore()
}
})
test('a leaver is not told they left, and a lone leaver tells nobody', async () => {
const { emit, calls, restore } = setup({ links: { 401: 41, 402: 42 }, members: { 'main:6:1': ['401', '402'] } })
try {
await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 6, steamId: '402' } })
assert.deepStrictEqual(calls[0][1].recipientUserIds, [41])
await emit.onEvent(SERVER, { kind: 'clan.member.left', frame: { kind: 'clan.member.left', t: NOW, clanId: 7, steamId: '402' } })
assert.strictEqual(calls.length, 1)
} finally {
restore()
}
})
// ── Moderation ─────────────────────────────────────────────────────────────
test('a ban never carries the address the frame does', async () => {
const { emit, calls, restore } = setup()
try {
await emit.onEvent(SERVER, {
kind: 'player.banned',
frame: { kind: 'player.banned', t: NOW, steamId: '555', name: 'Cheater', ip: '203.0.113.9', reason: 'aimbot' },
})
assert.strictEqual(calls.length, 1)
assert.ok(!JSON.stringify(calls[0][1]).includes('203.0.113.9'))
assert.strictEqual(calls[0][1].data.reason, 'aimbot')
} finally {
restore()
}
})
test('an unapproved login becomes a staff notice, keyed on the attempt', async () => {
const { emit, calls, restore, eventsDb } = setup()
try {
const asked = []
eventsDb.unapprovedLogins = async (q) => {
asked.push(q)
return [{ steamId: '777', t: NOW - 120000, name: 'Knocker' }]
}
const sent = await emit.sweepLoginDenied([SERVER], NOW)
await emit.sweepLoginDenied([SERVER], NOW)
assert.strictEqual(sent, 1)
assert.strictEqual(asked[0].to, NOW - emit.LOGIN_APPROVAL_WINDOW_MS, 'an attempt waits its minute first')
assert.strictEqual(calls[0][0], 'rust.login.denied')
assert.strictEqual(calls[0][1].dedupeKey, calls[1][1].dedupeKey, 'a second sweep is a no-op in core')
} finally {
restore()
}
})
// ── The rest ───────────────────────────────────────────────────────────────
test('a new link tells its owner, and only its owner', () => {
const { emit, calls, restore } = setup()
try {
emit.linked({ userId: 5, steamId: '76561198000000001', name: 'Me' })
assert.strictEqual(calls[0][0], 'rust.player.linked')
assert.strictEqual(calls[0][1].ownerUserId, 5)
assert.strictEqual(emit.linked({ userId: 0, steamId: 'x' }), 0)
} finally {
restore()
}
})
test('a frame the fan-out cannot handle costs one notice, never the caller', async () => {
const { emit, restore } = setup()
try {
const linksDb = require('../model/links/links.db')
linksDb.userIdsForSteamIds = async () => { throw new Error('database gone') }
const sent = await emit.onEvent(SERVER, { kind: 'entity.destroyed', frame: raidFrame() })
assert.strictEqual(sent, 0)
} finally {
restore()
}
})
test('an audience that fails answers nobody, never everybody', async () => {
require('../core')._reset()
const ctx = fakeCtx({ db: { query: spy(() => Promise.reject(new Error('down'))), pool: {} } })
require('../core').init(ctx)
const { AUDIENCES } = require('../engagement/audiences')
for (const a of AUDIENCES) {
assert.deepStrictEqual(await a.resolve({ clan: 'main:1:1', serverId: 'main' }), [], a.id)
assert.deepStrictEqual(await a.resolve({}), [], `${a.id} with no param`)
}
})

View File

@@ -137,17 +137,36 @@ test('nothing is registered that has nothing behind it yet', () => {
// surfaces an operator can configure and then wait on — worse than an absent
// one, because the absence is visible. Each of these arrives with the phase
// that has something real to put in it, and this assertion is what that phase
// deletes. Phase 9 deleted the Team provider's line.
assert.strictEqual(api.record.triggers, null)
assert.strictEqual(api.record.audiences, null)
assert.strictEqual(api.record.engagementSeeds, null)
assert.strictEqual(api.record.streams, null)
// deletes. Phase 9 deleted the Team provider's line; phase 10 the four
// engagement lines, and the announce leg and post hook it deliberately did
// NOT register (D62) moved into the assertions below.
assert.deepStrictEqual(api.record.legs, [])
assert.strictEqual(api.record.hooks.post, undefined)
assert.strictEqual(api.record.eventBudgets, null)
assert.strictEqual(api.record.eventOptionSources, null)
assert.strictEqual(api.record.eventLeases, null)
assert.strictEqual(api.record.eventActions, null)
})
test('the engagement set is registered as one decision (phase 10, R7)', () => {
const { api } = register()
const triggers = api.record.triggers
const streams = api.record.streams
assert.ok(Array.isArray(triggers) && triggers.length > 0)
assert.ok(Array.isArray(api.record.audiences) && api.record.audiences.length === 3)
assert.ok(api.record.engagementSeeds && Array.isArray(api.record.engagementSeeds.ruleGroups))
// A stream is a toggle for a trigger; one with no trigger behind it is a
// toggle nothing can ever fire (D65, one namespace across both facets).
const triggerIds = new Set(triggers.map((t) => t.id))
for (const s of streams) assert.ok(triggerIds.has(s.id), `stream ${s.id} has no trigger`)
assert.deepStrictEqual(
streams.map((s) => s.id).sort(),
['rust.base.destroyed', 'rust.server.offline', 'rust.server.online', 'rust.wipe.started'],
)
})
test('the module’s protocol version agrees with the manifest it ships beside', () => {
const sidecar = require('../sidecarClient')

View File

@@ -91,9 +91,12 @@ test('neither unhappy path calls putState', async () => {
const originalPut = db.putState
const originalMark = db.markUnreachable
const originalBoards = sidecar.boards
const originalHealth = sidecar.health
const marked = []
let putCalls = 0
sidecar.health = async () => ({ ok: false, status: 0, data: null })
db.putState = async () => { putCalls += 1 }
db.markUnreachable = async (id, reachable) => { marked.push([id, reachable]) }
@@ -112,5 +115,83 @@ test('neither unhappy path calls putState', async () => {
db.putState = originalPut
db.markUnreachable = originalMark
sidecar.boards = originalBoards
sidecar.health = originalHealth
}
})
test('a board the game left behind is not a game that is up (D68)', async () => {
// The sidecar keeps its last `server.hello` after the plugin disconnects, so
// until phase 10 a hung game — or an unloaded bridge — with the sidecar still
// up read as ONLINE here, with the players it had when it stopped. Only
// `/health` knows whether the plugin is connected now.
withCore(fakeCtx({ db: { query: () => Promise.resolve([]), pool: {} } }))
const db = require('../model/servers/servers.db')
const sidecar = require('../sidecarClient')
const ingest = require('../ingest')
const boot = require('../boot')
const engagement = require('../engagement/emit')
const saved = { put: db.putState, boards: sidecar.boards, health: sidecar.health, apply: ingest.applyBoards }
const puts = []
const applied = []
engagement.reset()
db.putState = async (state) => { puts.push(state) }
ingest.applyBoards = async (id, boards) => { applied.push(boards) }
sidecar.boards = async () => ({
ok: true,
status: 200,
data: { boards: {
'server.hello': { players: 12, maxPlayers: 100, hostname: 'Main' },
'players.online': { players: [{ steamId: '1', name: 'Still here?' }] },
} },
})
const server = { id: 'main', name: 'Main', baseUrl: 'http://127.0.0.1:1', token: 't', protocol: 7 }
try {
sidecar.health = async () => ({ ok: true, status: 200, data: { plugin_connected: false } })
await boot.refreshOne(server)
assert.strictEqual(puts[0].online, false)
assert.strictEqual(puts[0].players, 0, 'the last count is not a count')
assert.strictEqual(puts[0].seen, false, 'a stale board must not move "last seen"')
assert.strictEqual(puts[0].hostname, 'Main', 'the description is still written')
assert.deepStrictEqual(applied[0]['players.online'].players, [], 'nobody is named as online')
sidecar.health = async () => ({ ok: true, status: 200, data: { plugin_connected: true } })
await boot.refreshOne(server)
assert.strictEqual(puts[1].online, true)
assert.strictEqual(puts[1].players, 12)
assert.notStrictEqual(puts[1].seen, false)
assert.strictEqual(applied[1]['players.online'].players.length, 1)
// An unanswered /health is unknown, and unknown is not up.
sidecar.health = async () => ({ ok: false, status: 0, data: null })
await boot.refreshOne(server)
assert.strictEqual(puts[2].online, false)
} finally {
db.putState = saved.put
sidecar.boards = saved.boards
sidecar.health = saved.health
ingest.applyBoards = saved.apply
engagement.reset()
}
})
test('putState moves last_seen_at only for a game that was seen', async () => {
const queries = []
withCore(fakeCtx({
db: { query: (sql, params) => { queries.push({ sql, params }); return Promise.resolve([]) }, pool: {} },
}))
const db = require('../model/servers/servers.db')
await db.putState({ serverId: 'main', reachable: true, online: false, seen: false })
await db.putState({ serverId: 'main', reachable: true, online: true })
// The two trailing parameters feed the two IF(?, CURRENT_TIMESTAMP, …)s.
assert.deepStrictEqual(queries[0].params.slice(-2), [0, 0])
assert.deepStrictEqual(queries[1].params.slice(-2), [1, 1])
assert.strictEqual((queries[0].sql.match(/\?/g) || []).length, queries[0].params.length, 'every placeholder has a value')
})