feat(rust): Teams from first-party clans (phase 9, protocol 6)
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

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:
2026-09-23 05:14:18 -05:00
parent da1a393702
commit c94271104f
33 changed files with 3456 additions and 32 deletions

View 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,
}