The module half of the Protocol 4 rank amendment (servuo-plugins, same wire
version -- Protocol 4 is unreleased on `edge`, so it is amended rather than
bumped).
`shard_guild_members` gains `rank`, `rank_cliloc` and `rank_name`. The provider
then answers the question it previously could not: `getTeamLeaders()` returns
EVERY member at rank 4, not just the board's single `leader_serial`. That
limitation was the whole reason the wire grew a per-member rank -- TEAMS.md §2.5
treats multiple leaders as the normal case and core has always supported them.
The board's `leader_serial` is folded in as a floor rather than replaced. It
comes from a different frame, so on a shard whose roster has not been re-emitted
since the amendment it is the only leadership signal there is, and moving to
ranks must not lose it.
## NULL rank is a real state, and it is load-bearing
The shard withholds the rank for a staff account, because ServUO's
`PlayerMobile.GuildRank` reports Leader for anyone at GameMaster or above
whatever their actual rank. Every layer here preserves that:
- the ingest stores NULL rather than defaulting to 0, which would be a
demotion this code invented;
- `leader` requires an integer rank >= 4, so absence is never leadership;
- the leaders query compares on `rank`, and NULL is excluded by the comparison.
Reading a missing rank as either 0 or "leader" would republish the exact lie the
shard went out of its way not to send.
## Rank labels
Three sources, in order: a custom rank's literal string, 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-file extraction, and a roster
on a shard that has not should still read "Warlord" rather than nothing. A
failing lookup falls back rather than failing the roster -- a label is decoration,
and losing it must not lose the data.
`rank` is backticked everywhere it is written, like `int` on shard_online: it is
reserved in MySQL 8 and merely a keyword in MariaDB, so it parses bare here and
must not be relied on to.
The schema fragment carries ALTERs as well as the CREATE. No production install
has this table -- it is new in an unreleased protocol -- but `edge` deployments do,
from the roster work that landed before the amendment, and CREATE TABLE IF NOT
EXISTS adds a table and never a column. Same gap the sidecar's own store hit when
`guilds.members` was added.
## Verification
The unit tests stub the db layer, so the round trip was proved separately: the
VERBATIM roster frame captured from the live ServUO run was fed through the real
ingest into MariaDB and then read back through the provider.
stored: 0x1F5 rank=4 0x1F6 rank=3 0x1F7 rank=2 0x1F8 rank=1
0x1F9 rank=NULL (the GameMaster) 0x2E0 rank=4
provider: leaders = [0x1F5, 0x2E0] <- two, which the board alone cannot express
labels = Leader / Warlord / Emissary / Member, with no cliloc table
0x1F9 = not a leader, no label
9/9 checks. Suite 413 -> 421 tests, all passing.
Refs docs/link/v4.md §2.3, docs/website/TEAMS.md §2.5
Co-Authored-By: Claude <noreply@anthropic.com>
338 lines
15 KiB
JavaScript
338 lines
15 KiB
JavaScript
// 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 clilocs = require('../model/shardClilocs/shardClilocs.model')
|
||
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 }))
|
||
// An operator who has never run the client extraction — the default. The standard
|
||
// rank names must still resolve from the fallback table.
|
||
patch(clilocs, 'resolveMany', async () => new Map())
|
||
patch(db, 'listGuildLeaders', async () => [])
|
||
}
|
||
|
||
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,
|
||
rank: 1, rank_cliloc: 1062962, rank_name: null,
|
||
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', rank: 4, rank_cliloc: 1062959, is_online: 1 }),
|
||
member({ serial: '0x2', name: 'Bree', acct: null, rank: 1, 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, 'rank 4 is Leader')
|
||
assert.equal(members[1].leader, false)
|
||
assert.equal(members[1].online, false)
|
||
})
|
||
|
||
// ── Rank (the Protocol 4 amendment) ────────────────────────────────────────
|
||
|
||
test('several members can be leaders at once', async () => {
|
||
// The whole reason the wire grew a per-member rank: the board carries one
|
||
// leader_serial, so before this only a single leader could ever be reported.
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [
|
||
member({ serial: '0x1', rank: 4 }),
|
||
member({ serial: '0x2', rank: 4 }),
|
||
member({ serial: '0x3', rank: 3 }),
|
||
])
|
||
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.deepEqual(members.filter((m) => m.leader).map((m) => m.memberKey), ['0x1', '0x2'])
|
||
})
|
||
|
||
test('getTeamLeaders returns everyone at rank 4, not just the board’s one', async () => {
|
||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
|
||
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
|
||
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x1', '0x2'])
|
||
})
|
||
|
||
test('the board’s leader is kept even when no roster row has rank yet', async () => {
|
||
// A shard whose roster has not been re-emitted since the amendment has no ranks
|
||
// stored. The founder-leader comes from a different frame and must not be lost
|
||
// by moving to ranks.
|
||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x9' })])
|
||
patch(db, 'listGuildLeaders', async () => [])
|
||
assert.deepEqual((await provider.getTeamLeaders('1')).leaders, ['0x9'])
|
||
})
|
||
|
||
test('the board’s leader is not duplicated when they also hold rank 4', async () => {
|
||
patch(db, 'findGuild', async () => [guild({ leader_serial: '0x1' })])
|
||
patch(db, 'listGuildLeaders', async () => [{ serial: '0x1' }, { serial: '0x2' }])
|
||
const { leaders } = await provider.getTeamLeaders('1')
|
||
assert.equal(new Set(leaders).size, leaders.length)
|
||
})
|
||
|
||
test('a NULL rank is not a leader — "not known" is not "leads this guild"', async () => {
|
||
// The shard withholds the rank for a staff account, because ServUO's GuildRank
|
||
// getter reports Leader for anyone at GameMaster or above whatever their real
|
||
// rank. Reading the absence as leadership would republish exactly that lie.
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: null, rank_cliloc: null })])
|
||
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.equal(members[0].leader, false)
|
||
assert.equal(members[0].rankLabel, null)
|
||
})
|
||
|
||
test('a standard rank resolves to its name without a cliloc table', async () => {
|
||
// The operator may never have run the client extraction, and a roster should
|
||
// still read "Warlord" rather than nothing.
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [
|
||
member({ serial: '0x1', rank: 4, rank_cliloc: 1062959 }),
|
||
member({ serial: '0x2', rank: 3, rank_cliloc: 1062960 }),
|
||
member({ serial: '0x3', rank: 0, rank_cliloc: 1062963 }),
|
||
])
|
||
|
||
const { members } = await provider.getTeamMembers('1')
|
||
assert.deepEqual(members.map((m) => m.rankLabel), ['Leader', 'Warlord', 'Ronin'])
|
||
})
|
||
|
||
test('the operator’s cliloc table wins over the built-in names', async () => {
|
||
// A localised or edited client should name the ranks, not this module's English
|
||
// fallback.
|
||
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Kriegsherr']]))
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
|
||
|
||
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Kriegsherr')
|
||
})
|
||
|
||
test('a custom rank’s literal name beats both', async () => {
|
||
// A shard that replaced RankDefinition.Ranks sends a string instead of a cliloc,
|
||
// and its own naming has to survive.
|
||
patch(clilocs, 'resolveMany', async () => new Map([[1062960, 'Warlord']]))
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [
|
||
member({ serial: '0x1', rank: 3, rank_cliloc: 1062960, rank_name: 'Sword-Captain' }),
|
||
])
|
||
|
||
assert.equal((await provider.getTeamMembers('1')).members[0].rankLabel, 'Sword-Captain')
|
||
})
|
||
|
||
test('a failing cliloc lookup falls back rather than failing the roster', async () => {
|
||
patch(clilocs, 'resolveMany', async () => { throw new Error('cliloc table missing') })
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 3, rank_cliloc: 1062960 })])
|
||
|
||
const answer = await provider.getTeamMembers('1')
|
||
assert.equal(answer.ok, true, 'a label is decoration; losing it must not lose the roster')
|
||
assert.equal(answer.members[0].rankLabel, 'Warlord')
|
||
})
|
||
|
||
test('an unknown cliloc leaves the label null rather than inventing one', async () => {
|
||
patch(db, 'findGuild', async () => [guild()])
|
||
patch(db, 'listGuildMembers', async () => [member({ serial: '0x1', rank: 2, rank_cliloc: 9999999 })])
|
||
assert.equal((await provider.getTeamMembers('1')).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('a guild with no leader anywhere reports none rather than guessing', async () => {
|
||
patch(db, 'findGuild', async () => [guild({ leader_serial: null })])
|
||
patch(db, 'listGuildLeaders', async () => [])
|
||
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, [])
|
||
})
|