Files
Module-Rust/server/model/clans/clans.model.js
wtclaude c94271104f
All checks were successful
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 46s
PR Checks / client-build (pull_request) Successful in 8m3s
feat(rust): Teams from first-party clans (phase 9, protocol 6)
A first-party Rust clan is a Team (R5). This module becomes the site's
Team provider and answers core from the plugin's `clans` board. Design
of record: docs/modules/rust/PLAN.md §24, D47-D58.

- The store: rust_clans, rust_clan_members and rust_clan_boards. A clan's
  identity is <serverId>:<clanId>:<createdMs> (D52), because the game
  restarts clan ids whenever its clan database version changes.
- The provider (D53): getTeams is complete only when every server's
  board is fresh, supported and untruncated. It is partial when some
  are, and refuses when none are. Freshness is judged by the website's
  clock, from when the board's `t` last advanced.
- Only a complete board may mark a clan gone. A board at the game's
  100-clan ceiling (D55), or one with an unreadable row, proves nothing
  about what it leaves out.
- Leadership is diffed board to board and published (D54). The five clan
  events are published as team.* kinds, and written to the Team feed as
  members-only lines (D49).
- Core only writes feed items for a Team it already holds. So the last 10
  minutes of clan events are re-offered on each board refresh, deduped by
  a sha1 key: core clamps a dedupeKey to 40 characters, and a readable key
  would be truncated into collisions.
- projectRoster and the clan page share one audience rule (D48): the
  clan's linked members and staff by default, re-read from the users row.
  The setting lives on Admin > Rust visibility, which also warns about
  uMod Clans (D47) and the ceiling.
- Public: GET servers/:id/clans (the list is public, D58) and
  GET clans/:externalId. The client adds a Clans tab and
  /rust/clans/:externalId, with three module slots for core's notify,
  activity and forum contributions (D56).
- Linking and unlinking an account ask core to reconcile Teams (D57).
- The clan kinds are staff-class in the public feed allowlist.
- PROTOCOL_VERSION is now 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-23 05:14:18 -05:00

574 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,
reofferActivity,
activityItem,
dedupeKeyOf,
canSeeRoster,
shapeBoard,
listForServer,
getForViewer,
}