// ── module-uo's Team provider ────────────────────────────────────────────── // // The three questions core asks this module about Teams // (docs/website/MODULE_API.md — `api.registerTeamProvider`, and TEAMS.md §2.3). // A UO guild is a Team; this file is the whole of the translation. // // **Every method returns an envelope, and answering `{ ok: false }` is a normal // outcome, not a failure to handle.** Core's contract is that module // unavailability becomes staleness and never emptiness, and the only way this // module can say "I cannot answer" is to say so — an empty array would be read as // an authoritative "there are none", which during a cold start is how every // roster on the site gets emptied. So the guard below is the most important code // in the file, and it is deliberately conservative: **an unreachable or // never-connected sidecar refuses, rather than reporting the board it happens to // still hold.** // // The board IS durable and would survive a sidecar outage, which is exactly what // makes this tempting to get wrong. The reason to refuse anyway: core cannot tell // a board that is five minutes stale from one that is five days stale, and it // makes destructive decisions — archiving Teams, departing members — from a // complete answer. Reporting a stale board as authoritative would license those. const core = require('../../core') const db = require('./teamProvider.db') const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model') const uoLinkSocket = require('../../utils/uoLinkSocket') const clilocs = require('../shardClilocs/shardClilocs.model') const log = core.logger('teams') /** * ServUO's five stock rank names, by the cliloc id the game names them with. * * A fallback, not the source of truth: the operator's own cliloc table is consulted * first, and a shard with custom rank definitions sends a literal string that beats * both. This exists because the cliloc table is populated only if someone ran the * client-file extraction, and a roster on a shard that has not should still say * "Warlord" rather than nothing. */ const STANDARD_RANK_NAMES = { 1062959: 'Leader', 1062960: 'Warlord', 1062961: 'Emissary', 1062962: 'Member', 1062963: 'Ronin', } /** A refusal, in the shape core reads (§2.3). */ const refuse = (reason) => ({ ok: false, reason }) /** * Is the bridge in a state where the board can be trusted as current? * * The board is only as good as the socket that fills it. Three states refuse, and * they are asked in this order because each is a different thing being wrong: * * - **no uo-link configured** — there is no shard behind this website at all; * - **the integration is disabled** — an admin turned it off, and the board is * frozen at whatever it held; * - **the socket is not connected** — the board is a snapshot of unknown age. * * The in-process socket state is preferred over the persisted status column, * which is written on transitions: a process that has just started has not * transitioned yet, so the column can still say `connected` from the last run * while this process has never opened a socket. */ async function boardIsCurrent() { const config = await uoLinkConfig.getSafe() if (!config || !config.baseUrl) return { ok: false, reason: 'no uo-link configured' } if (!config.enabled) return { ok: false, reason: 'the uo-link integration is disabled' } const state = uoLinkSocket.getState() if (!state || !state.connected) { return { ok: false, reason: 'the uo-link socket is not connected; the guild board may be stale' } } return { ok: true } } /** * `getTeams()` — every guild on the board. * * `externalId` is the ServUO `Guild.Id`, which survives a rename: renaming a * guild in-game keeps the id, so core sees "an id whose name changed" and applies * its rename rule (archive plus create). That mapping is this module's to make — * only the game knows what identity survives what (§10.5). * * `meta` carries the alliance, opaquely. Core stores and displays it and never * branches on it, which is what lets a UO concept reach a Team page without core * acquiring an opinion about alliances. */ async function getTeams() { const ready = await boardIsCurrent() if (!ready.ok) return refuse(ready.reason) try { const rows = await db.listGuilds() return { ok: true, complete: true, teams: rows.map((row) => ({ externalId: String(row.id), name: row.name, abbr: row.abbr || null, meta: row.alliance ? { alliance: row.alliance } : null, })), } } catch (err) { log.warn('getTeams failed', { message: err.message }) return refuse(`guild board unreadable: ${err.message}`) } } /** * `getTeamMembers(externalId)` — one guild's roster. * * **A guild with no roster rows is refused, not reported empty**, unless the board * itself says the guild has no members. Protocol 4's roster arrives on its own * frames, separately from the `guild.update` that creates the board row, so there * is a real window — a fresh guild, or a website that connected between the two — * where core would otherwise be told authoritatively that a 155-member guild has * nobody in it. The board's own `members` count is what distinguishes the two, * and it is the only thing that can. */ async function getTeamMembers(externalId) { const ready = await boardIsCurrent() if (!ready.ok) return refuse(ready.reason) try { const [guild] = await db.findGuild(externalId) if (!guild) return refuse(`guild ${externalId} is not on the board`) const rows = await db.listGuildMembers(externalId) if (!rows.length && guild.members > 0) { return refuse(`roster for guild ${externalId} has not arrived yet (board says ${guild.members} members)`) } const labels = await rankLabels(rows) return { ok: true, complete: true, members: rows.map((row) => ({ memberKey: row.serial, displayName: row.name || null, rankLabel: labels.get(row.serial) || null, // Rank 4 is Leader, and several members can hold it. A NULL rank is not a // leader: the shard withholds the rank for a staff account rather than // publishing the Leader its getter falsely reports, and "not known" must // never be read as "leads this guild". leader: Number.isInteger(row.rank) && row.rank >= db.LEADER_RANK, online: Boolean(row.is_online), userId: resolveUserId(row), })), } } catch (err) { log.warn('getTeamMembers failed', { externalId, message: err.message }) return refuse(`roster unreadable: ${err.message}`) } } /** * `getTeamLeaders(externalId)` — everyone at leader rank. * * **All of them, which is why Protocol 4 grew a per-member rank.** The guild board * carries one `leader_serial`, so before the rank amendment this could only ever * name a single member, while a UO guild routinely has several at rank 4 and * TEAMS.md §2.5 treats multiple leaders as the normal case. * * The board's own `leader_serial` is folded in as a floor. It is the guild's * founder-leader and it comes from a different frame (`guild.update`), so on a * shard whose roster has not been re-emitted since the amendment it is the only * leadership signal there is — and it should never be *lost* by moving to ranks. */ async function getTeamLeaders(externalId) { const ready = await boardIsCurrent() if (!ready.ok) return refuse(ready.reason) try { const [guild] = await db.findGuild(externalId) if (!guild) return refuse(`guild ${externalId} is not on the board`) const rows = await db.listGuildLeaders(externalId) const leaders = rows.map((r) => r.serial) if (guild.leader_serial && !leaders.includes(guild.leader_serial)) { leaders.push(guild.leader_serial) } return { ok: true, leaders } } catch (err) { log.warn('getTeamLeaders failed', { externalId, message: err.message }) return refuse(`leadership unreadable: ${err.message}`) } } /** * Resolve each member's rank to a display label, keyed by serial. * * The shard sends the rank's NAME as the game states it — a cliloc id for the five * standard ranks, or a literal string for a custom rank definition — and never a * resolved label, because ServUO ships no text for those clilocs. This module does * have a cliloc table, which is why the resolution belongs here. * * Three sources, in order: a custom string wins, then the operator's cliloc table, * then the five standard names. The last exists because the cliloc table is * populated only if someone ran the client extraction, and a shard that has not * should still read "Warlord" rather than nothing. * * Never throws: a rank label is decoration on a roster, and a lookup failure must * not turn a good roster into a refusal. */ async function rankLabels(rows) { const out = new Map() const wanted = [] for (const row of rows) { if (row.rank_name) { out.set(row.serial, row.rank_name) } else if (Number.isInteger(row.rank_cliloc)) { wanted.push(row.rank_cliloc) } } let resolved = new Map() if (wanted.length) { try { resolved = await clilocs.resolveMany(wanted) } catch (err) { log.warn('rank cliloc lookup failed; falling back to the standard names', { message: err.message }) } } for (const row of rows) { if (out.has(row.serial) || !Number.isInteger(row.rank_cliloc)) continue const label = resolved.get(row.rank_cliloc) || STANDARD_RANK_NAMES[row.rank_cliloc] || null if (label) out.set(row.serial, label) } return out } /** * The site account behind a character, or null. * * `web_id` is what the shard itself asserted when it emitted the roster; the * account-link join is the fallback for a member whose roster row predates their * link. Both are coerced through the same check, because `web_id` arrives from * the wire as a string. */ function resolveUserId(row) { const fromRoster = Number.parseInt(row.web_id, 10) if (Number.isInteger(fromRoster) && fromRoster > 0) return fromRoster const fromLink = Number.parseInt(row.linked_user_id, 10) return Number.isInteger(fromLink) && fromLink > 0 ? fromLink : null } module.exports = { getTeams, getTeamMembers, getTeamLeaders, boardIsCurrent }