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

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

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

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

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

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

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

View File

@@ -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,
],
)
}