feat(guilds): a UO guild is a Team (Teams cutover 5/6) #16
@@ -45,6 +45,7 @@ module.exports = function register(ctx, api) {
|
|||||||
|
|
||||||
const shardStreams = require('./config/shardStreams')
|
const shardStreams = require('./config/shardStreams')
|
||||||
const townCrierLeg = require('./utils/shardAnnounce')
|
const townCrierLeg = require('./utils/shardAnnounce')
|
||||||
|
const teamProvider = require('./model/teamProvider/teamProvider.model')
|
||||||
const boot = require('./boot')
|
const boot = require('./boot')
|
||||||
/* eslint-enable global-require */
|
/* eslint-enable global-require */
|
||||||
|
|
||||||
@@ -86,6 +87,15 @@ module.exports = function register(ctx, api) {
|
|||||||
api.registerNotificationStreams(shardStreams.STREAMS)
|
api.registerNotificationStreams(shardStreams.STREAMS)
|
||||||
api.registerAnnounceLeg(townCrierLeg.leg)
|
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.onBoot(boot.onBoot)
|
||||||
api.onShutdown(boot.onShutdown)
|
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: [],
|
extensions: [],
|
||||||
streams: null,
|
streams: null,
|
||||||
legs: [],
|
legs: [],
|
||||||
|
teamProvider: null,
|
||||||
hooks: {},
|
hooks: {},
|
||||||
}
|
}
|
||||||
const called = new Set()
|
const called = new Set()
|
||||||
@@ -107,6 +108,10 @@ function fakeApi() {
|
|||||||
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
|
registerExtension(slot, router) { record.extensions.push({ slot, router }) },
|
||||||
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
|
registerNotificationStreams(streams) { once('registerNotificationStreams'); record.streams = streams },
|
||||||
registerAnnounceLeg(leg) { record.legs.push(leg) },
|
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 },
|
onBoot(fn) { once('onBoot'); record.hooks.onBoot = fn },
|
||||||
onShutdown(fn) { once('onShutdown'); record.hooks.onShutdown = 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', () => {
|
test('takes a frozen ctx and does not try to write to it', () => {
|
||||||
const ctx = fakeCtx()
|
const ctx = fakeCtx()
|
||||||
assert.ok(Object.isFrozen(ctx))
|
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