// ── 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 visibility = require('../../utils/shardVisibility') 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 } /** * Which roster rows a viewer may see (TEAMS.md §3.3, MODULE_API 1.6.0). * * The optional fourth provider method, and the only one core calls on a REQUEST * path rather than from the reconciler. Core holds the roster and its public * shape; the question that is this module's is "who is allowed to look", because * the audience rungs and their configuration live here (`utils/shardVisibility`) * and core does not know what a rung is. * * **The answer is all-or-nothing, and that is correct rather than a shortcut.** * A rung is a property of the FEATURE, not of a member: `guilds` is either * visible to this viewer or it is not, and there is no configuration in which * some members of a guild are public and others are not. Returning every key or * none is the honest translation of the model this module actually has. * * **A refusal here costs visibility, not staleness.** Core fails closed on this * one call — an unanswered visibility question serves an empty roster rather than * an unprojected one — so every path below that cannot reach a confident answer * refuses deliberately, and the catch does too. That is the opposite of the rule * governing the other three methods, and it is the right way round: for a roster * SYNC an unanswered call must change nothing, and for a roster READ it must * publish nothing. * * Note what this does NOT do: strip fields. `acct` and `webId` are the leak this * module's projection exists to prevent on the live feed, and neither is in * core's roster shape at all — core withholds the member key and the site account * id from every public roster whatever this returns. So there is nothing here to * redact, only rows to withhold. */ async function projectRoster(externalId, members, viewer) { try { const config = await visibility.getConfig() const feature = config.guilds // An admin turned guilds off. Nobody sees a roster, including staff — the // switch means "this shard does not publish guild data", not "publish it // quietly". if (!feature || !feature.enabled) return { ok: true, members: [] } // `viewerLevel` reads a REQUEST; core hands over a described viewer instead, // which is deliberate — it keeps the `users` row out of the contract. // // The no-viewer case is answered here rather than by handing `viewerLevel` an // empty object: given a request with no `req.user` it falls through to // `auth.getUserFromRequest`, which expects real cookies and headers and // throws on a synthetic one. That throw would land in the catch below and // become a REFUSAL, so every anonymous visitor would have been served an // empty roster on a shard whose guilds are public. Anonymous is a known // answer, not a failed lookup. const level = viewer ? await visibility.viewerLevel({ user: { id: viewer.userId, role: viewer.role } }) : 'anonymous' if (!visibility.meets(level, feature.audience)) return { ok: true, members: [] } return { ok: true, members: members.map((m) => m.member_key).filter(Boolean) } } catch (err) { // Core reads this as "withhold the roster". Saying so is the whole point: the // alternative — answering with every key because the config read failed — // publishes a roster an operator may have gated to staff. log.warn('projectRoster could not resolve visibility; withholding the roster', { externalId, message: err.message, }) return refuse(`visibility could not be resolved: ${err.message}`) } } // Where core should point a link at a guild (MODULE_API 1.6.0, TEAMS.md §6.4). // // **Core cannot work this out for itself, and it is not supposed to.** Teams are // a contract primitive with no core surface — this module owns the guild page, // because core does not own the word "guild" — so the one thing core needs back // is where the page it does not own actually lives. A notification email that // cannot link to the thread it is about is most of the way to useless. // // A relative path with `{externalId}` substituted, matching `Guild.jsx`'s route // (`/uo/guilds/:id`). Core does the substitution and nothing else with it; a // template naming its own host is refused at registration, which is why this is // data and not a callback. const pageUrlTemplate = '/uo/guilds/{externalId}' module.exports = { getTeams, getTeamMembers, getTeamLeaders, projectRoster, boardIsCurrent, pageUrlTemplate }