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
203 lines
8.2 KiB
JavaScript
203 lines
8.2 KiB
JavaScript
// ── 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 }
|