feat(teams): answer core's Team provider from the guild board
A UO guild is a Team. This registers module-uo as the authoritative source of
them (MODULE_API 1.6.0, docs/website/TEAMS.md §2.3) and answers the three
questions core asks, from the board and the roster Protocol 4 put there.
`externalId` is the persistent ServUO `Guild.Id`, which survives a rename -- so
core sees "an id whose name changed" and applies its rename rule rather than an
unrelated new guild appearing beside the old one. That mapping is this module's
to make: only the game knows what identity survives what.
The most important code here is the refusal guard, and it is deliberately
conservative. 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 reads as an authoritative "there are none", and core archives
Teams and departs members from an authoritative answer. Three states refuse: no
uo-link configured, the integration disabled, and the socket not connected.
**The third is the one worth arguing about.** The board is durable and survives an
outage, so serving it while disconnected looks harmless. It is not: core cannot
tell a board five minutes stale from one five days stale, and a complete answer
licenses destruction. There is a test named for that.
A fourth refusal has no equivalent anywhere else: a guild whose roster has not
arrived. Protocol 4's roster comes 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 zero roster rows. The board's own `members` count is the only
thing that distinguishes "the roster is late" from "this guild is empty", and it
is checked -- with the count in the refusal message, because it is the evidence.
The other side is tested too: when the board says zero, an empty roster is the
truth and withholding it would freeze a disbanding guild's membership forever.
Two limitations, both honest and both in the code as comments:
- **`rankLabel` is null.** The wire's roster member is the standard actor object
(`serial`, `name`, `player`, `acct?`, `webId?`) and carries no guild rank.
Inventing a label from the leader flag would be core displaying something this
module made up.
- **One leader, not several.** TEAMS.md §2.5 expects multiple leaders from
`GuildRank.Rank >= 4` and core supports them, but Protocol 4 does not put rank
on the wire, so the only leadership visible here is the board's single
`leader_serial`. Raising it to the full set is a protocol change, not
something this module can fix.
`online` comes from `shard_online` rather than the roster, which carries no
per-member presence and only a board-level count -- the same source the public
"who's online" surface already uses. `userId` prefers the roster's own `web_id`
(what the shard asserted at roster time) and falls back to the `shard_account_links`
join for a member whose row predates their link; resolving it here rather than in
core is the contract, since core reading `shard_account_links` would be core
naming a module's table.
`coreApi` stays `^1.3.0` -- 1.6.0 satisfies it, which is what makes the bump minor.
18 provider tests plus two on the entry point: that all three methods are
registered, and that registration performs no query. The second matters because
register() runs while core's app.js is still being required with the pool pointed
at a dead port, which both routeManifest.js and swagger.js depend on.
`fakeApi` gained `registerTeamProvider` with the same `once` rule core applies --
one provider per deployment, so a second registration has to fail here too rather
than passing a shape core rejects at load.
411 -> 413 tests, all passing.
Refs docs/website/TEAMS.md §2.3, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
66
server/model/teamProvider/teamProvider.db.js
Normal file
66
server/model/teamProvider/teamProvider.db.js
Normal 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 }
|
||||
182
server/model/teamProvider/teamProvider.model.js
Normal file
182
server/model/teamProvider/teamProvider.model.js
Normal 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 }
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
246
server/test/teamProvider.test.js
Normal file
246
server/test/teamProvider.test.js
Normal 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 board’s 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 roster’s 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 board’s 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, [])
|
||||
})
|
||||
Reference in New Issue
Block a user