// ── 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 log = core.logger('teams') /** 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 leaderSerial = guild.leader_serial || null return { ok: true, complete: true, members: rows.map((row) => ({ memberKey: row.serial, displayName: row.name || null, // Not on the wire. The roster member is the standard actor object, which // carries no guild rank — see the note at the bottom of this file. rankLabel: null, leader: Boolean(leaderSerial && row.serial === leaderSerial), 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)` — who leads the guild. * * **One leader, because that is all the wire carries.** TEAMS.md §2.5 expects * multiple leaders to be the normal case, from `PlayerMobile.GuildRank.Rank >= 4`, * and core supports them — but Protocol 4's roster member is the standard actor * object with no rank field, so the only leadership this module can see is the * board's single `leader_serial` from `guild.update`. Reporting a guessed second * leader would be worse than reporting one honestly. * * Raising this to the full set is a protocol change (rank on the actor object), * not something this file can fix. */ 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`) return { ok: true, leaders: guild.leader_serial ? [guild.leader_serial] : [] } } catch (err) { log.warn('getTeamLeaders failed', { externalId, message: err.message }) return refuse(`leadership unreadable: ${err.message}`) } } /** * 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 }