// ── 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) ──────────────────────────────────────────────────── // // `::`. 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, }