Files
Module-Rust/server/model/clans/clans.model.js
wtclaude 285db0baa7 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
2026-09-23 06:06:19 -05:00

575 lines
21 KiB
JavaScript

// ── First-party clans: the board, the events, and who may see a roster ────
//
// Rust's OWN clan system, which this module turns into core's Teams (R5,
// PLAN.md §24). Three jobs, one file, because all three have to agree on what a
// clan's identity is:
//
// applyBoard a `clans` snapshot → the store, plus what changed
// applyEvent a `clan.*` event → core (publish) and the Team feed
// canSeeRoster D48's audience, for core's `projectRoster` and our own page
//
// ── The identity (D52) ────────────────────────────────────────────────────
//
// `<serverId>:<clanId>:<createdMs>`. The game's clan id alone is not one: its
// database file carries a hard-coded version, so a game update that bumps it
// starts a fresh file and ids restart at 1. Keyed on the id, the new clan #1
// would inherit the old clan #1's Team, forum and history.
//
// ── What a board may conclude, and what it may not ───────────────────────
//
// A board is authoritative for the clans it CARRIES. It is authoritative about
// the clans it does NOT carry only when it is complete: a board truncated at the
// game's 100-clan ceiling (D55), or one with a row this build could not read,
// proves nothing about a clan it leaves out, and marking that clan gone would
// hand core an archive on no evidence.
const crypto = require('node:crypto')
const core = require('../../core')
const db = require('./clans.db')
const visibility = require('../visibility/visibility.model')
const log = core.logger('clans')
/**
* How long a board may go without its `t` advancing and still count as current.
*
* The plugin re-sends it every 60 seconds and this module reads it every 30, so
* three minutes tolerates two missed boards before a server stops vouching for
* its clans.
*/
const FRESH_MS = 3 * 60 * 1000
/**
* How far back a clan event's feed item is offered to core again.
*
* Core writes an item only for a Team it already holds, and a clan founded a
* moment ago is not one yet: its Team appears on core's next reconcile, which is
* debounced by up to 30 seconds. So the "founded" line — the first line of every
* clan's feed — would always be dropped if it were offered once. It is offered
* on every board refresh for this long instead, and core's dedupe key makes every
* offer after the first that lands a no-op.
*/
const REOFFER_MS = 10 * 60 * 1000
/** Team kinds core's `publish` takes, by the clan event that produces them. */
const PUBLISH = Object.freeze({
'clan.created': 'team.created',
'clan.disbanded': 'team.disbanded',
'clan.member.added': 'team.member.added',
'clan.member.left': 'team.member.removed',
'clan.member.kicked': 'team.member.removed',
})
/**
* The feed items D49 allows: membership, and nothing else. Every one is
* members-only. A disband is not here — it was not one of the four the org lead
* chose, and the Team it would be written to is about to be archived anyway.
*/
const ACTIVITY = Object.freeze({
'clan.created': 'rust.clan.founded',
'clan.member.added': 'rust.clan.joined',
'clan.member.left': 'rust.clan.left',
'clan.member.kicked': 'rust.clan.removed',
})
const CLAN_KINDS = Object.freeze(Object.keys(PUBLISH))
const STEAM_ID = /^\d{1,32}$/
const COLOR = /^#[0-9a-f]{6}$/i
/** The Team identity (D52). */
function externalIdOf(serverId, clanId, createdMs) {
return `${serverId}:${clanId}:${createdMs}`
}
const text = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null)
const int = (value) => (Number.isInteger(Number(value)) && value !== null && value !== '' ? Number(value) : null)
/**
* One board row as this module stores it, or null when it cannot be read.
*
* A member whose Steam id is not a Steam id is dropped rather than failing the
* clan: the roster is still true about everybody else. A clan with no id, no
* creation time or no name fails as a whole, because it has no identity to
* store it under.
*/
function normaliseClan(serverId, raw) {
if (!raw || typeof raw !== 'object') return null
const clanId = int(raw.clanId)
const createdMs = int(raw.createdMs)
const name = text(raw.name, 191)
if (clanId == null || createdMs == null || createdMs <= 0 || !name) return null
const members = []
for (const m of Array.isArray(raw.members) ? raw.members : []) {
const steamId = m && typeof m.steamId === 'string' && STEAM_ID.test(m.steamId) ? m.steamId : null
if (!steamId) continue
members.push({
steamId,
name: text(m.name, 191),
rank: int(m.rank),
role: text(m.role, 64),
joinedMs: int(m.joinedMs),
})
}
return {
externalId: externalIdOf(serverId, clanId, createdMs),
serverId,
clanId,
createdMs,
name,
color: typeof raw.color === 'string' && COLOR.test(raw.color) ? raw.color.toLowerCase() : null,
score: int(raw.score) || 0,
maxMembers: int(raw.maxMembers),
memberCount: members.length,
members,
}
}
/** A member signature, so an unchanged roster is not rewritten every minute. */
const signature = (members) =>
members
.map((m) => `${m.steamId}|${m.rank == null ? '' : m.rank}|${m.role || ''}|${m.name || ''}`)
.sort()
.join('\n')
const leadersOf = (members) => new Set(members.filter((m) => Number(m.rank) === 1).map((m) => m.steamId))
/**
* Tells core something, and never lets core's answer become this module's
* problem. Both calls are fire-and-forget by contract; the catch is for a core
* that throws synchronously all the same.
*/
function publish(event) {
try {
Promise.resolve(core.teams.publish(event)).catch((err) => {
log.warn('teams publish failed', { kind: event.kind, externalId: event.externalId, error: err.message })
})
} catch (err) {
log.warn('teams publish threw', { kind: event.kind, externalId: event.externalId, error: err.message })
}
}
function requestReconcile(reason) {
try {
core.teams.reconcile({ reason })
} catch (err) {
log.warn('teams reconcile request threw', { reason, error: err.message })
}
}
function pushActivity(items) {
if (!items.length) return
try {
Promise.resolve(core.teams.pushActivity(items)).catch((err) => {
log.warn('teams activity push failed', { items: items.length, error: err.message })
})
} catch (err) {
log.warn('teams activity push threw', { items: items.length, error: err.message })
}
}
// ── The board ──────────────────────────────────────────────────────────────
/**
* Applies one server's `clans` board.
*
* `board` is undefined when the sidecar holds none — a plugin older than
* protocol 6, or one that has not connected since it was upgraded. That is
* recorded as unsupported, and the clans already stored are left exactly as they
* are: a missing board is the absence of an answer, not an answer of absence.
*
* Returns what happened, for the log and the tests.
*/
async function applyBoard(serverId, board) {
if (!board || typeof board !== 'object') {
await db.putBoard({
serverId,
boardT: null,
advanced: false,
enabled: true,
supported: false,
truncated: false,
backend: null,
reason: "this server has not sent a clan board; its plugin may predate protocol 6",
umodClans: false,
clanCount: 0,
})
return { applied: false, reason: 'no board' }
}
const previous = await db.getBoard(serverId)
const boardT = Number(board.t)
const known = previous && previous.boardT != null ? Number(previous.boardT) : null
const advanced = Number.isFinite(boardT) && (known == null || boardT > known)
const supported = board.supported === true
const raw = supported && Array.isArray(board.clans) ? board.clans : null
const clans = []
let unreadable = 0
for (const row of raw || []) {
const clan = normaliseClan(serverId, row)
if (clan) clans.push(clan)
else unreadable += 1
}
// A row this build could not read is treated like the ceiling: the board no
// longer vouches for what it leaves out.
const truncated = board.truncated === true || unreadable > 0
await db.putBoard({
serverId,
boardT: Number.isFinite(boardT) ? boardT : null,
advanced,
enabled: board.enabled !== false,
supported,
truncated,
backend: text(board.backend, 64),
reason: supported ? null : text(board.reason, 255) || 'the plugin could not read this server\'s clans',
umodClans: board.umodClans === true,
clanCount: clans.length,
})
if (unreadable) log.warn('clan board carried rows this build could not read', { server: serverId, unreadable })
// A board whose `t` has not moved is the one already applied. Re-applying it
// would rewrite every roster every 30 seconds to say what it already says.
if (!advanced || !raw) return { applied: false, reason: advanced ? 'unsupported' : 'unchanged' }
const [before, beforeMembers] = await Promise.all([
db.listClansForServer(serverId),
db.listMembersForServer(serverId),
])
const wasActive = new Map(before.filter((c) => !c.goneAt).map((c) => [c.externalId, c]))
const rosterBefore = new Map()
for (const m of beforeMembers) {
if (!rosterBefore.has(m.externalId)) rosterBefore.set(m.externalId, [])
rosterBefore.get(m.externalId).push(m)
}
let created = 0
let rosterChanged = 0
const leaderEvents = []
for (const clan of clans) {
// eslint-disable-next-line no-await-in-loop
await db.upsertClan(clan)
const old = rosterBefore.get(clan.externalId) || []
if (!wasActive.has(clan.externalId)) created += 1
if (signature(old) !== signature(clan.members)) {
// eslint-disable-next-line no-await-in-loop
await db.replaceMembers(clan.externalId, clan.members)
rosterChanged += 1
}
// Leadership is only ever learned here (D54): the game raises no hook when
// somebody is promoted. Published only for a clan that was already on the
// previous board — a brand-new clan's leaders reach core with the Team.
if (wasActive.has(clan.externalId)) {
const was = leadersOf(old)
const now = leadersOf(clan.members)
for (const key of now) if (!was.has(key)) leaderEvents.push({ kind: 'team.leader.added', externalId: clan.externalId, memberKey: key })
for (const key of was) if (!now.has(key)) leaderEvents.push({ kind: 'team.leader.removed', externalId: clan.externalId, memberKey: key })
}
}
// Only a complete board may say a clan is gone.
const onBoard = new Set(clans.map((c) => c.externalId))
const gone = truncated ? [] : [...wasActive.keys()].filter((id) => !onBoard.has(id))
await db.markGone(gone)
for (const event of leaderEvents) publish(event)
if (created || gone.length || rosterChanged) {
requestReconcile('rust clans board changed')
}
if (created || gone.length || rosterChanged || leaderEvents.length) {
log.info('clan board applied', {
server: serverId, clans: clans.length, created, gone: gone.length, rosterChanged,
leaderChanges: leaderEvents.length, truncated,
})
}
return { applied: true, clans: clans.length, created, gone: gone.length, rosterChanged, leaderChanges: leaderEvents.length }
}
// ── The events ─────────────────────────────────────────────────────────────
const nameOr = (name) => name || 'A player'
/** The feed line for one clan event, as core stores it verbatim. */
function summaryOf(kind, frame) {
switch (kind) {
case 'clan.created':
return `${nameOr(frame.name)} founded the clan.`
case 'clan.member.added':
return `${nameOr(frame.name)} joined the clan.`
case 'clan.member.left':
return `${nameOr(frame.name)} left the clan.`
case 'clan.member.kicked':
return frame.byName
? `${nameOr(frame.name)} was removed from the clan by ${frame.byName}.`
: `${nameOr(frame.name)} was removed from the clan.`
default:
return null
}
}
/**
* A key core can dedupe on, from the frame's own content.
*
* Content rather than this module's event row id, so that the same frame read
* twice — a cursor replayed after a crash, or the re-offer below — is the same
* item. **Hashed, because core clamps a dedupe key to 40 characters**, and a
* readable key long enough to be unique (server, clan, creation time, kind,
* player, instant) would be cut short into collisions without a word.
*/
function dedupeKeyOf(serverId, kind, frame) {
const parts = [serverId, frame.clanId, frame.createdMs, kind, frame.steamId || '', frame.t]
return crypto.createHash('sha1').update(parts.join('|')).digest('hex')
}
/** One clan event as a Team feed item, or null when D49 does not allow it. */
function activityItem(serverId, externalId, kind, frame) {
const itemKind = ACTIVITY[kind]
const summary = itemKind && summaryOf(kind, frame)
if (!summary) return null
const t = Number(frame.t)
return {
externalId,
kind: itemKind,
summary,
occurredAt: Number.isFinite(t) ? t : Date.now(),
visibility: 'members',
actorMemberKey: kind === 'clan.member.kicked' ? frame.bySteamId || null : frame.steamId || null,
payload: { serverId, steamId: frame.steamId || null },
dedupeKey: dedupeKeyOf(serverId, kind, frame),
}
}
/** The Team identity a clan event names, or null when it cannot be worked out. */
async function resolveExternalId(serverId, frame) {
const clanId = int(frame.clanId)
const createdMs = int(frame.createdMs)
if (clanId == null) return null
if (createdMs != null && createdMs > 0) return externalIdOf(serverId, clanId, createdMs)
// `clan.member.added` can arrive without a creation time when the plugin could
// not read the clan back. Matched on the game id, newest first.
const known = await db.findByGameId(serverId, clanId)
return known ? known.externalId : null
}
/**
* Applies one `clan.*` event: tells core, and writes the Team feed.
*
* Called from ingest, after the raw frame is stored. The board that follows
* every one of these (the plugin re-sends it a few seconds later) is what the
* store is rebuilt from; this only makes the change visible sooner and records
* the line for the feed.
*/
async function applyEvent(serverId, frame) {
const kind = frame && frame.kind
if (!PUBLISH[kind]) return { applied: false }
if (frame.steamId) await db.rememberName(frame.steamId, text(frame.name, 191))
if (frame.bySteamId) await db.rememberName(frame.bySteamId, text(frame.byName, 191))
const externalId = await resolveExternalId(serverId, frame)
if (!externalId) {
log.info('clan event names a clan this module has never seen', { server: serverId, kind, clanId: frame.clanId })
return { applied: false }
}
// The game said it: this clan is gone. Recorded here as well as by the next
// board, because a board truncated at the ceiling would never say so.
if (kind === 'clan.disbanded') await db.markGone([externalId])
const event = { kind: PUBLISH[kind], externalId }
if (event.kind.startsWith('team.member.')) {
if (!frame.steamId) return { applied: false }
event.memberKey = String(frame.steamId)
}
publish(event)
const item = activityItem(serverId, externalId, kind, frame)
if (item) pushActivity([item])
return { applied: true, externalId }
}
/**
* Offers the last few minutes of one server's clan feed items to core again.
*
* See `REOFFER_MS`. Called after each board refresh; idempotent by construction.
*/
async function reofferActivity(serverId, now = Date.now()) {
const rows = await db.recentClanEvents(serverId, now - REOFFER_MS)
const items = []
for (const row of rows) {
let frame
try {
frame = typeof row.raw === 'string' ? JSON.parse(row.raw) : row.raw
} catch (err) {
continue
}
if (!frame || !ACTIVITY[frame.kind]) continue
// eslint-disable-next-line no-await-in-loop
const externalId = await resolveExternalId(serverId, frame)
const item = externalId && activityItem(serverId, externalId, frame.kind, frame)
if (item) items.push(item)
}
pushActivity(items)
return items.length
}
// ── Who may see a roster (D48) ─────────────────────────────────────────────
/**
* May this viewer see this clan's roster?
*
* `viewer` is `{ userId, role }` or null — the shape core hands `projectRoster`,
* so core's roster and this module's page decide it with one function.
*
* The viewer's standing is re-read from the `users` row, never taken from what
* the caller says, for the same reason the presence gate does it: a moderator
* demoted this morning, or an account banned, must lose the roster on the next
* request. Everything that cannot be answered answers no.
*/
async function canSeeRoster(viewer, externalId) {
const audience = await visibility.clanRosterAudience()
if (audience === 'public') return true
if (!viewer || viewer.userId == null) return false
const user = await core.users.getById(viewer.userId)
if (!user || (user.status && user.status !== 'active')) return false
if (audience === 'signed_in') return true
if (user.role === 'admin' || user.role === 'moderator') return true
return db.userIsMember(externalId, user.id)
}
// ── The public reads ───────────────────────────────────────────────────────
const shapeBoard = (board, now = Date.now()) => {
if (!board || board.supported == null) {
return { supported: false, fresh: false, truncated: false, enabled: true, reason: 'this server has not sent a clan board yet' }
}
const seenAt = board.seenAt ? new Date(board.seenAt).getTime() : null
return {
supported: Boolean(board.supported),
enabled: Boolean(board.enabled),
truncated: Boolean(board.truncated),
fresh: Boolean(board.supported) && seenAt != null && now - seenAt < FRESH_MS,
reason: board.reason || null,
}
}
/** The Clans tab (D58): every clan on one server's board, best first. Public. */
async function listForServer(serverId, now = Date.now()) {
const [clans, board] = await Promise.all([db.listPublicForServer(serverId), db.getBoard(serverId)])
return {
clans: clans.map((c) => ({
externalId: c.externalId,
name: c.name,
color: c.color || null,
score: Number(c.score) || 0,
memberCount: Number(c.memberCount) || 0,
maxMembers: c.maxMembers == null ? null : Number(c.maxMembers),
})),
board: shapeBoard(board, now),
}
}
/**
* One clan, and its roster if the viewer may see it.
*
* The roster carries no Steam id and no website account id — the same two fields
* core withholds from every public roster. `online` is inside the audience by
* construction (D48): a viewer who may not see the roster sees no names at all.
*/
async function getForViewer(externalId, viewer) {
const clan = await db.findClan(externalId)
if (!clan) return null
const allowed = await canSeeRoster(viewer, externalId)
const audience = await visibility.clanRosterAudience()
const members = allowed && !clan.goneAt ? await db.listMembers(externalId) : []
return {
clan: {
externalId: clan.externalId,
name: clan.name,
color: clan.color || null,
score: Number(clan.score) || 0,
memberCount: Number(clan.memberCount) || 0,
maxMembers: clan.maxMembers == null ? null : Number(clan.maxMembers),
serverId: clan.serverId,
serverName: clan.serverName,
founded: Number(clan.createdMs) || null,
gone: Boolean(clan.goneAt),
},
roster: {
visible: allowed,
audience,
members: members.map((m) => ({
name: m.name || null,
role: m.role || null,
leader: Number(m.rank) === 1,
online: Boolean(Number(m.online)),
joined: m.joinedMs == null ? null : Number(m.joinedMs),
})),
},
}
}
/**
* Every configured server's clan board as the admin page shows it: whether it is
* current, whether it is at the ceiling (D55), why it cannot be read, and
* whether the uMod Clans plugin is loaded there (D47) — whose clans are a
* separate system and never Teams.
*/
async function boardsForAdmin(now = Date.now()) {
const rows = await db.listBoards()
return rows.map((row) => ({
id: row.serverId,
name: row.serverName,
...shapeBoard(row.supported == null ? null : row, now),
clans: Number(row.clanCount) || 0,
umodClans: Boolean(row.umodClans),
}))
}
module.exports = {
FRESH_MS,
boardsForAdmin,
REOFFER_MS,
CLAN_KINDS,
externalIdOf,
normaliseClan,
applyBoard,
applyEvent,
resolveExternalId,
reofferActivity,
activityItem,
dedupeKeyOf,
canSeeRoster,
shapeBoard,
listForServer,
getForViewer,
}