// ── 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 }