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
299 lines
11 KiB
JavaScript
299 lines
11 KiB
JavaScript
// ── 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,
|
|
}
|