Merge pull request 'feat(teams): answer core's Team provider from the guild board' (#9) from feat/teams-phase2-provider into edge

Reviewed-on: #9
This commit is contained in:
2026-08-17 22:19:01 +00:00
6 changed files with 538 additions and 0 deletions

View File

@@ -45,6 +45,7 @@ module.exports = function register(ctx, api) {
const shardStreams = require('./config/shardStreams')
const townCrierLeg = require('./utils/shardAnnounce')
const teamProvider = require('./model/teamProvider/teamProvider.model')
const boot = require('./boot')
/* eslint-enable global-require */
@@ -86,6 +87,15 @@ module.exports = function register(ctx, api) {
api.registerNotificationStreams(shardStreams.STREAMS)
api.registerAnnounceLeg(townCrierLeg.leg)
// Teams: a UO guild is a Team, and this module is the authoritative source of
// them for this deployment (MODULE_API 1.6.0). Core asks the three questions;
// everything about what a guild IS stays here.
//
// Registration is a claim, not a call — nothing below runs until core
// reconciles, which is after `onBoot`. That matters because every method reads
// the database, and registration must not.
api.registerTeamProvider(teamProvider)
api.onBoot(boot.onBoot)
api.onShutdown(boot.onShutdown)

View File

@@ -0,0 +1,66 @@
// SQL behind the Team provider — three questions core asks, answered from the
// guild board and the roster Protocol 4 put there.
//
// Every statement reads only THIS module's tables. Core's Team tables are
// core-internal (docs/website/TEAMS.md §10.3) and this module must never name
// one, even though it is what fills them.
const core = require('../../core')
const query = (...args) => core.db.query(...args)
/**
* The guild board — one row per guild the shard has told us about.
*
* `members`/`online` here are the COUNTS `guild.update` carries; the roster is a
* separate table (Protocol 4). Both are read, because a count is what the shard
* asserts and a roster is what it enumerated, and they can legitimately disagree
* for the moment between a membership change and the sweep that reports it.
*/
const listGuilds = () =>
query(
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
FROM shard_guilds ORDER BY name ASC`,
)
const findGuild = (id) =>
query(
`SELECT id, name, abbr, alliance, members, online, leader_serial, leader_name, leader_acct
FROM shard_guilds WHERE id = ? LIMIT 1`,
[id],
)
/**
* One guild's roster, with the site link and live presence folded in.
*
* Two LEFT JOINs, both deliberate:
*
* - `shard_account_links` resolves `user_id` HERE rather than in core, because
* this module owns that table and a core that read it would be core naming a
* module's table by name (§2.3). It is also why a freshly linked account
* appears as linked on the next reconcile rather than needing core to know
* anything about linking.
* - `shard_online` is how a member's `online` is answered at all. The roster
* frame does not carry it — the wire's member is the standard actor object
* (`serial`, `name`, `player`, `acct?`, `webId?`), and the board's `online` is
* a count, not a set. Presence therefore comes from the online table, which
* is the same source the public "who's online" surface already uses.
*
* `web_id` on the roster row is preferred over the link table when present: it is
* what the shard itself asserted at roster time, and the join is the fallback for
* a member whose row predates their link.
*/
const listGuildMembers = (guildId) =>
query(
`SELECT m.serial, m.name, m.acct, m.web_id, m.is_player,
l.user_id AS linked_user_id,
(o.serial IS NOT NULL) AS is_online
FROM shard_guild_members m
LEFT JOIN shard_account_links l ON l.account = m.acct
LEFT JOIN shard_online o ON o.serial = m.serial
WHERE m.guild_id = ?
ORDER BY m.name ASC`,
[guildId],
)
module.exports = { listGuilds, findGuild, listGuildMembers }

View File

@@ -0,0 +1,182 @@
// ── 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 }

View File

@@ -95,6 +95,7 @@ function fakeApi() {
extensions: [],
streams: null,
legs: [],
teamProvider: null,
hooks: {},
}
const called = new Set()
@@ -107,6 +108,10 @@ function fakeApi() {
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
registerAnnounceLeg(leg) { record.legs.push(leg) },
// MODULE_API 1.6.0. `once` because core holds a single provider per
// deployment — a second registration is a collision there, so it has to be
// one here too, or this suite would pass a shape core rejects at load.
registerTeamProvider(provider) { once('registerTeamProvider'); record.teamProvider = provider },
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = fn },
}

View File

@@ -85,6 +85,35 @@ test('every registered stream is namespaced or grandfathered', () => {
}
})
test('registers a Team provider with all three methods', () => {
// Core requires all three: a provider that could list Teams but not their
// members would leave core holding Teams it can never populate, which is not
// the same as a call that fails. Asserted here so a refactor that drops one
// fails in this suite rather than at load on an operator's install.
const api = fakeApi()
register(fakeCtx(), api)
const provider = api.record.teamProvider
assert.ok(provider, 'a UO guild is a Team; something has to answer for them')
for (const method of ['getTeams', 'getTeamMembers', 'getTeamLeaders']) {
assert.strictEqual(typeof provider[method], 'function', `${method} is missing`)
}
})
test('registration does not call the provider, or touch the database', async () => {
// register() runs while core's app.js is still being required, with the pool
// pointed at a dead port — routeManifest.js and swagger.js both depend on that.
// Registration is a CLAIM; core does not ask anything until it reconciles,
// which is after onBoot.
const ctx = fakeCtx()
let queried = false
const frozen = Object.freeze({ ...ctx, db: Object.freeze({ query: async () => { queried = true; return [] } }) })
const api = fakeApi()
register(frozen, api)
assert.equal(queried, false, 'a query at registration time would hang the manifest and the spec build')
})
test('takes a frozen ctx and does not try to write to it', () => {
const ctx = fakeCtx()
assert.ok(Object.isFrozen(ctx))

View File

@@ -0,0 +1,246 @@
// module-uo's Team provider (docs/website/TEAMS.md §2.3, MODULE_API.md 1.6.0).
//
// The tests that matter here are the REFUSALS. Core's contract is that module
// unavailability becomes staleness and never emptiness, and this module is the
// only thing that can honour it — an empty array from here is read as an
// authoritative "there are none", and core makes destructive decisions from an
// authoritative answer. Every state where this module cannot honestly claim to
// know is asserted below, because each one is a plausible place for someone to
// later "simplify" the guard away and get a plausible-looking empty list.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const core = require('../core')
// The provider reaches the database through core, which is initialised with a ctx
// in production. A minimal one is enough here — the db layer is stubbed anyway.
core.init({
db: { query: async () => [] },
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
moduleId: 'uo',
})
const db = require('../model/teamProvider/teamProvider.db')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
const uoLinkSocket = require('../utils/uoLinkSocket')
const provider = require('../model/teamProvider/teamProvider.model')
const saved = []
function patch(mod, name, fn) {
saved.push([mod, name, mod[name]])
mod[name] = fn
}
// The healthy default: configured, enabled, connected. Each test then breaks only
// the thing it is about.
function healthy() {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://127.0.0.1:7787', enabled: true }))
patch(uoLinkSocket, 'getState', () => ({ connected: true, running: true }))
}
const guild = (extra = {}) => ({
id: 1, name: 'The Silver Hand', abbr: 'TSH', alliance: null,
members: 2, online: 1, leader_serial: '0x1', leader_name: 'Aldric', leader_acct: 'aldric', ...extra,
})
const member = (extra = {}) => ({
serial: '0x1', name: 'Aldric', acct: 'aldric', web_id: null, is_player: 1,
linked_user_id: null, is_online: 0, ...extra,
})
beforeEach(healthy)
afterEach(() => {
while (saved.length) {
const [mod, name, fn] = saved.pop()
mod[name] = fn
}
})
// ── The refusals ───────────────────────────────────────────────────────────
test('no uo-link configured refuses, on all three methods', async () => {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: null, enabled: false }))
patch(db, 'listGuilds', async () => { throw new Error('must not be read') })
for (const answer of [await provider.getTeams(), await provider.getTeamMembers('1'), await provider.getTeamLeaders('1')]) {
assert.equal(answer.ok, false)
assert.match(answer.reason, /no uo-link configured/)
assert.equal(answer.teams, undefined)
assert.equal(answer.members, undefined)
}
})
test('a disabled integration refuses rather than reporting a frozen board', async () => {
patch(uoLinkConfig, 'getSafe', async () => ({ baseUrl: 'http://x', enabled: false }))
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /disabled/)
})
test('a disconnected socket refuses, even though the board is still there', async () => {
// The tempting mistake, stated as a test: the board is durable and survives an
// outage, so serving it looks harmless. Core cannot tell a board five minutes
// stale from one five days stale, and it archives Teams and departs members
// from a complete answer.
patch(uoLinkSocket, 'getState', () => ({ connected: false, running: true }))
patch(db, 'listGuilds', async () => [guild()])
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /not connected/)
assert.equal(answer.teams, undefined, 'a stale board must not arrive as authoritative')
})
test('a database error refuses instead of throwing at core', async () => {
patch(db, 'listGuilds', async () => { throw new Error('table gone') })
const answer = await provider.getTeams()
assert.equal(answer.ok, false)
assert.match(answer.reason, /table gone/)
})
test('a guild absent from the board refuses rather than reporting an empty roster', async () => {
patch(db, 'findGuild', async () => [])
const members = await provider.getTeamMembers('99')
assert.equal(members.ok, false)
assert.match(members.reason, /not on the board/)
const leaders = await provider.getTeamLeaders('99')
assert.equal(leaders.ok, false)
})
test('a roster that has not arrived yet refuses — the board count is what tells us', async () => {
// 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 where a
// 155-member guild has no roster rows. Reporting that as an empty roster would
// depart every member.
patch(db, 'findGuild', async () => [guild({ members: 155 })])
patch(db, 'listGuildMembers', async () => [])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, false)
assert.match(answer.reason, /has not arrived yet/)
assert.match(answer.reason, /155/, 'the count is in the message, because it is the evidence')
})
test('a guild the board says is genuinely empty reports an empty roster', async () => {
// The other side of the same coin: when the board itself says zero, an empty
// roster is the truth and withholding it would freeze a disbanding guild's
// membership forever.
patch(db, 'findGuild', async () => [guild({ members: 0 })])
patch(db, 'listGuildMembers', async () => [])
const answer = await provider.getTeamMembers('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.members, [])
})
// ── The good answers ───────────────────────────────────────────────────────
test('a guild becomes a Team keyed on its persistent ServUO id', async () => {
// The id survives a rename, which is what lets core apply its rename rule
// instead of seeing an unrelated new guild.
patch(db, 'listGuilds', async () => [guild()])
const answer = await provider.getTeams()
assert.equal(answer.ok, true)
assert.equal(answer.complete, true)
assert.deepEqual(answer.teams, [
{ externalId: '1', name: 'The Silver Hand', abbr: 'TSH', meta: null },
])
})
test('an alliance rides along as opaque meta', async () => {
patch(db, 'listGuilds', async () => [guild({ alliance: 'The Concord' })])
const { teams } = await provider.getTeams()
assert.deepEqual(teams[0].meta, { alliance: 'The Concord' })
})
test('the external id is a string, so core never compares a number to one', async () => {
patch(db, 'listGuilds', async () => [guild({ id: 42 })])
const { teams } = await provider.getTeams()
assert.equal(teams[0].externalId, '42')
})
test('a roster maps to the member shape core expects', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0x1', name: 'Aldric', is_online: 1 }),
member({ serial: '0x2', name: 'Bree', acct: null, is_online: 0 }),
])
const { members } = await provider.getTeamMembers('1')
assert.equal(members.length, 2)
assert.equal(members[0].memberKey, '0x1')
assert.equal(members[0].displayName, 'Aldric')
assert.equal(members[0].online, true)
assert.equal(members[0].leader, true, 'matches the boards leader_serial')
assert.equal(members[1].leader, false)
assert.equal(members[1].online, false)
})
test('rankLabel is null, honestly — the wire carries no guild rank', async () => {
// The roster member is the standard actor object (serial, name, player, acct?,
// webId?). Inventing a rank from the leader flag would be core displaying a
// label this module made up.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member()])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].rankLabel, null)
})
test('a member with no account at all is fine and unlinked', async () => {
// §2.3 of the protocol spec: acct is genuinely optional — a PlayerMobile can
// have no Account, and the local test world contains such mobiles.
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ acct: null, web_id: null, linked_user_id: null })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, null)
})
test('userId comes from the rosters web_id first, then the link table', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [
member({ serial: '0xA', web_id: '7', linked_user_id: 99 }), // roster wins
member({ serial: '0xB', web_id: null, linked_user_id: 12 }), // fallback
member({ serial: '0xC', web_id: '0', linked_user_id: null }), // neither
])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, 7, 'what the shard itself asserted at roster time')
assert.equal(members[1].userId, 12, 'the fallback for a row that predates the link')
assert.equal(members[2].userId, null)
})
test('web_id arrives as a string from the wire and is coerced', async () => {
patch(db, 'findGuild', async () => [guild()])
patch(db, 'listGuildMembers', async () => [member({ web_id: '42' })])
const { members } = await provider.getTeamMembers('1')
assert.equal(members[0].userId, 42)
assert.equal(typeof members[0].userId, 'number')
})
test('leadership is the boards single leader — one, and honestly one', async () => {
// TEAMS.md §2.5 expects multiple leaders (GuildRank.Rank >= 4) and core
// supports them, but Protocol 4 does not put rank on the wire. Raising this to
// the full set is a protocol change, not something this file can fix.
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1'])
})
test('a guild with no leader on the board reports none rather than guessing', async () => {
patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
const answer = await provider.getTeamLeaders('1')
assert.equal(answer.ok, true)
assert.deepEqual(answer.leaders, [])
})
test('an empty board is an authoritative empty list — the shard really has no guilds', async () => {
// Distinct from every refusal above: the socket is connected and the board is
// readable, so "no guilds" is a fact. Core still quarantines it before acting.
patch(db, 'listGuilds', async () => [])
const answer = await provider.getTeams()
assert.equal(answer.ok, true)
assert.deepEqual(answer.teams, [])
})