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
This commit is contained in:
298
server/model/clans/clans.db.js
Normal file
298
server/model/clans/clans.db.js
Normal file
@@ -0,0 +1,298 @@
|
||||
// ── SQL for first-party clans ─────────────────────────────────────────────
|
||||
//
|
||||
// Three tables (see `schema.sql`): the clans a board carried, their members, and
|
||||
// what this module knows about each server's board. Raw parameterised SQL, as
|
||||
// everywhere in this module; the model decides what any of it means.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const CLANS = 'rust_clans'
|
||||
const MEMBERS = 'rust_clan_members'
|
||||
const BOARDS = 'rust_clan_boards'
|
||||
const LINKS = 'rust_account_links'
|
||||
const PLAYERS = 'rust_players'
|
||||
const SERVERS = 'rust_servers'
|
||||
|
||||
// ── Boards ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One server's board record, or null when it has never sent one. */
|
||||
async function getBoard(serverId) {
|
||||
const rows = await core.query(
|
||||
`SELECT server_id AS serverId, board_t AS boardT, seen_at AS seenAt, enabled, supported,
|
||||
truncated, backend, reason, umod_clans AS umodClans, clan_count AS clanCount
|
||||
FROM ${BOARDS} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every configured server beside its board record, which may be absent. */
|
||||
async function listBoards() {
|
||||
return core.query(
|
||||
`SELECT s.id AS serverId, s.name AS serverName, s.enabled AS serverEnabled,
|
||||
b.board_t AS boardT, b.seen_at AS seenAt, b.enabled, b.supported, b.truncated,
|
||||
b.backend, b.reason, b.umod_clans AS umodClans, b.clan_count AS clanCount
|
||||
FROM ${SERVERS} s
|
||||
LEFT JOIN ${BOARDS} b ON b.server_id = s.id
|
||||
ORDER BY s.sort_order ASC, s.id ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Records what a board said about itself.
|
||||
*
|
||||
* `seenAt` is passed only when the board's `t` ADVANCED, and is then the
|
||||
* website's own now; otherwise the stored one is kept. That is the whole of the
|
||||
* freshness rule (see `schema.sql`), so it is done in SQL rather than trusted to
|
||||
* every caller to read-then-write.
|
||||
*/
|
||||
async function putBoard({ serverId, boardT, advanced, enabled, supported, truncated, backend, reason, umodClans, clanCount }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${BOARDS}
|
||||
(server_id, board_t, seen_at, enabled, supported, truncated, backend, reason, umod_clans, clan_count, updated_at)
|
||||
VALUES (?, ?, ${advanced ? 'CURRENT_TIMESTAMP' : 'NULL'}, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
board_t = VALUES(board_t),
|
||||
seen_at = ${advanced ? 'CURRENT_TIMESTAMP' : 'seen_at'},
|
||||
enabled = VALUES(enabled), supported = VALUES(supported), truncated = VALUES(truncated),
|
||||
backend = VALUES(backend), reason = VALUES(reason), umod_clans = VALUES(umod_clans),
|
||||
clan_count = VALUES(clan_count), updated_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
serverId,
|
||||
boardT,
|
||||
enabled ? 1 : 0,
|
||||
supported ? 1 : 0,
|
||||
truncated ? 1 : 0,
|
||||
backend || null,
|
||||
reason ? String(reason).slice(0, 255) : null,
|
||||
umodClans ? 1 : 0,
|
||||
clanCount || 0,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Clans ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every clan this module holds for one server, gone or not. */
|
||||
async function listClansForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT external_id AS externalId, clan_id AS clanId, created_ms AS createdMs, name,
|
||||
member_count AS memberCount, gone_at AS goneAt
|
||||
FROM ${CLANS} WHERE server_id = ?`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every member of one server's current clans, as the board last stated them,
|
||||
* for diffing the next board against. The name is the BOARD's, not the player
|
||||
* table's, because it is compared with the board.
|
||||
*/
|
||||
async function listMembersForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT m.external_id AS externalId, m.steam_id AS steamId, m.role_rank AS rank,
|
||||
m.role_name AS role, m.name
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${CLANS} c ON c.external_id = m.external_id
|
||||
WHERE c.server_id = ? AND c.gone_at IS NULL`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
async function upsertClan({ externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers }) {
|
||||
await core.query(
|
||||
`INSERT INTO ${CLANS}
|
||||
(external_id, server_id, clan_id, created_ms, name, color, score, member_count, max_members,
|
||||
first_seen, updated_at, gone_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name), color = VALUES(color), score = VALUES(score),
|
||||
member_count = VALUES(member_count), max_members = VALUES(max_members),
|
||||
updated_at = CURRENT_TIMESTAMP, gone_at = NULL`,
|
||||
[externalId, serverId, clanId, createdMs, name, color, score, memberCount, maxMembers],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces one clan's members.
|
||||
*
|
||||
* Delete then insert, not wrapped in a transaction — the same trade the presence
|
||||
* board makes (`events.db.replacePresence`): a fraction of a second in which a
|
||||
* roster read might come back short, against holding a lock on a table that core's
|
||||
* reconciler and two public routes read.
|
||||
*/
|
||||
async function replaceMembers(externalId, members) {
|
||||
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id = ?`, [externalId])
|
||||
|
||||
for (const m of members) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await core.query(
|
||||
`INSERT INTO ${MEMBERS} (external_id, steam_id, name, role_rank, role_name, joined_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), role_rank = VALUES(role_rank),
|
||||
role_name = VALUES(role_name), joined_ms = VALUES(joined_ms)`,
|
||||
[externalId, m.steamId, m.name, m.rank, m.role, m.joinedMs],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Marks clans gone. Their members are removed with them; a gone clan has no roster. */
|
||||
async function markGone(externalIds) {
|
||||
if (!externalIds.length) return
|
||||
const marks = externalIds.map(() => '?').join(', ')
|
||||
await core.query(
|
||||
`UPDATE ${CLANS} SET gone_at = CURRENT_TIMESTAMP WHERE external_id IN (${marks}) AND gone_at IS NULL`,
|
||||
externalIds,
|
||||
)
|
||||
await core.query(`DELETE FROM ${MEMBERS} WHERE external_id IN (${marks})`, externalIds)
|
||||
}
|
||||
|
||||
/** One clan by its Team identity, with its server's name, or null. */
|
||||
async function findClan(externalId) {
|
||||
const rows = await core.query(
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
|
||||
c.clan_id AS clanId, c.created_ms AS createdMs, c.name, c.color, c.score,
|
||||
c.member_count AS memberCount, c.max_members AS maxMembers,
|
||||
c.first_seen AS firstSeen, c.updated_at AS updatedAt, c.gone_at AS goneAt
|
||||
FROM ${CLANS} c
|
||||
JOIN ${SERVERS} s ON s.id = c.server_id
|
||||
WHERE c.external_id = ?`,
|
||||
[externalId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest clan this module holds under a game id on one server, or null.
|
||||
*
|
||||
* The fallback for the one event that can arrive without a creation time
|
||||
* (`clan.member.added`, when the plugin could not read the clan back). Newest,
|
||||
* because an id that the game has re-used belongs to the clan that re-used it.
|
||||
*/
|
||||
async function findByGameId(serverId, clanId) {
|
||||
const rows = await core.query(
|
||||
`SELECT external_id AS externalId, name
|
||||
FROM ${CLANS} WHERE server_id = ? AND clan_id = ?
|
||||
ORDER BY created_ms DESC LIMIT 1`,
|
||||
[serverId, clanId],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/** Every clan still on a board, for core's `getTeams`. */
|
||||
async function listActiveClans() {
|
||||
return core.query(
|
||||
`SELECT c.external_id AS externalId, c.server_id AS serverId, s.name AS serverName,
|
||||
c.name, c.color, c.score, c.member_count AS memberCount
|
||||
FROM ${CLANS} c
|
||||
JOIN ${SERVERS} s ON s.id = c.server_id
|
||||
WHERE c.gone_at IS NULL
|
||||
ORDER BY c.server_id ASC, c.score DESC, c.name ASC`,
|
||||
)
|
||||
}
|
||||
|
||||
/** One server's clans still on its board, for the public Clans tab. Best first. */
|
||||
async function listPublicForServer(serverId) {
|
||||
return core.query(
|
||||
`SELECT external_id AS externalId, name, color, score, member_count AS memberCount,
|
||||
max_members AS maxMembers
|
||||
FROM ${CLANS}
|
||||
WHERE server_id = ? AND gone_at IS NULL
|
||||
ORDER BY score DESC, name ASC`,
|
||||
[serverId],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One clan's roster, with the website account behind each member when there is
|
||||
* one and whether they are on the clan's server right now.
|
||||
*
|
||||
* Three joins, all of this module's own tables: the link (a Steam id to a user),
|
||||
* the player table (the newest name the game has sent for them) and the presence
|
||||
* board. Presence is joined on the CLAN's server — a member on another server of
|
||||
* the fleet is not online here.
|
||||
*/
|
||||
async function listMembers(externalId) {
|
||||
return core.query(
|
||||
`SELECT m.steam_id AS steamId, COALESCE(p.name, m.name) AS name, m.role_rank AS rank,
|
||||
m.role_name AS role, m.joined_ms AS joinedMs, l.user_id AS userId,
|
||||
(pr.steam_id IS NOT NULL) AS online
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${CLANS} c ON c.external_id = m.external_id
|
||||
LEFT JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
||||
LEFT JOIN ${PLAYERS} p ON p.steam_id = m.steam_id
|
||||
LEFT JOIN rust_presence pr ON pr.server_id = c.server_id AND pr.steam_id = m.steam_id
|
||||
WHERE m.external_id = ?
|
||||
ORDER BY (m.role_rank IS NULL) ASC, m.role_rank ASC, name ASC`,
|
||||
[externalId],
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a website user holds a linked Steam account that is a member of this clan. */
|
||||
async function userIsMember(externalId, userId) {
|
||||
const rows = await core.query(
|
||||
`SELECT 1 AS yes
|
||||
FROM ${MEMBERS} m
|
||||
JOIN ${LINKS} l ON l.steam_id = m.steam_id
|
||||
WHERE m.external_id = ? AND l.user_id = ?
|
||||
LIMIT 1`,
|
||||
[externalId, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent clan events for one server, oldest first, for re-offering their feed
|
||||
* items to core until the Team they name exists (see `model/clans`).
|
||||
*/
|
||||
async function recentClanEvents(serverId, sinceMs) {
|
||||
return core.query(
|
||||
`SELECT id, kind, t, raw
|
||||
FROM rust_events
|
||||
WHERE server_id = ? AND kind LIKE 'clan.%' AND t >= ?
|
||||
ORDER BY t ASC, id ASC
|
||||
LIMIT 200`,
|
||||
[serverId, sinceMs],
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes a player's name WITHOUT touching `last_seen`.
|
||||
*
|
||||
* `events.db.touchPlayer` also moves `last_seen`, which is right for a frame that
|
||||
* says a player was on and wrong for a clan frame: a kick is done TO somebody who
|
||||
* may be offline, and a leaderboard's "last seen" would then read as a presence
|
||||
* signal for a player who never connected (PLAN.md §23).
|
||||
*
|
||||
* A player this module has never heard of still gets a on the new row,
|
||||
* because the column is NOT NULL; what matters is that an existing row's is left
|
||||
* alone, and every surface that reads it is behind the presence gate anyway.
|
||||
*/
|
||||
async function rememberName(steamId, name) {
|
||||
if (!steamId) return
|
||||
await core.query(
|
||||
`INSERT INTO ${PLAYERS} (steam_id, name, first_seen, last_seen)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON DUPLICATE KEY UPDATE name = COALESCE(VALUES(name), name)`,
|
||||
[steamId, name || null],
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBoard,
|
||||
listBoards,
|
||||
putBoard,
|
||||
listClansForServer,
|
||||
listMembersForServer,
|
||||
upsertClan,
|
||||
replaceMembers,
|
||||
markGone,
|
||||
findClan,
|
||||
findByGameId,
|
||||
listActiveClans,
|
||||
listPublicForServer,
|
||||
listMembers,
|
||||
userIsMember,
|
||||
recentClanEvents,
|
||||
rememberName,
|
||||
}
|
||||
573
server/model/clans/clans.model.js
Normal file
573
server/model/clans/clans.model.js
Normal file
@@ -0,0 +1,573 @@
|
||||
// ── 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,
|
||||
}
|
||||
202
server/model/clans/teamProvider.js
Normal file
202
server/model/clans/teamProvider.js
Normal file
@@ -0,0 +1,202 @@
|
||||
// ── module-rust's Team provider ────────────────────────────────────────────
|
||||
//
|
||||
// The questions core asks this module about Teams (MODULE_API.md
|
||||
// `api.registerTeamProvider`, TEAMS.md §2.3). A first-party Rust clan is a Team
|
||||
// (R5); this file is the whole of the translation, and `model/clans` is where
|
||||
// the clans themselves are kept.
|
||||
//
|
||||
// ── The envelope is the contract ──────────────────────────────────────────
|
||||
//
|
||||
// Every method answers `{ ok, ... }` and `{ ok: false, reason }` is an ordinary
|
||||
// answer. Core reads it as "keep what you have" — staleness, never emptiness —
|
||||
// and there is no shape a failure can take that core reads as "zero Teams". An
|
||||
// empty array is the one thing this file must never say while it does not know.
|
||||
//
|
||||
// ── Many servers, one answer (D53) ─────────────────────────────────────────
|
||||
//
|
||||
// `module-uo` has one shard and one socket, so "is the board current" has one
|
||||
// answer. This module has a fleet, and the answer is per server. `getTeams` is
|
||||
// therefore:
|
||||
//
|
||||
// • `complete: true` only when EVERY configured server's board is fresh,
|
||||
// supported and untruncated — then core may archive a
|
||||
// Team that is missing;
|
||||
// • `complete: false` when at least one is current and some are not — core
|
||||
// adds and updates, and removes nothing. One server being
|
||||
// off for a patch must never archive its clans;
|
||||
// • a refusal when none is current.
|
||||
//
|
||||
// A clan is only ever as current as its own server's board, so the roster
|
||||
// methods ask about that server alone.
|
||||
|
||||
const core = require('../../core')
|
||||
|
||||
const db = require('./clans.db')
|
||||
const clans = require('./clans.model')
|
||||
const servers = require('../servers/servers.model')
|
||||
|
||||
const log = core.logger('teams')
|
||||
|
||||
const refuse = (reason) => ({ ok: false, reason })
|
||||
|
||||
/** Is this board record current? The rule `model/clans` states, applied to one row. */
|
||||
function isFresh(board, now = Date.now()) {
|
||||
return clans.shapeBoard(board, now).fresh
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeams()` — every clan on every server's board.
|
||||
*
|
||||
* `meta` carries the server and the clan's colour and score, opaquely: core
|
||||
* stores and shows it and never branches on it.
|
||||
*/
|
||||
async function getTeams(now = Date.now()) {
|
||||
try {
|
||||
const configured = await servers.listForPolling()
|
||||
if (!configured.length) return refuse('no Rust servers are configured')
|
||||
|
||||
const boards = await db.listBoards()
|
||||
const byServer = new Map(boards.map((b) => [b.serverId, b]))
|
||||
|
||||
const fresh = []
|
||||
const behind = []
|
||||
for (const server of configured) {
|
||||
const board = byServer.get(server.id)
|
||||
if (isFresh(board, now)) fresh.push(server.id)
|
||||
else behind.push(server.id)
|
||||
}
|
||||
|
||||
if (!fresh.length) {
|
||||
return refuse(`no server has sent a current clan board (${behind.join(', ')})`)
|
||||
}
|
||||
|
||||
// Complete only when nothing is behind, and nothing is at the ceiling. A
|
||||
// server that is configured but switched off in this module is "behind" by
|
||||
// construction — its board is never read — which is the conservative answer:
|
||||
// switching a server off is not a statement that its clans are gone.
|
||||
const truncated = fresh.filter((id) => byServer.get(id).truncated)
|
||||
const complete = behind.length === 0 && truncated.length === 0
|
||||
|
||||
const rows = await db.listActiveClans()
|
||||
const known = new Set(configured.map((s) => s.id))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
complete,
|
||||
teams: rows
|
||||
.filter((row) => known.has(row.serverId))
|
||||
.map((row) => ({
|
||||
externalId: row.externalId,
|
||||
name: row.name,
|
||||
abbr: null,
|
||||
meta: {
|
||||
server: row.serverName || row.serverId,
|
||||
serverId: row.serverId,
|
||||
color: row.color || null,
|
||||
score: Number(row.score) || 0,
|
||||
},
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('getTeams failed', { error: err.message })
|
||||
return refuse(`clans unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A clan and whether its server's board vouches for it right now, or a refusal. */
|
||||
async function currentClan(externalId, now) {
|
||||
const clan = await db.findClan(externalId)
|
||||
if (!clan) return { refusal: refuse(`clan ${externalId} is not on any board`) }
|
||||
if (clan.goneAt) return { refusal: refuse(`clan ${externalId} has left its server's board`) }
|
||||
|
||||
const board = await db.getBoard(clan.serverId)
|
||||
if (!isFresh(board, now)) {
|
||||
return { refusal: refuse(`server ${clan.serverId} has not sent a current clan board`) }
|
||||
}
|
||||
return { clan }
|
||||
}
|
||||
|
||||
/**
|
||||
* `getTeamMembers(externalId)` — one clan's roster.
|
||||
*
|
||||
* **A clan with no roster rows is refused, not reported empty**, unless the board
|
||||
* said it has none. A clan always has at least its leader, so an empty roster
|
||||
* beside a non-zero count is a read that happened between two writes, and
|
||||
* reporting it would tell core every member left.
|
||||
*/
|
||||
async function getTeamMembers(externalId, now = Date.now()) {
|
||||
try {
|
||||
const { clan, refusal } = await currentClan(externalId, now)
|
||||
if (refusal) return refusal
|
||||
|
||||
const rows = await db.listMembers(externalId)
|
||||
if (!rows.length && Number(clan.memberCount) > 0) {
|
||||
return refuse(`roster for clan ${externalId} is not stored yet (board says ${clan.memberCount} members)`)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
complete: true,
|
||||
members: rows.map((row) => ({
|
||||
memberKey: row.steamId,
|
||||
displayName: row.name || null,
|
||||
rankLabel: row.role || null,
|
||||
// Rank 1 is leader and several may hold it. A NULL rank — a role id the
|
||||
// board could not match — is not a leader: "not known" must never read
|
||||
// as "leads this clan".
|
||||
leader: Number(row.rank) === 1,
|
||||
online: Boolean(Number(row.online)),
|
||||
userId: Number.isInteger(Number(row.userId)) && Number(row.userId) > 0 ? Number(row.userId) : null,
|
||||
})),
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('getTeamMembers failed', { externalId, error: err.message })
|
||||
return refuse(`roster unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** `getTeamLeaders(externalId)` — everyone at rank 1, which may be several. */
|
||||
async function getTeamLeaders(externalId, now = Date.now()) {
|
||||
try {
|
||||
const { refusal } = await currentClan(externalId, now)
|
||||
if (refusal) return refusal
|
||||
|
||||
const rows = await db.listMembers(externalId)
|
||||
return { ok: true, leaders: rows.filter((row) => Number(row.rank) === 1).map((row) => row.steamId) }
|
||||
} catch (err) {
|
||||
log.warn('getTeamLeaders failed', { externalId, error: err.message })
|
||||
return refuse(`leadership unreadable: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which roster rows a viewer may see (D48, MODULE_API 1.6.0).
|
||||
*
|
||||
* The one provider method core calls on a REQUEST path, and the one that fails
|
||||
* CLOSED: core serves an empty roster when this refuses, because for a
|
||||
* visibility question "keep what you have" would mean publishing the roster to
|
||||
* whoever asked. So every path that cannot reach a confident answer refuses.
|
||||
*
|
||||
* All or nothing, and that is the model rather than a shortcut: the audience is
|
||||
* a property of the ROSTER, not of a member. There is no setting in which some
|
||||
* of a clan's members are visible and others are not.
|
||||
*/
|
||||
async function projectRoster(externalId, members, viewer) {
|
||||
try {
|
||||
const allowed = await clans.canSeeRoster(viewer, externalId)
|
||||
if (!allowed) return { ok: true, members: [] }
|
||||
return { ok: true, members: (members || []).map((m) => m.member_key).filter(Boolean) }
|
||||
} catch (err) {
|
||||
log.warn('projectRoster could not resolve the audience; withholding the roster', {
|
||||
externalId, error: err.message,
|
||||
})
|
||||
return refuse(`the roster audience could not be resolved: ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Where core should point a link at a clan (MODULE_API 1.6.0, TEAMS.md §6.4).
|
||||
// Core substitutes `{externalId}` and nothing else, which is why the page is not
|
||||
// nested under its server (D56): the server is inside the id already.
|
||||
const pageUrlTemplate = '/rust/clans/{externalId}'
|
||||
|
||||
module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, pageUrlTemplate, isFresh }
|
||||
Reference in New Issue
Block a user