Merge pull request 'feat(rust): notifications and engagement (phase 10, protocol 7)' (#13) from feat/phase-10-engagement into edge
Reviewed-on: #13
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
1203
engagement-triggers.json
Normal file
1203
engagement-triggers.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
}
|
||||
|
||||
107
server/engagement/audiences.js
Normal file
107
server/engagement/audiences.js
Normal 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 }
|
||||
563
server/engagement/emit.js
Normal file
563
server/engagement/emit.js
Normal file
@@ -0,0 +1,563 @@
|
||||
// ── 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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The headline every trigger carries (`triggers.js` HEADLINE).
|
||||
*
|
||||
* Core's generic bodies fall back to a trigger's LABEL and DESCRIPTION when the
|
||||
* payload has no `title`/`intro`, and on a multi-server site that fallback says
|
||||
* "A server came online" without ever saying which. So the sentence is written
|
||||
* here, from the payload, and core renders it. Plain register, no conditionals:
|
||||
* a missing part falls back to a neutral word rather than leaving a hole.
|
||||
*/
|
||||
const HEADLINES = Object.freeze({
|
||||
'rust.base.destroyed': (d) => ({
|
||||
title: `Your base on ${d.server} is being raided`,
|
||||
intro: `A ${d.structure} was destroyed${d.atGrid || ''} on ${d.server}.`,
|
||||
}),
|
||||
'rust.wipe.started': (d) => ({
|
||||
title: `${d.server} has wiped`,
|
||||
intro: `A new wipe has started on ${d.server}: a fresh map, and a fresh start for everyone.`,
|
||||
}),
|
||||
'rust.server.online': (d) => ({
|
||||
title: `${d.server} is online`,
|
||||
intro: `${d.server} is back up and talking to the website.`,
|
||||
}),
|
||||
'rust.server.offline': (d) => ({
|
||||
title: `${d.server} is offline`,
|
||||
intro: `${d.server} stopped, or stopped talking to the website.`,
|
||||
}),
|
||||
'rust.leaderboard.topped': (d) => ({
|
||||
title: `${d.leader} leads ${d.server}`,
|
||||
intro: `${d.leader} now leads this wipe's kills on ${d.server}, with ${d.kills}.`,
|
||||
}),
|
||||
'rust.player.linked': (d) => ({
|
||||
title: 'A Steam account was linked to your account',
|
||||
intro: `The Steam account ${d.player || d.steamId} was linked with an in-game code. `
|
||||
+ 'If that was not you, unlink it from your Rust account page.',
|
||||
}),
|
||||
'rust.clan.member.left': (d) => ({
|
||||
title: `${d.member || 'A member'} left ${d.clan}`,
|
||||
intro: `${d.member || 'A member'} left ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.clan.member.kicked': (d) => ({
|
||||
title: `${d.member || 'A member'} was removed from ${d.clan}`,
|
||||
intro: `${d.by || 'A clan leader'} removed ${d.member || 'a member'} from ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.clan.disbanded': (d) => ({
|
||||
title: `${d.clan} was disbanded`,
|
||||
intro: `${d.by || 'A clan leader'} disbanded ${d.clan} on ${d.server}.`,
|
||||
}),
|
||||
'rust.player.reported': (d) => ({
|
||||
title: `${d.player || d.steamId} was reported on ${d.server}`,
|
||||
intro: `${d.reporter || 'A player'} reported ${d.player || d.steamId}`
|
||||
+ `${d.reportType ? ` (${d.reportType})` : ''}${d.topic ? `: ${d.topic}` : '.'}`,
|
||||
}),
|
||||
'rust.player.banned': (d) => ({
|
||||
title: `${d.player || d.steamId} was banned on ${d.server}`,
|
||||
intro: d.reason ? `Reason given: ${d.reason}` : 'No reason was given.',
|
||||
}),
|
||||
'rust.player.unbanned': (d) => ({
|
||||
title: `${d.player || d.steamId} was unbanned on ${d.server}`,
|
||||
intro: `The ban on ${d.player || d.steamId} (${d.steamId}) was lifted.`,
|
||||
}),
|
||||
'rust.login.denied': (d) => ({
|
||||
title: `A login to ${d.server} was not approved`,
|
||||
intro: `${d.player || 'Someone'} (${d.steamId}) tried to join ${d.server} and was not let in within a minute.`,
|
||||
}),
|
||||
})
|
||||
|
||||
function headline(triggerId, data) {
|
||||
const make = HEADLINES[triggerId]
|
||||
return make ? make(data || {}) : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one event to core. Never throws.
|
||||
*
|
||||
* 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 {
|
||||
const data = envelope.data || {}
|
||||
core.emit(triggerId, { ...envelope, data: { ...headline(triggerId, data), ...data } })
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error('core refused an emit', { trigger: triggerId, error: err.message })
|
||||
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,
|
||||
headline,
|
||||
stillNews,
|
||||
BROADCAST_MAX_AGE_MS,
|
||||
PERSONAL_MAX_AGE_MS,
|
||||
LOGIN_APPROVAL_WINDOW_MS,
|
||||
STRUCTURE_LABELS,
|
||||
}
|
||||
315
server/engagement/seeds.js
Normal file
315
server/engagement/seeds.js
Normal 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 }
|
||||
53
server/engagement/streams.js
Normal file
53
server/engagement/streams.js
Normal 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 }
|
||||
381
server/engagement/triggers.js
Normal file
381
server/engagement/triggers.js
Normal file
@@ -0,0 +1,381 @@
|
||||
// ── What can happen, as core's engagement engine is told it ───────────────
|
||||
//
|
||||
// The payload contracts behind every notification this module can cause
|
||||
// (MODULE_API.md §2.4, `registerEventTriggers`; PLAN.md §25). A trigger says
|
||||
// what an event IS, what a template may interpolate, and — the part that is a
|
||||
// security boundary — the widest audience a rule on it may EVER be given.
|
||||
//
|
||||
// ── The ceiling is containment, not size ──────────────────────────────────
|
||||
//
|
||||
// `owner` is not a small `staff`, and `staff` does not permit `owner`. For the
|
||||
// raid alert "one person" is the person whose base it was; for a ban it is
|
||||
// nobody outside the staff room. Each ceiling below is chosen against that
|
||||
// lattice and not against a ladder, and core refuses a rule that widens one.
|
||||
//
|
||||
// ── What no variable here carries, on purpose ─────────────────────────────
|
||||
//
|
||||
// • An IP address. The login and ban frames carry one; the triggers do not,
|
||||
// so no template an operator writes can put an address in a mail. The
|
||||
// admin feed still shows it, to staff, where it is useful.
|
||||
// • The raider (D66). The raid alert says what was destroyed, where and
|
||||
// when. Who did it is gameplay intelligence the game does not hand the
|
||||
// victim, and a variable that is not declared cannot be interpolated.
|
||||
// • A Steam id other than the subject's own.
|
||||
//
|
||||
// ── Why `subjectKey` is what it is ────────────────────────────────────────
|
||||
//
|
||||
// Core's cooldown is per (rule, user, subject, channel). So the subject is the
|
||||
// thing a recipient should hear about once per cooldown: a BUILDING for a raid
|
||||
// (however many walls fall), a SERVER for a broadcast (however often it
|
||||
// bounces), a CLAN for a membership change. A subject that changed every firing
|
||||
// — a boot id, a timestamp — would make every cooldown a no-op.
|
||||
//
|
||||
// ── `version` ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The prop-schema version a template records it was authored against. Bump one
|
||||
// on a rename or a type change, never for a label.
|
||||
|
||||
const ID = 'rust'
|
||||
|
||||
/** Site-relative paths, built the way the client registers them. */
|
||||
const PATHS = {
|
||||
servers: `/${ID}`,
|
||||
account: `/player/${ID}`,
|
||||
}
|
||||
|
||||
// A server id is VARCHAR(64) of the operator's choosing, and a clan's
|
||||
// `externalId` is `<serverId>:<clanId>:<createdMs>`. Core validates a `url`
|
||||
// variable against a character class with no `:` in it, so every id that goes
|
||||
// into a path is percent-encoded — without it the clan link would be dropped at
|
||||
// emit in production, silently, for every clan there is.
|
||||
const serverPath = (serverId) => `/${ID}/servers/${encodeURIComponent(serverId)}`
|
||||
const leaderboardPath = (serverId) => `${serverPath(serverId)}?tab=leaderboard`
|
||||
const clanPath = (externalId) => `/${ID}/clans/${encodeURIComponent(externalId)}`
|
||||
|
||||
const V1 = 1
|
||||
|
||||
// ── Shared variables ───────────────────────────────────────────────────────
|
||||
|
||||
// **Every trigger carries its own headline.** Most rules here point at core's
|
||||
// generic `notify.event` / `inapp.event`, and core's structural projection fills
|
||||
// `title` and `intro` from the trigger's LABEL and DESCRIPTION only when the
|
||||
// payload does not define them — "the payload wins, the projection fills gaps"
|
||||
// (ENGAGEMENT.md §4.6.1). Without these two, the phase-10 walk rendered a
|
||||
// multi-server site's notice as "A server came online. A server's game
|
||||
// started…" — true, and useless, because it never said which. The emitter
|
||||
// writes the sentence (`emit.js` `headline`); an operator's own template can
|
||||
// still ignore it and interpolate the parts.
|
||||
const HEADLINE = [
|
||||
{ name: 'title', type: 'string', required: false, example: 'Main is back online',
|
||||
description: 'A one-line headline naming what happened and where. Core generic bodies use it as the title.' },
|
||||
{ name: 'intro', type: 'string', required: false, example: 'Main is back up and taking players.',
|
||||
description: 'One sentence of detail. Core generic bodies use it as the body.' },
|
||||
]
|
||||
|
||||
const SERVER = [
|
||||
{ name: 'serverId', type: 'string', required: true, example: 'main',
|
||||
description: 'The server the event happened on, as configured in Admin -> Rust. Also the cooldown subject for broadcasts.' },
|
||||
{ name: 'server', type: 'string', required: true, example: 'Runic Gateway | Main',
|
||||
description: 'The server\'s display name.' },
|
||||
{ name: 'serverUrl', type: 'url', required: false, example: '/rust/servers/main',
|
||||
description: 'Site-relative path to the server\'s page.' },
|
||||
]
|
||||
|
||||
const CLAN = [
|
||||
{ name: 'clanKey', type: 'string', required: true, example: 'main:12:1790142840000',
|
||||
description: 'The clan\'s stable identity. The cooldown subject; not meant for display.' },
|
||||
{ name: 'clan', type: 'string', required: true, example: 'The Rust Belt',
|
||||
description: 'The clan\'s name.' },
|
||||
{ name: 'clanUrl', type: 'url', required: false, example: '/rust/clans/main%3A12%3A1790142840000',
|
||||
description: 'Site-relative path to the clan\'s page.' },
|
||||
]
|
||||
|
||||
// ── The raid alert ─────────────────────────────────────────────────────────
|
||||
|
||||
const RAID = {
|
||||
id: 'rust.base.destroyed',
|
||||
label: 'Your base was raided',
|
||||
description:
|
||||
'Part of a base you are authorised on was destroyed by another player: a wall, a door, ' +
|
||||
'an external wall or gate, or the tool cupboard.',
|
||||
kind: 'event',
|
||||
// One per base per cooldown, however many walls fall. The building is the
|
||||
// tool cupboard's id — the game's own answer to "which base is this".
|
||||
subjectKey: 'building',
|
||||
// One emit per authorised, linked person, each with `ownerUserId` set (D59).
|
||||
// `owner` is the ceiling AND the default: there is nobody else this may reach.
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'building', type: 'string', required: true, example: '8113',
|
||||
description: 'The base, as the id of its tool cupboard. The cooldown subject.' },
|
||||
{ name: 'structure', type: 'string', required: true, example: 'door',
|
||||
description: 'What was destroyed: "building block", "door", "external wall" or "tool cupboard".' },
|
||||
{ name: 'grid', type: 'string', required: false, example: 'H7',
|
||||
description: 'The map grid square. Absent when the server could not work one out.' },
|
||||
// A FRAGMENT, for use inside a sentence. An unset optional interpolates to
|
||||
// the empty string, so "your door in {{grid}} was destroyed" reads "your
|
||||
// door in was destroyed" when the grid is unknown; this carries its own
|
||||
// leading space and vanishes cleanly instead.
|
||||
{ name: 'atGrid', type: 'string', required: false, example: ' in H7',
|
||||
description: 'Sentence fragment: " in H7" with its own leading space, or nothing when the grid is unknown.' },
|
||||
{ name: 'ownerOnline', type: 'boolean', required: true, example: false,
|
||||
description: 'Whether YOU were online when it happened. The seeded rule alerts only when this is false.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Server lifecycle ───────────────────────────────────────────────────────
|
||||
//
|
||||
// `everyone` because a server being up is what a server page already says to
|
||||
// anyone. The DEFAULT is `subscribers` — the people who asked — and an operator
|
||||
// widens deliberately.
|
||||
|
||||
const BROADCASTS = [
|
||||
{
|
||||
id: 'rust.wipe.started',
|
||||
label: 'A server wiped',
|
||||
description: 'A server started a new wipe: a fresh map, and everything built on the old one gone.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'wipeId', type: 'string', required: true, example: '1790142840-3000-1234',
|
||||
description: 'The new wipe\'s identity.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.server.online',
|
||||
label: 'A server came online',
|
||||
description: 'A server\'s game started, or came back after being unreachable.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE],
|
||||
},
|
||||
{
|
||||
id: 'rust.server.offline',
|
||||
label: 'A server went offline',
|
||||
description: 'A server\'s game stopped, crashed, or stopped talking to the website.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [...SERVER, ...HEADLINE],
|
||||
},
|
||||
{
|
||||
id: 'rust.leaderboard.topped',
|
||||
label: 'A new kills leader',
|
||||
description: 'Somebody new leads the current wipe\'s kills on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'serverId',
|
||||
audience: 'subscribers',
|
||||
ceiling: 'everyone',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'leader', type: 'string', required: true, example: 'Marisol',
|
||||
description: 'The new leader\'s in-game name.' },
|
||||
{ name: 'kills', type: 'int', required: true, example: 42,
|
||||
description: 'Their kills this wipe.' },
|
||||
{ name: 'leaderboardUrl', type: 'url', required: false, example: '/rust/servers/main?tab=leaderboard',
|
||||
description: 'Site-relative path to the server\'s leaderboard.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── The player's own account ───────────────────────────────────────────────
|
||||
|
||||
const ACCOUNT = {
|
||||
id: 'rust.player.linked',
|
||||
label: 'A Steam account was linked',
|
||||
description: 'A Steam account was linked to your website account with an in-game code.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
// PLAN.md §10 said `self`; core has no such ceiling (§25.1). `owner` with the
|
||||
// linking user as `ownerUserId` is the value that exists and means the same.
|
||||
audience: 'owner',
|
||||
ceiling: 'owner',
|
||||
version: V1,
|
||||
variables: [
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000001',
|
||||
description: 'The Steam account that was linked. Also the cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'The in-game name the game reported when it was linked.' },
|
||||
{ name: 'accountUrl', type: 'url', required: false, example: '/player/rust',
|
||||
description: 'Site-relative path to your Rust account page.' },
|
||||
],
|
||||
}
|
||||
|
||||
// ── Clans ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `members` ceiling — clan membership is the clan's business (D49). Recipients
|
||||
// travel on the envelope as `recipientUserIds`, because "the clan this was
|
||||
// about" is a different answer every firing and cannot be a saved audience.
|
||||
//
|
||||
// No `rust.clan.member.added`: core already fires `team.member.joined` for our
|
||||
// clans through the Team sync, and a second trigger would notify twice (D64).
|
||||
|
||||
const CLANS = [
|
||||
{
|
||||
id: 'rust.clan.member.left',
|
||||
label: 'Someone left your clan',
|
||||
description: 'A member left a clan you are in.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'member', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'Who left.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.clan.member.kicked',
|
||||
label: 'Someone was removed from your clan',
|
||||
description: 'A member was removed from a clan you are in — or you were.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'member', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'Who was removed.' },
|
||||
{ name: 'by', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who removed them.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.clan.disbanded',
|
||||
label: 'Your clan was disbanded',
|
||||
description: 'A clan you were in was disbanded.',
|
||||
kind: 'event',
|
||||
subjectKey: 'clanKey',
|
||||
audience: 'members',
|
||||
ceiling: 'members',
|
||||
version: V1,
|
||||
variables: [
|
||||
...CLAN,
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'by', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who disbanded it.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Moderation — staff, and never wider ────────────────────────────────────
|
||||
|
||||
const MODERATION = [
|
||||
{
|
||||
id: 'rust.player.reported',
|
||||
label: 'A player was reported',
|
||||
description: 'A player filed an in-game report against another.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The reported player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The reported player\'s name.' },
|
||||
{ name: 'reporter', type: 'string', required: false, example: 'Marisol',
|
||||
description: 'Who filed the report.' },
|
||||
{ name: 'reportType', type: 'string', required: false, example: 'cheat',
|
||||
description: 'The category the reporter chose.' },
|
||||
{ name: 'topic', type: 'string', required: false, example: 'Aimbot at the dome',
|
||||
description: 'The report\'s subject line.' },
|
||||
{ name: 'message', type: 'string', required: false, example: 'Headshots through two walls.',
|
||||
description: 'The report\'s text.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.player.banned',
|
||||
label: 'A player was banned',
|
||||
description: 'A player was banned on a server.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The banned player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The banned player\'s name.' },
|
||||
{ name: 'reason', type: 'string', required: false, example: 'Cheating',
|
||||
description: 'The reason given.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.player.unbanned',
|
||||
label: 'A player was unbanned',
|
||||
description: 'A ban on a server was lifted.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The player\'s Steam id. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The player\'s name.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rust.login.denied',
|
||||
label: 'A login was not approved',
|
||||
description:
|
||||
'Somebody tried to join a server and was not let in within a minute: a ban, a failed ' +
|
||||
'authentication, or a player who gave up while connecting.',
|
||||
kind: 'event',
|
||||
subjectKey: 'steamId',
|
||||
audience: 'staff',
|
||||
ceiling: 'staff',
|
||||
version: V1,
|
||||
variables: [
|
||||
...SERVER,
|
||||
...HEADLINE,
|
||||
{ name: 'steamId', type: 'string', required: true, example: '76561198000000002',
|
||||
description: 'The Steam id that tried to connect. The cooldown subject.' },
|
||||
{ name: 'player', type: 'string', required: false, example: 'Darrow',
|
||||
description: 'The name it connected with.' },
|
||||
{ name: 'attemptedAt', type: 'datetime', required: true, example: '2026-09-23T03:10:00Z',
|
||||
description: 'When the attempt was made.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGERS = Object.freeze([RAID, ...BROADCASTS, ACCOUNT, ...CLANS, ...MODERATION])
|
||||
|
||||
const TRIGGER_IDS = Object.freeze(Object.fromEntries(TRIGGERS.map((t) => [t.id, t.id])))
|
||||
|
||||
module.exports = { TRIGGERS, TRIGGER_IDS, PATHS, serverPath, leaderboardPath, clanPath }
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -563,6 +563,7 @@ module.exports = {
|
||||
normaliseClan,
|
||||
applyBoard,
|
||||
applyEvent,
|
||||
resolveExternalId,
|
||||
reofferActivity,
|
||||
activityItem,
|
||||
dedupeKeyOf,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
146
server/scripts/engagementManifest.js
Normal file
146
server/scripts/engagementManifest.js
Normal 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 }
|
||||
@@ -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) {
|
||||
|
||||
492
server/test/engagement.test.js
Normal file
492
server/test/engagement.test.js
Normal file
@@ -0,0 +1,492 @@
|
||||
// ── 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)
|
||||
})
|
||||
|
||||
test('every trigger names what happened and where, even with its optionals missing', () => {
|
||||
// The walk found a multi-server site's generic notice reading "A server came
|
||||
// online" — core's projection falls back to the LABEL when the payload carries
|
||||
// no `title`. Every trigger therefore declares its own, and the emitter writes
|
||||
// it; a headline that printed "undefined" would be worse than the label.
|
||||
const { TRIGGERS } = require('../engagement/triggers')
|
||||
const { headline } = require('../engagement/emit')
|
||||
for (const t of TRIGGERS) {
|
||||
const minimal = Object.fromEntries(t.variables.filter((v) => v.required).map((v) => [v.name, v.example]))
|
||||
const h = headline(t.id, minimal)
|
||||
assert.ok(h.title && h.intro, `${t.id} has a headline`)
|
||||
assert.ok(!/undefined|null/.test(h.title + h.intro), `${t.id}: ${h.title} / ${h.intro}`)
|
||||
}
|
||||
const online = headline('rust.server.online', { server: 'EU 2' })
|
||||
assert.match(online.title, /EU 2/, 'the notice says WHICH server')
|
||||
})
|
||||
|
||||
// ── The seeds ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('every seeded rule is ours, off, and names bodies that exist', () => {
|
||||
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`)
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user