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

Merged
whitlocktech merged 2 commits from feat/phase-10-engagement into edge 2026-09-23 18:44:00 +00:00
22 changed files with 3555 additions and 32 deletions

View File

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

View File

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

1203
engagement-triggers.json Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

563
server/engagement/emit.js Normal file
View 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
View File

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

View File

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

View File

@@ -0,0 +1,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 }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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`)
}
})

View File

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

View File

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